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 1/7] 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 From 9fb766aa9bf561abd3bb75d2f3718facad939af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 14 Feb 2026 12:43:15 +0100 Subject: [PATCH 2/7] remove the explicit predicate types in json schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../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 | 7 +- .../schema/openapi/generated/models/index.ts | 9 -- .../catalog-backend/src/schema/openapi.yaml | 86 +---------- .../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 | 7 +- .../schema/openapi/generated/models/index.ts | 9 -- .../src/schema/openapi/generated/router.ts | 139 +----------------- .../request/parseLocationQuery.test.ts | 4 +- 25 files changed, 12 insertions(+), 781 deletions(-) delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts 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 deleted file mode 100644 index b27f3d1c3e..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 deleted file mode 100644 index e21f98abbf..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4e6cb62d01..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 7800fcd20c..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 deleted file mode 100644 index af0640e96a..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 deleted file mode 100644 index 5557d3fef9..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 2b4e3d7c2f..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 deleted file mode 100644 index a5fc06f39b..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 5627155073..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 index 699c7a71fd..ad6e8297e9 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts @@ -18,13 +18,14 @@ // * 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; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } 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 6de4e7da9e..abdb58378c 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,6 @@ 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'; diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 785e40bcf4..9f78cc6210 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -338,90 +338,6 @@ 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: {} @@ -1324,7 +1240,7 @@ paths: limit: type: number query: - $ref: '#/components/schemas/EntityPredicate' + $ref: '#/components/schemas/JsonObject' /locations/{id}: get: operationId: GetLocation 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 deleted file mode 100644 index b27f3d1c3e..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 deleted file mode 100644 index e21f98abbf..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4e6cb62d01..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 7800fcd20c..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 deleted file mode 100644 index af0640e96a..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateHasPrefix.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 deleted file mode 100644 index 5557d3fef9..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 2b4e3d7c2f..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 deleted file mode 100644 index a5fc06f39b..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 deleted file mode 100644 index 5627155073..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 index 699c7a71fd..ad6e8297e9 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetLocationsByQueryRequest.model.ts @@ -18,13 +18,14 @@ // * 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; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } 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 6de4e7da9e..abdb58378c 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,6 @@ 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'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index debfecc510..b4078df721 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -262,143 +262,6 @@ 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: {}, @@ -1574,7 +1437,7 @@ export const spec = { type: 'number', }, query: { - $ref: '#/components/schemas/EntityPredicate', + $ref: '#/components/schemas/JsonObject', }, }, }, diff --git a/plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts b/plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts index bffa2b38f2..ca5f5a8382 100644 --- a/plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/parseLocationQuery.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityPredicate } from '../../schema/openapi/generated/models/EntityPredicate.model'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { encodeLocationQueryCursor, parseLocationQuery, @@ -45,7 +45,7 @@ describe('parseLocationQuery', () => { }); it('should parse a complex query with $all', () => { - const query: EntityPredicate = { + const query: FilterPredicate = { $all: [{ type: 'url' }, { target: { $in: ['a', 'b'] } }], }; const result = parseLocationQuery({ query }); From 67806440792cbfb383f599f8341fef6507dfc6c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 14 Feb 2026 17:00:19 +0100 Subject: [PATCH 3/7] copilot comments 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 | 2 +- packages/catalog-client/src/types/api.ts | 2 +- plugins/catalog-backend/src/service/DefaultLocationService.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/metal-trains-smell-1.md b/.changeset/metal-trains-smell-1.md index 4a4db1dd6e..2bf814bbf5 100644 --- a/.changeset/metal-trains-smell-1.md +++ b/.changeset/metal-trains-smell-1.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': minor --- -Implemented the `POST /locations/by-query` endpoints that allows paginated, filtered location queries +Implemented the `POST /locations/by-query` endpoint which allows paginated, filtered location queries diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index c1f75f2c79..12c5e00c03 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -697,7 +697,7 @@ export interface CatalogApi { * * ``` * const secondBatchResponse = await catalogClient - * .queryLocations({ cursor: response.nextCursor }); + * .queryLocations({ cursor: response.pageInfo.nextCursor }); * ``` * * `secondBatchResponse` will contain the next batch of (maximum) 20 locations, diff --git a/plugins/catalog-backend/src/service/DefaultLocationService.ts b/plugins/catalog-backend/src/service/DefaultLocationService.ts index f65ff18271..ad3044ffd9 100644 --- a/plugins/catalog-backend/src/service/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/service/DefaultLocationService.ts @@ -76,7 +76,7 @@ export class DefaultLocationService implements LocationService { async queryLocations(options: { limit: number; - afterId: string; + afterId?: string; query?: FilterPredicate; credentials: BackstageCredentials; }): Promise<{ items: Location[]; totalItems: number }> { From aee4f26c88a711d200507577e5f9fa39970dec98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 14 Feb 2026 17:13:35 +0100 Subject: [PATCH 4/7] add router level end to end tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/service/createRouter.test.ts | 181 +++++++++++++++++- 1 file changed, 180 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index dbbc70328f..aa3ed3a551 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -16,7 +16,12 @@ import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter'; import { wrapServer } from '@backstage/backend-openapi-utils/testUtils'; -import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { + mockCredentials, + mockServices, + TestDatabaseId, + TestDatabases, +} from '@backstage/backend-test-utils'; import type { Location } from '@backstage/catalog-client'; import { ANNOTATION_LOCATION, @@ -38,12 +43,18 @@ import { Server } from 'node:http'; import request from 'supertest'; import { z } from 'zod'; import { Cursor, EntitiesCatalog } from '../catalog/types'; +import { applyDatabaseMigrations } from '../database/migrations'; +import { DbLocationsRow } from '../database/tables'; import { CatalogProcessingOrchestrator } from '../processing/types'; +import { DefaultLocationStore } from '../providers/DefaultLocationStore'; import { createRouter } from './createRouter'; +import { DefaultLocationService } from './DefaultLocationService'; import { basicEntityFilter } from './request'; import { LocationInput, LocationService, RefreshService } from './types'; import { decodeCursor, encodeCursor } from './util'; +jest.setTimeout(60_000); + const middleware = MiddlewareFactory.create({ logger: mockServices.logger.mock(), config: mockServices.rootConfig(), @@ -1408,6 +1419,174 @@ describe('NextRouter permissioning', () => { }); }); +describe('POST /locations/by-query works end to end', () => { + const databases = TestDatabases.create(); + + async function createTestRouter(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + await applyDatabaseMigrations(knex); + + const mockScmEvents = { + subscribe: jest.fn(), + publish: jest.fn(), + }; + + const store = new DefaultLocationStore(knex, mockScmEvents, { + refresh: false, + unregister: false, + move: false, + }); + await store.connect({ applyMutation: jest.fn(), refresh: jest.fn() }); + + const locationService = new DefaultLocationService( + store, + { process: jest.fn() }, + { allowedLocationTypes: ['url'] }, + ); + + 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(), + }); + + const errorMiddleware = MiddlewareFactory.create({ + logger: mockServices.logger.mock(), + config: mockServices.rootConfig(), + }); + router.use(errorMiddleware.error()); + + return { knex, app: express().use(router) }; + } + + it.each(databases.eachSupportedId())( + 'paginates through locations correctly, %p', + async databaseId => { + const { knex, app } = await createTestRouter(databaseId); + + // Insert 5 locations directly into the database + const locations = [ + { + id: '00000000-0000-0000-0000-000000000001', + type: 'url', + target: 'https://example.com/a.yaml', + }, + { + id: '00000000-0000-0000-0000-000000000002', + type: 'url', + target: 'https://example.com/b.yaml', + }, + { + id: '00000000-0000-0000-0000-000000000003', + type: 'url', + target: 'https://example.com/c.yaml', + }, + { + id: '00000000-0000-0000-0000-000000000004', + type: 'url', + target: 'https://example.com/d.yaml', + }, + { + id: '00000000-0000-0000-0000-000000000005', + type: 'url', + target: 'https://example.com/e.yaml', + }, + ]; + + // Clear the table and insert our test data + await knex('locations').delete(); + for (const location of locations) { + await knex('locations').insert(location); + } + + // First request: get first 2 locations + const response1 = await request(app) + .post('/locations/by-query') + .send({ limit: 2 }); + + expect(response1.status).toEqual(200); + expect(response1.body).toEqual({ + items: [locations[0], locations[1]], + totalItems: 5, + pageInfo: { nextCursor: expect.any(String) }, + }); + + // Second request: use the cursor to get the next batch + const response2 = await request(app) + .post('/locations/by-query') + .send({ cursor: response1.body.pageInfo.nextCursor }); + + expect(response2.status).toEqual(200); + expect(response2.body).toEqual({ + items: [locations[2], locations[3]], + totalItems: 5, + pageInfo: { nextCursor: expect.any(String) }, + }); + + // Third request: get the last location (no more pages) + const response3 = await request(app) + .post('/locations/by-query') + .send({ cursor: response2.body.pageInfo.nextCursor }); + + expect(response3.status).toEqual(200); + expect(response3.body).toEqual({ + items: [locations[4]], + totalItems: 5, + pageInfo: {}, + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'filters locations with query parameter, %p', + async databaseId => { + const { knex, app } = await createTestRouter(databaseId); + + // Insert locations with different types + const locations = [ + { + id: '00000000-0000-0000-0000-000000000001', + type: 'url', + target: 'https://example.com/a.yaml', + }, + { + id: '00000000-0000-0000-0000-000000000002', + type: 'file', + target: '/tmp/b.yaml', + }, + { + id: '00000000-0000-0000-0000-000000000003', + type: 'url', + target: 'https://example.com/c.yaml', + }, + ]; + + // Clear the table and insert our test data + await knex('locations').delete(); + for (const location of locations) { + await knex('locations').insert(location); + } + + // Query only url type locations + const response = await request(app) + .post('/locations/by-query') + .send({ limit: 10, query: { type: 'url' } }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items: [locations[0], locations[2]], + totalItems: 2, + pageInfo: {}, + }); + }, + ); +}); + function mockCursor(partialCursor?: Partial): Cursor { return { orderFields: [], From 1e5464cdf0eac841176479897b52d63828112d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 14 Feb 2026 20:59:16 +0100 Subject: [PATCH 5/7] more copilot comments fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../providers/DefaultLocationStore.test.ts | 21 ++++++ .../src/providers/DefaultLocationStore.ts | 68 +++++++++++-------- .../src/service/createRouter.ts | 2 +- .../src/service/request/parseLocationQuery.ts | 37 ++++++++-- 4 files changed, 96 insertions(+), 32 deletions(-) diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts index ac57a8d075..f12293fa1f 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.test.ts @@ -951,6 +951,27 @@ describe('DefaultLocationStore', () => { items: [l1, l2, l3, l4], totalItems: 4, }); + + await expect( + store.queryLocations({ limit: 10, query: { $all: [] } }), + ).resolves.toEqual({ + items: [], + totalItems: 0, + }); + + await expect( + store.queryLocations({ limit: 10, query: { $any: [] } }), + ).resolves.toEqual({ + items: [], + totalItems: 0, + }); + + await expect( + store.queryLocations({ limit: 10, query: { type: { $in: [] } } }), + ).resolves.toEqual({ + items: [], + totalItems: 0, + }); }, ); }); diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index 2c3f2a0da5..74509eb1d5 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -109,7 +109,10 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { afterId?: string; query?: FilterPredicate; }): Promise<{ items: Location[]; totalItems: number }> { - let itemsQuery = this.db('locations'); + let itemsQuery = this.db('locations').whereNot( + 'type', + 'bootstrap', + ); if (options.query) { itemsQuery = applyLocationFilterToQuery( @@ -558,6 +561,9 @@ function applyLocationFilterToQuery( } if ('$all' in query) { + if (query.$all.length === 0) { + return result.whereRaw('1 = 0'); + } return result.where(outer => { for (const subQuery of query.$all) { outer.andWhere(inner => { @@ -568,6 +574,9 @@ function applyLocationFilterToQuery( } if ('$any' in query) { + if (query.$any.length === 0) { + return result.whereRaw('1 = 0'); + } return result.where(outer => { for (const subQuery of query.$any) { outer.orWhere(inner => { @@ -636,46 +645,51 @@ function applyLocationFilterToQuery( ? result.whereNotNull(key) : result.whereNull(key); } else if ('$in' in value) { - if (key === 'id') { + if (value.$in.length === 0) { + result = result.whereRaw('1 = 0'); + } else 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], - ); + const rhs = value.$in.map(() => 'UPPER(?::text)').join(', '); + result = result.whereRaw(`UPPER(??::text) IN (${rhs})`, [ + 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], - ); + const rhs = value.$in.map(() => 'UPPER(CAST(? AS CHAR))').join(', '); + result = result.whereRaw(`UPPER(CAST(?? AS CHAR)) IN (${rhs})`, [ + key, + ...value.$in, + ]); } else { - result = result.whereRaw( - `UPPER(??) IN (${value.$in.map(() => 'UPPER(?)').join(', ')})`, - [key, ...value.$in], - ); + const rhs = value.$in.map(() => 'UPPER(?)').join(', '); + result = result.whereRaw(`UPPER(??) IN (${rhs})`, [ + key, + ...value.$in, + ]); } } else if ('$hasPrefix' in value) { const escaped = value.$hasPrefix.replace(/([\\%_])/g, '\\$1'); if (clientType === 'pg') { - result = result.whereRaw('?? ilike ?', [key, `${escaped}%`]); + result = result.whereRaw("?? ilike ? escape '\\'", [ + key, + `${escaped}%`, + ]); + } else if (clientType.includes('mysql')) { + result = result.whereRaw("UPPER(??) like UPPER(?) escape '\\\\'", [ + key, + `${escaped}%`, + ]); } else { - result = result.whereRaw('UPPER(??) like UPPER(?)', [ + result = result.whereRaw("UPPER(??) like UPPER(?) escape '\\'", [ 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`, - ); + // There are no array shaped values for location queries, so we just + // always fail here + result = result.whereRaw('1 = 0'); } else { throw new InputError( `Invalid filter predicate, got unknown matcher object '${JSON.stringify( diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 9dba615d36..11e459ae82 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -605,7 +605,7 @@ export async function createRouter( }); try { - const request = parseLocationQuery(req.body); + const request = parseLocationQuery(req.body ?? {}); const result = await locationService.queryLocations({ ...request, limit: request.limit + 1, diff --git a/plugins/catalog-backend/src/service/request/parseLocationQuery.ts b/plugins/catalog-backend/src/service/request/parseLocationQuery.ts index 6021c148f7..61a926613e 100644 --- a/plugins/catalog-backend/src/service/request/parseLocationQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseLocationQuery.ts @@ -32,6 +32,20 @@ const locationCursorParser = z.object({ query: filterPredicateSchema.optional(), }); +function isSupportedFilterPredicateRoot( + value: FilterPredicate | undefined, +): boolean { + if (value === undefined) { + return true; + } + + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + return true; +} + export function parseLocationQuery( request: Readonly, ): { @@ -39,7 +53,11 @@ export function parseLocationQuery( afterId?: string; query?: FilterPredicate; } { - if (request.cursor) { + if (request.cursor !== undefined) { + if (!request.cursor) { + throw new InputError('Cursor cannot be empty'); + } + let parsed: JsonValue; try { const data = Buffer.from(request.cursor, 'base64').toString('utf8'); @@ -52,10 +70,16 @@ export function parseLocationQuery( if (!result.success) { throw new InputError(`Malformed cursor: ${fromZodError(result.error)}`); } + + const { query, limit, afterId } = result.data; + if (!isSupportedFilterPredicateRoot(query)) { + throw new InputError('Query must be an object'); + } + return { - limit: result.data.limit, - afterId: result.data.afterId, - query: result.data.query, + limit, + afterId, + query, }; } @@ -70,6 +94,11 @@ export function parseLocationQuery( if (!result.success) { throw new InputError(`Invalid query: ${fromZodError(result.error)}`); } + + if (!isSupportedFilterPredicateRoot(result.data)) { + throw new InputError('Query must be an object'); + } + query = result.data; } From 8aa6c513d8551e5e8fc504252ac03b1cf4acad7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 14 Feb 2026 21:30:41 +0100 Subject: [PATCH 6/7] break out the value matching to make things more readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/providers/DefaultLocationStore.ts | 223 ++++++++++-------- 1 file changed, 128 insertions(+), 95 deletions(-) diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index 74509eb1d5..3d48eb93d1 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -43,7 +43,10 @@ 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'; +import { + FilterPredicate, + FilterPredicateValue, +} from '@backstage/filter-predicates'; export class DefaultLocationStore implements LocationStore, EntityProvider { private _connection: EntityProviderConnection | undefined; @@ -561,9 +564,11 @@ function applyLocationFilterToQuery( } if ('$all' in query) { + // Explicitly handle the empty case to avoid malformed SQL if (query.$all.length === 0) { return result.whereRaw('1 = 0'); } + return result.where(outer => { for (const subQuery of query.$all) { outer.andWhere(inner => { @@ -574,9 +579,11 @@ function applyLocationFilterToQuery( } if ('$any' in query) { + // Explicitly handle the empty case to avoid malformed SQL if (query.$any.length === 0) { return result.whereRaw('1 = 0'); } + return result.where(outer => { for (const subQuery of query.$any) { outer.orWhere(inner => { @@ -608,101 +615,127 @@ function applyLocationFilterToQuery( ); } - 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 (value.$in.length === 0) { - result = result.whereRaw('1 = 0'); - } else if (key === 'id') { - result = result.whereIn(key, value.$in); - } else if (clientType === 'pg') { - const rhs = value.$in.map(() => 'UPPER(?::text)').join(', '); - result = result.whereRaw(`UPPER(??::text) IN (${rhs})`, [ - key, - ...value.$in, - ]); - } else if (clientType.includes('mysql')) { - const rhs = value.$in.map(() => 'UPPER(CAST(? AS CHAR))').join(', '); - result = result.whereRaw(`UPPER(CAST(?? AS CHAR)) IN (${rhs})`, [ - key, - ...value.$in, - ]); - } else { - const rhs = value.$in.map(() => 'UPPER(?)').join(', '); - result = result.whereRaw(`UPPER(??) IN (${rhs})`, [ - key, - ...value.$in, - ]); - } - } else if ('$hasPrefix' in value) { - const escaped = value.$hasPrefix.replace(/([\\%_])/g, '\\$1'); - if (clientType === 'pg') { - result = result.whereRaw("?? ilike ? escape '\\'", [ - key, - `${escaped}%`, - ]); - } else if (clientType.includes('mysql')) { - result = result.whereRaw("UPPER(??) like UPPER(?) escape '\\\\'", [ - key, - `${escaped}%`, - ]); - } else { - result = result.whereRaw("UPPER(??) like UPPER(?) escape '\\'", [ - key, - `${escaped}%`, - ]); - } - } else if ('$contains' in value) { - // There are no array shaped values for location queries, so we just - // always fail here - result = result.whereRaw('1 = 0'); - } 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}'`, - ); - } + result = applyFilterValueToQuery(clientType, result, key, value); } return result; } + +function applyFilterValueToQuery( + clientType: string, + result: Knex.QueryBuilder, + key: string, + value: FilterPredicateValue, +): Knex.QueryBuilder { + // Is it a primitive value? + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + // The id is matched with plain equality; it's of UUID type and case + // insensitivity does not apply. + if (key === 'id') { + return result.where({ id: value }); + } + + if (clientType === 'pg') { + return result.whereRaw(`UPPER(??::text) = UPPER(?::text)`, [key, value]); + } + + if (clientType.includes('mysql')) { + return result.whereRaw( + `UPPER(CAST(?? AS CHAR)) = UPPER(CAST(? AS CHAR))`, + [key, value], + ); + } + + return result.whereRaw(`UPPER(??) = UPPER(?)`, [key, value]); + } + + // Is it a matcher object? + if (typeof value === 'object') { + if (!value || Array.isArray(value)) { + throw new InputError( + `Invalid filter predicate, got unknown matcher object '${JSON.stringify( + value, + )}'`, + ); + } + + // Technically existence checks do not make much sense in the context of + // this table at the time of writing (values are always present), but + // there's nothing gained by prohibiting it. + if ('$exists' in value) { + return value.$exists ? result.whereNotNull(key) : result.whereNull(key); + } + + if ('$in' in value) { + // Explicitly handle the empty case to avoid malformed SQL + if (value.$in.length === 0) { + return result.whereRaw('1 = 0'); + } + + // The id is matched with plain equality; it's of UUID type and case + // insensitivity does not apply. + if (key === 'id') { + return result.whereIn(key, value.$in); + } + + if (clientType === 'pg') { + const rhs = value.$in.map(() => 'UPPER(?::text)').join(', '); + return result.whereRaw(`UPPER(??::text) IN (${rhs})`, [ + key, + ...value.$in, + ]); + } + + if (clientType.includes('mysql')) { + const rhs = value.$in.map(() => 'UPPER(CAST(? AS CHAR))').join(', '); + return result.whereRaw(`UPPER(CAST(?? AS CHAR)) IN (${rhs})`, [ + key, + ...value.$in, + ]); + } + + const rhs = value.$in.map(() => 'UPPER(?)').join(', '); + return result.whereRaw(`UPPER(??) IN (${rhs})`, [key, ...value.$in]); + } + + if ('$hasPrefix' in value) { + const escaped = value.$hasPrefix.replace(/([\\%_])/g, '\\$1'); + + if (clientType === 'pg') { + return result.whereRaw("?? ilike ? escape '\\'", [key, `${escaped}%`]); + } + + if (clientType.includes('mysql')) { + return result.whereRaw("UPPER(??) like UPPER(?) escape '\\\\'", [ + key, + `${escaped}%`, + ]); + } + + return result.whereRaw("UPPER(??) like UPPER(?) escape '\\'", [ + key, + `${escaped}%`, + ]); + } + + // There are no array shaped values for location queries, so we just always + // fail here + if ('$contains' in value) { + return result.whereRaw('1 = 0'); + } + + throw new InputError( + `Invalid filter predicate, got unknown matcher object '${JSON.stringify( + value, + )}'`, + ); + } + + throw new InputError( + `Invalid filter predicate, expected value to be a primitive value or a matcher object, got '${typeof value}'`, + ); +} From d038038fb226e6eb129f00120e1a597e1d557c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 14 Feb 2026 21:58:06 +0100 Subject: [PATCH 7/7] even more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../src/providers/DefaultLocationStore.ts | 14 ++------------ .../GenericScmEventRefreshProvider.test.ts | 2 ++ plugins/catalog-node/report.api.md | 2 +- plugins/catalog-node/src/catalogService.ts | 2 +- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts index 3d48eb93d1..305bd611dc 100644 --- a/plugins/catalog-backend/src/providers/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/providers/DefaultLocationStore.ts @@ -543,7 +543,7 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { * @remarks * * Design note: The code prefers to let the SQL engine achieve case - * insensitivitiy. We could attempt to use `.toUpperCase` etc on the client + * insensitivity. 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. @@ -628,17 +628,7 @@ function applyFilterValueToQuery( value: FilterPredicateValue, ): Knex.QueryBuilder { // Is it a primitive value? - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - // The id is matched with plain equality; it's of UUID type and case - // insensitivity does not apply. - if (key === 'id') { - return result.where({ id: value }); - } - + if (['string', 'number', 'boolean'].includes(typeof value)) { if (clientType === 'pg') { return result.whereRaw(`UPPER(??::text) = UPPER(?::text)`, [key, value]); } diff --git a/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts b/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts index 8c74f350bb..bef8d1555d 100644 --- a/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts +++ b/plugins/catalog-backend/src/providers/GenericScmEventRefreshProvider.test.ts @@ -29,6 +29,8 @@ import { } from '../database/tables'; import { GenericScmEventRefreshProvider } from './GenericScmEventRefreshProvider'; +jest.setTimeout(60_000); + describe('GenericScmEventRefreshProvider', () => { const databases = TestDatabases.create(); diff --git a/plugins/catalog-node/report.api.md b/plugins/catalog-node/report.api.md index 24b4410a9d..0c27645796 100644 --- a/plugins/catalog-node/report.api.md +++ b/plugins/catalog-node/report.api.md @@ -238,7 +238,7 @@ export interface CatalogService { ): Promise; // (undocumented) queryLocations( - request: QueryLocationsRequest, + request: QueryLocationsRequest | undefined, options: CatalogServiceRequestOptions, ): Promise; // (undocumented) diff --git a/plugins/catalog-node/src/catalogService.ts b/plugins/catalog-node/src/catalogService.ts index 4f4fd6d764..be9616d7f6 100644 --- a/plugins/catalog-node/src/catalogService.ts +++ b/plugins/catalog-node/src/catalogService.ts @@ -111,7 +111,7 @@ export interface CatalogService { ): Promise; queryLocations( - request: QueryLocationsRequest, + request: QueryLocationsRequest | undefined, options: CatalogServiceRequestOptions, ): Promise;