add support for location queries in the catalog

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-02-12 14:26:14 +01:00
parent 6ada8bcf5d
commit b4e82492b9
61 changed files with 2674 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Implemented the `POST /locations/by-query` endpoints that allows paginated, filtered location queries
+7
View File
@@ -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
+1
View File
@@ -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"
@@ -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<QueryEntitiesResponse>;
// (undocumented)
queryLocations(
_request?: QueryLocationsRequest,
): Promise<QueryLocationsResponse>;
// (undocumented)
refreshEntity(_entityRef: string): Promise<void>;
// (undocumented)
removeEntityByUid(uid: string): Promise<void>;
@@ -73,6 +80,10 @@ export class InMemoryCatalogClient implements CatalogApi {
// (undocumented)
streamEntities(request?: StreamEntitiesRequest): AsyncIterable<Entity[]>;
// (undocumented)
streamLocations(
_request?: QueryLocationsInitialRequest,
): AsyncIterable<Location_2[]>;
// (undocumented)
validateEntity(
_entity: Entity,
_locationRef: string,
+48
View File
@@ -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<QueryEntitiesResponse>;
queryLocations(
request?: QueryLocationsRequest,
options?: CatalogRequestOptions,
): Promise<QueryLocationsResponse>;
refreshEntity(
entityRef: string,
options?: CatalogRequestOptions,
@@ -92,6 +97,10 @@ export interface CatalogApi {
request?: StreamEntitiesRequest,
options?: CatalogRequestOptions,
): AsyncIterable<Entity[]>;
streamLocations(
request?: QueryLocationsInitialRequest,
options?: CatalogRequestOptions,
): AsyncIterable<Location_2[]>;
validateEntity(
entity: Entity,
locationRef: string,
@@ -162,6 +171,10 @@ export class CatalogClient implements CatalogApi {
request?: QueryEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<QueryEntitiesResponse>;
queryLocations(
request?: QueryLocationsRequest,
options?: CatalogRequestOptions,
): Promise<QueryLocationsResponse>;
refreshEntity(
entityRef: string,
options?: CatalogRequestOptions,
@@ -178,6 +191,10 @@ export class CatalogClient implements CatalogApi {
request?: StreamEntitiesRequest,
options?: CatalogRequestOptions,
): AsyncIterable<Entity[]>;
streamLocations(
request?: QueryLocationsInitialRequest,
options?: CatalogRequestOptions,
): AsyncIterable<Location_2[]>;
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,
@@ -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: {
+58 -2
View File
@@ -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<QueryLocationsResponse> {
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<Location[]> {
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}
*/
+1
View File
@@ -15,3 +15,4 @@
*/
export const DEFAULT_STREAM_ENTITIES_LIMIT = 500;
export const DEFAULT_STREAM_LOCATIONS_LIMIT = 500;
@@ -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<TypedResponse<LocationsQueryResponse>> {
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),
});
}
}
@@ -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 };
@@ -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<EntityPredicate>;
}
@@ -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<EntityPredicate>;
}
@@ -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;
}
@@ -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;
}
@@ -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<EntityPredicateInInInner>;
}
@@ -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;
@@ -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;
}
@@ -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;
@@ -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;
}
@@ -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<Location>;
totalItems: number;
pageInfo: LocationsQueryResponsePageInfo;
}
@@ -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;
}
@@ -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';
@@ -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<QueryLocationsResponse> {
throw new NotImplementedError('Method not implemented.');
}
async *streamLocations(
_request?: QueryLocationsInitialRequest,
): AsyncIterable<Location[]> {
throw new NotImplementedError('Method not implemented.');
}
async getLocationById(_id: string): Promise<Location | undefined> {
throw new NotImplementedError('Method not implemented.');
}
+98
View File
@@ -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<GetLocationsResponse>;
/**
* 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<QueryLocationsResponse>;
/**
* 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<Location[]>;
/**
* Gets a registered location by its ID.
*
@@ -39,5 +39,9 @@ export type {
QueryEntitiesRequest,
QueryEntitiesResponse,
StreamEntitiesRequest,
QueryLocationsRequest,
QueryLocationsCursorRequest,
QueryLocationsInitialRequest,
QueryLocationsResponse,
} from './api';
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
+2 -1
View File
@@ -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;
}
+3 -1
View File
@@ -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:^",
@@ -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<DbLocationsRow>('locations').delete();
for (const location of locations) {
await knex<DbLocationsRow>('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,
});
},
);
});
});
@@ -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<DbLocationsRow>('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<Location> {
const items = await this.db<DbLocationsRow>('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;
}
@@ -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
@@ -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<GetLocations200ResponseInner> | 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;
};
@@ -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 };
@@ -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<EntityPredicate>;
}
@@ -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<EntityPredicate>;
}
@@ -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;
}
@@ -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;
}
@@ -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<EntityPredicateInInInner>;
}
@@ -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;
@@ -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;
}
@@ -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;
@@ -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;
}
@@ -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<Location>;
totalItems: number;
pageInfo: LocationsQueryResponsePageInfo;
}
@@ -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;
}
@@ -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';
@@ -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',
@@ -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(),
@@ -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 },
@@ -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');
@@ -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<Location[]> {
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<Location> {
return this.store.getLocation(id);
}
@@ -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(),
@@ -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;
@@ -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();
});
});
});
@@ -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<GetLocationsByQueryRequest>,
): {
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');
}
@@ -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<Location[]>;
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<Location>;
listLocations(): Promise<Location[]>;
queryLocations(options: {
limit: number;
afterId?: string;
query?: FilterPredicate;
}): Promise<{ items: Location[]; totalItems: number }>;
getLocation(id: string): Promise<Location>;
deleteLocation(id: string): Promise<void>;
getLocationByEntity(entityRef: CompoundEntityRef | string): Promise<Location>;
@@ -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<QueryEntitiesResponse>;
// (undocumented)
queryLocations(
request?: QueryLocationsRequest,
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): Promise<QueryLocationsResponse>;
// (undocumented)
refreshEntity(
entityRef: string,
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
@@ -113,6 +121,11 @@ export interface CatalogServiceMock extends CatalogService, CatalogApi {
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): AsyncIterable<Entity[]>;
// (undocumented)
streamLocations(
request?: QueryLocationsInitialRequest,
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): AsyncIterable<Location_2[]>;
// (undocumented)
validateEntity(
entity: Entity,
locationRef: string,
+13
View File
@@ -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<QueryEntitiesResponse>;
// (undocumented)
queryLocations(
request: QueryLocationsRequest,
options: CatalogServiceRequestOptions,
): Promise<QueryLocationsResponse>;
// (undocumented)
refreshEntity(
entityRef: string,
options: CatalogServiceRequestOptions,
@@ -254,6 +262,11 @@ export interface CatalogService {
options: CatalogServiceRequestOptions,
): AsyncIterable<Entity[]>;
// (undocumented)
streamLocations(
request: QueryLocationsInitialRequest | undefined,
options: CatalogServiceRequestOptions,
): AsyncIterable<Location_2[]>;
// (undocumented)
validateEntity(
entity: Entity,
locationRef: string,
@@ -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<GetLocationsResponse>;
queryLocations(
request: QueryLocationsRequest,
options: CatalogServiceRequestOptions,
): Promise<QueryLocationsResponse>;
streamLocations(
request: QueryLocationsInitialRequest | undefined,
options: CatalogServiceRequestOptions,
): AsyncIterable<Location[]>;
getLocationById(
id: string,
options: CatalogServiceRequestOptions,
@@ -254,6 +267,26 @@ class DefaultCatalogService implements CatalogService {
);
}
async queryLocations(
request: QueryLocationsRequest,
options: CatalogServiceRequestOptions,
): Promise<QueryLocationsResponse> {
return this.#catalogApi.queryLocations(
request,
await this.#getOptions(options),
);
}
async *streamLocations(
request: QueryLocationsInitialRequest | undefined,
options: CatalogServiceRequestOptions,
): AsyncIterable<Location[]> {
yield* this.#catalogApi.streamLocations(
request,
await this.#getOptions(options),
);
}
async getLocationById(
id: string,
options: CatalogServiceRequestOptions,
@@ -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(),
@@ -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<GetLocationsResponse>;
queryLocations(
request?: QueryLocationsRequest,
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): Promise<QueryLocationsResponse>;
streamLocations(
request?: QueryLocationsInitialRequest,
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
): AsyncIterable<Location[]>;
getLocationById(
id: string,
options?: CatalogServiceRequestOptions | CatalogRequestOptions,
@@ -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(),
+3
View File
@@ -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