[OAS] Pass down env to router processors and remove added in... from serverless OAS (#222189)

## Summary

* Addresses this comment
https://github.com/elastic/kibana/issues/221056#issuecomment-2917522252,
essentially avoid stating `added in...` if we are generating OAS for a
serverless Kibana
* Refactors `processRouter` and `processVersionedRouter` to take in
object (for easier refactoring)
* Adds test coverage


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2025-06-03 12:41:03 +02:00 committed by GitHub
parent 539409d082
commit c76d06fb6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 322 additions and 104 deletions

View file

@ -5431,7 +5431,7 @@
"tags": [
"alerting"
],
"x-state": "Generally available; added in 8.19.0"
"x-state": "Generally available"
}
},
"/api/alerting/rule/{ruleId}/snooze_schedule/{scheduleId}": {
@ -5485,7 +5485,7 @@
"tags": [
"alerting"
],
"x-state": "Generally available; added in 8.19.0"
"x-state": "Generally available"
}
},
"/api/alerting/rule/{rule_id}/alert/{alert_id}/_mute": {

View file

@ -4218,7 +4218,7 @@ paths:
summary: Schedule a snooze for the rule
tags:
- alerting
x-state: Generally available; added in 8.19.0
x-state: Generally available
/api/alerting/rule/{rule_id}/alert/{alert_id}/_mute:
post:
operationId: post-alerting-rule-rule-id-alert-alert-id-mute
@ -4324,7 +4324,7 @@ paths:
summary: Delete a snooze schedule for a rule
tags:
- alerting
x-state: Generally available; added in 8.19.0
x-state: Generally available
/api/alerting/rules/_find:
get:
operationId: get-alerting-rules-find

View file

@ -311,6 +311,7 @@ export class HttpService
title: 'Kibana HTTP APIs',
version: '0.0.0', // TODO get a better version here
filters,
env: { serverless: this.env.packageInfo.buildFlavor === 'serverless' },
}
);
return h.response(result);

View file

@ -7,9 +7,27 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
jest.mock('./process_router', () => {
const module = jest.requireActual('./process_router');
return {
...module,
processRouter: jest.fn(module.processRouter),
};
});
jest.mock('./process_versioned_router', () => {
const module = jest.requireActual('./process_versioned_router');
return {
...module,
processVersionedRouter: jest.fn(module.processVersionedRouter),
};
});
import { schema, Type } from '@kbn/config-schema';
import { get } from 'lodash';
import { generateOpenApiDocument } from './generate_oas';
import { processRouter } from './process_router';
import { processVersionedRouter } from './process_versioned_router';
import {
createTestRouters,
createRouter,
@ -27,6 +45,10 @@ interface RecursiveType {
self: undefined | RecursiveType;
}
afterEach(() => {
jest.clearAllMocks();
});
describe('generateOpenApiDocument', () => {
describe('@kbn/config-schema', () => {
it('generates the expected OpenAPI document for the shared schema', async () => {
@ -562,6 +584,7 @@ describe('generateOpenApiDocument', () => {
'$name: $expectedState',
async ({ routerConfig, expectedPath, expectedState }) => {
const [routers, versionedRouters] = createTestRouters(routerConfig);
const env = { serverless: false, dummy: true };
const result = await generateOpenApiDocument(
{
routers,
@ -571,9 +594,23 @@ describe('generateOpenApiDocument', () => {
title: 'test',
baseUrl: 'https://test.oas',
version: '99.99.99',
env,
}
);
// Assert that the env has been passed down as expected
if ((processRouter as jest.Mock).mock.calls.length) {
(processRouter as jest.Mock).mock.calls.forEach(([{ env: routerEnv }]) =>
expect(routerEnv).toEqual({ serverless: false, dummy: true })
);
}
if ((processVersionedRouter as jest.Mock).mock.calls.length) {
(processVersionedRouter as jest.Mock).mock.calls.forEach(
([{ env: versionedRouterEnv }]) =>
expect(versionedRouterEnv).toEqual({ serverless: false, dummy: true })
);
}
if (expectedState) {
expect(result.paths[expectedPath]!.get).toMatchObject({
'x-state': expectedState,

View file

@ -17,6 +17,10 @@ import { buildGlobalTags, createOpIdGenerator } from './util';
export const openApiVersion = '3.0.0';
export interface Env {
serverless: boolean;
}
export interface GenerateOpenApiDocumentOptionsFilters {
pathStartsWith?: string[];
excludePathsMatching?: string[];
@ -36,6 +40,7 @@ export interface GenerateOpenApiDocumentOptions {
baseUrl: string;
docsUrl?: string;
tags?: string[];
env?: Env;
filters?: GenerateOpenApiDocumentOptionsFilters;
}
@ -50,12 +55,25 @@ export const generateOpenApiDocument = async (
const converter = new OasConverter();
const paths: OpenAPIV3.PathsObject = {};
const getOpId = createOpIdGenerator();
const env = opts.env || { serverless: false };
for (const router of appRouters.routers) {
const result = await processRouter(router, converter, getOpId, filters);
const result = await processRouter({
appRouter: router,
converter,
getOpId,
filters,
env,
});
Object.assign(paths, result.paths);
}
for (const router of appRouters.versionedRouters) {
const result = await processVersionedRouter(router, converter, getOpId, filters);
const result = await processVersionedRouter({
appRouter: router,
converter,
getOpId,
filters,
env,
});
Object.assign(paths, result.paths);
}
const tags = buildGlobalTags(paths, opts.tags);

View file

@ -7,12 +7,24 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
jest.mock('./util', () => {
const module = jest.requireActual('./util');
return {
...module,
setXState: jest.fn(module.setXState),
};
});
import { schema } from '@kbn/config-schema';
import { Router } from '@kbn/core-http-router-server-internal';
import { OasConverter } from './oas_converter';
import { extractResponses, processRouter } from './process_router';
import { type InternalRouterRoute } from './type';
import { createOpIdGenerator } from './util';
import { createOpIdGenerator, setXState } from './util';
afterEach(() => {
jest.clearAllMocks();
});
describe('extractResponses', () => {
let oasConverter: OasConverter;
@ -149,24 +161,39 @@ describe('processRouter', () => {
} as unknown as Router;
it('only provides routes for version 2023-10-31', async () => {
const result1 = await processRouter(testRouter, new OasConverter(), createOpIdGenerator(), {
version: '2023-10-31',
access: 'public',
const result1 = await processRouter({
appRouter: testRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: {
version: '2023-10-31',
access: 'public',
},
});
expect(Object.keys(result1.paths!)).toHaveLength(5);
const result2 = await processRouter(testRouter, new OasConverter(), createOpIdGenerator(), {
version: '2024-10-31',
access: 'public',
const result2 = await processRouter({
appRouter: testRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: {
version: '2024-10-31',
access: 'public',
},
});
expect(Object.keys(result2.paths!)).toHaveLength(0);
});
it('updates description with privileges required', async () => {
const result = await processRouter(testRouter, new OasConverter(), createOpIdGenerator(), {
version: '2023-10-31',
access: 'public',
const result = await processRouter({
appRouter: testRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: {
version: '2023-10-31',
access: 'public',
},
});
expect(result.paths['/qux']?.post).toBeDefined();
@ -179,4 +206,26 @@ describe('processRouter', () => {
'This a test route description.<br/><br/>[Required authorization] Route required privileges: (manage_spaces AND taskmanager) AND (console OR devtools).'
);
});
it('calls setXState with correct arguments', async () => {
await processRouter({
appRouter: testRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: {
version: '2023-10-31',
access: 'public',
},
env: { serverless: true },
});
const routes = testRouter.getRoutes();
expect(setXState).toHaveBeenCalledTimes(routes.length);
routes.forEach((_, idx) => {
const [availability, operation, env] = (setXState as jest.Mock).mock.calls[idx];
expect(availability === undefined || typeof availability === 'object').toBe(true);
expect(typeof operation === 'object').toBe(true);
expect(env).toEqual({ serverless: true });
});
});
});

View file

@ -25,17 +25,26 @@ import {
setXState,
GetOpId,
} from './util';
import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
import type { Env, GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
import type { CustomOperationObject, InternalRouterRoute } from './type';
import { extractAuthzDescription } from './extract_authz_description';
import { mergeOperation } from './merge_operation';
export const processRouter = async (
appRouter: Router,
converter: OasConverter,
getOpId: GetOpId,
filters: GenerateOpenApiDocumentOptionsFilters
) => {
export interface ProcessRouterOptions {
appRouter: Router;
converter: OasConverter;
getOpId: GetOpId;
filters: GenerateOpenApiDocumentOptionsFilters;
env?: Env;
}
export const processRouter = async ({
appRouter,
converter,
getOpId,
filters,
env = { serverless: false },
}: ProcessRouterOptions) => {
const paths: OpenAPIV3.PathsObject = {};
if (filters?.version && filters.version !== SERVERLESS_VERSION_2023_10_31) return { paths };
const routes = prepareRoutes(appRouter.getRoutes({ excludeVersionedRoutes: true }), filters);
@ -98,7 +107,7 @@ export const processRouter = async (
operationId: getOpId({ path: route.path, method: route.method }),
};
setXState(route.options.availability, operation);
setXState(route.options.availability, operation, env);
if (route.options.oasOperationObject) {
await mergeOperation(route.options.oasOperationObject(), operation);

View file

@ -7,6 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
jest.mock('./util', () => {
const module = jest.requireActual('./util');
return {
...module,
setXState: jest.fn(module.setXState),
};
});
import { schema } from '@kbn/config-schema';
import type { CoreVersionedRouter } from '@kbn/core-http-router-server-internal';
import { get } from 'lodash';
@ -17,13 +25,17 @@ import {
processVersionedRouter,
} from './process_versioned_router';
import { VersionedRouterRoute } from '@kbn/core-http-server';
import { createOpIdGenerator } from './util';
import { createOpIdGenerator, setXState } from './util';
let oasConverter: OasConverter;
beforeEach(() => {
oasConverter = new OasConverter();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('extractVersionedRequestBodies', () => {
test('handles full request config as expected', () => {
expect(
@ -122,35 +134,35 @@ describe('extractVersionedResponses', () => {
describe('processVersionedRouter', () => {
it('correctly extracts the version based on the version filter', async () => {
const baseCase = await processVersionedRouter(
{ getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter,
new OasConverter(),
createOpIdGenerator(),
{ access: 'public', version: '2023-10-31' }
);
const baseCase = await processVersionedRouter({
appRouter: { getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: { access: 'public', version: '2023-10-31' },
});
expect(Object.keys(get(baseCase, 'paths["/foo"].get.responses.200.content')!)).toEqual([
'application/test+json',
]);
const filteredCase = await processVersionedRouter(
{ getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter,
new OasConverter(),
createOpIdGenerator(),
{ version: '2024-12-31', access: 'public' }
);
const filteredCase = await processVersionedRouter({
appRouter: { getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: { version: '2024-12-31', access: 'public' },
});
expect(Object.keys(get(filteredCase, 'paths["/foo"].get.responses.200.content')!)).toEqual([
'application/test+json',
]);
});
it('correctly updates the authz description for routes that require privileges', async () => {
const results = await processVersionedRouter(
{ getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter,
new OasConverter(),
createOpIdGenerator(),
{ version: '2023-10-31', access: 'public' }
);
const results = await processVersionedRouter({
appRouter: { getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: { version: '2023-10-31', access: 'public' },
});
expect(results.paths['/foo']).toBeDefined();
expect(results.paths['/foo']!.get).toBeDefined();
@ -159,6 +171,29 @@ describe('processVersionedRouter', () => {
'This is a test route description.<br/><br/>[Required authorization] Route required privileges: manage_spaces.'
);
});
it('calls setXState with correct arguments', async () => {
const testRouter = { getRoutes: () => [createTestRoute()] } as unknown as CoreVersionedRouter;
await processVersionedRouter({
appRouter: testRouter,
converter: new OasConverter(),
getOpId: createOpIdGenerator(),
filters: {
version: '2023-10-31',
access: 'public',
},
env: { serverless: true },
});
const routes = testRouter.getRoutes();
expect(setXState).toHaveBeenCalledTimes(routes.length);
routes.forEach((_, idx) => {
const [availability, operation, env] = (setXState as jest.Mock).mock.calls[idx];
expect(availability === undefined || typeof availability === 'object').toBe(true);
expect(typeof operation === 'object').toBe(true);
expect(env).toEqual({ serverless: true });
});
});
});
const createTestRoute: () => VersionedRouterRoute = () => ({

View file

@ -15,7 +15,7 @@ import {
import type { RouteMethod, VersionedRouterRoute } from '@kbn/core-http-server';
import type { OpenAPIV3 } from 'openapi-types';
import { extractAuthzDescription } from './extract_authz_description';
import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
import type { Env, GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
import type { OasConverter } from './oas_converter';
import {
prepareRoutes,
@ -33,12 +33,21 @@ import {
import { isReferenceObject } from './oas_converter/common';
import { mergeOperation } from './merge_operation';
export const processVersionedRouter = async (
appRouter: CoreVersionedRouter,
converter: OasConverter,
getOpId: GetOpId,
filters: GenerateOpenApiDocumentOptionsFilters
) => {
export interface ProcessVersionedRouterOptions {
appRouter: CoreVersionedRouter;
converter: OasConverter;
getOpId: GetOpId;
filters: GenerateOpenApiDocumentOptionsFilters;
env?: Env;
}
export const processVersionedRouter = async ({
appRouter,
converter,
getOpId,
filters,
env = { serverless: false },
}: ProcessVersionedRouterOptions) => {
const routes = prepareRoutes(appRouter.getRoutes(), filters);
const paths: OpenAPIV3.PathsObject = {};
for (const route of routes) {
@ -129,7 +138,7 @@ export const processVersionedRouter = async (
operationId: getOpId({ path: route.path, method: route.method }),
};
setXState(route.options.options?.availability, operation);
setXState(route.options.options?.availability, operation, env);
if (handler.options.options?.oasOperationObject) {
await mergeOperation(handler.options.options.oasOperationObject(), operation);

View file

@ -351,57 +351,115 @@ describe('createOpIdGenerator', () => {
});
describe('setXState', () => {
test.each([
{
name: 'stable with since',
availability: { stability: 'stable' as const, since: '8.0.0' },
expected: 'Generally available; added in 8.0.0',
},
{
name: 'stable without since',
availability: { stability: 'stable' as const },
expected: 'Generally available',
},
{
name: 'experimental with since',
availability: { stability: 'experimental' as const, since: '8.0.0' },
expected: 'Technical Preview; added in 8.0.0',
},
{
name: 'experimental without since',
availability: { stability: 'experimental' as const },
expected: 'Technical Preview',
},
{
name: 'beta with since',
availability: { stability: 'beta' as const, since: '8.0.0' },
expected: 'Beta; added in 8.0.0',
},
{
name: 'beta without since',
availability: { stability: 'beta' as const },
expected: 'Beta',
},
{
name: 'no availability',
availability: undefined,
expected: undefined,
},
{
name: 'only since',
availability: { since: '8.0.0' },
expected: 'Added in 8.0.0',
},
])('$name', ({ availability, expected }) => {
// Create a minimal valid CustomOperationObject with required responses property
const operation: CustomOperationObject = {
responses: {
'200': {
description: 'OK',
},
describe('with serverless=false (default)', () => {
test.each([
{
name: 'stable with since',
availability: { stability: 'stable' as const, since: '8.0.0' },
expected: 'Generally available; added in 8.0.0',
},
};
setXState(availability, operation);
expect(operation['x-state']).toBe(expected);
{
name: 'stable without since',
availability: { stability: 'stable' as const },
expected: 'Generally available',
},
{
name: 'experimental with since',
availability: { stability: 'experimental' as const, since: '8.0.0' },
expected: 'Technical Preview; added in 8.0.0',
},
{
name: 'experimental without since',
availability: { stability: 'experimental' as const },
expected: 'Technical Preview',
},
{
name: 'beta with since',
availability: { stability: 'beta' as const, since: '8.0.0' },
expected: 'Beta; added in 8.0.0',
},
{
name: 'beta without since',
availability: { stability: 'beta' as const },
expected: 'Beta',
},
{
name: 'no availability',
availability: undefined,
expected: undefined,
},
{
name: 'only since',
availability: { since: '8.0.0' },
expected: 'Added in 8.0.0',
},
])('$name', ({ availability, expected }) => {
// Create a minimal valid CustomOperationObject with required responses property
const operation: CustomOperationObject = {
responses: {
'200': {
description: 'OK',
},
},
};
setXState(availability, operation, { serverless: false });
expect(operation['x-state']).toBe(expected);
});
});
describe('with serverless=true', () => {
test.each([
{
name: 'stable with since',
availability: { stability: 'stable' as const, since: '8.0.0' },
expected: 'Generally available',
},
{
name: 'stable without since',
availability: { stability: 'stable' as const },
expected: 'Generally available',
},
{
name: 'experimental with since',
availability: { stability: 'experimental' as const, since: '8.0.0' },
expected: 'Technical Preview',
},
{
name: 'experimental without since',
availability: { stability: 'experimental' as const },
expected: 'Technical Preview',
},
{
name: 'beta with since',
availability: { stability: 'beta' as const, since: '8.0.0' },
expected: 'Beta',
},
{
name: 'beta without since',
availability: { stability: 'beta' as const },
expected: 'Beta',
},
{
name: 'no availability',
availability: undefined,
expected: undefined,
},
{
name: 'only since',
availability: { since: '8.0.0' },
expected: '',
},
])('$name', ({ availability, expected }) => {
// Create a minimal valid CustomOperationObject with required responses property
const operation: CustomOperationObject = {
responses: {
'200': {
description: 'OK',
},
},
};
setXState(availability, operation, { serverless: true });
expect(operation['x-state']).toBe(expected);
});
});
});

View file

@ -19,6 +19,7 @@ import {
} from '@kbn/core-http-server';
import { CustomOperationObject, KnownParameters } from './type';
import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
import { Env } from './generate_oas';
const tagPrefix = 'oas-tag:';
const extractTag = (tag: string) => {
@ -183,7 +184,8 @@ export const getXsrfHeaderForMethod = (
export const setXState = (
availability: RouteConfigOptions<RouteMethod>['availability'],
operation: CustomOperationObject
operation: CustomOperationObject,
env: Env
): void => {
if (availability) {
let state = '';
@ -194,7 +196,7 @@ export const setXState = (
} else if (availability.stability === 'beta') {
state = 'Beta';
}
if (availability.since) {
if (!env.serverless && availability.since) {
state = state ? `${state}; added in ${availability.since}` : `Added in ${availability.since}`;
}
operation['x-state'] = state;