feat(compat): add clawhub registry adapter

This commit is contained in:
yun-zhi-ztl 2026-03-13 16:04:18 +08:00
parent 3682a4646b
commit f96fa74413
11 changed files with 598 additions and 0 deletions

View file

@ -0,0 +1,54 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse;
import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillResponse;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1")
public class ClawHubRegistryController {
private final ClawHubRegistryFacade facade;
public ClawHubRegistryController(ClawHubRegistryFacade facade) {
this.facade = facade;
}
@GetMapping("/search")
public ClawHubRegistrySearchResponse search(
@RequestParam String q,
@RequestParam(defaultValue = "20") int limit,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return facade.search(q, limit, userId, userNsRoles);
}
@GetMapping("/skills/{slug}")
public ClawHubRegistrySkillResponse getSkill(
@org.springframework.web.bind.annotation.PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return facade.getSkill(slug, userId, userNsRoles);
}
@GetMapping("/download")
public ResponseEntity<Void> download(
@RequestParam String slug,
@RequestParam(required = false) String version,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
String location = facade.resolveDownloadUrl(slug, version, userId, userNsRoles);
return ResponseEntity.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, location)
.build();
}
}

View file

@ -0,0 +1,213 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.compat.dto.ClawHubRegistryModeration;
import com.iflytek.skillhub.compat.dto.ClawHubRegistryOwner;
import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchItem;
import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse;
import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkill;
import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillResponse;
import com.iflytek.skillhub.compat.dto.ClawHubRegistrySkillVersion;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Component;
@Component
public class ClawHubRegistryFacade {
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private final CanonicalSlugMapper canonicalSlugMapper;
private final SkillSearchAppService skillSearchAppService;
private final SkillQueryService skillQueryService;
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final UserAccountRepository userAccountRepository;
public ClawHubRegistryFacade(
CanonicalSlugMapper canonicalSlugMapper,
SkillSearchAppService skillSearchAppService,
SkillQueryService skillQueryService,
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
UserAccountRepository userAccountRepository) {
this.canonicalSlugMapper = canonicalSlugMapper;
this.skillSearchAppService = skillSearchAppService;
this.skillQueryService = skillQueryService;
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.userAccountRepository = userAccountRepository;
}
public ClawHubRegistrySearchResponse search(
String keyword,
int limit,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
int boundedLimit = clampLimit(limit);
List<SkillSummaryResponse> items = skillSearchAppService.search(
keyword,
null,
"relevance",
0,
boundedLimit,
userId,
normalizeRoles(userNsRoles))
.items();
List<ClawHubRegistrySearchItem> results = buildSearchResults(items);
return new ClawHubRegistrySearchResponse(results);
}
public ClawHubRegistrySkillResponse getSkill(
String canonicalSlug,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coordinate = canonicalSlugMapper.fromCanonical(canonicalSlug);
SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail(
coordinate.namespace(),
coordinate.slug(),
userId,
normalizeRoles(userNsRoles));
Skill skill = skillRepository.findById(detail.id())
.orElseThrow(() -> new IllegalStateException("Skill unexpectedly missing: " + canonicalSlug));
ClawHubRegistrySkill payload = new ClawHubRegistrySkill(
canonicalSlugMapper.toCanonical(coordinate.namespace(), detail.slug()),
normalizeDisplayName(detail.displayName(), canonicalSlug),
detail.summary(),
List.of(),
Map.of(),
toEpochMillis(skill.getCreatedAt()),
toEpochMillis(skill.getUpdatedAt())
);
ClawHubRegistrySkillVersion latestVersion = buildLatestVersion(skill, detail.latestVersion());
ClawHubRegistryOwner owner = buildOwner(skill.getOwnerId());
return new ClawHubRegistrySkillResponse(
payload,
latestVersion,
owner,
ClawHubRegistryModeration.clean()
);
}
public String resolveDownloadUrl(
String canonicalSlug,
String version,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coordinate = canonicalSlugMapper.fromCanonical(canonicalSlug);
String normalizedVersion = normalizeVersion(version);
return skillQueryService.resolveVersion(
coordinate.namespace(),
coordinate.slug(),
normalizedVersion,
null,
null,
userId,
normalizeRoles(userNsRoles))
.downloadUrl();
}
private List<ClawHubRegistrySearchItem> buildSearchResults(List<SkillSummaryResponse> items) {
return java.util.stream.IntStream.range(0, items.size())
.mapToObj(index -> toSearchItem(items.get(index), index))
.toList();
}
private ClawHubRegistrySearchItem toSearchItem(SkillSummaryResponse item, int index) {
String canonicalSlug = canonicalSlugMapper.toCanonical(item.namespace(), item.slug());
return new ClawHubRegistrySearchItem(
canonicalSlug,
normalizeDisplayName(item.displayName(), canonicalSlug),
item.summary(),
item.latestVersion(),
scoreFor(index),
toEpochMillis(item.updatedAt())
);
}
private ClawHubRegistrySkillVersion buildLatestVersion(Skill skill, String version) {
if (version == null || version.isBlank()) {
return null;
}
Optional<SkillVersion> latestVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version);
if (latestVersion.isEmpty()) {
return new ClawHubRegistrySkillVersion(version, 0L, "", null);
}
SkillVersion entity = latestVersion.get();
LocalDateTime createdAt = entity.getPublishedAt() != null ? entity.getPublishedAt() : entity.getCreatedAt();
return new ClawHubRegistrySkillVersion(
entity.getVersion(),
toEpochMillis(createdAt),
entity.getChangelog() != null ? entity.getChangelog() : "",
null
);
}
private ClawHubRegistryOwner buildOwner(String ownerId) {
if (ownerId == null || ownerId.isBlank()) {
return ClawHubRegistryOwner.empty();
}
return userAccountRepository.findById(ownerId)
.map(user -> new ClawHubRegistryOwner(
null,
user.getDisplayName(),
user.getAvatarUrl()))
.orElseGet(ClawHubRegistryOwner::empty);
}
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNsRoles) {
return userNsRoles != null ? userNsRoles : Map.of();
}
private int clampLimit(int limit) {
if (limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private String normalizeDisplayName(String displayName, String fallback) {
if (displayName == null || displayName.isBlank()) {
return fallback;
}
return displayName;
}
private String normalizeVersion(String version) {
if (version == null || version.isBlank()) {
return null;
}
return version;
}
private double scoreFor(int index) {
return Math.max(0.001d, 1.0d - (index * 0.001d));
}
private long toEpochMillis(LocalDateTime timestamp) {
if (timestamp == null) {
return 0L;
}
return timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
}

View file

@ -0,0 +1,24 @@
package com.iflytek.skillhub.compat;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ClawHubRegistrySecurityConfig {
@Bean
@Order(0)
public SecurityFilterChain clawHubRegistryFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/v1/search", "/api/v1/download", "/api/v1/skills/*")
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.requestCache(cache -> cache.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}

View file

@ -0,0 +1,25 @@
package com.iflytek.skillhub.compat.dto;
import java.util.List;
public record ClawHubRegistryModeration(
boolean isSuspicious,
boolean isMalwareBlocked,
String verdict,
List<String> reasonCodes,
Long updatedAt,
String engineVersion,
String summary
) {
public static ClawHubRegistryModeration clean() {
return new ClawHubRegistryModeration(
false,
false,
"clean",
List.of(),
null,
null,
null
);
}
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.compat.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.ALWAYS)
public record ClawHubRegistryOwner(
String handle,
String displayName,
String image
) {
public static ClawHubRegistryOwner empty() {
return new ClawHubRegistryOwner(null, null, null);
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubRegistrySearchItem(
String slug,
String displayName,
String summary,
String version,
double score,
long updatedAt
) {}

View file

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

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.compat.dto;
import java.util.List;
import java.util.Map;
public record ClawHubRegistrySkill(
String slug,
String displayName,
String summary,
List<String> tags,
Map<String, Object> stats,
long createdAt,
long updatedAt
) {}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.compat.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.ALWAYS)
public record ClawHubRegistrySkillResponse(
ClawHubRegistrySkill skill,
ClawHubRegistrySkillVersion latestVersion,
ClawHubRegistryOwner owner,
ClawHubRegistryModeration moderation
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.compat.dto;
public record ClawHubRegistrySkillVersion(
String version,
long createdAt,
String changelog,
Object license
) {}

View file

@ -0,0 +1,220 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
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.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ClawHubRegistryControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private SkillSearchAppService skillSearchAppService;
@MockBean
private SkillQueryService skillQueryService;
@MockBean
private SkillRepository skillRepository;
@MockBean
private SkillVersionRepository skillVersionRepository;
@MockBean
private UserAccountRepository userAccountRepository;
@Test
void search_returns_clawhub_registry_schema() throws Exception {
LocalDateTime updatedAt = LocalDateTime.of(2026, 3, 13, 10, 30);
given(skillSearchAppService.search("test", null, "relevance", 0, 2, null, Map.of()))
.willReturn(new SkillSearchAppService.SearchResponse(
List.of(
new SkillSummaryResponse(
1L,
"global-skill",
"Global Skill",
"global summary",
10L,
5,
BigDecimal.ZERO,
0,
"1.2.0",
"global",
updatedAt
),
new SkillSummaryResponse(
2L,
"team-skill",
"Team Skill",
"team summary",
20L,
8,
BigDecimal.ONE,
2,
"2.0.0",
"team-ai",
updatedAt.plusHours(1)
)
),
2,
0,
2
));
mockMvc.perform(get("/api/v1/search")
.param("q", "test")
.param("limit", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.results").isArray())
.andExpect(jsonPath("$.results[0].slug").value("global-skill"))
.andExpect(jsonPath("$.results[0].displayName").value("Global Skill"))
.andExpect(jsonPath("$.results[0].version").value("1.2.0"))
.andExpect(jsonPath("$.results[0].score").value(1.0))
.andExpect(jsonPath("$.results[0].updatedAt").value(toEpochMillis(updatedAt)))
.andExpect(jsonPath("$.results[1].slug").value("team-ai--team-skill"))
.andExpect(jsonPath("$.results[1].displayName").value("Team Skill"))
.andExpect(jsonPath("$.results[1].version").value("2.0.0"))
.andExpect(jsonPath("$.results[1].score").value(0.999))
.andExpect(jsonPath("$.results[1].updatedAt").value(toEpochMillis(updatedAt.plusHours(1))));
}
@Test
void get_skill_returns_clawhub_install_metadata() throws Exception {
LocalDateTime skillCreatedAt = LocalDateTime.of(2026, 1, 1, 9, 0);
LocalDateTime skillUpdatedAt = LocalDateTime.of(2026, 3, 10, 18, 30);
LocalDateTime versionPublishedAt = LocalDateTime.of(2026, 3, 12, 12, 0);
given(skillQueryService.getSkillDetail(
eq("global"),
eq("global-skill"),
isNull(),
eq(Map.<Long, NamespaceRole>of())))
.willReturn(new SkillQueryService.SkillDetailDTO(
1L,
"global-skill",
"Global Skill",
"global summary",
"PUBLIC",
"ACTIVE",
10L,
5,
BigDecimal.ZERO,
0,
false,
"1.2.0",
1L
));
Skill skill = new Skill(1L, "global-skill", "owner-1", SkillVisibility.PUBLIC);
skill.setDisplayName("Global Skill");
skill.setSummary("global summary");
ReflectionTestUtils.setField(skill, "id", 1L);
ReflectionTestUtils.setField(skill, "createdAt", skillCreatedAt);
ReflectionTestUtils.setField(skill, "updatedAt", skillUpdatedAt);
SkillVersion version = new SkillVersion(1L, "1.2.0", "owner-1");
version.setChangelog("Initial release");
version.setPublishedAt(versionPublishedAt);
ReflectionTestUtils.setField(version, "id", 11L);
ReflectionTestUtils.setField(version, "createdAt", versionPublishedAt.minusHours(2));
given(skillRepository.findById(1L)).willReturn(java.util.Optional.of(skill));
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.0")).willReturn(java.util.Optional.of(version));
given(userAccountRepository.findById("owner-1"))
.willReturn(java.util.Optional.of(new UserAccount(
"owner-1",
"Skill Owner",
"owner@example.com",
"https://example.com/avatar.png"
)));
mockMvc.perform(get("/api/v1/skills/global-skill"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.skill.slug").value("global-skill"))
.andExpect(jsonPath("$.skill.displayName").value("Global Skill"))
.andExpect(jsonPath("$.skill.summary").value("global summary"))
.andExpect(jsonPath("$.skill.createdAt").value(toEpochMillis(skillCreatedAt)))
.andExpect(jsonPath("$.skill.updatedAt").value(toEpochMillis(skillUpdatedAt)))
.andExpect(jsonPath("$.latestVersion.version").value("1.2.0"))
.andExpect(jsonPath("$.latestVersion.createdAt").value(toEpochMillis(versionPublishedAt)))
.andExpect(jsonPath("$.latestVersion.changelog").value("Initial release"))
.andExpect(jsonPath("$.owner.displayName").value("Skill Owner"))
.andExpect(jsonPath("$.owner.image").value("https://example.com/avatar.png"))
.andExpect(jsonPath("$.moderation.isSuspicious").value(false))
.andExpect(jsonPath("$.moderation.isMalwareBlocked").value(false))
.andExpect(jsonPath("$.moderation.verdict").value("clean"));
}
@Test
void download_redirects_to_versioned_skill_url() throws Exception {
given(skillQueryService.resolveVersion(
eq("team-ai"),
eq("team-skill"),
eq("2.0.0"),
isNull(),
isNull(),
isNull(),
eq(Map.<Long, NamespaceRole>of())))
.willReturn(new SkillQueryService.ResolvedVersionDTO(
2L,
"team-ai",
"team-skill",
"2.0.0",
21L,
"sha256:test",
true,
"/api/v1/skills/team-ai/team-skill/versions/2.0.0/download"
));
mockMvc.perform(get("/api/v1/download")
.param("slug", "team-ai--team-skill")
.param("version", "2.0.0"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "/api/v1/skills/team-ai/team-skill/versions/2.0.0/download"));
}
private long toEpochMillis(LocalDateTime timestamp) {
return timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
}