[OAS] Support tags (#184320)

This commit is contained in:
Jean-Louis Leysens 2024-06-04 17:45:12 +02:00 committed by GitHub
parent c89ee65c70
commit 5fd4795b77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 447 additions and 65 deletions

View file

@ -521,7 +521,8 @@
"description": "Get Kibana's current status."
}
},
"summary": "Get Kibana's current status."
"summary": "Get Kibana's current status.",
"tags": []
}
}
},
@ -534,5 +535,6 @@
{
"url": "http://localhost:5622"
}
]
],
"tags": []
}

View file

@ -89,6 +89,7 @@ Object {
},
},
"summary": "",
"tags": Array [],
},
},
},
@ -102,7 +103,7 @@ Object {
"url": "https://test.oas",
},
],
"tags": undefined,
"tags": Array [],
}
`;
@ -213,6 +214,9 @@ Object {
},
},
"summary": "versioned route",
"tags": Array [
"versioned",
],
},
},
"/foo/{id}/{path*}": Object {
@ -357,6 +361,154 @@ Object {
},
},
"summary": "route",
"tags": Array [
"bar",
],
},
"post": Object {
"operationId": "/foo/{id}/{path*}#1",
"parameters": Array [
Object {
"description": "The version of the API to use",
"in": "header",
"name": "elastic-api-version",
"schema": Object {
"default": "2023-10-31",
"enum": Array [
"2023-10-31",
],
"type": "string",
},
},
Object {
"description": "id",
"in": "path",
"name": "id",
"required": true,
"schema": Object {
"maxLength": 36,
"type": "string",
},
},
Object {
"description": "path",
"in": "path",
"name": "path",
"required": true,
"schema": Object {
"maxLength": 36,
"type": "string",
},
},
Object {
"description": "page",
"in": "query",
"name": "page",
"required": false,
"schema": Object {
"default": 1,
"maximum": 999,
"minimum": 1,
"type": "number",
},
},
],
"requestBody": Object {
"content": Object {
"application/json; Elastic-Api-Version=2023-10-31": Object {
"schema": Object {
"additionalProperties": false,
"properties": Object {
"any": Object {},
"booleanDefault": Object {
"default": true,
"description": "defaults to to true",
"type": "boolean",
},
"ipType": Object {
"format": "ipv4",
"type": "string",
},
"literalType": Object {
"enum": Array [
"literallythis",
],
"type": "string",
},
"map": Object {
"additionalProperties": Object {
"type": "string",
},
"type": "object",
},
"maybeNumber": Object {
"maximum": 1000,
"minimum": 1,
"type": "number",
},
"record": Object {
"additionalProperties": Object {
"type": "string",
},
"type": "object",
},
"string": Object {
"maxLength": 10,
"minLength": 1,
"type": "string",
},
"union": Object {
"anyOf": Array [
Object {
"description": "Union string",
"maxLength": 1,
"type": "string",
},
Object {
"description": "Union number",
"minimum": 0,
"type": "number",
},
],
},
"uri": Object {
"default": "prototest://something",
"format": "uri",
"type": "string",
},
},
"required": Array [
"string",
"ipType",
"literalType",
"map",
"record",
"union",
"any",
],
"type": "object",
},
},
},
},
"responses": Object {
"200": Object {
"content": Object {
"application/json; Elastic-Api-Version=2023-10-31": Object {
"schema": Object {
"maxLength": 10,
"minLength": 1,
"type": "string",
},
},
},
"description": "route",
},
},
"summary": "route",
"tags": Array [
"bar",
],
},
},
},
@ -370,7 +522,14 @@ Object {
"url": "https://test.oas",
},
],
"tags": undefined,
"tags": Array [
Object {
"name": "bar",
},
Object {
"name": "versioned",
},
],
}
`;
@ -456,6 +615,7 @@ Object {
},
},
"summary": "",
"tags": Array [],
},
},
},
@ -469,7 +629,7 @@ Object {
"url": "https://test.oas",
},
],
"tags": undefined,
"tags": Array [],
}
`;
@ -532,6 +692,7 @@ Object {
},
},
"summary": "",
"tags": Array [],
},
},
"/test": Object {
@ -568,6 +729,7 @@ Object {
},
},
"summary": "",
"tags": Array [],
},
},
},
@ -581,6 +743,6 @@ Object {
"url": "https://test.oas",
},
],
"tags": undefined,
"tags": Array [],
}
`;

View file

@ -18,7 +18,10 @@ interface RecursiveType {
describe('generateOpenApiDocument', () => {
describe('@kbn/config-schema', () => {
it('generates the expected OpenAPI document', () => {
const [routers, versionedRouters] = createTestRouters();
const [routers, versionedRouters] = createTestRouters({
routers: { testRouter: { routes: [{ method: 'get' }, { method: 'post' }] } },
versionedRouters: { testVersionedRouter: { routes: [{}] } },
});
expect(
generateOpenApiDocument(
{
@ -185,4 +188,58 @@ describe('generateOpenApiDocument', () => {
).toMatchSnapshot();
});
});
describe('tags', () => {
it('handles tags as expected', () => {
const [routers, versionedRouters] = createTestRouters({
routers: {
testRouter1: {
routes: [
{ path: '/1-1/{id}/{path*}', options: { tags: ['oas-tag:1', 'oas-tag:2', 'foo'] } },
{ path: '/1-2/{id}/{path*}', options: { tags: ['oas-tag:1', 'foo'] } },
],
},
testRouter2: { routes: [{ path: '/2-1/{id}/{path*}', options: { tags: undefined } }] },
},
versionedRouters: {
testVersionedRouter1: {
routes: [
{ path: '/v1-1', options: { access: 'public', options: { tags: ['oas-tag:v1'] } } },
{
path: '/v1-2',
options: {
access: 'public',
options: { tags: ['foo', 'bar', 'oas-tag:v2', 'oas-tag:v3'] },
},
},
],
},
testVersionedRouter2: {
routes: [
{ path: '/v2-1', options: { access: 'public', options: { tags: undefined } } },
],
},
},
});
const result = generateOpenApiDocument(
{
routers,
versionedRouters,
},
{
title: 'test',
baseUrl: 'https://test.oas',
version: '99.99.99',
}
);
// router paths
expect(result.paths['/1-1/{id}/{path*}']!.get!.tags).toEqual(['1', '2']);
expect(result.paths['/1-2/{id}/{path*}']!.get!.tags).toEqual(['1']);
expect(result.paths['/2-1/{id}/{path*}']!.get!.tags).toEqual([]);
// versioned router paths
expect(result.paths['/v1-1']!.get!.tags).toEqual(['v1']);
expect(result.paths['/v1-2']!.get!.tags).toEqual(['v2', 'v3']);
expect(result.paths['/v2-1']!.get!.tags).toEqual([]);
});
});
});

View file

@ -35,26 +35,26 @@ export const testSchema = schema.object({
any: schema.any({ meta: { description: 'any type' } }),
});
type RouterMeta = ReturnType<Router['getRoutes']>[number];
type VersionedRouterMeta = ReturnType<CoreVersionedRouter['getRoutes']>[number];
type RoutesMeta = ReturnType<Router['getRoutes']>[number];
type VersionedRoutesMeta = ReturnType<CoreVersionedRouter['getRoutes']>[number];
export const createRouter = (args: { routes: RouterMeta[] }) => {
export const createRouter = (args: { routes: RoutesMeta[] }) => {
return {
getRoutes: () => args.routes,
} as unknown as Router;
};
export const createVersionedRouter = (args: { routes: VersionedRouterMeta[] }) => {
export const createVersionedRouter = (args: { routes: VersionedRoutesMeta[] }) => {
return {
getRoutes: () => args.routes,
} as unknown as CoreVersionedRouter;
};
const getRouterDefaults = () => ({
export const getRouterDefaults = () => ({
isVersioned: false,
path: '/foo/{id}/{path*}',
method: 'get',
options: {
tags: ['foo'],
tags: ['foo', 'oas-tag:bar'],
description: 'route',
},
validationSchemas: {
@ -78,12 +78,15 @@ const getRouterDefaults = () => ({
handler: jest.fn(),
});
const getVersionedRouterDefaults = () => ({
export const getVersionedRouterDefaults = () => ({
method: 'get',
path: '/bar',
options: {
description: 'versioned route',
access: 'public',
options: {
tags: ['ignore-me', 'oas-tag:versioned'],
},
},
handlers: [
{
@ -130,25 +133,29 @@ const getVersionedRouterDefaults = () => ({
],
});
interface CreatTestRouterArgs {
routers?: { [routerId: string]: { routes: Array<Partial<RoutesMeta>> } };
versionedRouters?: {
[routerId: string]: { routes: Array<Partial<VersionedRoutesMeta>> };
};
}
export const createTestRouters = (
{
routers = [],
versionedRouters = [],
}: {
routers?: Array<Array<Partial<RouterMeta>>>;
versionedRouters?: Array<Array<Partial<VersionedRouterMeta>>>;
} = { routers: [[{}]], versionedRouters: [[{}]] }
{ routers = {}, versionedRouters = {} }: CreatTestRouterArgs = {
routers: { testRouter: { routes: [{}] } },
versionedRouters: { testVersionedRouter: { routes: [{}] } },
}
): [routers: Router[], versionedRouters: CoreVersionedRouter[]] => {
return [
[
...routers.map((rs) =>
createRouter({ routes: rs.map((r) => Object.assign(getRouterDefaults(), r)) })
...Object.values(routers).map((rs) =>
createRouter({ routes: rs.routes.map((r) => Object.assign(getRouterDefaults(), r)) })
),
],
[
...versionedRouters.map((rs) =>
...Object.values(versionedRouters).map((rs) =>
createVersionedRouter({
routes: rs.map((r) => Object.assign(getVersionedRouterDefaults(), r)),
routes: rs.routes.map((r) => Object.assign(getVersionedRouterDefaults(), r)),
})
),
],

View file

@ -12,6 +12,7 @@ import { OasConverter } from './oas_converter';
import { createOperationIdCounter } from './operation_id_counter';
import { processRouter } from './process_router';
import { processVersionedRouter } from './process_versioned_router';
import { buildGlobalTags } from './util';
export const openApiVersion = '3.0.0';
@ -48,6 +49,7 @@ export const generateOpenApiDocument = (
const result = processVersionedRouter(router, converter, getOpId, filters);
Object.assign(paths, result.paths);
}
const tags = buildGlobalTags(paths, opts.tags);
return {
openapi: openApiVersion,
info: {
@ -76,7 +78,7 @@ export const generateOpenApiDocument = (
},
},
security: [{ basicAuth: [] }],
tags: opts.tags?.map((tag) => ({ name: tag })),
tags,
externalDocs: opts.docsUrl ? { url: opts.docsUrl } : undefined,
};
};

View file

@ -12,8 +12,9 @@ import { ALLOWED_PUBLIC_VERSION as SERVERLESS_VERSION_2023_10_31 } from '@kbn/co
import type { OpenAPIV3 } from 'openapi-types';
import type { OasConverter } from './oas_converter';
import {
assignToPathsObject,
assignToPaths,
extractContentType,
extractTags,
extractValidationSchemaFromRoute,
getPathParameters,
getVersionedContentTypeString,
@ -58,24 +59,27 @@ export const processRouter = (
];
}
const path: OpenAPIV3.PathItemObject = {
[route.method]: {
summary: route.options.description ?? '',
requestBody: !!validationSchemas?.body
? {
content: {
[getVersionedContentTypeString(SERVERLESS_VERSION_2023_10_31, contentType)]: {
schema: converter.convert(validationSchemas.body),
},
const operation: OpenAPIV3.OperationObject = {
summary: route.options.description ?? '',
tags: route.options.tags ? extractTags(route.options.tags) : [],
requestBody: !!validationSchemas?.body
? {
content: {
[getVersionedContentTypeString(SERVERLESS_VERSION_2023_10_31, contentType)]: {
schema: converter.convert(validationSchemas.body),
},
}
: undefined,
responses: extractResponses(route, converter),
parameters,
operationId: getOpId(route.path),
},
},
}
: undefined,
responses: extractResponses(route, converter),
parameters,
operationId: getOpId(route.path),
};
assignToPathsObject(paths, route.path, path);
const path: OpenAPIV3.PathItemObject = {
[route.method]: operation,
};
assignToPaths(paths, route.path, path);
} catch (e) {
// Enrich the error message with a bit more context
e.message = `Error generating OpenAPI for route '${route.path}': ${e.message}`;

View file

@ -20,9 +20,10 @@ import {
prepareRoutes,
getPathParameters,
extractContentType,
assignToPathsObject,
assignToPaths,
getVersionedHeaderParam,
getVersionedContentTypeString,
extractTags,
} from './util';
export const processVersionedRouter = (
@ -82,25 +83,27 @@ export const processVersionedRouter = (
const hasBody = Boolean(extractValidationSchemaFromVersionedHandler(handler)?.request?.body);
const contentType = extractContentType(route.options.options?.body);
const hasVersionFilter = Boolean(filters?.version);
const operation: OpenAPIV3.OperationObject = {
summary: route.options.description ?? '',
tags: route.options.options?.tags ? extractTags(route.options.options.tags) : [],
requestBody: hasBody
? {
content: hasVersionFilter
? extractVersionedRequestBody(handler, converter, contentType)
: extractVersionedRequestBodies(route, converter, contentType),
}
: undefined,
responses: hasVersionFilter
? extractVersionedResponse(handler, converter, contentType)
: extractVersionedResponses(route, converter, contentType),
parameters,
operationId: getOpId(route.path),
};
const path: OpenAPIV3.PathItemObject = {
[route.method]: {
summary: route.options.description ?? '',
requestBody: hasBody
? {
content: hasVersionFilter
? extractVersionedRequestBody(handler, converter, contentType)
: extractVersionedRequestBodies(route, converter, contentType),
}
: undefined,
responses: hasVersionFilter
? extractVersionedResponse(handler, converter, contentType)
: extractVersionedResponses(route, converter, contentType),
parameters,
operationId: getOpId(route.path),
},
[route.method]: operation,
};
assignToPathsObject(paths, route.path, path);
assignToPaths(paths, route.path, path);
} catch (e) {
// Enrich the error message with a bit more context
e.message = `Error generating OpenAPI for route '${route.path}' using newest version '${version}': ${e.message}`;

View file

@ -6,12 +6,115 @@
* Side Public License, v 1.
*/
import { prepareRoutes } from './util';
import { OpenAPIV3 } from 'openapi-types';
import { buildGlobalTags, prepareRoutes } from './util';
import { assignToPaths, extractTags } from './util';
const internal = 'internal' as const;
const pub = 'public' as const;
describe('extractTags', () => {
test.each([
[[], []],
[['a', 'b', 'c'], []],
[
['oas-tag:foo', 'b', 'oas-tag:bar'],
['foo', 'bar'],
],
])('given %s returns %s', (input, output) => {
expect(extractTags(input)).toEqual(output);
});
});
describe('buildGlobalTags', () => {
test.each([
{
name: 'base case',
paths: {},
additionalTags: [],
output: [],
},
{
name: 'all methods',
paths: {
'/foo': {
get: { tags: ['get'] },
put: { tags: ['put'] },
post: { tags: ['post'] },
patch: { tags: ['patch'] },
delete: { tags: ['delete'] },
options: { tags: ['options'] },
head: { tags: ['head'] },
trace: { tags: ['trace'] },
},
},
additionalTags: [],
output: [
{ name: 'delete' },
{ name: 'get' },
{ name: 'head' },
{ name: 'options' },
{ name: 'patch' },
{ name: 'post' },
{ name: 'put' },
{ name: 'trace' },
],
},
{
name: 'unknown method',
paths: {
'/foo': {
unknown: { tags: ['not-included'] },
},
'/bar': {
post: { tags: ['bar'] },
},
},
additionalTags: [],
output: [{ name: 'bar' }],
},
{
name: 'dedup',
paths: {
'/foo': {
get: { tags: ['foo'] },
patch: { tags: ['foo'] },
},
'/bar': {
get: { tags: ['foo'] },
post: { tags: ['foo'] },
},
},
additionalTags: [],
output: [{ name: 'foo' }],
},
{
name: 'dedups with additional tags',
paths: {
'/foo': { get: { tags: ['foo'] } },
'/baz': { patch: { tags: ['foo'] } },
'/bar': { patch: { tags: ['bar'] } },
},
additionalTags: ['foo', 'bar', 'baz'],
output: [{ name: 'bar' }, { name: 'baz' }, { name: 'foo' }],
},
])('$name', ({ paths, additionalTags, output }) => {
expect(buildGlobalTags(paths as OpenAPIV3.PathsObject, additionalTags)).toEqual(output);
});
});
describe('assignToPaths', () => {
it('should transform path names', () => {
const paths = {};
assignToPaths(paths, '/foo', {});
assignToPaths(paths, '/bar/{id?}', {});
expect(paths).toEqual({
'/foo': {},
'/bar/{id}': {},
});
});
});
describe('prepareRoutes', () => {
const internal = 'internal' as const;
const pub = 'public' as const;
test.each([
{
input: [{ path: '/api/foo', options: { access: internal } }],

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import type { OpenAPIV3 } from 'openapi-types';
// eslint-disable-next-line import/no-extraneous-dependencies
import { OpenAPIV3 } from 'openapi-types';
import {
getRequestValidation,
type RouteConfigOptionsBody,
@ -16,6 +17,47 @@ import {
import { KnownParameters } from './type';
import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
const tagPrefix = 'oas-tag:';
const extractTag = (tag: string) => {
if (tag.startsWith(tagPrefix)) {
return tag.slice(tagPrefix.length);
}
};
/**
* Given an array of tags ([oas-tag:beep, oas-tag:boop]) will return a new array
* with the tag prefix removed.
*/
export const extractTags = (tags?: readonly string[]) => {
if (!tags) return [];
return tags.flatMap((tag) => {
const value = extractTag(tag);
if (value) {
return value;
}
return [];
});
};
/**
* Build the top-level tags entry based on the paths we extracted. We could
* handle this while we are iterating over the routes, but this approach allows
* us to keep this as a global document concern at the expense of some extra
* processing.
*/
export const buildGlobalTags = (paths: OpenAPIV3.PathsObject, additionalTags: string[] = []) => {
const tags = new Set<string>(additionalTags);
for (const path of Object.values(paths)) {
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
if (path?.[method]?.tags) {
path[method]!.tags!.forEach((tag) => tags.add(tag));
}
}
}
return Array.from(tags)
.sort((a, b) => a.localeCompare(b))
.map<OpenAPIV3.TagObject>((name) => ({ name }));
};
export const getPathParameters = (path: string): KnownParameters => {
return Array.from(path.matchAll(/\{(.+?)\}/g)).reduce<KnownParameters>((acc, [_, key]) => {
const optional = key.endsWith('?');
@ -81,7 +123,7 @@ export const prepareRoutes = <
});
};
export const assignToPathsObject = (
export const assignToPaths = (
paths: OpenAPIV3.PathsObject,
path: string,
pathObject: OpenAPIV3.PathItemObject