[HTTP/OAS] Commit OAS snapshot (#183338)

Close https://github.com/elastic/kibana/issues/181992

## Summary

First iteration of a CLI to capture an OAS snapshot.

## How to test

Run `node ./scripts/capture_oas_snapshot.js --update --include-path
/api/status` and see result in `oas_docs/bundle.json`.

If you have the [bump CLI](https://www.npmjs.com/package/bump-cli)
installed you can preview the hosted output with `bump preview
./oas_docs/bundle.json`

## Notes
* Added ability to filter by `version`, `access` (public/internal) and
excluding paths explicitly to the OAS generation lib
* Follows the same general pattern as our other "capture" CLIs like
`packages/kbn-check-mappings-update-cli`
* Result includes only `/api/status` for now, waiting for other paths to
add missing parts

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2024-05-30 15:02:19 +02:00 committed by GitHub
parent 7fef12bca0
commit 975eeed255
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1378 additions and 200 deletions

View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
source .buildkite/scripts/common/util.sh
echo --- Capture OAS snapshot
cmd="node scripts/capture_oas_snapshot --include-path /api/status"
if is_pr && ! is_auto_commit_disabled; then
cmd="$cmd --update"
fi
eval "$cmd"
check_for_changed_files "$cmd" true

View file

@ -7,6 +7,7 @@ export DISABLE_BOOTSTRAP_VALIDATION=false
.buildkite/scripts/steps/checks/saved_objects_compat_changes.sh
.buildkite/scripts/steps/checks/saved_objects_definition_change.sh
.buildkite/scripts/steps/capture_oas_snapshot.sh
.buildkite/scripts/steps/code_generation/elastic_assistant_codegen.sh
.buildkite/scripts/steps/code_generation/security_solution_codegen.sh
.buildkite/scripts/steps/code_generation/osquery_codegen.sh

1
.github/CODEOWNERS vendored
View file

@ -67,6 +67,7 @@ src/plugins/bfetch @elastic/appex-sharedux
packages/kbn-calculate-auto @elastic/obs-ux-management-team
packages/kbn-calculate-width-from-char-count @elastic/kibana-visualizations
x-pack/plugins/canvas @elastic/kibana-presentation
packages/kbn-capture-oas-snapshot-cli @elastic/kibana-core
x-pack/test/cases_api_integration/common/plugins/cases @elastic/response-ops
packages/kbn-cases-components @elastic/response-ops
x-pack/plugins/cases @elastic/response-ops

538
oas_docs/bundle.json Normal file
View file

@ -0,0 +1,538 @@
{
"components": {
"schemas": {
"core_status_redactedResponse": {
"additionalProperties": false,
"description": "A minimal representation of Kibana's operational status.",
"properties": {
"status": {
"additionalProperties": false,
"properties": {
"overall": {
"additionalProperties": false,
"properties": {
"level": {
"anyOf": [
{
"enum": [
"available"
],
"type": "string"
},
{
"enum": [
"degraded"
],
"type": "string"
},
{
"enum": [
"unavailable"
],
"type": "string"
},
{
"enum": [
"critical"
],
"type": "string"
}
],
"description": "Service status levels as human and machine readable values."
}
},
"required": [
"level"
],
"type": "object"
}
},
"required": [
"overall"
],
"type": "object"
}
},
"required": [
"status"
],
"type": "object"
},
"core_status_response": {
"additionalProperties": false,
"description": "Kibana's operational status as well as a detailed breakdown of plugin statuses indication of various loads (like event loop utilization and network traffic) at time of request.",
"properties": {
"metrics": {
"additionalProperties": false,
"description": "Metric groups collected by Kibana.",
"properties": {
"collection_interval_in_millis": {
"description": "The interval at which metrics should be collected.",
"type": "number"
},
"elasticsearch_client": {
"additionalProperties": false,
"description": "Current network metrics of Kibana's Elasticsearch client.",
"properties": {
"totalActiveSockets": {
"description": "Count of network sockets currently in use.",
"type": "number"
},
"totalIdleSockets": {
"description": "Count of network sockets currently idle.",
"type": "number"
},
"totalQueuedRequests": {
"description": "Count of requests not yet assigned to sockets.",
"type": "number"
}
},
"required": [
"totalActiveSockets",
"totalIdleSockets",
"totalQueuedRequests"
],
"type": "object"
},
"last_updated": {
"description": "The time metrics were collected.",
"type": "string"
}
},
"required": [
"elasticsearch_client",
"last_updated",
"collection_interval_in_millis"
],
"type": "object"
},
"name": {
"description": "Kibana instance name.",
"type": "string"
},
"status": {
"additionalProperties": false,
"properties": {
"core": {
"additionalProperties": false,
"description": "Statuses of core Kibana services.",
"properties": {
"elasticsearch": {
"additionalProperties": false,
"properties": {
"detail": {
"description": "Human readable detail of the service status.",
"type": "string"
},
"documentationUrl": {
"description": "A URL to further documentation regarding this service.",
"type": "string"
},
"level": {
"anyOf": [
{
"enum": [
"available"
],
"type": "string"
},
{
"enum": [
"degraded"
],
"type": "string"
},
{
"enum": [
"unavailable"
],
"type": "string"
},
{
"enum": [
"critical"
],
"type": "string"
}
],
"description": "Service status levels as human and machine readable values."
},
"meta": {
"additionalProperties": {},
"description": "An unstructured set of extra metadata about this service.",
"type": "object"
},
"summary": {
"description": "A human readable summary of the service status.",
"type": "string"
}
},
"required": [
"level",
"summary",
"meta"
],
"type": "object"
},
"savedObjects": {
"additionalProperties": false,
"properties": {
"detail": {
"description": "Human readable detail of the service status.",
"type": "string"
},
"documentationUrl": {
"description": "A URL to further documentation regarding this service.",
"type": "string"
},
"level": {
"anyOf": [
{
"enum": [
"available"
],
"type": "string"
},
{
"enum": [
"degraded"
],
"type": "string"
},
{
"enum": [
"unavailable"
],
"type": "string"
},
{
"enum": [
"critical"
],
"type": "string"
}
],
"description": "Service status levels as human and machine readable values."
},
"meta": {
"additionalProperties": {},
"description": "An unstructured set of extra metadata about this service.",
"type": "object"
},
"summary": {
"description": "A human readable summary of the service status.",
"type": "string"
}
},
"required": [
"level",
"summary",
"meta"
],
"type": "object"
}
},
"required": [
"elasticsearch",
"savedObjects"
],
"type": "object"
},
"overall": {
"additionalProperties": false,
"properties": {
"detail": {
"description": "Human readable detail of the service status.",
"type": "string"
},
"documentationUrl": {
"description": "A URL to further documentation regarding this service.",
"type": "string"
},
"level": {
"anyOf": [
{
"enum": [
"available"
],
"type": "string"
},
{
"enum": [
"degraded"
],
"type": "string"
},
{
"enum": [
"unavailable"
],
"type": "string"
},
{
"enum": [
"critical"
],
"type": "string"
}
],
"description": "Service status levels as human and machine readable values."
},
"meta": {
"additionalProperties": {},
"description": "An unstructured set of extra metadata about this service.",
"type": "object"
},
"summary": {
"description": "A human readable summary of the service status.",
"type": "string"
}
},
"required": [
"level",
"summary",
"meta"
],
"type": "object"
},
"plugins": {
"additionalProperties": {
"additionalProperties": false,
"properties": {
"detail": {
"description": "Human readable detail of the service status.",
"type": "string"
},
"documentationUrl": {
"description": "A URL to further documentation regarding this service.",
"type": "string"
},
"level": {
"anyOf": [
{
"enum": [
"available"
],
"type": "string"
},
{
"enum": [
"degraded"
],
"type": "string"
},
{
"enum": [
"unavailable"
],
"type": "string"
},
{
"enum": [
"critical"
],
"type": "string"
}
],
"description": "Service status levels as human and machine readable values."
},
"meta": {
"additionalProperties": {},
"description": "An unstructured set of extra metadata about this service.",
"type": "object"
},
"summary": {
"description": "A human readable summary of the service status.",
"type": "string"
}
},
"required": [
"level",
"summary",
"meta"
],
"type": "object"
},
"description": "A dynamic mapping of plugin ID to plugin status.",
"type": "object"
}
},
"required": [
"overall",
"core",
"plugins"
],
"type": "object"
},
"uuid": {
"description": "Unique, generated Kibana instance UUID. This UUID should persist even if the Kibana process restarts.",
"type": "string"
},
"version": {
"additionalProperties": false,
"properties": {
"build_date": {
"description": "The date and time of this build.",
"type": "string"
},
"build_flavor": {
"anyOf": [
{
"enum": [
"serverless"
],
"type": "string"
},
{
"enum": [
"traditional"
],
"type": "string"
}
],
"description": "The build flavour determines configuration and behavior of Kibana. On premise users will almost always run the \"traditional\" flavour, while other flavours are reserved for Elastic-specific use cases."
},
"build_hash": {
"description": "A unique hash value representing the git commit of this Kibana build.",
"type": "string"
},
"build_number": {
"description": "A monotonically increasing number, each subsequent build will have a higher number.",
"type": "number"
},
"build_snapshot": {
"description": "Whether this build is a snapshot build.",
"type": "boolean"
},
"number": {
"description": "A semantic version number.",
"type": "string"
}
},
"required": [
"number",
"build_hash",
"build_number",
"build_snapshot",
"build_flavor",
"build_date"
],
"type": "object"
}
},
"required": [
"name",
"uuid",
"version",
"status",
"metrics"
],
"type": "object"
}
},
"securitySchemes": {
"apiKeyAuth": {
"in": "header",
"name": "Authorization",
"type": "apiKey"
},
"basicAuth": {
"scheme": "basic",
"type": "http"
}
}
},
"info": {
"title": "Kibana HTTP APIs",
"version": "0.0.0"
},
"openapi": "3.0.0",
"paths": {
"/api/status": {
"get": {
"operationId": "/api/status#0",
"parameters": [
{
"description": "The version of the API to use",
"in": "header",
"name": "elastic-api-version",
"schema": {
"default": "2023-10-31",
"enum": [
"2023-10-31"
],
"type": "string"
}
},
{
"description": "Set to \"true\" to get the response in v7 format.",
"in": "query",
"name": "v7format",
"required": false,
"schema": {
"type": "boolean"
}
},
{
"description": "Set to \"true\" to get the response in v8 format.",
"in": "query",
"name": "v8format",
"required": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json; Elastic-Api-Version=2023-10-31": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/core_status_response"
},
{
"$ref": "#/components/schemas/core_status_redactedResponse"
}
],
"description": "Kibana's operational status. A minimal response is sent for unauthorized users."
}
}
},
"description": "Get Kibana's current status."
},
"503": {
"content": {
"application/json; Elastic-Api-Version=2023-10-31": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/core_status_response"
},
{
"$ref": "#/components/schemas/core_status_redactedResponse"
}
],
"description": "Kibana's operational status. A minimal response is sent for unauthorized users."
}
}
},
"description": "Get Kibana's current status."
}
},
"summary": "Get Kibana's current status."
}
}
},
"security": [
{
"basicAuth": []
}
],
"servers": [
{
"url": "http://localhost:5622"
}
]
}

View file

@ -1241,6 +1241,7 @@
"@kbn/babel-register": "link:packages/kbn-babel-register",
"@kbn/babel-transform": "link:packages/kbn-babel-transform",
"@kbn/bazel-runner": "link:packages/kbn-bazel-runner",
"@kbn/capture-oas-snapshot-cli": "link:packages/kbn-capture-oas-snapshot-cli",
"@kbn/check-mappings-update-cli": "link:packages/kbn-check-mappings-update-cli",
"@kbn/ci-stats-core": "link:packages/kbn-ci-stats-core",
"@kbn/ci-stats-performance-metrics": "link:packages/kbn-ci-stats-performance-metrics",

View file

@ -109,7 +109,10 @@ export class CoreVersionedRoute implements VersionedRoute {
/** This method assumes that one or more versions handlers are registered */
private getDefaultVersion(): undefined | ApiVersion {
return resolvers[this.router.defaultHandlerResolutionStrategy]([...this.handlers.keys()]);
return resolvers[this.router.defaultHandlerResolutionStrategy](
[...this.handlers.keys()],
this.options.access
);
}
private versionsToString(): string {

View file

@ -9,27 +9,61 @@
import { resolvers } from './handler_resolvers';
describe('default handler resolvers', () => {
describe('sort', () => {
test.each([
[['1', '10', '2'], 'internal', ['1', '2', '10']],
[
['2023-01-01', '2002-10-10', '2005-01-01'],
'public',
['2002-10-10', '2005-01-01', '2023-01-01'],
],
[[], 'internal', []],
[[], 'public', []],
])('%s, %s returns %s', (input, access, output) => {
expect(resolvers.sort(input, access as 'internal' | 'public')).toEqual(output);
});
test('copy, not mutate', () => {
const input = ['1', '12', '0'];
const output = resolvers.sort(input, 'internal');
expect(output).not.toBe(input);
});
test('throw for non numeric input when access is internal', () => {
expect(() => resolvers.sort(['abc'], 'internal')).toThrow(/found non numeric/i);
});
});
describe('oldest', () => {
test.each([
{ versions: ['2002-02-02', '2022-02-02', '2021-02-02'], expected: '2002-02-02' },
{ versions: ['abc', 'def', 'ghi'], expected: 'abc' },
{ versions: ['1', '2', '400'], expected: '1' },
{ versions: ['2002-02-02'], expected: '2002-02-02' },
{ versions: [], expected: undefined },
])(`$versions returns $expected`, ({ versions, expected }) => {
expect(resolvers.oldest(versions)).toBe(expected);
{
versions: ['2002-02-02', '2022-02-02', '2021-02-02'],
expected: '2002-02-02',
access: 'public',
},
{ versions: ['abc', 'def', 'ghi'], expected: 'abc', access: 'public' },
{ versions: ['1', '2', '400'], expected: '1', access: 'internal' },
{ versions: ['1', '10', '2'], expected: '1', access: 'internal' },
{ versions: ['2002-02-02'], expected: '2002-02-02', access: 'public' },
{ versions: [], expected: undefined, access: 'public' },
])(`$versions returns $expected`, ({ versions, expected, access }) => {
expect(resolvers.oldest(versions, access as 'internal' | 'public')).toBe(expected);
});
});
describe('newest', () => {
test.each([
{ versions: ['2002-02-02', '2022-02-02', '2021-02-02'], expected: '2022-02-02' },
{ versions: ['abc', 'def', 'ghi'], expected: 'ghi' },
{ versions: ['1', '2', '400'], expected: '400' },
{ versions: ['2002-02-02'], expected: '2002-02-02' },
{ versions: [], expected: undefined },
])(`$versions returns $expected`, ({ versions, expected }) => {
expect(resolvers.newest(versions)).toBe(expected);
{
versions: ['2002-02-02', '2022-02-02', '2021-02-02'],
expected: '2022-02-02',
access: 'public',
},
{ versions: ['abc', 'def', 'ghi'], expected: 'ghi', access: 'public' },
{ versions: ['1', '2', '400'], expected: '400', access: 'internal' },
{ versions: ['1', '10', '2'], expected: '10', access: 'internal' },
{ versions: ['2002-02-02'], expected: '2002-02-02', access: 'public' },
{ versions: [], expected: undefined, access: 'public' },
])(`$versions returns $expected`, ({ versions, expected, access }) => {
expect(resolvers.newest(versions, access as 'internal' | 'public')).toBe(expected);
});
});
});

View file

@ -6,19 +6,38 @@
* Side Public License, v 1.
*/
/**
* Sort Kibana HTTP API versions from oldest to newest
*
* @example Given 'internal' versions ["1", "10", "2"] it will return ["1", "2", "10]
* @example Given 'public' versions ["2023-01-01", "2002-10-10", "2005-01-01"] it will return ["2002-10-10", "2005-01-01", "2023-01-01"]
*/
export const sort = (versions: string[], access: 'public' | 'internal') => {
if (access === 'internal') {
const versionNrs = versions.map((v) => {
const nr = parseInt(v, 10);
if (isNaN(nr)) throw new Error(`Found non numeric input for internal version: ${v}`);
return nr;
});
return versionNrs.sort((a, b) => a - b).map((n) => n.toString());
}
return [...versions].sort((a, b) => a.localeCompare(b));
};
/**
* Assumes that there is at least one version in the array.
* @internal
*/
type Resolver = (versions: string[]) => undefined | string;
type Resolver = (versions: string[], access: 'public' | 'internal') => undefined | string;
const oldest: Resolver = (versions) => [...versions].sort((a, b) => a.localeCompare(b))[0];
const oldest: Resolver = (versions, access) => sort(versions, access)[0];
const newest: Resolver = (versions) => [...versions].sort((a, b) => b.localeCompare(a))[0];
const newest: Resolver = (versions, access) => sort(versions, access).reverse()[0];
const none: Resolver = () => undefined;
export const resolvers = {
sort,
oldest,
newest,
none,

View file

@ -250,8 +250,33 @@ export class HttpService
path: '/api/oas',
method: 'GET',
handler: async (req, h) => {
const pathStartsWith = req.query?.pathStartsWith;
const version = req.query?.version;
let pathStartsWith: undefined | string[];
if (typeof req.query?.pathStartsWith === 'string') {
pathStartsWith = [req.query.pathStartsWith];
} else {
pathStartsWith = req.query?.pathStartsWith;
}
let excludePathsMatching: undefined | string[];
if (typeof req.query?.excludePathsMatching === 'string') {
excludePathsMatching = [req.query.excludePathsMatching];
} else {
excludePathsMatching = req.query?.excludePathsMatching;
}
const pluginId = req.query?.pluginId;
const access = req.query?.access as 'public' | 'internal' | undefined;
if (access && !['public', 'internal'].some((a) => a === access)) {
return h
.response({
message: 'Invalid access query parameter. Must be one of "public" or "internal".',
})
.code(400);
}
return await firstValueFrom(
of(1).pipe(
HttpService.generateOasSemaphore.acquire(),
@ -262,7 +287,7 @@ export class HttpService
baseUrl,
title: 'Kibana HTTP APIs',
version: '0.0.0', // TODO get a better version here
pathStartsWith,
filters: { pathStartsWith, excludePathsMatching, access, version },
});
return h.response(result);
} catch (e) {

View file

@ -0,0 +1,6 @@
# @kbn/capture-oas-snapshot-cli
A CLI to capture OpenAPI spec snapshots from the `/api/oas` API.
See `node scripts/capture_oas_snapshot --help` for more info.

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-capture-oas-snapshot-cli'],
};

View file

@ -0,0 +1,6 @@
{
"type": "shared-common",
"id": "@kbn/capture-oas-snapshot-cli",
"owner": "@elastic/kibana-core",
"devOnly": true
}

View file

@ -0,0 +1,7 @@
{
"name": "@kbn/capture-oas-snapshot-cli",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0",
"main": "./src/run_capture_oas_snapshot_cli"
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createRootWithCorePlugins } from '@kbn/core-test-helpers-kbn-server';
import { set } from '@kbn/safer-lodash-set';
import { PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH } from '@kbn/core-plugins-server-internal/src/constants';
export type Result = 'ready';
(async () => {
if (!process.send) {
throw new Error('worker must be run in a node.js fork');
}
const settings = {
logging: {
loggers: [{ name: 'root', level: 'info', appenders: ['console'] }],
},
server: {
port: 5622,
oas: {
enabled: true,
},
},
};
set(settings, PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH, true);
const root = createRootWithCorePlugins(settings, {
basePath: false,
cache: false,
dev: true,
disableOptimizer: true,
silent: false,
dist: false,
oss: false,
runExamples: false,
watch: false,
});
await root.preboot();
await root.setup();
await root.start();
const result: Result = 'ready';
process.send(result);
})().catch((error) => {
process.stderr.write(`UNHANDLED ERROR: ${error.stack}`);
process.exit(1);
});

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import path from 'node:path';
import fs from 'node:fs/promises';
import { encode } from 'node:querystring';
import fetch from 'node-fetch';
import { run } from '@kbn/dev-cli-runner';
import { startTSWorker } from '@kbn/dev-utils';
import { createTestEsCluster } from '@kbn/test';
import * as Rx from 'rxjs';
import { REPO_ROOT } from '@kbn/repo-info';
import chalk from 'chalk';
import type { Result } from './kibana_worker';
const OAS_FILE_PATH = path.resolve(REPO_ROOT, './oas_docs/bundle.json');
export const sortAndPrettyPrint = (object: object) => {
const keys = new Set<string>();
JSON.stringify(object, (key, value) => {
keys.add(key);
return value;
});
return JSON.stringify(object, Array.from(keys).sort(), 2);
};
const MB = 1024 * 1024;
const twoDeci = (num: number) => Math.round(num * 100) / 100;
run(
async ({ log, flagsReader, addCleanupTask }) => {
const update = flagsReader.boolean('update');
const pathStartsWith = flagsReader.arrayOfStrings('include-path');
const excludePathsMatching = flagsReader.arrayOfStrings('exclude-path') ?? [];
// internal consts
const port = 5622;
// We are only including /api/status for now
excludePathsMatching.push(
'/{path*}',
// Our internal asset paths
'/XXXXXXXXXXXX/'
);
log.info('Starting es...');
await log.indent(4, async () => {
const cluster = createTestEsCluster({ log });
await cluster.start();
addCleanupTask(() => cluster.cleanup());
});
log.info('Starting Kibana...');
await log.indent(4, async () => {
log.info('Loading core with all plugins enabled so that we can capture OAS for all...');
const { msg$, proc } = startTSWorker<Result>({
log,
src: require.resolve('./kibana_worker'),
});
await Rx.firstValueFrom(
msg$.pipe(
Rx.map((msg) => {
if (msg !== 'ready')
throw new Error(`received unexpected message from worker (expected "ready"): ${msg}`);
})
)
);
addCleanupTask(() => proc.kill('SIGILL'));
});
try {
const qs = encode({
access: 'public',
version: '2023-10-31', // hard coded for now, we can make this configurable later
pathStartsWith,
excludePathsMatching,
});
const url = `http://localhost:${port}/api/oas?${qs}`;
log.info(`Fetching OAS at ${url}...`);
const result = await fetch(url, {
headers: {
'kbn-xsrf': 'kbn-oas-snapshot',
authorization: `Basic ${Buffer.from('elastic:changeme').toString('base64')}`,
},
});
if (result.status !== 200) {
log.error(`Failed to fetch OAS: ${JSON.stringify(result, null, 2)}`);
throw new Error(`Failed to fetch OAS: ${result.status}`);
}
const currentOas = await result.json();
log.info(`Recieved OAS, writing to ${OAS_FILE_PATH}...`);
if (update) {
await fs.writeFile(OAS_FILE_PATH, sortAndPrettyPrint(currentOas));
const { size: sizeBytes } = await fs.stat(OAS_FILE_PATH);
log.success(`OAS written to ${OAS_FILE_PATH}. File size ~${twoDeci(sizeBytes / MB)} MB.`);
} else {
log.success(
`OAS recieved, not writing to file. Got OAS for ${
Object.keys(currentOas.paths).length
} paths.`
);
}
} catch (err) {
log.error(`Failed to capture OAS: ${JSON.stringify(err, null, 2)}`);
throw err;
}
},
{
description: `
Get the current OAS from Kibana's /api/oas API
`,
flags: {
boolean: ['update'],
string: ['include-path', 'exclude-path'],
default: {
fix: false,
},
help: `
--include-path Path to include. Path must start with provided value. Can be passed multiple times.
--exclude-path Path to exclude. Path must NOT start with provided value. Can be passed multiple times.
--update Write the current OAS to ${chalk.cyan(OAS_FILE_PATH)}.
`,
},
}
);

View file

@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/repo-info",
"@kbn/core-test-helpers-kbn-server",
"@kbn/safer-lodash-set",
"@kbn/core-plugins-server-internal",
"@kbn/dev-cli-runner",
"@kbn/test",
"@kbn/dev-utils",
]
}

View file

@ -6,15 +6,10 @@
* Side Public License, v 1.
*/
import ChildProcess from 'child_process';
import { Readable } from 'stream';
import * as Rx from 'rxjs';
import { REPO_ROOT } from '@kbn/repo-info';
import { SomeDevLog } from '@kbn/some-dev-log';
import { observeLines } from '@kbn/stdio-dev-helpers';
import type { SavedObjectsTypeMappingDefinitions } from '@kbn/core-saved-objects-base-server-internal';
import { startTSWorker } from '@kbn/dev-utils';
import type { Result } from './extract_mappings_from_plugins_worker';
/**
@ -30,33 +25,16 @@ export async function extractMappingsFromPlugins(
): Promise<SavedObjectsTypeMappingDefinitions> {
log.info('Loading core with all plugins enabled so that we can get all savedObject mappings...');
const fork = ChildProcess.fork(require.resolve('./extract_mappings_from_plugins_worker.ts'), {
execArgv: ['--require=@kbn/babel-register/install'],
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
const { msg$, proc } = startTSWorker<Result>({
log,
src: require.resolve('./extract_mappings_from_plugins_worker.ts'),
});
const mappings = await Rx.firstValueFrom(
Rx.merge(
// the actual value we are interested in
Rx.fromEvent(fork, 'message'),
// worker logs are written to the logger, but dropped from the stream
routeToLog(fork.stdout!, log, 'debug'),
routeToLog(fork.stderr!, log, 'error'),
// if an error occurs running the worker throw it into the stream
Rx.fromEvent(fork, 'error').pipe(
Rx.map((err) => {
throw err;
})
)
).pipe(
Rx.takeUntil(Rx.fromEvent(fork, 'exit')),
Rx.map((results) => {
const [result] = results as [Result];
msg$.pipe(
Rx.map((result) => {
log.debug('message received from worker', result);
fork.kill('SIGILL');
proc.kill('SIGILL');
return result.mappings;
}),
Rx.defaultIfEmpty(undefined)
@ -71,12 +49,3 @@ export async function extractMappingsFromPlugins(
return mappings;
}
function routeToLog(readable: Readable, log: SomeDevLog, level: 'debug' | 'error') {
return observeLines(readable).pipe(
Rx.tap((line) => {
log[level](line);
}),
Rx.ignoreElements()
);
}

View file

@ -6,12 +6,9 @@
* Side Public License, v 1.
*/
import ChildProcess from 'child_process';
import { Readable } from 'stream';
import * as Rx from 'rxjs';
import { REPO_ROOT } from '@kbn/repo-info';
import { SomeDevLog } from '@kbn/some-dev-log';
import { observeLines } from '@kbn/stdio-dev-helpers';
import { startTSWorker } from '@kbn/dev-utils';
import type { Result } from './extract_field_lists_from_plugins_worker';
/**
@ -25,33 +22,16 @@ import type { Result } from './extract_field_lists_from_plugins_worker';
export async function extractFieldListsFromPlugins(log: SomeDevLog): Promise<Result> {
log.info('Loading core with all plugins enabled so that we can get all savedObject mappings...');
const fork = ChildProcess.fork(require.resolve('./extract_field_lists_from_plugins_worker.ts'), {
execArgv: ['--require=@kbn/babel-register/install'],
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
const { msg$, proc } = startTSWorker<Result>({
log,
src: require.resolve('./extract_field_lists_from_plugins_worker.ts'),
});
const result = await Rx.firstValueFrom(
Rx.merge(
// the actual value we are interested in
Rx.fromEvent(fork, 'message'),
// worker logs are written to the logger, but dropped from the stream
routeToLog(fork.stdout!, log, 'debug'),
routeToLog(fork.stderr!, log, 'error'),
// if an error occurs running the worker throw it into the stream
Rx.fromEvent(fork, 'error').pipe(
Rx.map((err) => {
throw err;
})
)
).pipe(
Rx.takeUntil(Rx.fromEvent(fork, 'exit')),
Rx.map((results) => {
const [outcome] = results as [Result];
msg$.pipe(
Rx.map((outcome) => {
log.debug('message received from worker', outcome);
fork.kill('SIGILL');
proc.kill('SIGILL');
return outcome;
}),
Rx.defaultIfEmpty(undefined)
@ -64,12 +44,3 @@ export async function extractFieldListsFromPlugins(log: SomeDevLog): Promise<Res
return result;
}
function routeToLog(readable: Readable, log: SomeDevLog, level: 'debug' | 'error') {
return observeLines(readable).pipe(
Rx.tap((line) => {
log[level](line);
}),
Rx.ignoreElements()
);
}

View file

@ -17,8 +17,6 @@
"@kbn/some-dev-log",
"@kbn/dev-cli-errors",
"@kbn/core-saved-objects-base-server-internal",
"@kbn/repo-info",
"@kbn/stdio-dev-helpers",
"@kbn/core-test-helpers-kbn-server",
"@kbn/core-plugins-server-internal",
"@kbn/core-saved-objects-migration-server-internal",
@ -29,5 +27,6 @@
"@kbn/tooling-log",
"@kbn/core-saved-objects-server",
"@kbn/utils",
"@kbn/dev-utils",
]
}

View file

@ -30,3 +30,4 @@ export * from './src/plugin_list';
export * from './src/streams';
export * from './src/extract';
export * from './src/diff_strings';
export * from './src/worker';

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import ChildProcess from 'child_process';
import { Readable } from 'stream';
import * as Rx from 'rxjs';
import { REPO_ROOT } from '@kbn/repo-info';
import { SomeDevLog } from '@kbn/some-dev-log';
import { observeLines } from '@kbn/stdio-dev-helpers';
// import type { Result } from './kibana_worker';
interface StartTSWorkerArgs {
log: SomeDevLog;
/** Path to worker source */
src: string;
/** Defaults to repo root */
cwd?: string;
}
/**
* Provide a TS file as the src of a NodeJS Worker with some built-in handling
* of std streams and debugging.
*/
export function startTSWorker<Message>({ log, src, cwd = REPO_ROOT }: StartTSWorkerArgs) {
const fork = ChildProcess.fork(require.resolve(src), {
execArgv: ['--require=@kbn/babel-register/install'],
cwd,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});
const msg$ = Rx.merge(
// the actual value we are interested in
Rx.fromEvent(fork, 'message'),
// worker logs are written to the logger, but dropped from the stream
routeToLog(fork.stdout!, log, 'debug'),
routeToLog(fork.stderr!, log, 'error'),
// if an error occurs running the worker throw it into the stream
Rx.fromEvent(fork, 'error').pipe(
Rx.map((err) => {
throw err;
})
)
).pipe(
Rx.takeUntil(Rx.fromEvent(fork, 'exit')),
Rx.map((mergedResults) => {
const [message] = mergedResults as [Message];
log.debug('message received from worker', message);
return message;
})
);
return { msg$, proc: fork };
}
function routeToLog(readable: Readable, log: SomeDevLog, level: 'debug' | 'error') {
return observeLines(readable).pipe(
Rx.tap((line) => {
log[level](line);
}),
Rx.ignoreElements()
);
}

View file

@ -15,6 +15,8 @@
"@kbn/dev-cli-errors",
"@kbn/repo-info",
"@kbn/repo-packages",
"@kbn/some-dev-log",
"@kbn/stdio-dev-helpers",
],
"exclude": [
"target/**/*",

View file

@ -215,9 +215,9 @@ Object {
"summary": "versioned route",
},
},
"/foo/{id}": Object {
"/foo/{id}/{path*}": Object {
"get": Object {
"operationId": "/foo/{id}#0",
"operationId": "/foo/{id}/{path*}#0",
"parameters": Array [
Object {
"description": "The version of the API to use",
@ -241,6 +241,16 @@ Object {
"type": "string",
},
},
Object {
"description": "path",
"in": "path",
"name": "path",
"required": true,
"schema": Object {
"maxLength": 36,
"type": "string",
},
},
Object {
"description": "page",
"in": "query",

View file

@ -51,7 +51,7 @@ export const createVersionedRouter = (args: { routes: VersionedRouterMeta[] }) =
const getRouterDefaults = () => ({
isVersioned: false,
path: '/foo/{id}',
path: '/foo/{id}/{path*}',
method: 'get',
options: {
tags: ['foo'],
@ -61,6 +61,7 @@ const getRouterDefaults = () => ({
request: {
params: schema.object({
id: schema.string({ maxLength: 36, meta: { description: 'id' } }),
path: schema.string({ maxLength: 36, meta: { description: 'path' } }),
}),
query: schema.object({
page: schema.number({ max: 999, min: 1, defaultValue: 1, meta: { description: 'page' } }),

View file

@ -15,6 +15,13 @@ import { processVersionedRouter } from './process_versioned_router';
export const openApiVersion = '3.0.0';
export interface GenerateOpenApiDocumentOptionsFilters {
pathStartsWith?: string[];
excludePathsMatching?: string[];
access?: 'public' | 'internal';
version?: string;
}
export interface GenerateOpenApiDocumentOptions {
title: string;
description?: string;
@ -22,22 +29,23 @@ export interface GenerateOpenApiDocumentOptions {
baseUrl: string;
docsUrl?: string;
tags?: string[];
pathStartsWith?: string;
filters?: GenerateOpenApiDocumentOptionsFilters;
}
export const generateOpenApiDocument = (
appRouters: { routers: Router[]; versionedRouters: CoreVersionedRouter[] },
opts: GenerateOpenApiDocumentOptions
): OpenAPIV3.Document => {
const { filters } = opts;
const converter = new OasConverter();
const getOpId = createOperationIdCounter();
const paths: OpenAPIV3.PathsObject = {};
for (const router of appRouters.routers) {
const result = processRouter(router, converter, getOpId, opts.pathStartsWith);
const result = processRouter(router, converter, getOpId, filters);
Object.assign(paths, result.paths);
}
for (const router of appRouters.versionedRouters) {
const result = processVersionedRouter(router, converter, getOpId, opts.pathStartsWith);
const result = processVersionedRouter(router, converter, getOpId, filters);
Object.assign(paths, result.paths);
}
return {

View file

@ -8,7 +8,7 @@
import type { OpenAPIV3 } from 'openapi-types';
const trimTrailingStar = (str: string) => str.replace(/\*$/, '');
export const trimTrailingStar = (str: string) => str.replace(/\*$/, '');
export const validatePathParameters = (pathParameters: string[], schemaKeys: string[]) => {
if (pathParameters.length !== schemaKeys.length) {

View file

@ -7,7 +7,7 @@
*/
import { schema } from '@kbn/config-schema';
import { is, isNullableObjectType } from './lib';
import { is, isNullableObjectType, getParamSchema } from './lib';
describe('is', () => {
test.each([
@ -41,3 +41,14 @@ test('isNullableObjectType', () => {
const nullableObject = schema.nullable(schema.object({}));
expect(isNullableObjectType(nullableObject.getSchema().describe())).toBe(true);
});
test('getParamSchema from {pathVar*}', () => {
const a = { optional: true };
const b = { optional: true };
const c = { optional: true };
const keyName = 'pathVar';
// Special * syntax in API defs
expect(getParamSchema({ a, b, [`${keyName}*`]: c }, keyName)).toBe(c);
// Special * syntax with ? in API defs
expect(getParamSchema({ a, b, [`${keyName}?*`]: c }, keyName)).toBe(c);
});

View file

@ -72,6 +72,15 @@ export const convert = (kbnConfigSchema: unknown) => {
return { schema: result, shared };
};
export const getParamSchema = (knownParameters: KnownParameters, schemaKey: string) => {
return (
knownParameters[schemaKey] ??
// Handle special path parameters
knownParameters[schemaKey + '*'] ??
knownParameters[schemaKey + '?*']
);
};
const convertObjectMembersToParameterObjects = (
ctx: IContext,
schema: joi.Schema,
@ -95,7 +104,8 @@ const convertObjectMembersToParameterObjects = (
}
return Object.entries(properties).map(([schemaKey, schemaObject]) => {
if (!knownParameters[schemaKey] && isPathParameter) {
const paramSchema = getParamSchema(knownParameters, schemaKey);
if (!paramSchema && isPathParameter) {
throw createError(`Unknown parameter: ${schemaKey}, are you sure this is in your path?`);
}
const isSubSchemaRequired = required.has(schemaKey);
@ -111,7 +121,7 @@ const convertObjectMembersToParameterObjects = (
return {
name: schemaKey,
in: isPathParameter ? 'path' : 'query',
required: isPathParameter ? !knownParameters[schemaKey].optional : isSubSchemaRequired,
required: isPathParameter ? !paramSchema.optional : isSubSchemaRequired,
schema: finalSchema,
description,
};

View file

@ -7,8 +7,10 @@
*/
import { schema } from '@kbn/config-schema';
import { Router } from '@kbn/core-http-router-server-internal';
import { OasConverter } from './oas_converter';
import { extractResponses, type InternalRouterRoute } from './process_router';
import { createOperationIdCounter } from './operation_id_counter';
import { extractResponses, processRouter, type InternalRouterRoute } from './process_router';
describe('extractResponses', () => {
let oasConverter: OasConverter;
@ -75,3 +77,41 @@ describe('extractResponses', () => {
});
});
});
describe('processRouter', () => {
const testRouter = {
getRoutes: () => [
{
path: '/foo',
options: {},
handler: jest.fn(),
validationSchemas: { request: { body: schema.object({}) } },
},
{
path: '/bar',
options: {},
handler: jest.fn(),
validationSchemas: { request: { body: schema.object({}) } },
},
{
path: '/baz',
options: {},
handler: jest.fn(),
validationSchemas: { request: { body: schema.object({}) } },
},
],
} as unknown as Router;
it('only provides routes for version 2023-10-31', () => {
const result1 = processRouter(testRouter, new OasConverter(), createOperationIdCounter(), {
version: '2023-10-31',
});
expect(Object.keys(result1.paths!)).toHaveLength(3);
const result2 = processRouter(testRouter, new OasConverter(), createOperationIdCounter(), {
version: '2024-10-31',
});
expect(Object.keys(result2.paths!)).toHaveLength(0);
});
});

View file

@ -8,7 +8,7 @@
import type { Router } from '@kbn/core-http-router-server-internal';
import { getResponseValidation } from '@kbn/core-http-server';
import { ALLOWED_PUBLIC_VERSION as LATEST_SERVERLESS_VERSION } from '@kbn/core-http-router-server-internal';
import { ALLOWED_PUBLIC_VERSION as SERVERLESS_VERSION_2023_10_31 } from '@kbn/core-http-router-server-internal';
import type { OpenAPIV3 } from 'openapi-types';
import type { OasConverter } from './oas_converter';
import {
@ -21,19 +21,18 @@ import {
prepareRoutes,
} from './util';
import type { OperationIdCounter } from './operation_id_counter';
import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
export const processRouter = (
appRouter: Router,
converter: OasConverter,
getOpId: OperationIdCounter,
pathStartsWith?: string
filters?: GenerateOpenApiDocumentOptionsFilters
) => {
const routes = prepareRoutes(
appRouter.getRoutes({ excludeVersionedRoutes: true }),
pathStartsWith
);
const paths: OpenAPIV3.PathsObject = {};
if (filters?.version && filters.version !== SERVERLESS_VERSION_2023_10_31) return { paths };
const routes = prepareRoutes(appRouter.getRoutes({ excludeVersionedRoutes: true }), filters);
for (const route of routes) {
try {
const pathParams = getPathParameters(route.path);
@ -53,7 +52,7 @@ export const processRouter = (
queryObjects = converter.convertQuery(reqQuery);
}
parameters = [
getVersionedHeaderParam(LATEST_SERVERLESS_VERSION, [LATEST_SERVERLESS_VERSION]),
getVersionedHeaderParam(SERVERLESS_VERSION_2023_10_31, [SERVERLESS_VERSION_2023_10_31]),
...pathObjects,
...queryObjects,
];
@ -65,7 +64,7 @@ export const processRouter = (
requestBody: !!validationSchemas?.body
? {
content: {
[getVersionedContentTypeString(LATEST_SERVERLESS_VERSION, contentType)]: {
[getVersionedContentTypeString(SERVERLESS_VERSION_2023_10_31, contentType)]: {
schema: converter.convert(validationSchemas.body),
},
},
@ -104,7 +103,7 @@ export const extractResponses = (route: InternalRouterRoute, converter: OasConve
content: {
...((acc[statusCode] ?? {}) as OpenAPIV3.ResponseObject).content,
[getVersionedContentTypeString(
LATEST_SERVERLESS_VERSION,
SERVERLESS_VERSION_2023_10_31,
schema.bodyContentType ? [schema.bodyContentType] : contentType
)]: {
schema: oasSchema,

View file

@ -7,9 +7,18 @@
*/
import { schema } from '@kbn/config-schema';
import type { VersionedRouterRoute } from '@kbn/core-http-router-server-internal';
import type {
CoreVersionedRouter,
VersionedRouterRoute,
} from '@kbn/core-http-router-server-internal';
import { get } from 'lodash';
import { OasConverter } from './oas_converter';
import { extractVersionedRequestBody, extractVersionedResponses } from './process_versioned_router';
import { createOperationIdCounter } from './operation_id_counter';
import {
processVersionedRouter,
extractVersionedResponses,
extractVersionedRequestBodies,
} from './process_versioned_router';
const route: VersionedRouterRoute = {
path: '/foo',
@ -70,9 +79,9 @@ beforeEach(() => {
oasConverter = new OasConverter();
});
describe('extractVersionedRequestBody', () => {
describe('extractVersionedRequestBodies', () => {
test('handles full request config as expected', () => {
expect(extractVersionedRequestBody(route, oasConverter)).toEqual({
expect(extractVersionedRequestBodies(route, oasConverter, ['application/json'])).toEqual({
'application/json; Elastic-Api-Version=2023-10-31': {
schema: {
additionalProperties: false,
@ -103,7 +112,7 @@ describe('extractVersionedRequestBody', () => {
describe('extractVersionedResponses', () => {
test('handles full response config as expected', () => {
expect(extractVersionedResponses(route, oasConverter)).toEqual({
expect(extractVersionedResponses(route, oasConverter, ['application/test+json'])).toEqual({
200: {
content: {
'application/test+json; Elastic-Api-Version=2023-10-31': {
@ -159,3 +168,29 @@ describe('extractVersionedResponses', () => {
});
});
});
describe('processVersionedRouter', () => {
it('correctly extracts the version based on the version filter', () => {
const baseCase = processVersionedRouter(
{ getRoutes: () => [route] } as unknown as CoreVersionedRouter,
new OasConverter(),
createOperationIdCounter(),
{}
);
expect(Object.keys(get(baseCase, 'paths["/foo"].get.responses.200.content'))).toEqual([
'application/test+json; Elastic-Api-Version=2023-10-31',
'application/test+json; Elastic-Api-Version=2024-12-31',
]);
const filteredCase = processVersionedRouter(
{ getRoutes: () => [route] } as unknown as CoreVersionedRouter,
new OasConverter(),
createOperationIdCounter(),
{ version: '2023-10-31' }
);
expect(Object.keys(get(filteredCase, 'paths["/foo"].get.responses.200.content'))).toEqual([
'application/test+json; Elastic-Api-Version=2023-10-31',
]);
});
});

View file

@ -13,6 +13,7 @@ import {
unwrapVersionedResponseBodyValidation,
} from '@kbn/core-http-router-server-internal';
import type { OpenAPIV3 } from 'openapi-types';
import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
import type { OasConverter } from './oas_converter';
import type { OperationIdCounter } from './operation_id_counter';
import {
@ -28,25 +29,43 @@ export const processVersionedRouter = (
appRouter: CoreVersionedRouter,
converter: OasConverter,
getOpId: OperationIdCounter,
pathStartsWith?: string
filters?: GenerateOpenApiDocumentOptionsFilters
) => {
const routes = prepareRoutes(appRouter.getRoutes(), pathStartsWith);
const routes = prepareRoutes(appRouter.getRoutes(), filters);
const paths: OpenAPIV3.PathsObject = {};
for (const route of routes) {
const pathParams = getPathParameters(route.path);
/**
* Note: for a given route we accept that route params and query params remain BWC
* so we only take the latest version of the params and query params, we also
* assume at this point that we are generating for serverless.
*/
let parameters: OpenAPIV3.ParameterObject[] = [];
const versions = route.handlers.map(({ options: { version: v } }) => v).sort();
const newestVersion = versionHandlerResolvers.newest(versions);
const handler = route.handlers.find(({ options: { version: v } }) => v === newestVersion);
const schemas = handler ? extractValidationSchemaFromVersionedHandler(handler) : undefined;
let version: undefined | string;
let handler: undefined | VersionedRouterRoute['handlers'][0];
let versions: string[] = versionHandlerResolvers.sort(
route.handlers.map(({ options: { version: v } }) => v),
route.options.access
);
if (filters?.version) {
const versionIdx = versions.indexOf(filters.version);
if (versionIdx === -1) return { paths };
versions = versions.slice(0, versionIdx + 1);
handler = route.handlers.find(({ options: { version: v } }) => v === filters.version);
version = filters.version;
} else {
version = versionHandlerResolvers.newest(versions, route.options.access);
handler = route.handlers.find(({ options: { version: v } }) => v === version);
}
if (!handler) return { paths };
const schemas = extractValidationSchemaFromVersionedHandler(handler);
try {
if (handler && schemas) {
if (schemas) {
/**
* Note: for a given route we accept that route params and query params remain BWC
* so we only take the latest version of the params and query params, we also
* assume at this point that we are generating for serverless.
*/
const reqParams = schemas.request?.params as unknown;
let pathObjects: OpenAPIV3.ParameterObject[] = [];
let queryObjects: OpenAPIV3.ParameterObject[] = [];
@ -57,25 +76,25 @@ export const processVersionedRouter = (
if (reqQuery) {
queryObjects = converter.convertQuery(reqQuery);
}
parameters = [
getVersionedHeaderParam(newestVersion, versions),
...pathObjects,
...queryObjects,
];
parameters = [getVersionedHeaderParam(version, versions), ...pathObjects, ...queryObjects];
}
const hasBody = Boolean(
handler && extractValidationSchemaFromVersionedHandler(handler)?.request?.body
);
const hasBody = Boolean(extractValidationSchemaFromVersionedHandler(handler)?.request?.body);
const contentType = extractContentType(route.options.options?.body);
const hasVersionFilter = Boolean(filters?.version);
const path: OpenAPIV3.PathItemObject = {
[route.method]: {
summary: route.options.description ?? '',
requestBody: hasBody
? {
content: extractVersionedRequestBody(route, converter),
content: hasVersionFilter
? extractVersionedRequestBody(handler, converter, contentType)
: extractVersionedRequestBodies(route, converter, contentType),
}
: undefined,
responses: extractVersionedResponses(route, converter),
responses: hasVersionFilter
? extractVersionedResponse(handler, converter, contentType)
: extractVersionedResponses(route, converter, contentType),
parameters,
operationId: getOpId(route.path),
},
@ -84,7 +103,7 @@ export const processVersionedRouter = (
assignToPathsObject(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 '${newestVersion}': ${e.message}`;
e.message = `Error generating OpenAPI for route '${route.path}' using newest version '${version}': ${e.message}`;
throw e;
}
}
@ -92,49 +111,86 @@ export const processVersionedRouter = (
};
export const extractVersionedRequestBody = (
handler: VersionedRouterRoute['handlers'][0],
converter: OasConverter,
contentType: string[]
) => {
const schemas = extractValidationSchemaFromVersionedHandler(handler);
if (!schemas?.request) return {};
const schema = converter.convert(schemas.request.body);
return {
[getVersionedContentTypeString(handler.options.version, contentType)]: {
schema,
},
};
};
export const extractVersionedRequestBodies = (
route: VersionedRouterRoute,
converter: OasConverter
converter: OasConverter,
contentType: string[]
): OpenAPIV3.RequestBodyObject['content'] => {
const contentType = extractContentType(route.options.options?.body);
return route.handlers.reduce<OpenAPIV3.RequestBodyObject['content']>((acc, handler) => {
const schemas = extractValidationSchemaFromVersionedHandler(handler);
if (!schemas?.request) return acc;
const schema = converter.convert(schemas.request.body);
return {
...acc,
[getVersionedContentTypeString(handler.options.version, contentType)]: {
schema,
},
...extractVersionedRequestBody(handler, converter, contentType),
};
}, {});
};
export const extractVersionedResponse = (
handler: VersionedRouterRoute['handlers'][0],
converter: OasConverter,
contentType: string[]
) => {
const schemas = extractValidationSchemaFromVersionedHandler(handler);
if (!schemas?.response) return {};
const result: OpenAPIV3.ResponsesObject = {};
const { unsafe, ...responses } = schemas.response;
for (const [statusCode, responseSchema] of Object.entries(responses)) {
const maybeSchema = unwrapVersionedResponseBodyValidation(responseSchema.body);
const schema = converter.convert(maybeSchema);
const contentTypeString = getVersionedContentTypeString(
handler.options.version,
responseSchema.bodyContentType ? [responseSchema.bodyContentType] : contentType
);
result[statusCode] = {
...result[statusCode],
content: {
...((result[statusCode] ?? {}) as OpenAPIV3.ResponseObject).content,
[contentTypeString]: {
schema,
},
},
};
}
return result;
};
const mergeVersionedResponses = (a: OpenAPIV3.ResponsesObject, b: OpenAPIV3.ResponsesObject) => {
const result: OpenAPIV3.ResponsesObject = Object.assign({}, a);
for (const [statusCode, responseContent] of Object.entries(b)) {
const existing = (result[statusCode] as OpenAPIV3.ResponseObject) ?? {};
result[statusCode] = {
...result[statusCode],
content: Object.assign(
{},
existing.content,
(responseContent as OpenAPIV3.ResponseObject).content
),
};
}
return result;
};
export const extractVersionedResponses = (
route: VersionedRouterRoute,
converter: OasConverter
converter: OasConverter,
contentType: string[]
): OpenAPIV3.ResponsesObject => {
const contentType = extractContentType(route.options.options?.body);
return route.handlers.reduce<OpenAPIV3.ResponsesObject>((acc, handler) => {
const schemas = extractValidationSchemaFromVersionedHandler(handler);
if (!schemas?.response) return acc;
const { unsafe, ...responses } = schemas.response;
for (const [statusCode, responseSchema] of Object.entries(responses)) {
const maybeSchema = unwrapVersionedResponseBodyValidation(responseSchema.body);
const schema = converter.convert(maybeSchema);
acc[statusCode] = {
...acc[statusCode],
content: {
...((acc[statusCode] ?? {}) as OpenAPIV3.ResponseObject).content,
[getVersionedContentTypeString(
handler.options.version,
responseSchema.bodyContentType ? [responseSchema.bodyContentType] : contentType
)]: {
schema,
},
},
};
}
return acc;
const responses = extractVersionedResponse(handler, converter, contentType);
return mergeVersionedResponses(acc, responses);
}, {});
};

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { prepareRoutes } from './util';
const internal = 'internal' as const;
const pub = 'public' as const;
describe('prepareRoutes', () => {
test.each([
{
input: [{ path: '/api/foo', options: { access: internal } }],
output: [{ path: '/api/foo', options: { access: internal } }],
filters: {},
},
{
input: [
{ path: '/api/foo', options: { access: internal } },
{ path: '/api/bar', options: { access: internal } },
],
output: [{ path: '/api/bar', options: { access: internal } }],
filters: { pathStartsWith: ['/api/bar'] },
},
{
input: [
{ path: '/api/foo', options: { access: pub } },
{ path: '/api/bar', options: { access: internal } },
],
output: [{ path: '/api/foo', options: { access: pub } }],
filters: { access: pub },
},
{
input: [
{ path: '/api/foo', options: { access: pub } },
{ path: '/api/bar', options: { access: internal } },
{ path: '/api/baz', options: { access: pub } },
],
output: [{ path: '/api/foo', options: { access: pub } }],
filters: { pathStartsWith: ['/api/foo'], access: pub },
},
{
input: [
{ path: '/api/foo', options: { access: pub } },
{ path: '/api/bar', options: { access: internal } },
{ path: '/api/baz', options: { access: pub } },
],
output: [{ path: '/api/foo', options: { access: pub } }],
filters: { excludePathsMatching: ['/api/b'], access: pub },
},
])('returns the expected routes #%#', ({ input, output, filters }) => {
expect(prepareRoutes(input, filters)).toEqual(output);
});
});

View file

@ -14,6 +14,7 @@ import {
type RouteValidatorConfig,
} from '@kbn/core-http-server';
import { KnownParameters } from './type';
import type { GenerateOpenApiDocumentOptionsFilters } from './generate_oas';
export const getPathParameters = (path: string): KnownParameters => {
return Array.from(path.matchAll(/\{(.+?)\}/g)).reduce<KnownParameters>((acc, [_, key]) => {
@ -62,11 +63,22 @@ export const prepareRoutes = <
R extends { path: string; options: { access?: 'public' | 'internal' } }
>(
routes: R[],
pathStartsWith?: string
filters: GenerateOpenApiDocumentOptionsFilters = {}
): R[] => {
return routes.filter(
pathStartsWith ? (route) => route.path.startsWith(pathStartsWith) : () => true
);
if (Object.getOwnPropertyNames(filters).length === 0) return routes;
return routes.filter((route) => {
if (
filters.excludePathsMatching &&
filters.excludePathsMatching.some((ex) => route.path.startsWith(ex))
) {
return false;
}
if (filters.pathStartsWith && !filters.pathStartsWith.some((p) => route.path.startsWith(p))) {
return false;
}
if (filters.access && route.options.access !== filters.access) return false;
return true;
});
};
export const assignToPathsObject = (

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
require('../src/setup_node_env');
require('@kbn/capture-oas-snapshot-cli');

View file

@ -88,11 +88,7 @@ it.each([
'/api/include-test/{id}': {},
},
},
excludes: {
paths: {
'/my-other-plugin': {},
},
},
excludes: ['/my-other-plugin'],
},
{
queryParam: { pluginId: 'myPlugin' },
@ -105,24 +101,18 @@ it.each([
'/api/include-test/{id}': {},
},
},
excludes: {
paths: {
'/my-other-plugin': {},
},
},
excludes: ['/my-other-plugin'],
},
{
queryParam: { pluginId: 'nonExistant' },
includes: {},
excludes: {
paths: {
'/my-include-test': {},
'/my-other-plugin': {},
},
},
excludes: ['/my-include-test', '/my-other-plugin'],
},
{
queryParam: { pluginId: 'myOtherPlugin', pathStartsWith: '/api/my-other-plugin' },
queryParam: {
pluginId: 'myOtherPlugin',
pathStartsWith: ['/api/my-other-plugin', '/api/versioned'],
},
includes: {
paths: {
'/api/my-other-plugin': {
@ -132,11 +122,35 @@ it.each([
},
},
},
excludes: {
excludes: ['/my-include-test'],
},
{
queryParam: { access: 'public', version: '2023-10-31' },
includes: {
paths: {
'/my-include-test': {},
'/api/include-test': {
get: {},
},
'/api/versioned': {
get: {},
},
},
},
excludes: ['/api/my-include-test/{id}', '/api/exclude-test', '/api/my-other-plugin'],
},
{
queryParam: { excludePathsMatching: ['/api/exclude-test', '/api/my-other-plugin'] },
includes: {
paths: {
'/api/include-test': {
get: {},
},
'/api/versioned': {
get: {},
},
},
},
excludes: ['/api/exclude-test', '/api/my-other-plugin'],
},
])(
'can filter paths based on query params $queryParam',
@ -145,11 +159,18 @@ it.each([
config: { server: { oas: { enabled: true } } },
createRoutes: (getRouter) => {
const router1 = getRouter(Symbol('myPlugin'));
router1.get({ path: '/api/include-test', validate: false }, (_, __, res) => res.ok());
router1.get(
{ path: '/api/include-test', validate: false, options: { access: 'public' } },
(_, __, res) => res.ok()
);
router1.post({ path: '/api/include-test', validate: false }, (_, __, res) => res.ok());
router1.get({ path: '/api/include-test/{id}', validate: false }, (_, __, res) => res.ok());
router1.get({ path: '/api/exclude-test', validate: false }, (_, __, res) => res.ok());
router1.versioned
.get({ path: '/api/versioned', access: 'public' })
.addVersion({ version: '2023-10-31', validate: false }, (_, __, res) => res.ok());
const router2 = getRouter(Symbol('myOtherPlugin'));
router2.get({ path: '/api/my-other-plugin', validate: false }, (_, __, res) => res.ok());
router2.post({ path: '/api/my-other-plugin', validate: false }, (_, __, res) => res.ok());
@ -159,6 +180,17 @@ it.each([
const result = await supertest(server.listener).get('/api/oas').query(queryParam);
expect(result.status).toBe(200);
expect(result.body).toMatchObject(includes);
expect(result.body).not.toMatchObject(excludes);
excludes.forEach((exclude) => {
expect(result.body.paths).not.toHaveProperty(exclude);
});
}
);
it('only accepts "public" or "internal" for "access" query param', async () => {
const server = await startService({ config: { server: { oas: { enabled: true } } } });
const result = await supertest(server.listener).get('/api/oas').query({ access: 'invalid' });
expect(result.body.message).toBe(
'Invalid access query parameter. Must be one of "public" or "internal".'
);
expect(result.status).toBe(400);
});

View file

@ -128,6 +128,8 @@
"@kbn/calculate-width-from-char-count/*": ["packages/kbn-calculate-width-from-char-count/*"],
"@kbn/canvas-plugin": ["x-pack/plugins/canvas"],
"@kbn/canvas-plugin/*": ["x-pack/plugins/canvas/*"],
"@kbn/capture-oas-snapshot-cli": ["packages/kbn-capture-oas-snapshot-cli"],
"@kbn/capture-oas-snapshot-cli/*": ["packages/kbn-capture-oas-snapshot-cli/*"],
"@kbn/cases-api-integration-test-plugin": ["x-pack/test/cases_api_integration/common/plugins/cases"],
"@kbn/cases-api-integration-test-plugin/*": ["x-pack/test/cases_api_integration/common/plugins/cases/*"],
"@kbn/cases-components": ["packages/kbn-cases-components"],

View file

@ -3291,6 +3291,10 @@
version "0.0.0"
uid ""
"@kbn/capture-oas-snapshot-cli@link:packages/kbn-capture-oas-snapshot-cli":
version "0.0.0"
uid ""
"@kbn/cases-api-integration-test-plugin@link:x-pack/test/cases_api_integration/common/plugins/cases":
version "0.0.0"
uid ""