From b4e82492b90db705af35fbfb6f20e460e6946f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 12 Feb 2026 14:26:14 +0100 Subject: [PATCH] add support for location queries in the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/metal-trains-smell-1.md | 5 + .changeset/metal-trains-smell-2.md | 7 + packages/catalog-client/package.json | 1 + .../catalog-client/report-testUtils.api.md | 11 + packages/catalog-client/report.api.md | 48 ++++ .../catalog-client/src/CatalogClient.test.ts | 178 +++++++++++++ packages/catalog-client/src/CatalogClient.ts | 60 ++++- packages/catalog-client/src/constants.ts | 1 + .../openapi/generated/apis/Api.client.ts | 33 +++ .../generated/models/EntityPredicate.model.ts | 37 +++ .../models/EntityPredicateAll.model.ts | 29 +++ .../models/EntityPredicateAny.model.ts | 29 +++ .../models/EntityPredicateExists.model.ts | 27 ++ .../models/EntityPredicateHasPrefix.model.ts | 27 ++ .../models/EntityPredicateIn.model.ts | 29 +++ .../models/EntityPredicateInInInner.model.ts | 24 ++ .../models/EntityPredicateNot.model.ts | 29 +++ .../models/EntityPredicateValue.model.ts | 35 +++ .../GetLocationsByQueryRequest.model.ts | 30 +++ .../models/LocationsQueryResponse.model.ts | 34 +++ .../LocationsQueryResponsePageInfo.model.ts | 29 +++ .../schema/openapi/generated/models/index.ts | 12 + .../src/testUtils/InMemoryCatalogClient.ts | 15 ++ packages/catalog-client/src/types/api.ts | 98 +++++++ packages/catalog-client/src/types/index.ts | 4 + packages/catalog-client/src/utils.ts | 3 +- plugins/catalog-backend/package.json | 4 +- .../providers/DefaultLocationStore.test.ts | 241 ++++++++++++++++++ .../src/providers/DefaultLocationStore.ts | 203 ++++++++++++++- .../catalog-backend/src/schema/openapi.yaml | 136 ++++++++++ .../openapi/generated/apis/Api.server.ts | 11 + .../generated/models/EntityPredicate.model.ts | 37 +++ .../models/EntityPredicateAll.model.ts | 29 +++ .../models/EntityPredicateAny.model.ts | 29 +++ .../models/EntityPredicateExists.model.ts | 27 ++ .../models/EntityPredicateHasPrefix.model.ts | 27 ++ .../models/EntityPredicateIn.model.ts | 29 +++ .../models/EntityPredicateInInInner.model.ts | 24 ++ .../models/EntityPredicateNot.model.ts | 29 +++ .../models/EntityPredicateValue.model.ts | 35 +++ .../GetLocationsByQueryRequest.model.ts | 30 +++ .../models/LocationsQueryResponse.model.ts | 34 +++ .../LocationsQueryResponsePageInfo.model.ts | 29 +++ .../schema/openapi/generated/models/index.ts | 12 + .../src/schema/openapi/generated/router.ts | 212 +++++++++++++++ .../service/AuthorizedLocationService.test.ts | 1 + .../src/service/AuthorizedLocationService.ts | 21 ++ .../service/DefaultLocationService.test.ts | 17 ++ .../src/service/DefaultLocationService.ts | 16 ++ .../src/service/createRouter.test.ts | 185 ++++++++++++++ .../src/service/createRouter.ts | 48 ++++ .../request/parseLocationQuery.test.ts | 199 +++++++++++++++ .../src/service/request/parseLocationQuery.ts | 88 +++++++ plugins/catalog-backend/src/service/types.ts | 12 + plugins/catalog-node/report-testUtils.api.md | 13 + plugins/catalog-node/report.api.md | 13 + plugins/catalog-node/src/catalogService.ts | 33 +++ .../src/testUtils/catalogServiceMock.ts | 2 + plugins/catalog-node/src/testUtils/types.ts | 13 + .../src/testUtils/catalogApiMock.ts | 2 + yarn.lock | 3 + 61 files changed, 2674 insertions(+), 5 deletions(-) create mode 100644 .changeset/metal-trains-smell-1.md create mode 100644 .changeset/metal-trains-smell-2.md create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts create mode 100644 plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts create mode 100644 plugins/catalog-backend/src/service/request/parseLocationQuery.ts diff --git a/.changeset/metal-trains-smell-1.md b/.changeset/metal-trains-smell-1.md new file mode 100644 index 0000000000..4a4db1dd6e --- /dev/null +++ b/.changeset/metal-trains-smell-1.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Implemented the `POST /locations/by-query` endpoints that allows paginated, filtered location queries diff --git a/.changeset/metal-trains-smell-2.md b/.changeset/metal-trains-smell-2.md new file mode 100644 index 0000000000..d3bbf64820 --- /dev/null +++ b/.changeset/metal-trains-smell-2.md @@ -0,0 +1,7 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog-node': minor +--- + +Implemented support for the new `queryLocations` and `streamLocations` that allow paginated/streamed and filtered location queries diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 00f73e338a..3ebf43762d 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -50,6 +50,7 @@ "dependencies": { "@backstage/catalog-model": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "cross-fetch": "^4.0.0", "lodash": "^4.17.21", "uri-template": "^2.0.0" diff --git a/packages/catalog-client/report-testUtils.api.md b/packages/catalog-client/report-testUtils.api.md index b96c4f01ca..f53abba669 100644 --- a/packages/catalog-client/report-testUtils.api.md +++ b/packages/catalog-client/report-testUtils.api.md @@ -22,6 +22,9 @@ import { GetLocationsResponse } from '@backstage/catalog-client'; import { Location as Location_2 } from '@backstage/catalog-client'; import { QueryEntitiesRequest } from '@backstage/catalog-client'; import { QueryEntitiesResponse } from '@backstage/catalog-client'; +import { QueryLocationsInitialRequest } from '@backstage/catalog-client'; +import { QueryLocationsRequest } from '@backstage/catalog-client'; +import { QueryLocationsResponse } from '@backstage/catalog-client'; import { StreamEntitiesRequest } from '@backstage/catalog-client'; import { ValidateEntityResponse } from '@backstage/catalog-client'; @@ -65,6 +68,10 @@ export class InMemoryCatalogClient implements CatalogApi { // (undocumented) queryEntities(request?: QueryEntitiesRequest): Promise; // (undocumented) + queryLocations( + _request?: QueryLocationsRequest, + ): Promise; + // (undocumented) refreshEntity(_entityRef: string): Promise; // (undocumented) removeEntityByUid(uid: string): Promise; @@ -73,6 +80,10 @@ export class InMemoryCatalogClient implements CatalogApi { // (undocumented) streamEntities(request?: StreamEntitiesRequest): AsyncIterable; // (undocumented) + streamLocations( + _request?: QueryLocationsInitialRequest, + ): AsyncIterable; + // (undocumented) validateEntity( _entity: Entity, _locationRef: string, diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 452d788ba9..58fc4bc8a1 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -7,6 +7,7 @@ import type { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common'; import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { SerializedError } from '@backstage/errors'; // @public @@ -76,6 +77,10 @@ export interface CatalogApi { request?: QueryEntitiesRequest, options?: CatalogRequestOptions, ): Promise; + queryLocations( + request?: QueryLocationsRequest, + options?: CatalogRequestOptions, + ): Promise; refreshEntity( entityRef: string, options?: CatalogRequestOptions, @@ -92,6 +97,10 @@ export interface CatalogApi { request?: StreamEntitiesRequest, options?: CatalogRequestOptions, ): AsyncIterable; + streamLocations( + request?: QueryLocationsInitialRequest, + options?: CatalogRequestOptions, + ): AsyncIterable; validateEntity( entity: Entity, locationRef: string, @@ -162,6 +171,10 @@ export class CatalogClient implements CatalogApi { request?: QueryEntitiesRequest, options?: CatalogRequestOptions, ): Promise; + queryLocations( + request?: QueryLocationsRequest, + options?: CatalogRequestOptions, + ): Promise; refreshEntity( entityRef: string, options?: CatalogRequestOptions, @@ -178,6 +191,10 @@ export class CatalogClient implements CatalogApi { request?: StreamEntitiesRequest, options?: CatalogRequestOptions, ): AsyncIterable; + streamLocations( + request?: QueryLocationsInitialRequest, + options?: CatalogRequestOptions, + ): AsyncIterable; validateEntity( entity: Entity, locationRef: string, @@ -325,6 +342,37 @@ export type QueryEntitiesResponse = { }; }; +// @public +export interface QueryLocationsCursorRequest { + // (undocumented) + cursor: string; +} + +// @public +export interface QueryLocationsInitialRequest { + // (undocumented) + limit?: number; + // (undocumented) + query?: FilterPredicate; +} + +// @public +export type QueryLocationsRequest = + | QueryLocationsInitialRequest + | QueryLocationsCursorRequest; + +// @public +export interface QueryLocationsResponse { + // (undocumented) + items: Location_2[]; + // (undocumented) + pageInfo: { + nextCursor?: string; + }; + // (undocumented) + totalItems: number; +} + // @public export type StreamEntitiesRequest = Omit< QueryEntitiesInitialRequest, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 8307f77cdd..d62323852e 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -741,6 +741,184 @@ describe('CatalogClient', () => { }); }); + describe('queryLocations', () => { + it('should fetch locations from correct endpoint', async () => { + const defaultResponse = { + items: [ + { id: '1', type: 'url', target: 'https://example.com/1' }, + { id: '2', type: 'url', target: 'https://example.com/2' }, + ], + totalItems: 3, + pageInfo: { + nextCursor: 'next', + }, + }; + + server.use( + rest.post(`${mockBaseUrl}/locations/by-query`, (_, res, ctx) => { + return res(ctx.json(defaultResponse)); + }), + ); + + const response = await client.queryLocations({}, { token }); + expect(response).toEqual(defaultResponse); + }); + + it('should send request body correctly', async () => { + expect.assertions(2); + + server.use( + rest.post( + `${mockBaseUrl}/locations/by-query`, + async (req, res, ctx) => { + const body = await req.json(); + expect(body).toEqual({ + limit: 50, + query: { type: 'url' }, + }); + return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} })); + }, + ), + ); + + const response = await client.queryLocations( + { + limit: 50, + query: { type: 'url' }, + }, + { token }, + ); + + expect(response.items).toEqual([]); + }); + + it('should handle cursor-based pagination', async () => { + expect.assertions(3); + + server.use( + rest.post( + `${mockBaseUrl}/locations/by-query`, + async (req, res, ctx) => { + const body = await req.json(); + expect(body).toEqual({ cursor: 'mycursor' }); + return res( + ctx.json({ + items: [ + { id: '3', type: 'url', target: 'https://example.com/3' }, + ], + totalItems: 10, + pageInfo: { + nextCursor: 'nextcursor', + }, + }), + ); + }, + ), + ); + + const response = await client.queryLocations({ cursor: 'mycursor' }); + + expect(response.items).toHaveLength(1); + expect(response.pageInfo.nextCursor).toBe('nextcursor'); + }); + + it('should handle errors', async () => { + server.use( + rest.post(`${mockBaseUrl}/locations/by-query`, (_req, res, ctx) => + res(ctx.status(401)), + ), + ); + + await expect(() => client.queryLocations()).rejects.toThrow( + /Request failed with 401 Unauthorized/, + ); + }); + + it('should forward token', async () => { + expect.assertions(1); + + server.use( + rest.post(`${mockBaseUrl}/locations/by-query`, (req, res, ctx) => { + expect(req.headers.get('authorization')).toBe(`Bearer ${token}`); + return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} })); + }), + ); + + await client.queryLocations({}, { token }); + }); + }); + + describe('streamLocations', () => { + it('should stream locations through pagination', async () => { + const firstPage = { + items: [ + { id: '1', type: 'url', target: 'https://example.com/1' }, + { id: '2', type: 'url', target: 'https://example.com/2' }, + ], + totalItems: 3, + pageInfo: { nextCursor: 'cursor2' }, + }; + const secondPage = { + items: [{ id: '3', type: 'url', target: 'https://example.com/3' }], + totalItems: 3, + pageInfo: {}, + }; + + server.use( + rest.post( + `${mockBaseUrl}/locations/by-query`, + async (req, res, ctx) => { + const body = await req.json(); + if (body.cursor === 'cursor2') { + return res(ctx.json(secondPage)); + } + return res(ctx.json(firstPage)); + }, + ), + ); + + const stream = client.streamLocations({}, { token }); + const results = []; + for await (const page of stream) { + results.push(page); + } + + expect(results).toEqual([firstPage.items, secondPage.items]); + }); + + it('should handle empty results', async () => { + server.use( + rest.post(`${mockBaseUrl}/locations/by-query`, (_req, res, ctx) => { + return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} })); + }), + ); + + const stream = client.streamLocations({}, { token }); + const results = []; + for await (const page of stream) { + results.push(page); + } + + expect(results).toEqual([]); + }); + + it('should handle errors', async () => { + server.use( + rest.post(`${mockBaseUrl}/locations/by-query`, (_req, res, ctx) => + res(ctx.status(401)), + ), + ); + + const stream = client.streamLocations({}, { token }); + await expect(async () => { + const results = []; + for await (const page of stream) { + results.push(page); + } + }).rejects.toThrow(/Request failed with 401 Unauthorized/); + }); + }); + describe('getLocationById', () => { const defaultResponse = { data: { diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 07e6ad2946..a537e8b3a5 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -40,16 +40,26 @@ import { Location, QueryEntitiesRequest, QueryEntitiesResponse, + QueryLocationsInitialRequest, + QueryLocationsRequest, + QueryLocationsResponse, StreamEntitiesRequest, ValidateEntityResponse, } from './types/api'; import { isQueryEntitiesInitialRequest, splitRefsIntoChunks } from './utils'; -import { DefaultApiClient, TypedResponse } from './schema/openapi'; +import { + DefaultApiClient, + GetLocationsByQueryRequest, + TypedResponse, +} from './schema/openapi'; import type { AnalyzeLocationRequest, AnalyzeLocationResponse, } from '@backstage/plugin-catalog-common'; -import { DEFAULT_STREAM_ENTITIES_LIMIT } from './constants.ts'; +import { + DEFAULT_STREAM_ENTITIES_LIMIT, + DEFAULT_STREAM_LOCATIONS_LIMIT, +} from './constants'; /** * A frontend and backend compatible client for communicating with the Backstage @@ -97,6 +107,52 @@ export class CatalogClient implements CatalogApi { }; } + /** + * {@inheritdoc CatalogApi.queryLocations} + */ + async queryLocations( + request?: QueryLocationsRequest, + options?: CatalogRequestOptions, + ): Promise { + const res = await this.requestRequired( + await this.apiClient.getLocationsByQuery( + { body: (request ?? {}) as unknown as GetLocationsByQueryRequest }, + options, + ), + ); + return { + items: res.items, + totalItems: res.totalItems, + pageInfo: res.pageInfo, + }; + } + + /** + * {@inheritdoc CatalogApi.streamLocations} + */ + async *streamLocations( + request?: QueryLocationsInitialRequest, + options?: CatalogRequestOptions, + ): AsyncIterable { + let response = await this.queryLocations( + { limit: DEFAULT_STREAM_LOCATIONS_LIMIT, ...request }, + options, + ); + if (response.items.length) { + yield response.items; + } + + while (response.pageInfo.nextCursor) { + response = await this.queryLocations( + { cursor: response.pageInfo.nextCursor }, + options, + ); + if (response.items.length) { + yield response.items; + } + } + } + /** * {@inheritdoc CatalogApi.getLocationById} */ diff --git a/packages/catalog-client/src/constants.ts b/packages/catalog-client/src/constants.ts index dd8d0530df..4764443b39 100644 --- a/packages/catalog-client/src/constants.ts +++ b/packages/catalog-client/src/constants.ts @@ -15,3 +15,4 @@ */ export const DEFAULT_STREAM_ENTITIES_LIMIT = 500; +export const DEFAULT_STREAM_LOCATIONS_LIMIT = 500; diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 0784dae5d7..4530cd795c 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -36,7 +36,9 @@ import { AnalyzeLocationResponse } from '../models/AnalyzeLocationResponse.model import { CreateLocation201Response } from '../models/CreateLocation201Response.model'; import { CreateLocationRequest } from '../models/CreateLocationRequest.model'; import { GetLocations200ResponseInner } from '../models/GetLocations200ResponseInner.model'; +import { GetLocationsByQueryRequest } from '../models/GetLocationsByQueryRequest.model'; import { Location } from '../models/Location.model'; +import { LocationsQueryResponse } from '../models/LocationsQueryResponse.model'; /** * Wraps the Response type to convey a type on the json call. @@ -194,6 +196,12 @@ export type GetLocationByEntity = { * @public */ export type GetLocations = {}; +/** + * @public + */ +export type GetLocationsByQuery = { + body: GetLocationsByQueryRequest; +}; /** * @public @@ -648,4 +656,29 @@ export class DefaultApiClient { method: 'GET', }); } + + /** + * Query for locations + * @param getLocationsByQueryRequest - + */ + public async getLocationsByQuery( + // @ts-ignore + request: GetLocationsByQuery, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/locations/by-query`; + + const uri = parser.parse(uriTemplate).expand({}); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify(request.body), + }); + } } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts new file mode 100644 index 0000000000..b27f3d1c3e --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; +import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; +import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; +import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; + +/** + * A predicate-based filter supporting logical operators. + * @public + */ +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts new file mode 100644 index 0000000000..e21f98abbf --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * All conditions must match (AND logic) + * @public + */ +export interface EntityPredicateAll { + $all: Array; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts new file mode 100644 index 0000000000..4e6cb62d01 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * At least one condition must match (OR logic) + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts new file mode 100644 index 0000000000..7800fcd20c --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Check if field exists + * @public + */ +export interface EntityPredicateExists { + $exists: boolean; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts new file mode 100644 index 0000000000..af0640e96a --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Match a string that starts with the given value + * @public + */ +export interface EntityPredicateHasPrefix { + $hasPrefix: string; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts new file mode 100644 index 0000000000..5557d3fef9 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; + +/** + * Match any value in array + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts new file mode 100644 index 0000000000..2b4e3d7c2f --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type EntityPredicateInInInner = boolean | number | string; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts new file mode 100644 index 0000000000..a5fc06f39b --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * Negates the condition + * @public + */ +export interface EntityPredicateNot { + $not: EntityPredicate; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts new file mode 100644 index 0000000000..5627155073 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; +import { EntityPredicateHasPrefix } from '../models/EntityPredicateHasPrefix.model'; +import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; + +/** + * Value for a field predicate + * @public + */ +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateHasPrefix + | EntityPredicateIn + | boolean + | number + | string; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts new file mode 100644 index 0000000000..699c7a71fd --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface GetLocationsByQueryRequest { + cursor?: string; + limit?: number; + query?: EntityPredicate; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts new file mode 100644 index 0000000000..c231fccc57 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { Location } from '../models/Location.model'; +import { LocationsQueryResponsePageInfo } from '../models/LocationsQueryResponsePageInfo.model'; + +/** + * @public + */ +export interface LocationsQueryResponse { + /** + * The list of locations paginated by a specific query. + */ + items: Array; + totalItems: number; + pageInfo: LocationsQueryResponsePageInfo; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts new file mode 100644 index 0000000000..5ced68ec62 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface LocationsQueryResponsePageInfo { + /** + * The cursor for the next batch of locations. + */ + nextCursor?: string; +} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/index.ts b/packages/catalog-client/src/schema/openapi/generated/models/index.ts index f57470377b..6de4e7da9e 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -31,15 +31,27 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; +export * from '../models/EntityPredicate.model'; +export * from '../models/EntityPredicateAll.model'; +export * from '../models/EntityPredicateAny.model'; +export * from '../models/EntityPredicateExists.model'; +export * from '../models/EntityPredicateHasPrefix.model'; +export * from '../models/EntityPredicateIn.model'; +export * from '../models/EntityPredicateInInInner.model'; +export * from '../models/EntityPredicateNot.model'; +export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; export * from '../models/ErrorResponse.model'; export * from '../models/GetEntitiesByRefsRequest.model'; export * from '../models/GetLocations200ResponseInner.model'; +export * from '../models/GetLocationsByQueryRequest.model'; export * from '../models/Location.model'; export * from '../models/LocationInput.model'; export * from '../models/LocationSpec.model'; +export * from '../models/LocationsQueryResponse.model'; +export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; export * from '../models/RecursivePartialEntity.model'; diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index a769f1996d..e64706ff8d 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -33,6 +33,9 @@ import { Location, QueryEntitiesRequest, QueryEntitiesResponse, + QueryLocationsInitialRequest, + QueryLocationsRequest, + QueryLocationsResponse, StreamEntitiesRequest, ValidateEntityResponse, } from '@backstage/catalog-client'; @@ -528,6 +531,18 @@ export class InMemoryCatalogClient implements CatalogApi { throw new NotImplementedError('Method not implemented.'); } + async queryLocations( + _request?: QueryLocationsRequest, + ): Promise { + throw new NotImplementedError('Method not implemented.'); + } + + async *streamLocations( + _request?: QueryLocationsInitialRequest, + ): AsyncIterable { + throw new NotImplementedError('Method not implemented.'); + } + async getLocationById(_id: string): Promise { throw new NotImplementedError('Method not implemented.'); } diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 014baba613..c1f75f2c79 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -20,6 +20,7 @@ import type { AnalyzeLocationRequest, AnalyzeLocationResponse, } from '@backstage/plugin-catalog-common'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * This symbol can be used in place of a value when passed to filters in e.g. @@ -480,6 +481,47 @@ export type StreamEntitiesRequest = Omit< pageSize?: number; }; +/** + * The request type for {@link CatalogApi.queryLocations}. + * + * @public + */ +export type QueryLocationsRequest = + | QueryLocationsInitialRequest + | QueryLocationsCursorRequest; + +/** + * The request type for initial requests to {@link CatalogApi.queryLocations}. + * + * @public + */ +export interface QueryLocationsInitialRequest { + limit?: number; + query?: FilterPredicate; +} + +/** + * The request type for cursor requests to {@link CatalogApi.queryLocations}. + * + * @public + */ +export interface QueryLocationsCursorRequest { + cursor: string; +} + +/** + * The response type for {@link CatalogApi.queryLocations}. + * + * @public + */ +export interface QueryLocationsResponse { + items: Location[]; + totalItems: number; + pageInfo: { + nextCursor?: string; + }; +} + /** * A client for interacting with the Backstage software catalog through its API. * @@ -629,6 +671,62 @@ export interface CatalogApi { options?: CatalogRequestOptions, ): Promise; + /** + * Gets paginated locations from the catalog. + * + * @remarks + * + * @example + * + * ``` + * const response = await catalogClient.queryLocations({ + * limit: 20, + * query: { + * type: 'url', + * target: { $hasPrefix: 'https://github.com/backstage/backstage' }, + * }, + * }); + * ``` + * + * This will match all locations of type `url` having a target starting + * with `https://github.com/backstage/backstage`. + * + * The response will contain a maximum of 20 locations. In case + * more than 20 locations exist, the response will contain a `nextCursor` + * property that can be used to fetch the next batch of locations. + * + * ``` + * const secondBatchResponse = await catalogClient + * .queryLocations({ cursor: response.nextCursor }); + * ``` + * + * `secondBatchResponse` will contain the next batch of (maximum) 20 locations, + * again together with a `nextCursor` property if there is more data to fetch. + * + * @public + * + * @param request - Request parameters + * @param options - Additional options + */ + queryLocations( + request?: QueryLocationsRequest, + options?: CatalogRequestOptions, + ): Promise; + + /** + * Asynchronously streams locations from the catalog. Uses `queryLocations` + * to fetch locations in batches, and yields them one page at a time. + * + * @public + * + * @param request - Request parameters + * @param options - Additional options + */ + streamLocations( + request?: QueryLocationsInitialRequest, + options?: CatalogRequestOptions, + ): AsyncIterable; + /** * Gets a registered location by its ID. * diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 5b483d9b29..b280f1874f 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -39,5 +39,9 @@ export type { QueryEntitiesRequest, QueryEntitiesResponse, StreamEntitiesRequest, + QueryLocationsRequest, + QueryLocationsCursorRequest, + QueryLocationsInitialRequest, + QueryLocationsResponse, } from './api'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index 63afec668a..a16cf53db1 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -17,10 +17,11 @@ import { QueryEntitiesCursorRequest, QueryEntitiesInitialRequest, + QueryEntitiesRequest, } from './types/api'; export function isQueryEntitiesInitialRequest( - request: QueryEntitiesInitialRequest, + request: QueryEntitiesRequest, ): request is QueryEntitiesInitialRequest { return !(request as QueryEntitiesCursorRequest).cursor; } diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 5dd97896fc..c537ffae84 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -70,6 +70,7 @@ "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", @@ -94,7 +95,8 @@ "uuid": "^11.0.0", "yaml": "^2.0.0", "yn": "^4.0.0", - "zod": "^3.25.76" + "zod": "^3.25.76", + "zod-validation-error": "^4.0.2" }, "devDependencies": { "@backstage/backend-defaults": "workspace:^", diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts index e1ee5568ae..ac57a8d075 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts @@ -713,4 +713,245 @@ describe('DefaultLocationStore', () => { }); }); }); + + describe('queryLocations', () => { + const l1 = { + id: '00000000-0000-0000-0000-000000000001', + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/packages/catalog-model/catalog-info.yaml', + }; + const l2 = { + id: '00000000-0000-0000-0000-000000000002', + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/plugins/catalog/catalog-info.yaml', + }; + const l3 = { + id: '00000000-0000-0000-0000-000000000003', + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/plugins/scaffolder/catalog-info.yaml', + }; + const l4 = { + id: '00000000-0000-0000-0000-000000000004', + type: 'file', + target: '/tmp/catalog-info.yaml', + }; + + it.each(databases.eachSupportedId())( + 'queries locations correctly, %p', + async databaseId => { + const { store, knex } = await createLocationStore(databaseId); + + // Insert locations in a random order to test the sorting + const locations = [l1, l2, l3, l4]; + locations.sort(() => Math.random() - 0.5); + await knex('locations').delete(); + for (const location of locations) { + await knex('locations').insert(location); + } + + await expect( + store.queryLocations({ + limit: 10, + }), + ).resolves.toEqual({ + items: [l1, l2, l3, l4], + totalItems: 4, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { type: 'url' }, + }), + ).resolves.toEqual({ + items: [l1, l2, l3], + totalItems: 3, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/plugins/catalog/catalog-info.yaml', + }, + }), + ).resolves.toEqual({ + items: [l2], + totalItems: 1, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { Type: 'urL' }, + }), + ).resolves.toEqual({ + items: [l1, l2, l3], + totalItems: 3, + }); + + await expect( + store.queryLocations({ + limit: 2, + query: { type: 'url' }, + }), + ).resolves.toEqual({ + items: [l1, l2], + totalItems: 3, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { type: 'file' }, + }), + ).resolves.toEqual({ + items: [l4], + totalItems: 1, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + $all: [ + { type: 'url' }, + { + target: { + $hasPrefix: + 'https://github.com/backstage/backstage/blob/master/pa', + }, + }, + ], + }, + }), + ).resolves.toEqual({ + items: [l1], + totalItems: 1, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + $all: [ + { type: 'file' }, + { + target: { + $hasPrefix: + 'https://github.com/backstage/backstage/blob/master/pa', + }, + }, + ], + }, + }), + ).resolves.toEqual({ + items: [], + totalItems: 0, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + $any: [ + { type: 'file' }, + { + target: { + $hasPrefix: + 'https://github.com/backstage/backstage/blob/master/pa', + }, + }, + ], + }, + }), + ).resolves.toEqual({ + items: [l1, l4], + totalItems: 2, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + $not: { type: 'FILE' }, + }, + }), + ).resolves.toEqual({ + items: [l1, l2, l3], + totalItems: 3, + }); + + // Multiple fields in a single query object should be ANDed together + await expect( + store.queryLocations({ + limit: 10, + query: { + type: 'url', + target: { + $hasPrefix: + 'https://github.com/backstage/backstage/blob/master/plugins/catalog', + }, + }, + }), + ).resolves.toEqual({ + items: [l2], + totalItems: 1, + }); + + await expect( + store.queryLocations({ + limit: 1, + query: { + $not: { type: 'FILE' }, + }, + }), + ).resolves.toEqual({ + items: [l1], + totalItems: 3, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + $not: { id: '00000000-0000-0000-0000-000000000004' }, + }, + }), + ).resolves.toEqual({ + items: [l1, l2, l3], + totalItems: 3, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + id: { $exists: false }, + }, + }), + ).resolves.toEqual({ + items: [], + totalItems: 0, + }); + + await expect( + store.queryLocations({ + limit: 10, + query: { + $not: { id: { $exists: false } }, + }, + }), + ).resolves.toEqual({ + items: [l1, l2, l3, l4], + totalItems: 4, + }); + }, + ); + }); }); diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index afd5e338b1..2c3f2a0da5 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -15,7 +15,7 @@ */ import { Location } from '@backstage/catalog-client'; -import { ConflictError, NotFoundError } from '@backstage/errors'; +import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { v4 as uuid } from 'uuid'; import { @@ -43,6 +43,7 @@ import { import { chunk, uniqBy } from 'lodash'; import parseGitUrl, { type GitUrl } from 'git-url-parse'; import { ScmEventHandlingConfig } from '../util/readScmEventHandlingConfig'; +import { FilterPredicate } from '@backstage/filter-predicates'; export class DefaultLocationStore implements LocationStore, EntityProvider { private _connection: EntityProviderConnection | undefined; @@ -103,6 +104,43 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { return await this.locations(); } + async queryLocations(options: { + limit: number; + afterId?: string; + query?: FilterPredicate; + }): Promise<{ items: Location[]; totalItems: number }> { + let itemsQuery = this.db('locations'); + + if (options.query) { + itemsQuery = applyLocationFilterToQuery( + this.db.client.config.client, + itemsQuery, + options.query, + ); + } + + const countQuery = itemsQuery.clone().count('*', { as: 'count' }); + + itemsQuery = itemsQuery.orderBy('id', 'asc'); + if (options.afterId !== undefined) { + itemsQuery = itemsQuery.where('id', '>', options.afterId); + } + if (options.limit !== undefined) { + itemsQuery = itemsQuery.limit(options.limit); + } + + const [items, [{ count }]] = await Promise.all([itemsQuery, countQuery]); + + return { + items: items.map(item => ({ + id: item.id, + target: item.target, + type: item.type, + })), + totalItems: Number(count), + }; + } + async getLocation(id: string): Promise { const items = await this.db('locations') .where({ id }) @@ -491,3 +529,166 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { // #endregion } + +/** + * Recursively builds up the SQL expression corresponding to the given filter + * predicate. + * + * @remarks + * + * Design note: The code prefers to let the SQL engine achieve case + * insensitivitiy. We could attempt to use `.toUpperCase` etc on the client + * side, but that would only work for the values being passed in, not the column + * side of the expression. If we let the database perform UPPER on both, we know + * that they will always be locale consistent etc as well. + * + * This does come at a runtime cost. However, the data set is typically rather + * small in the grand scheme of things, and we can add the proper indices in the + * future if needed. At this point I considered it not worth the effort. + */ +function applyLocationFilterToQuery( + clientType: string, + inputQuery: Knex.QueryBuilder, + query: FilterPredicate, +): Knex.QueryBuilder { + let result = inputQuery; + + if (!query || typeof query !== 'object' || Array.isArray(query)) { + throw new InputError('Invalid filter predicate, expected an object'); + } + + if ('$all' in query) { + return result.where(outer => { + for (const subQuery of query.$all) { + outer.andWhere(inner => { + applyLocationFilterToQuery(clientType, inner, subQuery); + }); + } + }); + } + + if ('$any' in query) { + return result.where(outer => { + for (const subQuery of query.$any) { + outer.orWhere(inner => { + applyLocationFilterToQuery(clientType, inner, subQuery); + }); + } + }); + } + + if ('$not' in query) { + return result.whereNot(inner => { + applyLocationFilterToQuery(clientType, inner, query.$not); + }); + } + + const entries = Object.entries(query); + const keys = entries.map(e => e[0]); + if (keys.some(k => k.startsWith('$'))) { + throw new InputError( + `Invalid filter predicate, unknown logic operator '${keys.join(', ')}'`, + ); + } + + for (const [keyAnyCase, value] of entries) { + const key = keyAnyCase.toLocaleLowerCase('en-US'); + if (!['id', 'type', 'target'].includes(key)) { + throw new InputError( + `Invalid filter predicate, expected key to be 'id', 'type', or 'target', got '${keyAnyCase}'`, + ); + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + if (key === 'id') { + result = result.where({ id: value }); + } else if (clientType === 'pg') { + result = result.whereRaw(`UPPER(??::text) = UPPER(?::text)`, [ + key, + value, + ]); + } else if (clientType.includes('mysql')) { + result = result.whereRaw( + `UPPER(CAST(?? AS CHAR)) = UPPER(CAST(? AS CHAR))`, + [key, value], + ); + } else { + result = result.whereRaw(`UPPER(??) = UPPER(?)`, [key, value]); + } + } else if (typeof value === 'object') { + if (!value || Array.isArray(value)) { + throw new InputError( + `Invalid filter predicate, got unknown matcher object '${JSON.stringify( + value, + )}'`, + ); + } + + if ('$exists' in value) { + // Technically this matcher does not make much sense in the context of + // this table at the time of writing (values are always present), but + // there is nothing gained by prohibiting it. + result = value.$exists + ? result.whereNotNull(key) + : result.whereNull(key); + } else if ('$in' in value) { + if (key === 'id') { + result = result.whereIn(key, value.$in); + } else if (clientType === 'pg') { + result = result.whereRaw( + `UPPER(??::text) IN (${value.$in + .map(() => 'UPPER(?::text)') + .join(', ')})`, + [key, ...value.$in], + ); + } else if (clientType.includes('mysql')) { + result = result.whereRaw( + `UPPER(CAST(?? AS CHAR)) IN (${value.$in + .map(() => 'UPPER(CAST(? AS CHAR))') + .join(', ')})`, + [key, ...value.$in], + ); + } else { + result = result.whereRaw( + `UPPER(??) IN (${value.$in.map(() => 'UPPER(?)').join(', ')})`, + [key, ...value.$in], + ); + } + } else if ('$hasPrefix' in value) { + const escaped = value.$hasPrefix.replace(/([\\%_])/g, '\\$1'); + if (clientType === 'pg') { + result = result.whereRaw('?? ilike ?', [key, `${escaped}%`]); + } else { + result = result.whereRaw('UPPER(??) like UPPER(?)', [ + key, + `${escaped}%`, + ]); + } + } else if ('$contains' in value) { + // There are no array shaped values for location queries, so we throw + // an error since it cannot possibly match. An alternative could be to + // make the query always fail (eg with 1 = 0) but this felt more + // immediately helpful to the end user. + throw new InputError( + `Invalid filter predicate, '$contains' is not supported for location queries`, + ); + } else { + throw new InputError( + `Invalid filter predicate, got unknown matcher object '${JSON.stringify( + value, + )}'`, + ); + } + } else { + throw new InputError( + `Invalid filter predicate, expected value to be a primitive value or a matcher object, got '${typeof value}'`, + ); + } + } + + return result; +} diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index c758ef7b3d..785e40bcf4 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -338,6 +338,90 @@ components: properties: {} description: A type representing all allowed JSON object values. additionalProperties: {} + EntityPredicate: + description: A predicate-based filter supporting logical operators. + oneOf: + - type: string + - type: number + - type: boolean + - $ref: '#/components/schemas/EntityPredicateAll' + - $ref: '#/components/schemas/EntityPredicateAny' + - $ref: '#/components/schemas/EntityPredicateNot' + - type: object + additionalProperties: + $ref: '#/components/schemas/EntityPredicateValue' + EntityPredicateAll: + type: object + description: All conditions must match (AND logic) + additionalProperties: false + properties: + $all: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $all + EntityPredicateAny: + type: object + description: At least one condition must match (OR logic) + additionalProperties: false + properties: + $any: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $any + EntityPredicateNot: + type: object + description: Negates the condition + additionalProperties: false + properties: + $not: + $ref: '#/components/schemas/EntityPredicate' + required: + - $not + EntityPredicateValue: + description: Value for a field predicate + oneOf: + - type: string + - type: number + - type: boolean + - $ref: '#/components/schemas/EntityPredicateExists' + - $ref: '#/components/schemas/EntityPredicateIn' + - $ref: '#/components/schemas/EntityPredicateHasPrefix' + EntityPredicateExists: + type: object + description: Check if field exists + additionalProperties: false + properties: + $exists: + type: boolean + required: + - $exists + EntityPredicateIn: + type: object + description: Match any value in array + additionalProperties: false + properties: + $in: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + required: + - $in + EntityPredicateHasPrefix: + type: object + description: Match a string that starts with the given value + additionalProperties: false + properties: + $hasPrefix: + type: string + required: + - $hasPrefix MapStringString: type: object properties: {} @@ -589,6 +673,27 @@ components: - type description: Holds the entity location information. additionalProperties: false + LocationsQueryResponse: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/Location' + description: The list of locations paginated by a specific query. + totalItems: + type: number + pageInfo: + type: object + properties: + nextCursor: + type: string + description: The cursor for the next batch of locations. + required: + - items + - totalItems + - pageInfo + additionalProperties: false AnalyzeLocationExistingEntity: type: object properties: @@ -1189,6 +1294,37 @@ paths: - {} - JWT: [] parameters: [] + /locations/by-query: + post: + operationId: GetLocationsByQuery + tags: + - Locations + description: Query for locations + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/LocationsQueryResponse' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + cursor: + type: string + limit: + type: number + query: + $ref: '#/components/schemas/EntityPredicate' /locations/{id}: get: operationId: GetLocation diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index fd26b5269f..8254df6bab 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -139,7 +139,9 @@ import { AnalyzeLocationResponse } from '../models/AnalyzeLocationResponse.model import { CreateLocation201Response } from '../models/CreateLocation201Response.model'; import { CreateLocationRequest } from '../models/CreateLocationRequest.model'; import { GetLocations200ResponseInner } from '../models/GetLocations200ResponseInner.model'; +import { GetLocationsByQueryRequest } from '../models/GetLocationsByQueryRequest.model'; import { Location } from '../models/Location.model'; +import { LocationsQueryResponse } from '../models/LocationsQueryResponse.model'; /** * @public @@ -193,6 +195,13 @@ export type GetLocationByEntity = { export type GetLocations = { response: Array | Error; }; +/** + * @public + */ +export type GetLocationsByQuery = { + body: GetLocationsByQueryRequest; + response: LocationsQueryResponse | Error; +}; export type EndpointMap = { '#_delete|/entities/by-uid/{uid}': DeleteEntityByUid; @@ -226,4 +235,6 @@ export type EndpointMap = { '#get|/locations/by-entity/{kind}/{namespace}/{name}': GetLocationByEntity; '#get|/locations': GetLocations; + + '#post|/locations/by-query': GetLocationsByQuery; }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts new file mode 100644 index 0000000000..b27f3d1c3e --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; +import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; +import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; +import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; + +/** + * A predicate-based filter supporting logical operators. + * @public + */ +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts new file mode 100644 index 0000000000..e21f98abbf --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * All conditions must match (AND logic) + * @public + */ +export interface EntityPredicateAll { + $all: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts new file mode 100644 index 0000000000..4e6cb62d01 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * At least one condition must match (OR logic) + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts new file mode 100644 index 0000000000..7800fcd20c --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Check if field exists + * @public + */ +export interface EntityPredicateExists { + $exists: boolean; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts new file mode 100644 index 0000000000..af0640e96a --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Match a string that starts with the given value + * @public + */ +export interface EntityPredicateHasPrefix { + $hasPrefix: string; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts new file mode 100644 index 0000000000..5557d3fef9 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; + +/** + * Match any value in array + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts new file mode 100644 index 0000000000..2b4e3d7c2f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type EntityPredicateInInInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts new file mode 100644 index 0000000000..a5fc06f39b --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * Negates the condition + * @public + */ +export interface EntityPredicateNot { + $not: EntityPredicate; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts new file mode 100644 index 0000000000..5627155073 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; +import { EntityPredicateHasPrefix } from '../models/EntityPredicateHasPrefix.model'; +import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; + +/** + * Value for a field predicate + * @public + */ +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateHasPrefix + | EntityPredicateIn + | boolean + | number + | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts new file mode 100644 index 0000000000..699c7a71fd --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface GetLocationsByQueryRequest { + cursor?: string; + limit?: number; + query?: EntityPredicate; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts new file mode 100644 index 0000000000..c231fccc57 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponse.model.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +import { Location } from '../models/Location.model'; +import { LocationsQueryResponsePageInfo } from '../models/LocationsQueryResponsePageInfo.model'; + +/** + * @public + */ +export interface LocationsQueryResponse { + /** + * The list of locations paginated by a specific query. + */ + items: Array; + totalItems: number; + pageInfo: LocationsQueryResponsePageInfo; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts new file mode 100644 index 0000000000..5ced68ec62 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/LocationsQueryResponsePageInfo.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface LocationsQueryResponsePageInfo { + /** + * The cursor for the next batch of locations. + */ + nextCursor?: string; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index f57470377b..6de4e7da9e 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -31,15 +31,27 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; +export * from '../models/EntityPredicate.model'; +export * from '../models/EntityPredicateAll.model'; +export * from '../models/EntityPredicateAny.model'; +export * from '../models/EntityPredicateExists.model'; +export * from '../models/EntityPredicateHasPrefix.model'; +export * from '../models/EntityPredicateIn.model'; +export * from '../models/EntityPredicateInInInner.model'; +export * from '../models/EntityPredicateNot.model'; +export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; export * from '../models/ErrorResponse.model'; export * from '../models/GetEntitiesByRefsRequest.model'; export * from '../models/GetLocations200ResponseInner.model'; +export * from '../models/GetLocationsByQueryRequest.model'; export * from '../models/Location.model'; export * from '../models/LocationInput.model'; export * from '../models/LocationSpec.model'; +export * from '../models/LocationsQueryResponse.model'; +export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; export * from '../models/RecursivePartialEntity.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 35132e33e9..debfecc510 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -262,6 +262,143 @@ export const spec = { description: 'A type representing all allowed JSON object values.', additionalProperties: {}, }, + EntityPredicate: { + description: 'A predicate-based filter supporting logical operators.', + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + $ref: '#/components/schemas/EntityPredicateAll', + }, + { + $ref: '#/components/schemas/EntityPredicateAny', + }, + { + $ref: '#/components/schemas/EntityPredicateNot', + }, + { + type: 'object', + additionalProperties: { + $ref: '#/components/schemas/EntityPredicateValue', + }, + }, + ], + }, + EntityPredicateAll: { + type: 'object', + description: 'All conditions must match (AND logic)', + additionalProperties: false, + properties: { + $all: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$all'], + }, + EntityPredicateAny: { + type: 'object', + description: 'At least one condition must match (OR logic)', + additionalProperties: false, + properties: { + $any: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$any'], + }, + EntityPredicateNot: { + type: 'object', + description: 'Negates the condition', + additionalProperties: false, + properties: { + $not: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + required: ['$not'], + }, + EntityPredicateValue: { + description: 'Value for a field predicate', + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + $ref: '#/components/schemas/EntityPredicateExists', + }, + { + $ref: '#/components/schemas/EntityPredicateIn', + }, + { + $ref: '#/components/schemas/EntityPredicateHasPrefix', + }, + ], + }, + EntityPredicateExists: { + type: 'object', + description: 'Check if field exists', + additionalProperties: false, + properties: { + $exists: { + type: 'boolean', + }, + }, + required: ['$exists'], + }, + EntityPredicateIn: { + type: 'object', + description: 'Match any value in array', + additionalProperties: false, + properties: { + $in: { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + ], + }, + }, + }, + required: ['$in'], + }, + EntityPredicateHasPrefix: { + type: 'object', + description: 'Match a string that starts with the given value', + additionalProperties: false, + properties: { + $hasPrefix: { + type: 'string', + }, + }, + required: ['$hasPrefix'], + }, MapStringString: { type: 'object', properties: {}, @@ -538,6 +675,32 @@ export const spec = { description: 'Holds the entity location information.', additionalProperties: false, }, + LocationsQueryResponse: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Location', + }, + description: 'The list of locations paginated by a specific query.', + }, + totalItems: { + type: 'number', + }, + pageInfo: { + type: 'object', + properties: { + nextCursor: { + type: 'string', + description: 'The cursor for the next batch of locations.', + }, + }, + }, + }, + required: ['items', 'totalItems', 'pageInfo'], + additionalProperties: false, + }, AnalyzeLocationExistingEntity: { type: 'object', properties: { @@ -1371,6 +1534,55 @@ export const spec = { parameters: [], }, }, + '/locations/by-query': { + post: { + operationId: 'GetLocationsByQuery', + tags: ['Locations'], + description: 'Query for locations', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/LocationsQueryResponse', + }, + }, + }, + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + requestBody: { + required: false, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + cursor: { + type: 'string', + }, + limit: { + type: 'number', + }, + query: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + }, + }, + }, + }, + }, '/locations/{id}': { get: { operationId: 'GetLocation', diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts index 2eae1c7553..6d889ed990 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -23,6 +23,7 @@ describe('AuthorizedLocationService', () => { const fakeLocationService = { createLocation: jest.fn(), listLocations: jest.fn(), + queryLocations: jest.fn(), getLocation: jest.fn(), deleteLocation: jest.fn(), getLocationByEntity: jest.fn(), diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts index e1662861ff..83f5118153 100644 --- a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -28,6 +28,7 @@ import { BackstageCredentials, PermissionsService, } from '@backstage/backend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; export class AuthorizedLocationService implements LocationService { private readonly locationService: LocationService; @@ -83,6 +84,26 @@ export class AuthorizedLocationService implements LocationService { return this.locationService.listLocations(options); } + async queryLocations(options: { + limit: number; + afterId?: string; + query?: FilterPredicate; + credentials: BackstageCredentials; + }): Promise<{ items: Location[]; totalItems: number }> { + const authorizationResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogLocationReadPermission }], + { credentials: options.credentials }, + ) + )[0]; + + if (authorizationResponse.result === AuthorizeResult.DENY) { + return { items: [], totalItems: 0 }; + } + + return this.locationService.queryLocations(options); + } + async getLocation( id: string, options: { credentials: BackstageCredentials }, diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts index 1c17f271fb..822706ada4 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.test.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.test.ts @@ -27,6 +27,7 @@ describe('DefaultLocationServiceTest', () => { deleteLocation: jest.fn(), createLocation: jest.fn(), listLocations: jest.fn(), + queryLocations: jest.fn(), getLocation: jest.fn(), getLocationByEntity: jest.fn(), }; @@ -335,6 +336,22 @@ describe('DefaultLocationServiceTest', () => { }); }); + describe('queryLocations', () => { + it('should call locationStore.queryLocations', async () => { + await locationService.queryLocations({ + limit: 10, + afterId: '123', + query: { type: 'url' }, + credentials: null as any, + }); + expect(store.queryLocations).toHaveBeenCalledWith({ + limit: 10, + afterId: '123', + query: { type: 'url' }, + }); + }); + }); + describe('deleteLocation', () => { it('should call locationStore.deleteLocation', async () => { await locationService.deleteLocation('123'); diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index be84a7a84f..f65ff18271 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -28,6 +28,8 @@ import { LocationInput, LocationService, LocationStore } from './types'; import { locationSpecToMetadataName } from '../util/conversion'; import { InputError } from '@backstage/errors'; import { DeferredEntity } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; export type DefaultLocationServiceOptions = { allowedLocationTypes: string[]; @@ -71,6 +73,20 @@ export class DefaultLocationService implements LocationService { listLocations(): Promise { return this.store.listLocations(); } + + async queryLocations(options: { + limit: number; + afterId: string; + query?: FilterPredicate; + credentials: BackstageCredentials; + }): Promise<{ items: Location[]; totalItems: number }> { + return this.store.queryLocations({ + limit: options.limit, + afterId: options.afterId, + query: options.query, + }); + } + getLocation(id: string): Promise { return this.store.getLocation(id); } diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 0378a8d525..dbbc70328f 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -70,6 +70,7 @@ describe('createRouter readonly disabled', () => { locationService = { getLocation: jest.fn(), createLocation: jest.fn(), + queryLocations: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), getLocationByEntity: jest.fn(), @@ -748,6 +749,188 @@ describe('createRouter readonly disabled', () => { }); }); + describe('POST /locations/by-query', () => { + beforeEach(async () => { + locationService = { + getLocation: jest.fn(), + createLocation: jest.fn(), + queryLocations: jest.fn(), + listLocations: jest.fn(), + deleteLocation: jest.fn(), + getLocationByEntity: jest.fn(), + }; + const router = await createRouter({ + locationService, + logger: mockServices.logger.mock(), + config: new ConfigReader(undefined), + auth: mockServices.auth(), + httpAuth: mockServices.httpAuth(), + orchestrator: { process: jest.fn() }, + permissionsService: mockServices.permissions(), + auditor: mockServices.auditor.mock(), + }); + router.use(middleware.error()); + app = express().use(router); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('happy path: queries locations without pagination', async () => { + const locations: Location[] = [ + { id: 'loc1', type: 'url', target: 'https://example.com/a' }, + { id: 'loc2', type: 'url', target: 'https://example.com/b' }, + ]; + locationService.queryLocations.mockResolvedValueOnce({ + items: locations, + totalItems: 2, + }); + + const response = await request(app) + .post('/locations/by-query') + .send({ limit: 10 }); + + expect(locationService.queryLocations).toHaveBeenCalledTimes(1); + expect(locationService.queryLocations).toHaveBeenCalledWith({ + limit: 11, + credentials: mockCredentials.user(), + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items: locations, + totalItems: 2, + pageInfo: {}, + }); + }); + + it('happy path: queries locations with filter', async () => { + const locations: Location[] = [ + { id: 'loc1', type: 'url', target: 'https://example.com/a' }, + ]; + locationService.queryLocations.mockResolvedValueOnce({ + items: locations, + totalItems: 1, + }); + + const response = await request(app) + .post('/locations/by-query') + .send({ limit: 10, query: { type: 'url' } }); + + expect(locationService.queryLocations).toHaveBeenCalledTimes(1); + expect(locationService.queryLocations).toHaveBeenCalledWith({ + limit: 11, + query: { type: 'url' }, + credentials: mockCredentials.user(), + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items: locations, + totalItems: 1, + pageInfo: {}, + }); + }); + + it('returns nextCursor when more results exist', async () => { + const locations: Location[] = [ + { id: 'loc1', type: 'url', target: 'https://example.com/a' }, + { id: 'loc2', type: 'url', target: 'https://example.com/b' }, + { id: 'loc3', type: 'url', target: 'https://example.com/c' }, + ]; + locationService.queryLocations.mockResolvedValueOnce({ + items: locations, + totalItems: 5, + }); + + const response = await request(app) + .post('/locations/by-query') + .send({ limit: 2 }); + + expect(locationService.queryLocations).toHaveBeenCalledWith({ + limit: 3, + credentials: mockCredentials.user(), + }); + expect(response.status).toEqual(200); + expect(response.body.items).toHaveLength(2); + expect(response.body.totalItems).toEqual(5); + expect(response.body.pageInfo.nextCursor).toBeDefined(); + + const cursor = JSON.parse( + Buffer.from(response.body.pageInfo.nextCursor, 'base64').toString( + 'utf8', + ), + ); + expect(cursor).toEqual({ + limit: 2, + afterId: 'loc2', + }); + }); + + it('uses cursor for pagination', async () => { + const locations: Location[] = [ + { id: 'loc3', type: 'url', target: 'https://example.com/c' }, + ]; + locationService.queryLocations.mockResolvedValueOnce({ + items: locations, + totalItems: 3, + }); + + const cursor = Buffer.from( + JSON.stringify({ limit: 2, afterId: 'loc2', query: { type: 'url' } }), + ).toString('base64'); + + const response = await request(app) + .post('/locations/by-query') + .send({ cursor }); + + expect(locationService.queryLocations).toHaveBeenCalledWith({ + limit: 3, + afterId: 'loc2', + query: { type: 'url' }, + credentials: mockCredentials.user(), + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items: locations, + totalItems: 3, + pageInfo: {}, + }); + }); + + it('uses default limit when not specified', async () => { + locationService.queryLocations.mockResolvedValueOnce({ + items: [], + totalItems: 0, + }); + + const response = await request(app).post('/locations/by-query').send({}); + + expect(locationService.queryLocations).toHaveBeenCalledWith({ + limit: 1001, + credentials: mockCredentials.user(), + }); + expect(response.status).toEqual(200); + }); + + it('rejects invalid limit', async () => { + const response = await request(app) + .post('/locations/by-query') + .send({ limit: 0 }); + + expect(locationService.queryLocations).not.toHaveBeenCalled(); + expect(response.status).toEqual(400); + }); + + it('rejects malformed cursor', async () => { + const response = await request(app) + .post('/locations/by-query') + .send({ cursor: 'not-valid-base64!!!' }); + + expect(locationService.queryLocations).not.toHaveBeenCalled(); + expect(response.status).toEqual(400); + }); + }); + describe('DELETE /locations', () => { it('deletes the location', async () => { locationService.deleteLocation.mockResolvedValueOnce(undefined); @@ -936,6 +1119,7 @@ describe('createRouter readonly and raw json enabled', () => { getLocation: jest.fn(), createLocation: jest.fn(), listLocations: jest.fn(), + queryLocations: jest.fn(), deleteLocation: jest.fn(), getLocationByEntity: jest.fn(), }; @@ -1150,6 +1334,7 @@ describe('NextRouter permissioning', () => { locationService = { getLocation: jest.fn(), createLocation: jest.fn(), + queryLocations: jest.fn(), listLocations: jest.fn(), deleteLocation: jest.fn(), getLocationByEntity: jest.fn(), diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index dc7678a3b9..9dba615d36 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -61,6 +61,10 @@ import { locationInput, validateRequestBody, } from './util'; +import { + encodeLocationQueryCursor, + parseLocationQuery, +} from './request/parseLocationQuery'; /** * Options used by {@link createRouter}. @@ -591,6 +595,50 @@ export async function createRouter( } }) + .post('/locations/by-query', async (req, res) => { + const auditorEvent = await auditor.createEvent({ + eventId: 'location-fetch', + request: req, + meta: { + queryType: 'by-query', + }, + }); + + try { + const request = parseLocationQuery(req.body); + const result = await locationService.queryLocations({ + ...request, + limit: request.limit + 1, + credentials: await httpAuth.credentials(req), + }); + + const hasNextPage = result.items.length > request.limit; + const items = hasNextPage + ? result.items.slice(0, request.limit) + : result.items; + const nextCursor = hasNextPage + ? encodeLocationQueryCursor({ + limit: request.limit, + afterId: items[items.length - 1].id, + query: request.query, + }) + : undefined; + + await auditorEvent?.success(); + + res.status(200).json({ + items, + totalItems: result.totalItems, + pageInfo: { + nextCursor, + }, + }); + } catch (err) { + await auditorEvent?.fail({ error: err }); + throw err; + } + }) + .get('/locations/:id', async (req, res) => { const { id } = req.params; diff --git a/plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts b/plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts new file mode 100644 index 0000000000..bffa2b38f2 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityPredicate } from '../../schema/openapi/generated/models/EntityPredicate.model'; +import { + encodeLocationQueryCursor, + parseLocationQuery, +} from './parseLocationQuery'; + +function encodeCursor(cursor: object): string { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64'); +} + +describe('parseLocationQuery', () => { + describe('initial request (no cursor)', () => { + it('should use default limit of 1000 when not provided', () => { + const result = parseLocationQuery({}); + expect(result.limit).toBe(1000); + expect(result.afterId).toBeUndefined(); + expect(result.query).toBeUndefined(); + }); + + it('should use provided limit', () => { + const result = parseLocationQuery({ limit: 50 }); + expect(result.limit).toBe(50); + }); + + it('should parse a valid query', () => { + const query = { type: 'url' }; + const result = parseLocationQuery({ query }); + expect(result.query).toEqual(query); + }); + + it('should parse a complex query with $all', () => { + const query: EntityPredicate = { + $all: [{ type: 'url' }, { target: { $in: ['a', 'b'] } }], + }; + const result = parseLocationQuery({ query }); + expect(result.query).toEqual(query); + }); + + it('should throw on invalid limit (zero)', () => { + expect(() => + parseLocationQuery({ limit: 0 }), + ).toThrowErrorMatchingInlineSnapshot( + `"Limit must be a positive integer >= 1"`, + ); + }); + + it('should throw on invalid limit (negative)', () => { + expect(() => + parseLocationQuery({ limit: -5 }), + ).toThrowErrorMatchingInlineSnapshot( + `"Limit must be a positive integer >= 1"`, + ); + }); + + it('should throw on invalid limit (non-integer)', () => { + expect(() => + parseLocationQuery({ limit: 1.5 }), + ).toThrowErrorMatchingInlineSnapshot( + `"Limit must be a positive integer >= 1"`, + ); + }); + + it('should throw on invalid query', () => { + expect(() => + parseLocationQuery({ query: { $invalid: true } as any }), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid query: Validation error: Invalid at "$invalid""`, + ); + }); + }); + + describe('cursor request', () => { + it('should parse a valid cursor', () => { + const cursor = encodeCursor({ limit: 10, afterId: 'abc-123' }); + const result = parseLocationQuery({ cursor }); + expect(result.limit).toBe(10); + expect(result.afterId).toBe('abc-123'); + expect(result.query).toBeUndefined(); + }); + + it('should parse cursor with query', () => { + const query = { type: 'url' }; + const cursor = encodeCursor({ limit: 25, afterId: 'xyz', query }); + const result = parseLocationQuery({ cursor }); + expect(result.limit).toBe(25); + expect(result.afterId).toBe('xyz'); + expect(result.query).toEqual(query); + }); + + it('should parse cursor without afterId', () => { + const cursor = encodeCursor({ limit: 15 }); + const result = parseLocationQuery({ cursor }); + expect(result.limit).toBe(15); + expect(result.afterId).toBeUndefined(); + }); + + it('cursor takes precedence over other request fields', () => { + const cursor = encodeCursor({ limit: 10 }); + const result = parseLocationQuery({ + cursor, + limit: 100, + query: { type: 'file' }, + }); + expect(result.limit).toBe(10); + expect(result.query).toBeUndefined(); + }); + + it('should throw on invalid base64 cursor', () => { + expect(() => + parseLocationQuery({ cursor: '!!not-base64!!' }), + ).toThrowErrorMatchingInlineSnapshot( + `"Malformed cursor, unknown encoding"`, + ); + }); + + it('should throw on invalid JSON cursor', () => { + const cursor = Buffer.from('not json', 'utf8').toString('base64'); + expect(() => + parseLocationQuery({ cursor }), + ).toThrowErrorMatchingInlineSnapshot( + `"Malformed cursor, unknown encoding"`, + ); + }); + + it('should throw on cursor missing required limit', () => { + const cursor = encodeCursor({ afterId: 'abc' }); + expect(() => + parseLocationQuery({ cursor }), + ).toThrowErrorMatchingInlineSnapshot( + `"Malformed cursor: Validation error: Required at "limit""`, + ); + }); + + it('should throw on cursor with invalid limit (zero)', () => { + const cursor = encodeCursor({ limit: 0 }); + expect(() => + parseLocationQuery({ cursor }), + ).toThrowErrorMatchingInlineSnapshot( + `"Malformed cursor: Validation error: Number must be greater than or equal to 1 at "limit""`, + ); + }); + + it('should throw on cursor with invalid limit (non-integer)', () => { + const cursor = encodeCursor({ limit: 2.5 }); + expect(() => + parseLocationQuery({ cursor }), + ).toThrowErrorMatchingInlineSnapshot( + `"Malformed cursor: Validation error: Expected integer, received float at "limit""`, + ); + }); + + it('should throw on cursor with invalid query', () => { + const cursor = encodeCursor({ limit: 10, query: { $invalid: true } }); + expect(() => + parseLocationQuery({ cursor }), + ).toThrowErrorMatchingInlineSnapshot( + `"Malformed cursor: Validation error: Invalid at "query.$invalid""`, + ); + }); + }); + + describe('encodeLocationQueryCursor roundtrip', () => { + it('should encode and decode a cursor with all fields', () => { + const original = { + limit: 50, + afterId: 'some-uuid', + query: { type: 'url' }, + }; + const encoded = encodeLocationQueryCursor(original); + const decoded = parseLocationQuery({ cursor: encoded }); + expect(decoded).toEqual(original); + }); + + it('should encode and decode a cursor without optional fields', () => { + const original = { limit: 25 }; + const encoded = encodeLocationQueryCursor(original); + const decoded = parseLocationQuery({ cursor: encoded }); + expect(decoded.limit).toBe(25); + expect(decoded.afterId).toBeUndefined(); + expect(decoded.query).toBeUndefined(); + }); + }); +}); diff --git a/plugins/catalog-backend/src/service/request/parseLocationQuery.ts b/plugins/catalog-backend/src/service/request/parseLocationQuery.ts new file mode 100644 index 0000000000..6021c148f7 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseLocationQuery.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + createZodV3FilterPredicateSchema, + FilterPredicate, +} from '@backstage/filter-predicates'; +import { JsonValue } from '@backstage/types'; +import { z } from 'zod/v3'; +import { fromZodError } from 'zod-validation-error/v3'; +import { GetLocationsByQueryRequest } from '../../schema/openapi/generated/models/GetLocationsByQueryRequest.model'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + +const locationCursorParser = z.object({ + limit: z.number().int().min(1), + afterId: z.string().optional(), + query: filterPredicateSchema.optional(), +}); + +export function parseLocationQuery( + request: Readonly, +): { + limit: number; + afterId?: string; + query?: FilterPredicate; +} { + if (request.cursor) { + let parsed: JsonValue; + try { + const data = Buffer.from(request.cursor, 'base64').toString('utf8'); + parsed = JSON.parse(data); + } catch { + throw new InputError('Malformed cursor, unknown encoding'); + } + + const result = locationCursorParser.safeParse(parsed); + if (!result.success) { + throw new InputError(`Malformed cursor: ${fromZodError(result.error)}`); + } + return { + limit: result.data.limit, + afterId: result.data.afterId, + query: result.data.query, + }; + } + + const limit = request.limit ?? 1000; + if (!Number.isInteger(limit) || limit < 1) { + throw new InputError('Limit must be a positive integer >= 1'); + } + + let query: FilterPredicate | undefined; + if (request.query !== undefined) { + const result = filterPredicateSchema.safeParse(request.query); + if (!result.success) { + throw new InputError(`Invalid query: ${fromZodError(result.error)}`); + } + query = result.data; + } + + return { + limit, + query, + }; +} + +export function encodeLocationQueryCursor(cursor: { + limit: number; + afterId?: string; + query?: FilterPredicate; +}): string { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64'); +} diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index f9ec39c7d3..f452484b72 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -17,6 +17,7 @@ import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; import { Location } from '@backstage/catalog-client'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * Holds the information required to create a new location in the catalog location store. @@ -40,6 +41,12 @@ export interface LocationService { listLocations(options: { credentials: BackstageCredentials; }): Promise; + queryLocations(options: { + limit: number; + afterId?: string; + query?: FilterPredicate; + credentials: BackstageCredentials; + }): Promise<{ items: Location[]; totalItems: number }>; getLocation( id: string, options: { credentials: BackstageCredentials }, @@ -79,6 +86,11 @@ export interface RefreshService { export interface LocationStore { createLocation(location: LocationInput): Promise; listLocations(): Promise; + queryLocations(options: { + limit: number; + afterId?: string; + query?: FilterPredicate; + }): Promise<{ items: Location[]; totalItems: number }>; getLocation(id: string): Promise; deleteLocation(id: string): Promise; getLocationByEntity(entityRef: CompoundEntityRef | string): Promise; diff --git a/plugins/catalog-node/report-testUtils.api.md b/plugins/catalog-node/report-testUtils.api.md index d687ff387b..65e4d0267a 100644 --- a/plugins/catalog-node/report-testUtils.api.md +++ b/plugins/catalog-node/report-testUtils.api.md @@ -25,6 +25,9 @@ import { GetLocationsResponse } from '@backstage/catalog-client'; import { Location as Location_2 } from '@backstage/catalog-client'; import { QueryEntitiesRequest } from '@backstage/catalog-client'; import { QueryEntitiesResponse } from '@backstage/catalog-client'; +import { QueryLocationsInitialRequest } from '@backstage/catalog-client'; +import { QueryLocationsRequest } from '@backstage/catalog-client'; +import { QueryLocationsResponse } from '@backstage/catalog-client'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { ServiceMock } from '@backstage/backend-test-utils'; import { StreamEntitiesRequest } from '@backstage/catalog-client'; @@ -93,6 +96,11 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi { options?: CatalogServiceRequestOptions | CatalogRequestOptions, ): Promise; // (undocumented) + queryLocations( + request?: QueryLocationsRequest, + options?: CatalogServiceRequestOptions | CatalogRequestOptions, + ): Promise; + // (undocumented) refreshEntity( entityRef: string, options?: CatalogServiceRequestOptions | CatalogRequestOptions, @@ -113,6 +121,11 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi { options?: CatalogServiceRequestOptions | CatalogRequestOptions, ): AsyncIterable; // (undocumented) + streamLocations( + request?: QueryLocationsInitialRequest, + options?: CatalogServiceRequestOptions | CatalogRequestOptions, + ): AsyncIterable; + // (undocumented) validateEntity( entity: Entity, locationRef: string, diff --git a/plugins/catalog-node/report.api.md b/plugins/catalog-node/report.api.md index 2db2f883af..24b4410a9d 100644 --- a/plugins/catalog-node/report.api.md +++ b/plugins/catalog-node/report.api.md @@ -31,6 +31,9 @@ import { LocationSpec as LocationSpec_2 } from '@backstage/plugin-catalog-common import { PlaceholderResolver as PlaceholderResolver_2 } from '@backstage/plugin-catalog-node'; import { QueryEntitiesRequest } from '@backstage/catalog-client'; import { QueryEntitiesResponse } from '@backstage/catalog-client'; +import { QueryLocationsInitialRequest } from '@backstage/catalog-client'; +import { QueryLocationsRequest } from '@backstage/catalog-client'; +import { QueryLocationsResponse } from '@backstage/catalog-client'; import { ScmLocationAnalyzer as ScmLocationAnalyzer_2 } from '@backstage/plugin-catalog-node'; import { ServiceRef } from '@backstage/backend-plugin-api'; import { StreamEntitiesRequest } from '@backstage/catalog-client'; @@ -234,6 +237,11 @@ export interface CatalogService { options: CatalogServiceRequestOptions, ): Promise; // (undocumented) + queryLocations( + request: QueryLocationsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + // (undocumented) refreshEntity( entityRef: string, options: CatalogServiceRequestOptions, @@ -254,6 +262,11 @@ export interface CatalogService { options: CatalogServiceRequestOptions, ): AsyncIterable; // (undocumented) + streamLocations( + request: QueryLocationsInitialRequest | undefined, + options: CatalogServiceRequestOptions, + ): AsyncIterable; + // (undocumented) validateEntity( entity: Entity, locationRef: string, diff --git a/plugins/catalog-node/src/catalogService.ts b/plugins/catalog-node/src/catalogService.ts index a6ecedd5c5..4f4fd6d764 100644 --- a/plugins/catalog-node/src/catalogService.ts +++ b/plugins/catalog-node/src/catalogService.ts @@ -39,6 +39,9 @@ import { Location, QueryEntitiesRequest, QueryEntitiesResponse, + QueryLocationsInitialRequest, + QueryLocationsRequest, + QueryLocationsResponse, StreamEntitiesRequest, ValidateEntityResponse, } from '@backstage/catalog-client'; @@ -107,6 +110,16 @@ export interface CatalogService { options: CatalogServiceRequestOptions, ): Promise; + queryLocations( + request: QueryLocationsRequest, + options: CatalogServiceRequestOptions, + ): Promise; + + streamLocations( + request: QueryLocationsInitialRequest | undefined, + options: CatalogServiceRequestOptions, + ): AsyncIterable; + getLocationById( id: string, options: CatalogServiceRequestOptions, @@ -254,6 +267,26 @@ class DefaultCatalogService implements CatalogService { ); } + async queryLocations( + request: QueryLocationsRequest, + options: CatalogServiceRequestOptions, + ): Promise { + return this.#catalogApi.queryLocations( + request, + await this.#getOptions(options), + ); + } + + async *streamLocations( + request: QueryLocationsInitialRequest | undefined, + options: CatalogServiceRequestOptions, + ): AsyncIterable { + yield* this.#catalogApi.streamLocations( + request, + await this.#getOptions(options), + ); + } + async getLocationById( id: string, options: CatalogServiceRequestOptions, diff --git a/plugins/catalog-node/src/testUtils/catalogServiceMock.ts b/plugins/catalog-node/src/testUtils/catalogServiceMock.ts index 61dfde4ca7..59d817c012 100644 --- a/plugins/catalog-node/src/testUtils/catalogServiceMock.ts +++ b/plugins/catalog-node/src/testUtils/catalogServiceMock.ts @@ -70,6 +70,8 @@ export namespace catalogServiceMock { refreshEntity: jest.fn(), getEntityFacets: jest.fn(), getLocations: jest.fn(), + queryLocations: jest.fn(), + streamLocations: jest.fn(), getLocationById: jest.fn(), getLocationByRef: jest.fn(), addLocation: jest.fn(), diff --git a/plugins/catalog-node/src/testUtils/types.ts b/plugins/catalog-node/src/testUtils/types.ts index a5bf125cfd..e819af1b90 100644 --- a/plugins/catalog-node/src/testUtils/types.ts +++ b/plugins/catalog-node/src/testUtils/types.ts @@ -31,6 +31,9 @@ import { Location, QueryEntitiesRequest, QueryEntitiesResponse, + QueryLocationsInitialRequest, + QueryLocationsRequest, + QueryLocationsResponse, StreamEntitiesRequest, ValidateEntityResponse, } from '@backstage/catalog-client'; @@ -101,6 +104,16 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi { options?: CatalogServiceRequestOptions | CatalogRequestOptions, ): Promise; + queryLocations( + request?: QueryLocationsRequest, + options?: CatalogServiceRequestOptions | CatalogRequestOptions, + ): Promise; + + streamLocations( + request?: QueryLocationsInitialRequest, + options?: CatalogServiceRequestOptions | CatalogRequestOptions, + ): AsyncIterable; + getLocationById( id: string, options?: CatalogServiceRequestOptions | CatalogRequestOptions, diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts index 007345de22..86ac5bc2bf 100644 --- a/plugins/catalog-react/src/testUtils/catalogApiMock.ts +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts @@ -72,6 +72,8 @@ export namespace catalogApiMock { refreshEntity: jest.fn(), getEntityFacets: jest.fn(), getLocations: jest.fn(), + queryLocations: jest.fn(), + streamLocations: jest.fn(), getLocationById: jest.fn(), getLocationByRef: jest.fn(), addLocation: jest.fn(), diff --git a/yarn.lock b/yarn.lock index 97a438ad8a..3843c39f0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3222,6 +3222,7 @@ __metadata: "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@types/lodash": "npm:^4.14.151" cross-fetch: "npm:^4.0.0" @@ -5293,6 +5294,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" @@ -5330,6 +5332,7 @@ __metadata: yaml: "npm:^2.0.0" yn: "npm:^4.0.0" zod: "npm:^3.25.76" + zod-validation-error: "npm:^4.0.2" languageName: unknown linkType: soft