mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
feat(compat): add ClawHub compatibility layer
This commit is contained in:
parent
ddb940d5d5
commit
efd43ef3cd
13 changed files with 325 additions and 1 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
public record SkillCoordinate(String namespace, String slug) {}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubPublishResponse(
|
||||
String canonicalSlug,
|
||||
String version,
|
||||
String status
|
||||
) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubResolveResponse(
|
||||
String canonicalSlug,
|
||||
String version,
|
||||
String downloadUrl
|
||||
) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ClawHubSearchResponse(List<ClawHubSkillItem> items) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubSkillItem(
|
||||
String canonicalSlug,
|
||||
String description,
|
||||
String latestVersion,
|
||||
int starCount
|
||||
) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.compat.dto;
|
||||
|
||||
public record ClawHubWhoamiResponse(
|
||||
String userId,
|
||||
String displayName,
|
||||
String email
|
||||
) {}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue