Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions openapi/openapi-component_catalog-v1.0.0.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,72 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
/catalog-items/slug/{slug}:
get:
tags:
- CatalogItems
summary: Returns the CatalogItem associated to the provided slug.
description: >
Returns the CatalogItem identified by a composite slug with format `{project-key}_{catalog-item-repository-name}`.<br/>
The separator is the first underscore (`_`); everything after it (the repo name) may itself contain underscores.<br/>
The project-key is the normalised (lowercase) Bitbucket project key that owns the item's repository.
The catalog-item-repository-name is matched against the Bitbucket repository slug of the item.<br/>
Returns 404 if no catalog item matches the provided slug.
operationId: getCatalogItemBySlug
parameters:
- name: slug
in: path
description: >
Composite slug with format `{project-key}_{catalog-item-repository-name}`.
The separator is the first underscore; the repo name may contain additional underscores.
Example: `myproject_my-component-repo`
required: true
schema:
type: string
example: 'myproject_my-component-repo'
responses:
"200":
description: The CatalogItem.
content:
application/json:
schema:
$ref: '#/components/schemas/CatalogItem'
"400":
description: Invalid or malformed slug provided.
content:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
"401":
description: Invalid client token on the request.
content:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
"403":
description: Insufficient permissions for the client to access the resource.
content:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
"404":
description: No CatalogItem associated to the provided slug.
content:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
"422":
description: Invalid CatalogItem associated to the provided slug.
content:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
"500":
description: Server error.
content:
application/json:
schema:
$ref: '#/components/schemas/RestErrorMessage'
/catalog-items:
get:
tags:
Expand Down Expand Up @@ -826,6 +892,12 @@ components:
id:
type: string
example: 'aSdFam...yCg=='
slug:
type: string
description: >
Composite slug computed from the normalised Bitbucket project key and the repository slug of the item,
in the format `{project-key}_{repo-name}`. Calculated at mapping time; not retrieved from Bitbucket.
example: 'myproject_my-component-repo'
path:
type: string
example: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master
Expand Down Expand Up @@ -875,6 +947,7 @@ components:
- date
example:
id: aSdFam...yCg==
slug: myproject_some-repo
path: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master
title: An item title
shortDescription: This is a short description for the item
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogEntityException;
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogItemEntityException;
import org.opendevstack.component_catalog.server.services.exceptions.InvalidIdException;
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
import org.opendevstack.component_catalog.server.services.slug.InvalidCatalogItemSlugException;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
Expand Down Expand Up @@ -115,6 +117,23 @@ public ResponseEntity<CatalogItem> getCatalogItemByIdForProjectKey(String id, St
}
}

@Override
public ResponseEntity<CatalogItem> getCatalogItemBySlug(String slug) {
log.debug("User '{}' requested catalog item by slug: '{}'", authInfo.getCurrentPrincipalName(), slug);
try {
var catalogItemSlug = CatalogItemSlug.parse(slug);
var catItem = catalogItemsApiFacade.fetchCatalogItemBySlug(catalogItemSlug);
if (catItem == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(catItem);
} catch (InvalidCatalogItemSlugException e) {
throw new BadRequestException("Invalid slug format: " + e.getMessage());
} catch (InvalidIdException | InvalidCatalogEntityException e) {
throw new BadRequestException("Could not resolve catalog item for slug: " + slug);
}
}


}

Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
import org.opendevstack.component_catalog.server.model.CatalogItemRestriction;
import org.opendevstack.component_catalog.server.security.AuthorizationInfo;
import org.opendevstack.component_catalog.server.services.CatalogEntitiesService;
import org.opendevstack.component_catalog.server.services.CatalogItemBySlugService;
import org.opendevstack.component_catalog.server.services.ProjectsInfoService;
import org.opendevstack.component_catalog.server.services.UserActionsEntitiesService;
import org.opendevstack.component_catalog.server.services.catalog.CatalogEntityPermissionEnum;
import org.opendevstack.component_catalog.server.services.catalog.CatalogServiceAdapter;
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogEntityException;
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogItemEntityException;
import org.opendevstack.component_catalog.server.services.exceptions.InvalidIdException;
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
import org.springframework.stereotype.Component;

import java.util.Collections;
Expand All @@ -36,6 +38,7 @@ public class CatalogItemsApiFacade {
private final ProjectsInfoService projectsInfoService;
private final CatalogEntitiesService catalogEntitiesService;
private final UserActionsEntitiesService userActionsEntitiesService;
private final CatalogItemBySlugService catalogItemBySlugService;

public CatalogItem asCatalogItem(CatalogRequestParams catalogRequestParams) {
var clusters = getClusters(catalogRequestParams);
Expand Down Expand Up @@ -116,6 +119,28 @@ public CatalogItem fetchCatalogItem(CatalogRequestParams catalogRequestParams)
.orElse(null);
}

public CatalogItem fetchCatalogItemBySlug(CatalogItemSlug slug)
throws InvalidIdException, InvalidCatalogEntityException {
var maybeItemEntityCtx = catalogItemBySlugService.findByCatalogItemSlug(slug);

if (maybeItemEntityCtx.isEmpty()) {
return null;
}

var itemEntityCtx = maybeItemEntityCtx.get();
var principalPermissions = currentPrincipalCatalogPermissions(itemEntityCtx.getId());
var userActionsEntity = userActionsEntitiesService.getDefaultUserActionsEntity();

return asCatalogItem(
CatalogRequestParams.builder()
.catalogItemEntityContext(itemEntityCtx)
.catalogItemId(itemEntityCtx.getId())
.userActionsEntity(userActionsEntity)
.permissions(principalPermissions)
.build()
);
}

protected boolean filterByProject(CatalogItem item, String projectKey) {
var projects = Optional.ofNullable(item.getRestrictions())
.map(CatalogItemRestriction::getProjects)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import java.util.*;
import java.util.stream.Stream;

import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;

import static org.opendevstack.component_catalog.server.mappers.MapperUtils.nullish;
import static org.opendevstack.component_catalog.server.services.common.IdEncoderDecoder.nullableIdEncode;

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

var catalogProject = catalogItemEntityCtx.getRepoCatalogItemPathAt().getProjectKey();
var itemSlug = (catalogProject != null && !catalogProject.isBlank())
? CatalogItemSlug.normalise(catalogProject) + CatalogItemSlug.SEPARATOR + catalogItemEntityCtx.getRepoCatalogItemPathAt().getRepoSlug()
: null;

var catalogItem = CatalogItem.builder()
.id(catalogItemEntityCtx.getId())
.slug(itemSlug)
.path(catalogItemEntityCtx.getPath())
.title(catalogItemEntity.getMetadata().getName())
.shortDescription(catalogItemEntity.getMetadata().getShortDescription())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package org.opendevstack.component_catalog.server.services;

import org.opendevstack.component_catalog.server.services.catalog.CatalogServiceAdapter;
import org.opendevstack.component_catalog.server.services.catalog.InvalidCatalogEntityException;
import org.opendevstack.component_catalog.server.services.catalog.entity.CatalogItemEntityContext;
import org.opendevstack.component_catalog.server.services.exceptions.InvalidIdException;
import org.opendevstack.component_catalog.server.services.slug.CatalogItemSlug;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Optional;

import static org.opendevstack.component_catalog.server.services.common.IdEncoderDecoder.idEncode;

/**
* Domain service responsible for resolving a {@link CatalogItemSlug} to a {@link CatalogItemEntityContext}.
* <p>
* Resolution steps:
* <ol>
* <li>Load all catalogs from the marketplace (catalog-of-catalogs).</li>
* <li>For each catalog, load its items and find the one whose normalised Bitbucket project key
* matches the slug's {@code projectKey} component and whose repository slug matches
* the slug's {@code repoName} component.</li>
* </ol>
*/
@Service
@AllArgsConstructor
@Slf4j
public class CatalogItemBySlugService {

private final CatalogsCollectionService catalogsCollectionService;
private final CatalogEntitiesService catalogEntitiesService;
private final CatalogServiceAdapter catalogServiceAdapter;
private final BitbucketService bitbucketService;

/**
* Resolves a {@link CatalogItemSlug} to the matching {@link CatalogItemEntityContext}.
*
* @param slug the parsed composite slug
* @return an {@link Optional} with the matching context, or empty if not found
* @throws InvalidIdException if any catalog or item id is structurally invalid
* @throws InvalidCatalogEntityException if a catalog entity cannot be parsed
*/
public Optional<CatalogItemEntityContext> findByCatalogItemSlug(CatalogItemSlug slug)
throws InvalidIdException, InvalidCatalogEntityException {

log.debug("Resolving catalog item by slug: '{}'", slug);

var catalogsCollection = catalogsCollectionService.getCatalogsCollection();

if (catalogsCollection.isEmpty()) {
log.warn("No catalogs collection found while resolving slug '{}'", slug);
return Optional.empty();
}

var targets = catalogsCollection.get().getMetadata().getSpec().getTargets();
if (targets == null || targets.length == 0) {
log.warn("Catalogs collection has no targets while resolving slug '{}'", slug);
return Optional.empty();
}

for (var target : targets) {
var catalogId = idEncode(target.getUrl());
var catalogIdPathAt = catalogServiceAdapter.bitbucketPathAtFromId(catalogId);

// Optimisation: skip catalogs whose Bitbucket project key does not match the slug's project key,
// avoiding unnecessary item loading. This relies on the assumption that catalog items live in the
// same Bitbucket project as their catalog definition.
if (!CatalogItemSlug.normalise(catalogIdPathAt.getProjectKey()).equals(slug.getProjectKey())) {
continue;
}

log.debug("Catalog target '{}' matches slug project key '{}', checking items...",
target.getUrl(), slug.getProjectKey());

var match = findMatchingItemInCatalog(catalogId, slug, target.getUrl());
if (match.isPresent()) {
return match;
}
}

log.debug("No catalog item matched slug '{}'", slug);
return Optional.empty();
}

/**
* Loads the catalog entity for {@code catalogId} and searches its targets for the item
* matching {@code slug}. Returns the first match, or empty if none is found.
*/
private Optional<CatalogItemEntityContext> findMatchingItemInCatalog(String catalogId, CatalogItemSlug slug, String catalogUrl)
throws InvalidIdException, InvalidCatalogEntityException {
var slugCatalogEntity = catalogEntitiesService.getCatalogEntity(catalogId);
if (slugCatalogEntity.isEmpty()) {
return Optional.empty();
}

// A slug uniquely identifies one item. Multiple matches (e.g. the same repo referenced
// in more than one catalog entry) are not expected; we return the first one found.
for (var itemTarget : slugCatalogEntity.get().getMetadata().getSpec().getTargets()) {
var itemPathAt = bitbucketService.pathAtBuilder()
.rawUrl(itemTarget.getUrl())
.build();
if (CatalogItemSlug.normalise(itemPathAt.getProjectKey()).equals(slug.getProjectKey())
&& itemPathAt.getRepoSlug().equalsIgnoreCase(slug.getRepoName())) {
var itemId = idEncode(itemPathAt.getPathAt());
log.debug("Resolved slug '{}' to item id '{}' in catalog target '{}'", slug, itemId, catalogUrl);
return catalogEntitiesService.getCatalogItemEntity(itemId);
}
}
return Optional.empty();
}

}

Loading
Loading