From efd43ef3cd2b781e1a3d847c3a503f9760364ec8 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 19:20:02 +0800 Subject: [PATCH] feat(compat): add ClawHub compatibility layer --- .../skillhub/compat/CanonicalSlugMapper.java | 37 +++++++ .../compat/ClawHubCompatController.java | 48 +++++++++ .../skillhub/compat/SkillCoordinate.java | 3 + .../skillhub/compat/WellKnownController.java | 15 +++ .../compat/dto/ClawHubPublishResponse.java | 7 ++ .../compat/dto/ClawHubResolveResponse.java | 7 ++ .../compat/dto/ClawHubSearchResponse.java | 5 + .../skillhub/compat/dto/ClawHubSkillItem.java | 8 ++ .../compat/dto/ClawHubWhoamiResponse.java | 7 ++ .../compat/CanonicalSlugMapperTest.java | 50 ++++++++++ .../compat/ClawHubCompatControllerTest.java | 99 +++++++++++++++++++ .../compat/WellKnownControllerTest.java | 36 +++++++ .../skillhub/auth/config/SecurityConfig.java | 4 +- 13 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CanonicalSlugMapper.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CanonicalSlugMapperTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CanonicalSlugMapper.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CanonicalSlugMapper.java new file mode 100644 index 00000000..8e7d72f8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CanonicalSlugMapper.java @@ -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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java new file mode 100644 index 00000000..70717170 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -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() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java new file mode 100644 index 00000000..f1077287 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/SkillCoordinate.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.compat; + +public record SkillCoordinate(String namespace, String slug) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java new file mode 100644 index 00000000..0737ebd5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java @@ -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 clawhubConfig() { + return Map.of("apiBase", "/api/compat/v1"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java new file mode 100644 index 00000000..b117ebd9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubPublishResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubPublishResponse( + String canonicalSlug, + String version, + String status +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java new file mode 100644 index 00000000..79427437 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubResolveResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubResolveResponse( + String canonicalSlug, + String version, + String downloadUrl +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java new file mode 100644 index 00000000..ee03acce --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSearchResponse.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.compat.dto; + +import java.util.List; + +public record ClawHubSearchResponse(List items) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java new file mode 100644 index 00000000..3211adbc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubSkillItem.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubSkillItem( + String canonicalSlug, + String description, + String latestVersion, + int starCount +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java new file mode 100644 index 00000000..f2667ce1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/dto/ClawHubWhoamiResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.compat.dto; + +public record ClawHubWhoamiResponse( + String userId, + String displayName, + String email +) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CanonicalSlugMapperTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CanonicalSlugMapperTest.java new file mode 100644 index 00000000..87b34359 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CanonicalSlugMapperTest.java @@ -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()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java new file mode 100644 index 00000000..90e6b164 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -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")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java new file mode 100644 index 00000000..eca4f21b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/WellKnownControllerTest.java @@ -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")); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index ab002cbf..a8c94865 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -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()