feat(catalog): add POST /entity-facets endpoint with predicate support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-02-26 16:03:16 +01:00
parent adcd98eb0b
commit e0fc8ddaec
10 changed files with 271 additions and 0 deletions
@@ -30,6 +30,7 @@ import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model';
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model';
@@ -146,6 +147,12 @@ export type GetEntityFacets = {
export type QueryEntitiesByPredicate = {
body: QueryEntitiesByPredicateRequest;
};
/**
* @public
*/
export type QueryEntityFacetsByPredicate = {
body: QueryEntityFacetsByPredicateRequest;
};
/**
* @public
*/
@@ -481,6 +488,31 @@ export class DefaultApiClient {
});
}
/**
* Get entity facets using predicate-based filters.
* @param queryEntityFacetsByPredicateRequest -
*/
public async queryEntityFacetsByPredicate(
// @ts-ignore
request: QueryEntityFacetsByPredicate,
options?: RequestOptions,
): Promise<TypedResponse<EntityFacetsResponse>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/entity-facets`;
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),
});
}
/**
* Refresh the entity related to entityRef.
* @param refreshEntityRequest -
@@ -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. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntityFacetsByPredicateRequest {
facets: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -48,6 +48,7 @@ export * from '../models/NullableEntity.model';
export * from '../models/QueryEntitiesByPredicateRequest.model';
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/QueryEntityFacetsByPredicateRequest.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
@@ -1194,6 +1194,40 @@ paths:
value:
- spec.type
- $ref: '#/components/parameters/filter'
post:
operationId: QueryEntityFacetsByPredicate
tags:
- Entity
description: Get entity facets using predicate-based filters.
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/EntityFacetsResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- facets
properties:
facets:
type: array
items:
type: string
query:
$ref: '#/components/schemas/JsonObject'
/locations:
post:
operationId: CreateLocation
@@ -27,6 +27,7 @@ import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
import { QueryEntityFacetsByPredicateRequest } from '../models/QueryEntityFacetsByPredicateRequest.model';
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model';
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
@@ -128,6 +129,13 @@ export type QueryEntitiesByPredicate = {
body: QueryEntitiesByPredicateRequest;
response: EntitiesQueryResponse | Error | Error;
};
/**
* @public
*/
export type QueryEntityFacetsByPredicate = {
body: QueryEntityFacetsByPredicateRequest;
response: EntityFacetsResponse | Error | Error;
};
/**
* @public
*/
@@ -230,6 +238,8 @@ export type EndpointMap = {
'#post|/entities/by-query': QueryEntitiesByPredicate;
'#post|/entity-facets': QueryEntityFacetsByPredicate;
'#post|/refresh': RefreshEntity;
'#post|/validate-entity': ValidateEntity;
@@ -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. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntityFacetsByPredicateRequest {
facets: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -48,6 +48,7 @@ export * from '../models/NullableEntity.model';
export * from '../models/QueryEntitiesByPredicateRequest.model';
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/QueryEntityFacetsByPredicateRequest.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
@@ -1373,6 +1373,57 @@ export const spec = {
},
],
},
post: {
operationId: 'QueryEntityFacetsByPredicate',
tags: ['Entity'],
description: 'Get entity facets using predicate-based filters.',
responses: {
'200': {
description: 'Ok',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/EntityFacetsResponse',
},
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
{
JWT: [],
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['facets'],
properties: {
facets: {
type: 'array',
items: {
type: 'string',
},
},
query: {
$ref: '#/components/schemas/JsonObject',
},
},
},
},
},
},
},
},
'/locations': {
post: {
@@ -47,6 +47,7 @@ import {
parseQueryEntitiesParams,
} from './request';
import { parseEntityFacetParams } from './request/parseEntityFacetParams';
import { parseEntityFacetsQuery } from './request/parseEntityFacetsQuery';
import { parseEntityOrderParams } from './request/parseEntityOrderParams';
import { parseEntityPaginationParams } from './request/parseEntityPaginationParams';
import {
@@ -564,6 +565,31 @@ export async function createRouter(
await auditorEvent?.success();
res.status(200).json(response);
} catch (err) {
await auditorEvent?.fail({
error: err,
});
throw err;
}
})
.post('/entity-facets', async (req, res) => {
const auditorEvent = await auditor.createEvent({
eventId: 'entity-facets',
request: req,
});
try {
const { facets, query } = parseEntityFacetsQuery(req.body ?? {});
const response = await entitiesCatalog.facets({
query,
facets,
credentials: await httpAuth.credentials(req),
});
await auditorEvent?.success();
res.status(200).json(response);
} catch (err) {
await auditorEvent?.fail({
@@ -0,0 +1,56 @@
/*
* 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 { z } from 'zod/v3';
import { fromZodError } from 'zod-validation-error/v3';
import { QueryEntityFacetsByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntityFacetsByPredicateRequest.model';
const filterPredicateSchema = createZodV3FilterPredicateSchema(z);
export interface ParsedEntityFacetsQuery {
facets: string[];
query?: FilterPredicate;
}
export function parseEntityFacetsQuery(
request: Readonly<QueryEntityFacetsByPredicateRequest>,
): ParsedEntityFacetsQuery {
// Parse facets
if (!request.facets || request.facets.length === 0) {
throw new InputError('Missing or empty facets parameter');
}
const facets = request.facets.filter(f => f.length > 0);
if (facets.length === 0) {
throw new InputError('Missing or empty facets parameter');
}
// Parse query predicate
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 { facets, query };
}