mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
7fef12bca0
commit
975eeed255
38 changed files with 1378 additions and 200 deletions
14
.buildkite/scripts/steps/capture_oas_snapshot.sh
Executable file
14
.buildkite/scripts/steps/capture_oas_snapshot.sh
Executable 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
|
|
@ -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
1
.github/CODEOWNERS
vendored
|
@ -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
538
oas_docs/bundle.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
6
packages/kbn-capture-oas-snapshot-cli/README.md
Normal file
6
packages/kbn-capture-oas-snapshot-cli/README.md
Normal 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.
|
13
packages/kbn-capture-oas-snapshot-cli/jest.config.js
Normal file
13
packages/kbn-capture-oas-snapshot-cli/jest.config.js
Normal 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'],
|
||||
};
|
6
packages/kbn-capture-oas-snapshot-cli/kibana.jsonc
Normal file
6
packages/kbn-capture-oas-snapshot-cli/kibana.jsonc
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/capture-oas-snapshot-cli",
|
||||
"owner": "@elastic/kibana-core",
|
||||
"devOnly": true
|
||||
}
|
7
packages/kbn-capture-oas-snapshot-cli/package.json
Normal file
7
packages/kbn-capture-oas-snapshot-cli/package.json
Normal 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"
|
||||
}
|
55
packages/kbn-capture-oas-snapshot-cli/src/kibana_worker.ts
Normal file
55
packages/kbn-capture-oas-snapshot-cli/src/kibana_worker.ts
Normal 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);
|
||||
});
|
|
@ -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)}.
|
||||
`,
|
||||
},
|
||||
}
|
||||
);
|
25
packages/kbn-capture-oas-snapshot-cli/tsconfig.json
Normal file
25
packages/kbn-capture-oas-snapshot-cli/tsconfig.json
Normal 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",
|
||||
]
|
||||
}
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
71
packages/kbn-dev-utils/src/worker/index.ts
Normal file
71
packages/kbn-dev-utils/src/worker/index.ts
Normal 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()
|
||||
);
|
||||
}
|
|
@ -15,6 +15,8 @@
|
|||
"@kbn/dev-cli-errors",
|
||||
"@kbn/repo-info",
|
||||
"@kbn/repo-packages",
|
||||
"@kbn/some-dev-log",
|
||||
"@kbn/stdio-dev-helpers",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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' } }),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}, {});
|
||||
};
|
||||
|
||||
|
|
58
packages/kbn-router-to-openapispec/src/util.test.ts
Normal file
58
packages/kbn-router-to-openapispec/src/util.test.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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 = (
|
||||
|
|
10
scripts/capture_oas_snapshot.js
Normal file
10
scripts/capture_oas_snapshot.js
Normal 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');
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue