Skip to content

Commit 51bec77

Browse files
authored
Add slug into catalog items and add endpoint to find by it (#27)
* Add slug into catalog items and add endpoint to find by it * Improve get catalog by slug * Reduce cognitive complexity of findByCatalogItemSlug method * Refactor method to improve mantainability * Add missing line jump
1 parent f286ef6 commit 51bec77

File tree

10 files changed

+807
-0
lines changed

10 files changed

+807
-0
lines changed

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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import java.util.*;
1919
import java.util.stream.Stream;
2020

21+
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
22+
2123
import static org.opendevstack.component_catalog.server.mappers.MapperUtils.nullish;
2224
import static org.opendevstack.component_catalog.server.services.common.IdEncoderDecoder.nullableIdEncode;
2325

@@ -76,8 +78,14 @@ public CatalogItem asCatalogItem(CatalogItemEntityContext catalogItemEntityCtx,
7678
.projects(Collections.unmodifiableSet(new LinkedHashSet<>()))
7779
.build());
7880

81+
var catalogProject = catalogItemEntityCtx.getRepoCatalogItemPathAt().getProjectKey();
82+
var itemSlug = (catalogProject != null && !catalogProject.isBlank())
83+
? CatalogItemSlug.normalise(catalogProject) + CatalogItemSlug.SEPARATOR + catalogItemEntityCtx.getRepoCatalogItemPathAt().getRepoSlug()
84+
: null;
85+
7986
var catalogItem = CatalogItem.builder()
8087
.id(catalogItemEntityCtx.getId())
88+
.slug(itemSlug)
8189
.path(catalogItemEntityCtx.getPath())
8290
.title(catalogItemEntity.getMetadata().getName())
8391
.shortDescription(catalogItemEntity.getMetadata().getShortDescription())
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package org.opendevstack.component_catalog.server.services;
2+
3+
import org.opendevstack.component_catalog.server.services.catalog.CatalogServiceAdapter;
4+
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogEntityException;
5+
import org.opendevstack.component_catalog.server.services.catalog.entity.CatalogItemEntityContext;
6+
import org.opendevstack.component_catalog.server.services.exceptions.InvalidIdException;
7+
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
8+
import lombok.AllArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.stereotype.Service;
11+
12+
import java.util.Optional;
13+
14+
import static org.opendevstack.component_catalog.server.services.common.IdEncoderDecoder.idEncode;
15+
16+
/**
17+
* Domain service responsible for resolving a {@link CatalogItemSlug} to a {@link CatalogItemEntityContext}.
18+
* <p>
19+
* Resolution steps:
20+
* <ol>
21+
* <li>Load all catalogs from the marketplace (catalog-of-catalogs).</li>
22+
* <li>For each catalog, load its items and find the one whose normalised Bitbucket project key
23+
* matches the slug's {@code projectKey} component and whose repository slug matches
24+
* the slug's {@code repoName} component.</li>
25+
* </ol>
26+
*/
27+
@Service
28+
@AllArgsConstructor
29+
@Slf4j
30+
public class CatalogItemBySlugService {
31+
32+
private final CatalogsCollectionService catalogsCollectionService;
33+
private final CatalogEntitiesService catalogEntitiesService;
34+
private final CatalogServiceAdapter catalogServiceAdapter;
35+
private final BitbucketService bitbucketService;
36+
37+
/**
38+
* Resolves a {@link CatalogItemSlug} to the matching {@link CatalogItemEntityContext}.
39+
*
40+
* @param slug the parsed composite slug
41+
* @return an {@link Optional} with the matching context, or empty if not found
42+
* @throws InvalidIdException if any catalog or item id is structurally invalid
43+
* @throws InvalidCatalogEntityException if a catalog entity cannot be parsed
44+
*/
45+
public Optional<CatalogItemEntityContext> findByCatalogItemSlug(CatalogItemSlug slug)
46+
throws InvalidIdException, InvalidCatalogEntityException {
47+
48+
log.debug("Resolving catalog item by slug: '{}'", slug);
49+
50+
var catalogsCollection = catalogsCollectionService.getCatalogsCollection();
51+
52+
if (catalogsCollection.isEmpty()) {
53+
log.warn("No catalogs collection found while resolving slug '{}'", slug);
54+
return Optional.empty();
55+
}
56+
57+
var targets = catalogsCollection.get().getMetadata().getSpec().getTargets();
58+
if (targets == null || targets.length == 0) {
59+
log.warn("Catalogs collection has no targets while resolving slug '{}'", slug);
60+
return Optional.empty();
61+
}
62+
63+
for (var target : targets) {
64+
var catalogId = idEncode(target.getUrl());
65+
var catalogIdPathAt = catalogServiceAdapter.bitbucketPathAtFromId(catalogId);
66+
67+
// Optimisation: skip catalogs whose Bitbucket project key does not match the slug's project key,
68+
// avoiding unnecessary item loading. This relies on the assumption that catalog items live in the
69+
// same Bitbucket project as their catalog definition.
70+
if (!CatalogItemSlug.normalise(catalogIdPathAt.getProjectKey()).equals(slug.getProjectKey())) {
71+
continue;
72+
}
73+
74+
log.debug("Catalog target '{}' matches slug project key '{}', checking items...",
75+
target.getUrl(), slug.getProjectKey());
76+
77+
var match = findMatchingItemInCatalog(catalogId, slug, target.getUrl());
78+
if (match.isPresent()) {
79+
return match;
80+
}
81+
}
82+
83+
log.debug("No catalog item matched slug '{}'", slug);
84+
return Optional.empty();
85+
}
86+
87+
/**
88+
* Loads the catalog entity for {@code catalogId} and searches its targets for the item
89+
* matching {@code slug}. Returns the first match, or empty if none is found.
90+
*/
91+
private Optional<CatalogItemEntityContext> findMatchingItemInCatalog(String catalogId, CatalogItemSlug slug, String catalogUrl)
92+
throws InvalidIdException, InvalidCatalogEntityException {
93+
var slugCatalogEntity = catalogEntitiesService.getCatalogEntity(catalogId);
94+
if (slugCatalogEntity.isEmpty()) {
95+
return Optional.empty();
96+
}
97+
98+
// A slug uniquely identifies one item. Multiple matches (e.g. the same repo referenced
99+
// in more than one catalog entry) are not expected; we return the first one found.
100+
for (var itemTarget : slugCatalogEntity.get().getMetadata().getSpec().getTargets()) {
101+
var itemPathAt = bitbucketService.pathAtBuilder()
102+
.rawUrl(itemTarget.getUrl())
103+
.build();
104+
if (CatalogItemSlug.normalise(itemPathAt.getProjectKey()).equals(slug.getProjectKey())
105+
&& itemPathAt.getRepoSlug().equalsIgnoreCase(slug.getRepoName())) {
106+
var itemId = idEncode(itemPathAt.getPathAt());
107+
log.debug("Resolved slug '{}' to item id '{}' in catalog target '{}'", slug, itemId, catalogUrl);
108+
return catalogEntitiesService.getCatalogItemEntity(itemId);
109+
}
110+
}
111+
return Optional.empty();
112+
}
113+
114+
}
115+

0 commit comments

Comments
 (0)