feat(compat): add ClawHub compatibility layer

This commit is contained in:
vsxd 2026-03-12 19:20:02 +08:00
parent ddb940d5d5
commit efd43ef3cd
13 changed files with 325 additions and 1 deletions

View file

@ -0,0 +1,37 @@
package com.iflytek.skillhub.compat;
import org.springframework.stereotype.Component;
@Component
public class CanonicalSlugMapper {
private static final String GLOBAL_NAMESPACE = "global";
private static final String SEPARATOR = "--";
/**
* Convert namespace and slug to canonical slug format.
* If namespace is "global", return slug as-is.
* Otherwise, return "namespace--slug".
*/
public String toCanonical(String namespace, String slug) {
if (GLOBAL_NAMESPACE.equals(namespace)) {
return slug;
}
return namespace + SEPARATOR + slug;
}
/**
* Convert canonical slug back to namespace and slug.
* If contains "--", split into namespace and slug.
* Otherwise, treat as global namespace.
*/
public SkillCoordinate fromCanonical(String canonicalSlug) {
int separatorIndex = canonicalSlug.indexOf(SEPARATOR);
if (separatorIndex > 0) {
String namespace = canonicalSlug.substring(0, separatorIndex);
String slug = canonicalSlug.substring(separatorIndex + SEPARATOR.length());
return new SkillCoordinate(namespace, slug);
}
return new SkillCoordinate(GLOBAL_NAMESPACE, canonicalSlug);
}
}

View file

@ -0,0 +1,48 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.compat.dto.ClawHubResolveResponse;
import com.iflytek.skillhub.compat.dto.ClawHubSearchResponse;
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/compat/v1")
public class ClawHubCompatController {
private final CanonicalSlugMapper mapper;
public ClawHubCompatController(CanonicalSlugMapper mapper) {
this.mapper = mapper;
}
@GetMapping("/search")
public ClawHubSearchResponse search(@RequestParam String q) {
// Return empty results for now (placeholder)
return new ClawHubSearchResponse(List.of());
}
@GetMapping("/resolve/{canonicalSlug}")
public ClawHubResolveResponse resolve(
@PathVariable String canonicalSlug,
@RequestParam(defaultValue = "latest") String version) {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
return new ClawHubResolveResponse(
canonicalSlug,
version,
"/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
);
}
@GetMapping("/whoami")
public ClawHubWhoamiResponse whoami(@AuthenticationPrincipal PlatformPrincipal principal) {
return new ClawHubWhoamiResponse(
principal.userId(),
principal.displayName(),
principal.email()
);
}
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.compat;
public record SkillCoordinate(String namespace, String slug) {}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.compat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class WellKnownController {
@GetMapping("/.well-known/clawhub.json")
public Map<String, String> clawhubConfig() {
return Map.of("apiBase", "/api/compat/v1");
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubPublishResponse(
String canonicalSlug,
String version,
String status
) {}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubResolveResponse(
String canonicalSlug,
String version,
String downloadUrl
) {}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.compat.dto;
import java.util.List;
public record ClawHubSearchResponse(List<ClawHubSkillItem> items) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubSkillItem(
String canonicalSlug,
String description,
String latestVersion,
int starCount
) {}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubWhoamiResponse(
String userId,
String displayName,
String email
) {}

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.compat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CanonicalSlugMapperTest {
private final CanonicalSlugMapper mapper = new CanonicalSlugMapper();
@ParameterizedTest
@CsvSource({
"global,my-skill,my-skill",
"team-ai,my-skill,team-ai--my-skill",
"org-name,another-skill,org-name--another-skill"
})
void testToCanonical(String namespace, String slug, String expectedCanonical) {
String result = mapper.toCanonical(namespace, slug);
assertEquals(expectedCanonical, result);
}
@ParameterizedTest
@CsvSource({
"my-skill,global,my-skill",
"team-ai--my-skill,team-ai,my-skill",
"org-name--another-skill,org-name,another-skill"
})
void testFromCanonical(String canonical, String expectedNamespace, String expectedSlug) {
SkillCoordinate result = mapper.fromCanonical(canonical);
assertEquals(expectedNamespace, result.namespace());
assertEquals(expectedSlug, result.slug());
}
@Test
void testRoundTrip() {
// Test global namespace
String canonical1 = mapper.toCanonical("global", "my-skill");
SkillCoordinate coord1 = mapper.fromCanonical(canonical1);
assertEquals("global", coord1.namespace());
assertEquals("my-skill", coord1.slug());
// Test custom namespace
String canonical2 = mapper.toCanonical("team-ai", "my-skill");
SkillCoordinate coord2 = mapper.fromCanonical(canonical2);
assertEquals("team-ai", coord2.namespace());
assertEquals("my-skill", coord2.slug());
}
}

View file

@ -0,0 +1,99 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ClawHubCompatControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void search_returns_200() throws Exception {
mockMvc.perform(get("/api/compat/v1/search")
.param("q", "test"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items").isEmpty());
}
@Test
void resolve_returns_correct_downloadUrl() throws Exception {
mockMvc.perform(get("/api/compat/v1/resolve/my-skill"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.canonicalSlug").value("my-skill"))
.andExpect(jsonPath("$.version").value("latest"))
.andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download"));
}
@Test
void resolve_with_namespace_returns_correct_downloadUrl() throws Exception {
mockMvc.perform(get("/api/compat/v1/resolve/team-ai--my-skill"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.canonicalSlug").value("team-ai--my-skill"))
.andExpect(jsonPath("$.version").value("latest"))
.andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/team-ai/my-skill/download"));
}
@Test
void resolve_with_version_returns_specified_version() throws Exception {
mockMvc.perform(get("/api/compat/v1/resolve/my-skill")
.param("version", "1.0.0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.canonicalSlug").value("my-skill"))
.andExpect(jsonPath("$.version").value("1.0.0"))
.andExpect(jsonPath("$.downloadUrl").value("/api/v1/skills/global/my-skill/download"));
}
@Test
void whoami_with_auth_returns_user_info() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
"tester",
"tester@example.com",
"https://example.com/avatar.png",
"github",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
mockMvc.perform(get("/api/compat/v1/whoami")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value("user-42"))
.andExpect(jsonPath("$.displayName").value("tester"))
.andExpect(jsonPath("$.email").value("tester@example.com"));
}
}

View file

@ -0,0 +1,36 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class WellKnownControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void clawhubConfig_returns_apiBase() throws Exception {
mockMvc.perform(get("/.well-known/clawhub.json"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.apiBase").value("/api/compat/v1"));
}
}

View file

@ -71,7 +71,9 @@ public class SecurityConfig {
"/actuator/health",
"/v3/api-docs/**",
"/swagger-ui/**",
"/.well-known/**"
"/.well-known/**",
"/api/compat/v1/search",
"/api/compat/v1/resolve/**"
).permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/skills", "/api/v1/skills/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/namespaces", "/api/v1/namespaces/*").permitAll()