Skip to content

Commit

Permalink
[HTTP/OAS] @kbn/router-to-openapispec (elastic#180683)
Browse files Browse the repository at this point in the history
## Summary

Introduces a new package for generating OAS from Kibana's routers. This
first iteration includes:

* E2E conversion of Core's `Router` and `CoreVersionedRouter` routes
into a single OAS document (not written to disk or shared anywhere
yet...)
* Support for
[`$ref`](https://swagger.io/docs/specification/using-ref/?sbsearch=%24ref)
by introducing the `meta.id` field `@kbn/config-schema`'s base type.
This is intended to be used only response/request schemas initially.

## TODO

- [x] More unit tests

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
jloleysens and kibanamachine authored Apr 17, 2024
1 parent 05512f4 commit 29fb11b
Show file tree
Hide file tree
Showing 59 changed files with 2,332 additions and 40 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,7 @@ x-pack/test/plugin_functional/plugins/resolver_test @elastic/security-solution
examples/response_stream @elastic/ml-ui
packages/kbn-rison @elastic/kibana-operations
x-pack/plugins/rollup @elastic/kibana-management
packages/kbn-router-to-openapispec @elastic/kibana-core
packages/kbn-router-utils @elastic/obs-ux-logs-team
examples/routing_example @elastic/kibana-core
packages/kbn-rrule @elastic/response-ops
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@
"@kbn/response-stream-plugin": "link:examples/response_stream",
"@kbn/rison": "link:packages/kbn-rison",
"@kbn/rollup-plugin": "link:x-pack/plugins/rollup",
"@kbn/router-to-openapispec": "link:packages/kbn-router-to-openapispec",
"@kbn/router-utils": "link:packages/kbn-router-utils",
"@kbn/routing-example-plugin": "link:examples/routing_example",
"@kbn/rrule": "link:packages/kbn-rrule",
Expand Down
18 changes: 14 additions & 4 deletions packages/core/http/core-http-router-server-internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@
*/

export { filterHeaders } from './src/headers';
export { versionHandlerResolvers } from './src/versioned_router';
export { CoreVersionedRouter } from './src/versioned_router';
export { Router, type RouterOptions } from './src/router';
export type { HandlerResolutionStrategy } from './src/versioned_router';
export {
versionHandlerResolvers,
CoreVersionedRouter,
ALLOWED_PUBLIC_VERSION,
type VersionedRouterRoute,
type HandlerResolutionStrategy,
} from './src/versioned_router';
export { Router } from './src/router';
export type {
RouterOptions,
InternalRegistrar,
InternalRegistrarOptions,
InternalRouterRoute,
} from './src/router';
export { isKibanaRequest, isRealRequest, ensureRawRequest, CoreKibanaRequest } from './src/request';
export { isSafeMethod } from './src/route';
export { HapiResponseAdapter } from './src/response_adapter';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ describe('Router', () => {
}
);

it('constructs lazily provided validations once (idempotency)', async () => {
const router = new Router('', logger, enhanceWithContext, routerOptions);
const lazyValidation = jest.fn(() => fooValidation);
router.post(
{
path: '/',
validate: lazyValidation,
},
(context, req, res) => res.ok()
);
const [{ handler }] = router.getRoutes();
for (let i = 0; i < 10; i++) {
await handler(
createRequestMock({
params: { foo: 1 },
query: { foo: 1 },
payload: { foo: 1 },
}),
mockResponseToolkit
);
}
expect(lazyValidation).toHaveBeenCalledTimes(1);
});

describe('Options', () => {
it('throws if validation for a route is not defined explicitly', () => {
const router = new Router('', logger, enhanceWithContext, routerOptions);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { Request, ResponseToolkit } from '@hapi/hapi';
import { once } from 'lodash';
import apm from 'elastic-apm-node';
import { isConfigSchema } from '@kbn/config-schema';
import type { Logger } from '@kbn/logging';
Expand Down Expand Up @@ -134,19 +135,19 @@ export interface RouterOptions {
}

/** @internal */
interface InternalRegistrarOptions {
export interface InternalRegistrarOptions {
isVersioned: boolean;
}

/** @internal */
type InternalRegistrar<M extends Method, C extends RequestHandlerContextBase> = <P, Q, B>(
export type InternalRegistrar<M extends Method, C extends RequestHandlerContextBase> = <P, Q, B>(
route: RouteConfig<P, Q, B, M>,
handler: RequestHandler<P, Q, B, C, M>,
internalOpts?: InternalRegistrarOptions
) => ReturnType<RouteRegistrar<M, C>>;

/** @internal */
interface InternalRouterRoute extends RouterRoute {
export interface InternalRouterRoute extends RouterRoute {
readonly isVersioned: boolean;
}

Expand Down Expand Up @@ -181,6 +182,9 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
handler: RequestHandler<P, Q, B, Context, Method>,
internalOptions: { isVersioned: boolean } = { isVersioned: false }
) => {
if (typeof route.validate === 'function') {
route = { ...route, validate: once(route.validate) };
}
const routeSchemas = routeSchemasFromRouteConfig(route, method);

this.routes.push({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { createRouter } from './mocks';
import { CoreVersionedRouter } from '.';
import { passThroughValidation } from './core_versioned_route';
import { Method } from './types';
import { createRequest } from './core_versioned_route.test.utils';
import { createRequest } from './core_versioned_route.test.util';

describe('Versioned route', () => {
let router: Router;
Expand Down Expand Up @@ -192,6 +192,36 @@ describe('Versioned route', () => {
}
);

it('constructs lazily provided validations once (idempotency)', async () => {
let handler: RequestHandler;
(router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn));
const versionedRouter = CoreVersionedRouter.from({ router });
const lazyValidation = jest.fn(() => fooValidation);
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
validate: lazyValidation,
},
handlerFn
);

for (let i = 0; i < 10; i++) {
const { status } = await handler!(
{} as any,
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
}),
responseFactory
);
expect(status).toBe(200);
}

expect(lazyValidation).toHaveBeenCalledTimes(1);
});

describe('when in dev', () => {
// NOTE: Temporary test to ensure single public API version is enforced
it('only allows "2023-10-31" as public route versions', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import { once } from 'lodash';
import {
ELASTIC_HTTP_VERSION_HEADER,
ELASTIC_HTTP_VERSION_QUERY_PARAM,
Expand Down Expand Up @@ -237,7 +238,14 @@ export class CoreVersionedRoute implements VersionedRoute {

public addVersion(options: Options, handler: RequestHandler<any, any, any, any>): VersionedRoute {
this.validateVersion(options.version);
this.handlers.set(options.version, { fn: handler, options });
this.handlers.set(options.version, {
fn: handler,
options: {
...options,
validate:
typeof options.validate === 'function' ? once(options.validate) : options.validate,
},
});
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
*/
export { resolvers as versionHandlerResolvers } from './handler_resolvers';
export { CoreVersionedRouter } from './core_versioned_router';
export type { HandlerResolutionStrategy } from './types';
export type { HandlerResolutionStrategy, VersionedRouterRoute } from './types';
export { ALLOWED_PUBLIC_VERSION } from './route_version_utils';
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const INTERNAL_VERSION_REGEX = /^[1-9][0-9]*$/;
* release date we only allow one public version temporarily.
* @internal
*/
const ALLOWED_PUBLIC_VERSION = '2023-10-31';
export const ALLOWED_PUBLIC_VERSION = '2023-10-31';

export function isAllowedPublicVersion(version: string): undefined | string {
if (ALLOWED_PUBLIC_VERSION !== version) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,9 @@ export type Method = Exclude<RouteMethod, 'options'>;

/** @internal */
export interface VersionedRouterRoute {
/** @internal */
method: string;
/** @internal */
path: string;
/** @internal */
options: Omit<VersionedRouteConfig<RouteMethod>, 'path'>;
/** @internal */
handlers: Array<{
fn: RequestHandler;
options: AddVersionOpts<unknown, unknown, unknown>;
Expand Down
8 changes: 7 additions & 1 deletion packages/core/http/core-http-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,13 @@ export type {
RouteValidatorFullConfigRequest,
RouteValidatorFullConfigResponse,
} from './src/router';
export { validBodyOutput, RouteValidationError, getRequestValidation } from './src/router';
export {
validBodyOutput,
RouteValidationError,
getRequestValidation,
getResponseValidation,
isFullValidatorContainer,
} from './src/router';

export type { ICspConfig } from './src/csp';

Expand Down
2 changes: 1 addition & 1 deletion packages/core/http/core-http-server/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,4 @@ export type {
LifecycleResponseFactory,
} from './response_factory';
export type { RawRequest, FakeRawRequest } from './raw_request';
export { getRequestValidation, isFullValidatorContainer } from './utils';
export { getRequestValidation, getResponseValidation, isFullValidatorContainer } from './utils';
4 changes: 4 additions & 0 deletions packages/core/http/core-http-server/src/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ export interface RouterRoute {
method: RouteMethod;
path: string;
options: RouteConfigOptions<RouteMethod>;
/**
* @note if providing a function to lazily load your validation schemas assume
* that the function will only be called once.
*/
validationSchemas?:
| (() => RouteValidator<unknown, unknown, unknown>)
| RouteValidator<unknown, unknown, unknown>
Expand Down
29 changes: 28 additions & 1 deletion packages/core/http/core-http-server/src/router/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import type { ObjectType } from '@kbn/config-schema';
import type { RouteValidator } from './route_validator';
import { getRequestValidation, isFullValidatorContainer } from './utils';
import { getRequestValidation, getResponseValidation, isFullValidatorContainer } from './utils';

type Validator = RouteValidator<unknown, unknown, unknown>;

Expand Down Expand Up @@ -41,3 +41,30 @@ describe('getRequestValidation', () => {
expect(getRequestValidation(fullValidator)).toBe(validationDummy);
});
});

describe('getResponseValidation', () => {
it('extracts validation config', () => {
const validationDummy = {
body: {} as unknown as ObjectType,
};
const fullValidatorContainer: Validator = {
request: {},
response: validationDummy,
};

expect(getResponseValidation(fullValidatorContainer)).toBe(validationDummy);
});

it('returns "undefined" when there is no response validation configured', () => {
const validationDummy = {
body: {} as unknown as ObjectType,
};
const fullValidatorContainer: Validator = {
request: {},
};
const fullValidator: Validator = validationDummy;

expect(getResponseValidation(fullValidatorContainer)).toBe(undefined);
expect(getResponseValidation(fullValidator)).toBe(undefined);
});
});
15 changes: 15 additions & 0 deletions packages/core/http/core-http-server/src/router/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {
RouteValidator,
RouteValidatorFullConfigRequest,
RouteValidatorFullConfigResponse,
RouteValidatorRequestAndResponses,
} from './route_validator';

Expand Down Expand Up @@ -37,3 +38,17 @@ export function getRequestValidation<P, Q, B>(
if (typeof value === 'function') value = value();
return isFullValidatorContainer(value) ? value.request : value;
}

/**
* Extracts {@link RouteValidatorFullConfigRequest} from the validation container.
* This utility is intended to be used by code introspecting router validation configuration.
* @public
*/
export function getResponseValidation(
value:
| RouteValidator<unknown, unknown, unknown>
| (() => RouteValidator<unknown, unknown, unknown>)
): undefined | RouteValidatorFullConfigResponse {
if (typeof value === 'function') value = value();
return isFullValidatorContainer(value) ? value.response : undefined;
}
16 changes: 16 additions & 0 deletions packages/core/http/core-http-server/src/versioning/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ export type VersionedRouteConfig<Method extends RouteMethod> = Omit<
* @default false
*/
enableQueryVersion?: boolean;

/**
* Human-friendly description of this route, should be usable for documentation
*
* @example
* ```ts
* router.get({
* path: '/api/foo/{id}',
* access: 'public',
* description: `Retrieve foo resources given an ID. To retrieve a list of IDs use the GET /api/foo API.`,
* })
* ```
*/
description?: string;
};

/**
Expand Down Expand Up @@ -226,6 +240,8 @@ export interface AddVersionOpts<P, Q, B> {
version: ApiVersion;
/**
* Validation for this version of a route
* @note if providing a function to lazily load your validation schemas assume
* that the function will only be called once.
* @public
*/
validate: false | VersionedRouteValidation<P, Q, B> | (() => VersionedRouteValidation<P, Q, B>); // Provide a way to lazily load validation schemas
Expand Down
Loading

0 comments on commit 29fb11b

Please sign in to comment.