Skip to content

Commit 0254aae

Browse files
committed
Add slug into catalog items and add endpoint to find by it
1 parent f286ef6 commit 0254aae

10 files changed

Lines changed: 709 additions & 2 deletions

File tree

openapi/openapi-component_catalog-v1.0.0.yaml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,72 @@ paths:
162162
application/json:
163163
schema:
164164
$ref: '#/components/schemas/RestErrorMessage'
165+
/catalog-items/slug/{slug}:
166+
get:
167+
tags:
168+
- CatalogItems
169+
summary: Returns the CatalogItem associated to the provided slug.
170+
description: >
171+
Returns the CatalogItem identified by a composite slug with format `{project-key}_{catalog-item-repository-name}`.<br/>
172+
The separator is the first underscore (`_`); everything after it (the repo name) may itself contain underscores.<br/>
173+
The project-key is the normalised (lowercase) Bitbucket project key that owns the item's repository.
174+
The catalog-item-repository-name is matched against the Bitbucket repository slug of the item.<br/>
175+
Returns 404 if no catalog item matches the provided slug.
176+
operationId: getCatalogItemBySlug
177+
parameters:
178+
- name: slug
179+
in: path
180+
description: >
181+
Composite slug with format `{project-key}_{catalog-item-repository-name}`.
182+
The separator is the first underscore; the repo name may contain additional underscores.
183+
Example: `myproject_my-component-repo`
184+
required: true
185+
schema:
186+
type: string
187+
example: 'myproject_my-component-repo'
188+
responses:
189+
"200":
190+
description: The CatalogItem.
191+
content:
192+
application/json:
193+
schema:
194+
$ref: '#/components/schemas/CatalogItem'
195+
"400":
196+
description: Invalid or malformed slug provided.
197+
content:
198+
application/json:
199+
schema:
200+
$ref: '#/components/schemas/RestErrorMessage'
201+
"401":
202+
description: Invalid client token on the request.
203+
content:
204+
application/json:
205+
schema:
206+
$ref: '#/components/schemas/RestErrorMessage'
207+
"403":
208+
description: Insufficient permissions for the client to access the resource.
209+
content:
210+
application/json:
211+
schema:
212+
$ref: '#/components/schemas/RestErrorMessage'
213+
"404":
214+
description: No CatalogItem associated to the provided slug.
215+
content:
216+
application/json:
217+
schema:
218+
$ref: '#/components/schemas/RestErrorMessage'
219+
"422":
220+
description: Invalid CatalogItem associated to the provided slug.
221+
content:
222+
application/json:
223+
schema:
224+
$ref: '#/components/schemas/RestErrorMessage'
225+
"500":
226+
description: Server error.
227+
content:
228+
application/json:
229+
schema:
230+
$ref: '#/components/schemas/RestErrorMessage'
165231
/catalog-items:
166232
get:
167233
tags:
@@ -826,6 +892,12 @@ components:
826892
id:
827893
type: string
828894
example: 'aSdFam...yCg=='
895+
slug:
896+
type: string
897+
description: >
898+
Composite slug computed from the normalised Bitbucket project key and the repository slug of the item,
899+
in the format `{project-key}_{repo-name}`. Calculated at mapping time; not retrieved from Bitbucket.
900+
example: 'myproject_my-component-repo'
829901
path:
830902
type: string
831903
example: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master
@@ -875,6 +947,7 @@ components:
875947
- date
876948
example:
877949
id: aSdFam...yCg==
950+
slug: myproject_some-repo
878951
path: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master
879952
title: An item title
880953
shortDescription: This is a short description for the item

src/main/java/org/opendevstack/component_catalog/server/controllers/CatalogItemsApiController.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogEntityException;
1515
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogItemEntityException;
1616
import org.opendevstack.component_catalog.server.services.exceptions.InvalidIdException;
17+
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
18+
import org.opendevstack.component_catalog.server.services.slug.InvalidCatalogItemSlugException;
1719
import org.springframework.http.ResponseEntity;
1820
import org.springframework.stereotype.Controller;
1921
import org.springframework.web.bind.annotation.RequestMapping;
@@ -115,6 +117,23 @@ public ResponseEntity<CatalogItem> getCatalogItemByIdForProjectKey(String id, St
115117
}
116118
}
117119

120+
@Override
121+
public ResponseEntity<CatalogItem> getCatalogItemBySlug(String slug) {
122+
log.debug("User '{}' requested catalog item by slug: '{}'", authInfo.getCurrentPrincipalName(), slug);
123+
try {
124+
var catalogItemSlug = CatalogItemSlug.parse(slug);
125+
var catItem = catalogItemsApiFacade.fetchCatalogItemBySlug(catalogItemSlug);
126+
if (catItem == null) {
127+
return ResponseEntity.notFound().build();
128+
}
129+
return ResponseEntity.ok(catItem);
130+
} catch (InvalidCatalogItemSlugException e) {
131+
throw new BadRequestException("Invalid slug format: " + e.getMessage());
132+
} catch (InvalidIdException | InvalidCatalogEntityException e) {
133+
throw new BadRequestException("Could not resolve catalog item for slug: " + slug);
134+
}
135+
}
136+
118137

119138
}
120139

src/main/java/org/opendevstack/component_catalog/server/facade/CatalogItemsApiFacade.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
import org.opendevstack.component_catalog.server.model.CatalogItemRestriction;
1111
import org.opendevstack.component_catalog.server.security.AuthorizationInfo;
1212
import org.opendevstack.component_catalog.server.services.CatalogEntitiesService;
13+
import org.opendevstack.component_catalog.server.services.CatalogItemBySlugService;
1314
import org.opendevstack.component_catalog.server.services.ProjectsInfoService;
1415
import org.opendevstack.component_catalog.server.services.UserActionsEntitiesService;
1516
import org.opendevstack.component_catalog.server.services.catalog.CatalogEntityPermissionEnum;
1617
import org.opendevstack.component_catalog.server.services.catalog.CatalogServiceAdapter;
1718
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogEntityException;
1819
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogItemEntityException;
1920
import org.opendevstack.component_catalog.server.services.exceptions.InvalidIdException;
21+
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
2022
import org.springframework.stereotype.Component;
2123

2224
import java.util.Collections;
@@ -36,6 +38,7 @@ public class CatalogItemsApiFacade {
3638
private final ProjectsInfoService projectsInfoService;
3739
private final CatalogEntitiesService catalogEntitiesService;
3840
private final UserActionsEntitiesService userActionsEntitiesService;
41+
private final CatalogItemBySlugService catalogItemBySlugService;
3942

4043
public CatalogItem asCatalogItem(CatalogRequestParams catalogRequestParams) {
4144
var clusters = getClusters(catalogRequestParams);
@@ -116,6 +119,28 @@ public CatalogItem fetchCatalogItem(CatalogRequestParams catalogRequestParams)
116119
.orElse(null);
117120
}
118121

122+
public CatalogItem fetchCatalogItemBySlug(CatalogItemSlug slug)
123+
throws InvalidIdException, InvalidCatalogEntityException {
124+
var maybeItemEntityCtx = catalogItemBySlugService.findByCatalogItemSlug(slug);
125+
126+
if (maybeItemEntityCtx.isEmpty()) {
127+
return null;
128+
}
129+
130+
var itemEntityCtx = maybeItemEntityCtx.get();
131+
var principalPermissions = currentPrincipalCatalogPermissions(itemEntityCtx.getId());
132+
var userActionsEntity = userActionsEntitiesService.getDefaultUserActionsEntity();
133+
134+
return asCatalogItem(
135+
CatalogRequestParams.builder()
136+
.catalogItemEntityContext(itemEntityCtx)
137+
.catalogItemId(itemEntityCtx.getId())
138+
.userActionsEntity(userActionsEntity)
139+
.permissions(principalPermissions)
140+
.build()
141+
);
142+
}
143+
119144
protected boolean filterByProject(CatalogItem item, String projectKey) {
120145
var projects = Optional.ofNullable(item.getRestrictions())
121146
.map(CatalogItemRestriction::getProjects)

src/main/java/org/opendevstack/component_catalog/server/mappers/EntitiesMapper.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
import java.util.*;
1919
import java.util.stream.Stream;
2020

21-
import static org.opendevstack.component_catalog.server.mappers.MapperUtils.nullish;
22-
import static org.opendevstack.component_catalog.server.services.common.IdEncoderDecoder.nullableIdEncode;
21+
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
22+
23+
import static org.opendevstack.component_catalog.server.mappers.MapperUtils.nullish;import static org.opendevstack.component_catalog.server.services.common.IdEncoderDecoder.nullableIdEncode;
2324

2425
@Component
2526
@Slf4j
@@ -76,8 +77,14 @@ public CatalogItem asCatalogItem(CatalogItemEntityContext catalogItemEntityCtx,
7677
.projects(Collections.unmodifiableSet(new LinkedHashSet<>()))
7778
.build());
7879

80+
var catalogProject = catalogItemEntityCtx.getRepoCatalogItemPathAt().getProjectKey();
81+
var itemSlug = (catalogProject != null && !catalogProject.isBlank())
82+
? CatalogItemSlug.normalise(catalogProject) + CatalogItemSlug.SEPARATOR + catalogItemEntityCtx.getRepoCatalogItemPathAt().getRepoSlug()
83+
: null;
84+
7985
var catalogItem = CatalogItem.builder()
8086
.id(catalogItemEntityCtx.getId())
87+
.slug(itemSlug)
8188
.path(catalogItemEntityCtx.getPath())
8289
.title(catalogItemEntity.getMetadata().getName())
8390
.shortDescription(catalogItemEntity.getMetadata().getShortDescription())
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package org.opendevstack.component_catalog.server.services;
2+
3+
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogEntityException;
4+
import org.opendevstack.component_catalog.server.services.catalog.entity.CatalogItemEntityContext;
5+
import org.opendevstack.component_catalog.server.services.exceptions.InvalidIdException;
6+
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
7+
import lombok.AllArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.util.Optional;
12+
13+
import static org.opendevstack.component_catalog.server.services.common.IdEncoderDecoder.idEncode;
14+
15+
/**
16+
* Domain service responsible for resolving a {@link CatalogItemSlug} to a {@link CatalogItemEntityContext}.
17+
* <p>
18+
* Resolution steps:
19+
* <ol>
20+
* <li>Load all catalogs from the marketplace (catalog-of-catalogs).</li>
21+
* <li>For each catalog, load its items and find the one whose normalised Bitbucket project key
22+
* matches the slug's {@code projectKey} component and whose repository slug matches
23+
* the slug's {@code repoName} component.</li>
24+
* </ol>
25+
*/
26+
@Service
27+
@AllArgsConstructor
28+
@Slf4j
29+
public class CatalogItemBySlugService {
30+
31+
private final CatalogsCollectionService catalogsCollectionService;
32+
private final CatalogEntitiesService catalogEntitiesService;
33+
34+
/**
35+
* Resolves a {@link CatalogItemSlug} to the matching {@link CatalogItemEntityContext}.
36+
*
37+
* @param slug the parsed composite slug
38+
* @return an {@link Optional} with the matching context, or empty if not found
39+
* @throws InvalidIdException if any catalog id is structurally invalid
40+
* @throws InvalidCatalogEntityException if a catalog entity cannot be parsed
41+
*/
42+
public Optional<CatalogItemEntityContext> findByCatalogItemSlug(CatalogItemSlug slug)
43+
throws InvalidIdException, InvalidCatalogEntityException {
44+
45+
log.debug("Resolving catalog item by slug: '{}'", slug);
46+
47+
var catalogsCollection = catalogsCollectionService.getCatalogsCollection();
48+
49+
if (catalogsCollection.isEmpty()) {
50+
log.warn("No catalogs collection found while resolving slug '{}'", slug);
51+
return Optional.empty();
52+
}
53+
54+
var targets = catalogsCollection.get().getMetadata().getSpec().getTargets();
55+
if (targets == null || targets.length == 0) {
56+
log.warn("Catalogs collection has no targets while resolving slug '{}'", slug);
57+
return Optional.empty();
58+
}
59+
60+
for (var target : targets) {
61+
var catalogId = idEncode(target.getUrl());
62+
var itemContexts = catalogEntitiesService.getCatalogItemsEntities(catalogId);
63+
var match = itemContexts.stream()
64+
.filter(ctx -> itemMatchesSlug(ctx, slug))
65+
.findFirst();
66+
if (match.isPresent()) {
67+
log.debug("Resolved slug '{}' to item in catalog target '{}'", slug, target.getUrl());
68+
return match;
69+
}
70+
}
71+
72+
log.debug("No catalog item matched slug '{}'", slug);
73+
return Optional.empty();
74+
}
75+
76+
// --- private helpers ---
77+
78+
private boolean itemMatchesSlug(CatalogItemEntityContext ctx, CatalogItemSlug slug) {
79+
var normalisedProjectKey = CatalogItemSlug.normalise(ctx.getRepoCatalogItemPathAt().getProjectKey());
80+
return normalisedProjectKey.equals(slug.getProjectKey())
81+
&& slug.getRepoName().equalsIgnoreCase(ctx.getRepoCatalogItemPathAt().getRepoSlug());
82+
}
83+
84+
}
85+
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package org.opendevstack.component_catalog.server.services.slug;
2+
3+
import lombok.Getter;
4+
5+
/**
6+
* Value object representing a parsed composite catalog item slug.
7+
* <p>
8+
* The raw slug format is: {@code {project-key}_{catalog-item-repository-name}}.
9+
* The separator is the <em>first</em> underscore ({@code _}); everything before it is the
10+
* {@code projectKey} and everything after (which may itself contain underscores) is the
11+
* {@code repoName}.
12+
* <ul>
13+
* <li>{@code projectKey} – the normalised (lowercase) Bitbucket project key that owns the catalog item's repository.</li>
14+
* <li>{@code repoName} – the Bitbucket repository slug of the catalog item.</li>
15+
* </ul>
16+
* Example: {@code myproject_my-component-repo}
17+
*/
18+
@Getter
19+
public class CatalogItemSlug {
20+
21+
public static final String SEPARATOR = "_";
22+
23+
private final String projectKey;
24+
private final String repoName;
25+
26+
private CatalogItemSlug(String projectKey, String repoName) {
27+
this.projectKey = projectKey;
28+
this.repoName = repoName;
29+
}
30+
31+
/**
32+
* Parses a raw composite slug into its two components.
33+
*
34+
* @param rawSlug the composite slug in the form {@code {project-key}_{repo-name}}
35+
* @return a {@link CatalogItemSlug} instance
36+
* @throws InvalidCatalogItemSlugException if the slug does not contain the separator
37+
* or either component is blank
38+
*/
39+
public static CatalogItemSlug parse(String rawSlug) {
40+
if (rawSlug == null || rawSlug.isBlank()) {
41+
throw new InvalidCatalogItemSlugException("Slug must not be blank.");
42+
}
43+
44+
int separatorIndex = rawSlug.indexOf(SEPARATOR);
45+
if (separatorIndex <= 0 || separatorIndex == rawSlug.length() - 1) {
46+
throw new InvalidCatalogItemSlugException(
47+
"Slug '%s' does not conform to expected format '{project-key}_{repo-name}'.".formatted(rawSlug));
48+
}
49+
50+
String projectKey = rawSlug.substring(0, separatorIndex).trim();
51+
String repoName = rawSlug.substring(separatorIndex + 1).trim();
52+
53+
if (projectKey.isBlank() || repoName.isBlank()) {
54+
throw new InvalidCatalogItemSlugException(
55+
"Slug '%s' contains blank project-key or repo-name component.".formatted(rawSlug));
56+
}
57+
58+
return new CatalogItemSlug(projectKey, repoName);
59+
}
60+
61+
/**
62+
* Normalises an arbitrary string to lowercase form so it can be
63+
* compared against the {@code projectKey} component of a composite slug.
64+
*
65+
* @param value the value to normalise
66+
* @return the normalised value (lowercase, spaces replaced by hyphens)
67+
*/
68+
public static String normalise(String value) {
69+
if (value == null) {
70+
return "";
71+
}
72+
return value.trim().toLowerCase().replace(" ", "-");
73+
}
74+
75+
@Override
76+
public String toString() {
77+
return projectKey + SEPARATOR + repoName;
78+
}
79+
}
80+
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.opendevstack.component_catalog.server.services.slug;
2+
3+
/**
4+
* Thrown when a composite catalog item slug cannot be parsed or is otherwise invalid.
5+
*/
6+
public class InvalidCatalogItemSlugException extends RuntimeException {
7+
8+
public InvalidCatalogItemSlugException(String message) {
9+
super(message);
10+
}
11+
}
12+

0 commit comments

Comments
 (0)