add support for location queries in the catalog
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Implemented the `POST /locations/by-query` endpoints that allows paginated, filtered location queries
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+24
@@ -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;
|
||||
+29
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
+30
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+29
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+24
@@ -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;
|
||||
+29
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
+30
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+29
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user