feat(api): return skill labels from the skill listing endpoints on request (#730)

* feat(api): return skill labels from the skill listing endpoints on request

Skill labels were reachable only one skill at a time, through
/api/{v1,web}/skills/{namespace}/{slug}/labels, so a client rendering a list had
to issue a follow-up request per row.

Add includeLabels=true to GET /api/v1/skills and GET /api/web/skills. The labels
array is populated only when the parameter is set and left out of the payload
otherwise, so existing responses are byte-identical.

Labels for the whole page are resolved by SkillLabelProjectionService in three
queries — assignments, definitions, translations — rather than three per skill.

Closes #710

Signed-off-by: FenjuFu <fufenjupku@gmail.com>

* fix(api): use include parameter for skill labels

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(api): reject unsupported include before search

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: FenjuFu <fufenjupku@gmail.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
FenjuFu 2026-08-25 13:49:24 +08:00 • committed by GitHub
parent 1c3e9be9e9
commit 91d0ae1504
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 548 additions and 11 deletions

View file

@ -20,8 +20,10 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.io.IOException;
import java.util.HashMap;
@ -51,6 +53,7 @@ public class ClawHubCompatAppService {
private final CompatSkillLookupService compatSkillLookupService;
private final SkillStarService skillStarService;
private final RequestIdAccessor requestIdAccessor;
private final SkillLabelProjectionService skillLabelProjectionService;
public ClawHubCompatAppService(CanonicalSlugMapper mapper,
SkillSearchAppService skillSearchAppService,
@ -61,7 +64,8 @@ public class ClawHubCompatAppService {
AuditLogService auditLogService,
CompatSkillLookupService compatSkillLookupService,
SkillStarService skillStarService,
RequestIdAccessor requestIdAccessor) {
RequestIdAccessor requestIdAccessor,
SkillLabelProjectionService skillLabelProjectionService) {
this.mapper = mapper;
this.skillSearchAppService = skillSearchAppService;
this.skillQueryService = skillQueryService;
@ -72,6 +76,7 @@ public class ClawHubCompatAppService {
this.compatSkillLookupService = compatSkillLookupService;
this.skillStarService = skillStarService;
this.requestIdAccessor = requestIdAccessor;
this.skillLabelProjectionService = skillLabelProjectionService;
}
public ClawHubSearchResponse search(String q,
@ -195,6 +200,15 @@ public class ClawHubCompatAppService {
String sort,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
return listSkills(page, limit, sort, false, userId, userNsRoles);
}
public ClawHubSkillListResponse listSkills(int page,
int limit,
String sort,
boolean includeLabels,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
String sortBy = sort != null ? sort : "newest";
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
"",
@ -206,8 +220,15 @@ public class ClawHubCompatAppService {
userNsRoles
);
Map<Long, List<SkillLabelDto>> labelsBySkillId = includeLabels
? skillLabelProjectionService.labelsBySkillIds(
response.items().stream().map(SkillSummaryResponse::id).toList())
: Map.of();
List<ClawHubSkillListResponse.SkillListItem> items = response.items().stream()
.map(this::toSkillListItem)
.map(item -> toSkillListItem(
item,
includeLabels ? labelsBySkillId.getOrDefault(item.id(), List.of()) : null))
.toList();
String nextCursor = null;
@ -383,7 +404,8 @@ public class ClawHubCompatAppService {
return new ClawHubResolveResponse(matchVersion, latestVersion);
}
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) {
private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item,
List<SkillLabelDto> labels) {
long createdAt = 0;
long updatedAt = item.updatedAt() != null ? item.updatedAt().toEpochMilli() : 0;
@ -413,7 +435,8 @@ public class ClawHubCompatAppService {
stats,
createdAt,
updatedAt,
latestVersion
latestVersion,
labels
);
}

View file

@ -10,10 +10,13 @@ import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
import com.iflytek.skillhub.compat.dto.ClawHubStarResponse;
import com.iflytek.skillhub.compat.dto.ClawHubUnstarResponse;
import com.iflytek.skillhub.compat.dto.ClawHubWhoamiResponse;
import com.iflytek.skillhub.controller.support.IncludeOptions;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.ratelimit.RateLimit;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@ -93,9 +96,12 @@ public class ClawHubCompatController {
public ClawHubSkillListResponse listSkills(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int limit,
@RequestParam(required = false) String sort,
@Parameter(description = "Optional response expansions. Supported value: labels")
@RequestParam(name = "include", required = false) List<String> include,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return clawHubCompatAppService.listSkills(page, limit, sort, userId, userNsRoles);
return clawHubCompatAppService.listSkills(
page, limit, sort, IncludeOptions.includesLabels(include), userId, userNsRoles);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)

View file

@ -1,5 +1,7 @@
package com.iflytek.skillhub.compat.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.iflytek.skillhub.dto.SkillLabelDto;
import java.util.List;
public record ClawHubSkillListResponse(
@ -14,8 +16,27 @@ public record ClawHubSkillListResponse(
Object stats,
long createdAt,
long updatedAt,
LatestVersion latestVersion
LatestVersion latestVersion,
/**
* Labels attached to the skill, present only when the caller passes
* {@code include=labels}. Omitted otherwise, so the legacy payload is unchanged.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
List<SkillLabelDto> labels
) {
public SkillListItem(
String slug,
String displayName,
String summary,
Object tags,
Object stats,
long createdAt,
long updatedAt,
LatestVersion latestVersion) {
this(slug, displayName, summary, tags, stats, createdAt, updatedAt, latestVersion, null);
}
public record LatestVersion(
String version,
long createdAt,

View file

@ -1,16 +1,20 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.controller.support.IncludeOptions;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@ -27,11 +31,14 @@ public class SkillSearchController extends BaseApiController {
private static final int DEFAULT_SIZE = 20;
private final SkillSearchAppService skillSearchAppService;
private final SkillLabelProjectionService skillLabelProjectionService;
public SkillSearchController(SkillSearchAppService skillSearchAppService,
SkillLabelProjectionService skillLabelProjectionService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.skillSearchAppService = skillSearchAppService;
this.skillLabelProjectionService = skillLabelProjectionService;
}
@GetMapping
@ -40,6 +47,8 @@ public class SkillSearchController extends BaseApiController {
@RequestParam(required = false) String q,
@RequestParam(required = false) String namespace,
@RequestParam(name = "label", required = false) java.util.List<String> labels,
@Parameter(description = "Optional response expansions. Supported value: labels")
@RequestParam(name = "include", required = false) List<String> include,
@Parameter(schema = @Schema(defaultValue = DEFAULT_SORT))
@RequestParam(required = false) String sort,
@Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0"))
@ -49,6 +58,7 @@ public class SkillSearchController extends BaseApiController {
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
boolean includeLabels = IncludeOptions.includesLabels(include);
SkillSearchAppService.SearchResponse response = skillSearchAppService.search(
q,
namespace,
@ -60,7 +70,18 @@ public class SkillSearchController extends BaseApiController {
userNsRoles
);
return ok("response.success.read", response);
return ok("response.success.read", includeLabels ? withLabels(response) : response);
}
private SkillSearchAppService.SearchResponse withLabels(SkillSearchAppService.SearchResponse response) {
Map<Long, List<SkillLabelDto>> labelsBySkillId = skillLabelProjectionService.labelsBySkillIds(
response.items().stream().map(SkillSummaryResponse::id).toList());
List<SkillSummaryResponse> items = response.items().stream()
.map(item -> item.withLabels(labelsBySkillId.getOrDefault(item.id(), List.of())))
.toList();
return new SkillSearchAppService.SearchResponse(items, response.total(), response.page(), response.size());
}
private String normalizeSort(String sort) {

View file

@ -0,0 +1,42 @@
package com.iflytek.skillhub.controller.support;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Parses optional response expansions from {@code include=...} query parameters.
*/
public final class IncludeOptions {
private static final String LABELS = "labels";
private static final Set<String> SUPPORTED = Set.of(LABELS);
private IncludeOptions() {
}
public static boolean includesLabels(List<String> include) {
if (include == null || include.isEmpty()) {
return false;
}
boolean requested = false;
for (String rawValue : include) {
if (rawValue == null || rawValue.isBlank()) {
continue;
}
for (String rawOption : rawValue.split(",")) {
String option = rawOption.trim().toLowerCase(Locale.ROOT);
if (option.isBlank()) {
continue;
}
if (!SUPPORTED.contains(option)) {
throw new DomainBadRequestException("error.request.include.unsupported", option);
}
requested = true;
}
}
return requested;
}
}

View file

@ -1,7 +1,9 @@
package com.iflytek.skillhub.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record SkillSummaryResponse(
Long id,
@ -21,5 +23,46 @@ public record SkillSummaryResponse(
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode,
ComplianceSnapshotResponse complianceSnapshot
) {}
ComplianceSnapshotResponse complianceSnapshot,
/**
* Labels attached to the skill, present only when the caller asked for them.
* Left out of the payload otherwise, so responses are unchanged for callers
* that do not opt in.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
List<SkillLabelDto> labels
) {
/**
* Summary without label projection.
*/
public SkillSummaryResponse(
Long id,
String slug,
String displayName,
String summary,
String visibility,
String status,
Long downloadCount,
Integer starCount,
BigDecimal ratingAvg,
Integer ratingCount,
String namespace,
Instant updatedAt,
boolean canSubmitPromotion,
SkillLifecycleVersionResponse headlineVersion,
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String resolutionMode,
ComplianceSnapshotResponse complianceSnapshot) {
this(id, slug, displayName, summary, visibility, status, downloadCount, starCount, ratingAvg,
ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion, publishedVersion,
ownerPreviewVersion, resolutionMode, complianceSnapshot, null);
}
public SkillSummaryResponse withLabels(List<SkillLabelDto> labels) {
return new SkillSummaryResponse(id, slug, displayName, summary, visibility, status, downloadCount,
starCount, ratingAvg, ratingCount, namespace, updatedAt, canSubmitPromotion, headlineVersion,
publishedVersion, ownerPreviewVersion, resolutionMode, complianceSnapshot, labels);
}
}

View file

@ -0,0 +1,94 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
import com.iflytek.skillhub.domain.label.LabelTranslation;
import com.iflytek.skillhub.domain.label.SkillLabel;
import com.iflytek.skillhub.domain.label.SkillLabelService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
/**
* Projects skill labels for a whole page of skills in a fixed number of queries.
*
* <p>Listing endpoints need labels for every item they return, so resolving them one
* skill at a time would issue three queries per row. This service batches the
* assignment, definition, and translation lookups instead.</p>
*/
@Service
public class SkillLabelProjectionService {
private final SkillLabelService skillLabelService;
private final LabelDefinitionService labelDefinitionService;
private final LabelLocalizationService labelLocalizationService;
public SkillLabelProjectionService(SkillLabelService skillLabelService,
LabelDefinitionService labelDefinitionService,
LabelLocalizationService labelLocalizationService) {
this.skillLabelService = skillLabelService;
this.labelDefinitionService = labelDefinitionService;
this.labelLocalizationService = labelLocalizationService;
}
/**
* Labels for each requested skill, keyed by skill id. Skills without labels are absent
* from the map rather than mapped to an empty list.
*/
public Map<Long, List<SkillLabelDto>> labelsBySkillIds(List<Long> skillIds) {
if (skillIds == null || skillIds.isEmpty()) {
return Map.of();
}
List<Long> distinctSkillIds = skillIds.stream()
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
if (distinctSkillIds.isEmpty()) {
return Map.of();
}
List<SkillLabel> assignments = skillLabelService.listSkillLabelsBySkillIds(distinctSkillIds);
if (assignments.isEmpty()) {
return Map.of();
}
List<Long> labelIds = assignments.stream()
.map(SkillLabel::getLabelId)
.distinct()
.toList();
Map<Long, LabelDefinition> definitionsById = labelDefinitionService.listByIds(labelIds).stream()
.collect(Collectors.toMap(LabelDefinition::getId, Function.identity()));
Map<Long, List<LabelTranslation>> translationsByLabelId =
labelDefinitionService.listTranslationsByLabelIds(labelIds);
return assignments.stream()
.filter(assignment -> definitionsById.containsKey(assignment.getLabelId()))
.collect(Collectors.groupingBy(
SkillLabel::getSkillId,
Collectors.collectingAndThen(
Collectors.toList(),
skillAssignments -> skillAssignments.stream()
.map(assignment -> toDto(
definitionsById.get(assignment.getLabelId()),
translationsByLabelId))
.sorted(Comparator.comparing(SkillLabelDto::type)
.thenComparing(SkillLabelDto::slug))
.toList())));
}
private SkillLabelDto toDto(LabelDefinition definition,
Map<Long, List<LabelTranslation>> translationsByLabelId) {
return new SkillLabelDto(
definition.getSlug(),
definition.getType().name(),
labelLocalizationService.resolveDisplayName(
definition.getSlug(),
translationsByLabelId.getOrDefault(definition.getId(), List.of()))
);
}
}

View file

@ -54,6 +54,7 @@ error.forbidden=Forbidden
error.apiToken.scope.missing=API token is missing required scope: {0}
error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0}
error.request.timeout=Request timed out
error.request.include.unsupported=Unsupported include option: {0}
error.rateLimit.exceeded=Rate limit exceeded
error.storage.unavailable=Object storage is temporarily unavailable. Please try again later.
error.internal=An unexpected error occurred

View file

@ -54,6 +54,7 @@ error.forbidden=没有权限执行该操作
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}
error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0}
error.request.timeout=请求超时
error.request.include.unsupported=不支持的 include 参数:{0}
error.rateLimit.exceeded=请求过于频繁,请稍后再试
error.storage.unavailable=对象存储暂时不可用,请稍后再试
error.internal=服务器内部错误

View file

@ -15,8 +15,13 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.compat.dto.ClawHubSkillListResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
@ -31,6 +36,7 @@ class ClawHubCompatAppServiceTest {
private final AuditLogService auditLogService = mock(AuditLogService.class);
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
private final SkillStarService skillStarService = mock(SkillStarService.class);
private final SkillLabelProjectionService skillLabelProjectionService = mock(SkillLabelProjectionService.class);
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
new CanonicalSlugMapper(),
@ -42,7 +48,8 @@ class ClawHubCompatAppServiceTest {
auditLogService,
compatSkillLookupService,
skillStarService,
new RequestIdAccessor()
new RequestIdAccessor(),
skillLabelProjectionService
);
@Test
@ -120,4 +127,35 @@ class ClawHubCompatAppServiceTest {
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download");
}
@Test
void listSkills_omitsLabelsByDefault() {
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
ClawHubSkillListResponse response = service.listSkills(0, 25, null, null, Map.of());
assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0).labels()).isNull();
}
@Test
void listSkills_returnsLabelsWhenRequested() {
when(skillSearchAppService.search("", null, "newest", 0, 25, null, Map.of()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation"))));
ClawHubSkillListResponse response = service.listSkills(0, 25, null, true, null, Map.of());
assertThat(response.items().get(0).labels())
.extracting(SkillLabelDto::slug)
.containsExactly("automation");
}
private static SkillSummaryResponse summary(Long id) {
return new SkillSummaryResponse(
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
}
}

View file

@ -19,7 +19,9 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
@ -48,6 +50,7 @@ import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@ -72,6 +75,9 @@ class ClawHubCompatControllerTest {
@MockBean
private SkillSearchAppService skillSearchAppService;
@MockBean
private SkillLabelProjectionService skillLabelProjectionService;
@MockBean
private SkillQueryService skillQueryService;
@ -154,6 +160,41 @@ class ClawHubCompatControllerTest {
verify(apiTokenService).touchLastUsed(same(token));
}
@Test
void listSkills_shouldOmitLabelsByDefault() throws Exception {
when(skillSearchAppService.search(eq(""), isNull(), eq("newest"), eq(0), eq(25), isNull(), isNull()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
mockMvc.perform(get("/api/v1/skills"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].slug").value("demo-skill"))
.andExpect(jsonPath("$.items[0].labels").doesNotExist());
}
@Test
void listSkills_shouldReturnLabelsWhenIncluded() throws Exception {
when(skillSearchAppService.search(eq(""), isNull(), eq("newest"), eq(0), eq(25), isNull(), isNull()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 25));
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
.thenReturn(java.util.Map.of(
7L,
List.of(new SkillLabelDto("automation", "RECOMMENDED", "Automation"))));
mockMvc.perform(get("/api/v1/skills").param("include", "labels"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].labels[0].slug").value("automation"))
.andExpect(jsonPath("$.items[0].labels[0].type").value("RECOMMENDED"))
.andExpect(jsonPath("$.items[0].labels[0].displayName").value("Automation"));
}
@Test
void listSkills_shouldRejectUnsupportedIncludeOptions() throws Exception {
mockMvc.perform(get("/api/v1/skills").param("include", "labels,stats"))
.andExpect(status().isBadRequest());
verifyNoInteractions(skillSearchAppService);
}
@Test
void downloadQuery_withBearerToken_shouldProjectNamespaceRolesIntoRequestContext() throws Exception {
ApiToken token = new ApiToken("user-7", "cli", "sk_test", "hash", "[]");
@ -401,6 +442,28 @@ class ClawHubCompatControllerTest {
return version;
}
private SkillSummaryResponse summary(Long id) {
return new SkillSummaryResponse(
id,
"demo-skill",
"Demo Skill",
"A demo",
"PUBLIC",
"PUBLISHED",
0L,
0,
null,
0,
"global",
null,
false,
null,
null,
null,
null,
null);
}
private UsernamePasswordAuthenticationToken superAdminAuth() {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",

View file

@ -1,6 +1,9 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillLabelProjectionService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -15,6 +18,7 @@ import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@ -34,6 +38,9 @@ class SkillSearchControllerTest {
@MockBean
private SkillSearchAppService skillSearchAppService;
@MockBean
private SkillLabelProjectionService skillLabelProjectionService;
@Test
void searchShouldUseUnifiedEnvelopeAndItemsField() throws Exception {
when(skillSearchAppService.search(
@ -143,4 +150,59 @@ class SkillSearchControllerTest {
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20));
}
@Test
void searchShouldOmitLabelsUnlessRequested() throws Exception {
when(skillSearchAppService.search(
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
mockMvc.perform(get("/api/web/skills"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].slug").value("demo-skill"))
.andExpect(jsonPath("$.data.items[0].labels").doesNotExist());
}
@Test
void searchShouldReturnLabelsWhenRequested() throws Exception {
when(skillSearchAppService.search(
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L)))
.thenReturn(Map.of(7L, List.of(new SkillLabelDto("automation", "TOPIC", "Automation"))));
mockMvc.perform(get("/api/web/skills").param("include", "labels"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].labels[0].slug").value("automation"))
.andExpect(jsonPath("$.data.items[0].labels[0].type").value("TOPIC"))
.andExpect(jsonPath("$.data.items[0].labels[0].displayName").value("Automation"));
}
@Test
void searchShouldReturnEmptyLabelArrayForSkillsWithoutLabels() throws Exception {
when(skillSearchAppService.search(
eq(null), eq(null), eq("newest"), eq(0), eq(20), eq(null), any(), any()))
.thenReturn(new SkillSearchAppService.SearchResponse(List.of(summary(7L)), 1, 0, 20));
when(skillLabelProjectionService.labelsBySkillIds(List.of(7L))).thenReturn(Map.of());
mockMvc.perform(get("/api/web/skills").param("include", "labels"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].labels").isArray())
.andExpect(jsonPath("$.data.items[0].labels").isEmpty());
}
@Test
void searchShouldRejectUnsupportedIncludeOptions() throws Exception {
mockMvc.perform(get("/api/web/skills").param("include", "labels,stats"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verifyNoInteractions(skillSearchAppService);
}
private static SkillSummaryResponse summary(Long id) {
return new SkillSummaryResponse(
id, "demo-skill", "Demo Skill", "A demo", "PUBLIC", "PUBLISHED",
0L, 0, null, 0, "global", null, false, null, null, null, null, null);
}
}

View file

@ -0,0 +1,28 @@
package com.iflytek.skillhub.controller.support;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import java.util.List;
import org.junit.jupiter.api.Test;
class IncludeOptionsTest {
@Test
void includesLabels_acceptsAbsentBlankRepeatedAndCommaSeparatedInputs() {
assertThat(IncludeOptions.includesLabels(null)).isFalse();
assertThat(IncludeOptions.includesLabels(List.of("", " "))).isFalse();
assertThat(IncludeOptions.includesLabels(List.of("labels"))).isTrue();
assertThat(IncludeOptions.includesLabels(List.of(" LABELS "))).isTrue();
assertThat(IncludeOptions.includesLabels(List.of("labels,"))).isTrue();
assertThat(IncludeOptions.includesLabels(List.of("", "labels"))).isTrue();
}
@Test
void includesLabels_rejectsUnsupportedOptions() {
assertThatThrownBy(() -> IncludeOptions.includesLabels(List.of("labels,stats")))
.isInstanceOf(DomainBadRequestException.class)
.hasMessage("error.request.include.unsupported");
}
}

View file

@ -0,0 +1,81 @@
package com.iflytek.skillhub.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.label.LabelDefinition;
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
import com.iflytek.skillhub.domain.label.LabelType;
import com.iflytek.skillhub.domain.label.SkillLabel;
import com.iflytek.skillhub.domain.label.SkillLabelService;
import com.iflytek.skillhub.dto.SkillLabelDto;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class SkillLabelProjectionServiceTest {
private final SkillLabelService skillLabelService = mock(SkillLabelService.class);
private final LabelDefinitionService labelDefinitionService = mock(LabelDefinitionService.class);
private final LabelLocalizationService labelLocalizationService = new LabelLocalizationService();
private final SkillLabelProjectionService service = new SkillLabelProjectionService(
skillLabelService, labelDefinitionService, labelLocalizationService);
@Test
void labelsBySkillIds_groupsLabelsPerSkillInOneBatch() {
LabelDefinition automation = definition(10L, "automation", LabelType.RECOMMENDED);
LabelDefinition audited = definition(11L, "audited", LabelType.PRIVILEGED);
when(skillLabelService.listSkillLabelsBySkillIds(List.of(1L, 2L))).thenReturn(List.of(
new SkillLabel(1L, 10L, "owner-1"),
new SkillLabel(1L, 11L, "owner-1"),
new SkillLabel(2L, 10L, "owner-2")
));
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of(automation, audited));
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
Map<Long, List<SkillLabelDto>> labels = service.labelsBySkillIds(List.of(1L, 2L));
// sorted by label type, then slug: PRIVILEGED before RECOMMENDED
assertEquals(List.of("audited", "automation"), labels.get(1L).stream().map(SkillLabelDto::slug).toList());
assertEquals(List.of("automation"), labels.get(2L).stream().map(SkillLabelDto::slug).toList());
// One query per lookup for the whole page, not per skill.
verify(skillLabelService, times(1)).listSkillLabelsBySkillIds(anyList());
verify(labelDefinitionService, times(1)).listByIds(anyList());
verify(labelDefinitionService, times(1)).listTranslationsByLabelIds(anyList());
}
@Test
void labelsBySkillIds_skipsAssignmentsWithoutADefinition() {
when(skillLabelService.listSkillLabelsBySkillIds(anyList()))
.thenReturn(List.of(new SkillLabel(1L, 99L, "owner-1")));
when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of());
when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of());
assertTrue(service.labelsBySkillIds(List.of(1L)).isEmpty());
}
@Test
void labelsBySkillIds_touchesNoRepositoryForAnEmptyPage() {
assertTrue(service.labelsBySkillIds(List.of()).isEmpty());
assertTrue(service.labelsBySkillIds(null).isEmpty());
verify(skillLabelService, never()).listSkillLabelsBySkillIds(any());
}
private static LabelDefinition definition(Long id, String slug, LabelType type) {
LabelDefinition definition = new LabelDefinition(slug, type, true, 0, "admin");
ReflectionTestUtils.setField(definition, "id", id);
return definition;
}
}

View file

@ -38,6 +38,13 @@ public class SkillLabelService {
return skillLabelRepository.findBySkillId(skillId);
}
public List<SkillLabel> listSkillLabelsBySkillIds(List<Long> skillIds) {
if (skillIds == null || skillIds.isEmpty()) {
return List.of();
}
return skillLabelRepository.findBySkillIdIn(skillIds);
}
public List<SkillLabel> listByLabelId(Long labelId) {
return skillLabelRepository.findByLabelId(labelId);
}

View file

@ -4353,6 +4353,7 @@ export interface components {
ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
resolutionMode?: string;
complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"];
labels?: components["schemas"]["SkillLabelDto"][];
};
ApiResponseBoolean: {
/** Format: int32 */
@ -4980,6 +4981,7 @@ export interface components {
/** Format: int64 */
updatedAt?: number;
latestVersion?: components["schemas"]["LatestVersion"];
labels?: components["schemas"]["SkillLabelDto"][];
};
ApiResponseListSecurityAuditResponse: {
/** Format: int32 */
@ -8002,6 +8004,8 @@ export interface operations {
page?: number;
limit?: number;
sort?: string;
/** @description Optional response expansions. Supported value: labels */
include?: string[];
};
header?: never;
path?: never;
@ -9039,6 +9043,8 @@ export interface operations {
q?: string;
namespace?: string;
label?: string[];
/** @description Optional response expansions. Supported value: labels */
include?: string[];
sort?: string;
page?: number;
size?: number;