diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6d6f2a8fb82a9..ec9cabb80a621 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -175,6 +175,7 @@ packages/core/http/core-http-router-server-mocks @elastic/kibana-core packages/core/http/core-http-server @elastic/kibana-core packages/core/http/core-http-server-internal @elastic/kibana-core packages/core/http/core-http-server-mocks @elastic/kibana-core +packages/core/http/core-http-versioned-router-server-internal @elastic/kibana-core packages/core/i18n/core-i18n-browser @elastic/kibana-core packages/core/i18n/core-i18n-browser-internal @elastic/kibana-core packages/core/i18n/core-i18n-browser-mocks @elastic/kibana-core @@ -284,7 +285,6 @@ packages/core/usage-data/core-usage-data-base-server-internal @elastic/kibana-co packages/core/usage-data/core-usage-data-server @elastic/kibana-core packages/core/usage-data/core-usage-data-server-internal @elastic/kibana-core packages/core/usage-data/core-usage-data-server-mocks @elastic/kibana-core -packages/core/versioning/core-version-http-server @elastic/kibana-core x-pack/plugins/cross_cluster_replication @elastic/platform-deployment-management packages/kbn-crypto @elastic/kibana-security packages/kbn-crypto-browser @elastic/kibana-core diff --git a/package.json b/package.json index 3c19ecef9cf7c..96a8829a1beee 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "@kbn/core-http-router-server-internal": "link:packages/core/http/core-http-router-server-internal", "@kbn/core-http-server": "link:packages/core/http/core-http-server", "@kbn/core-http-server-internal": "link:packages/core/http/core-http-server-internal", + "@kbn/core-http-versioned-router-server-internal": "link:packages/core/http/core-http-versioned-router-server-internal", "@kbn/core-i18n-browser": "link:packages/core/i18n/core-i18n-browser", "@kbn/core-i18n-browser-internal": "link:packages/core/i18n/core-i18n-browser-internal", "@kbn/core-i18n-server": "link:packages/core/i18n/core-i18n-server", @@ -329,7 +330,6 @@ "@kbn/core-usage-data-base-server-internal": "link:packages/core/usage-data/core-usage-data-base-server-internal", "@kbn/core-usage-data-server": "link:packages/core/usage-data/core-usage-data-server", "@kbn/core-usage-data-server-internal": "link:packages/core/usage-data/core-usage-data-server-internal", - "@kbn/core-version-http-server": "link:packages/core/versioning/core-version-http-server", "@kbn/cross-cluster-replication-plugin": "link:x-pack/plugins/cross_cluster_replication", "@kbn/crypto": "link:packages/kbn-crypto", "@kbn/crypto-browser": "link:packages/kbn-crypto-browser", diff --git a/packages/core/http/core-http-router-server-internal/index.ts b/packages/core/http/core-http-router-server-internal/index.ts index 8c3795306dd9b..c783f5967d89c 100644 --- a/packages/core/http/core-http-router-server-internal/index.ts +++ b/packages/core/http/core-http-router-server-internal/index.ts @@ -17,3 +17,4 @@ export { isKibanaResponse, KibanaResponse, } from './src/response'; +export { RouteValidator } from './src/validator'; diff --git a/packages/core/http/core-http-server/index.ts b/packages/core/http/core-http-server/index.ts index fe08c6a34f2d4..de373db4c27de 100644 --- a/packages/core/http/core-http-server/index.ts +++ b/packages/core/http/core-http-server/index.ts @@ -128,3 +128,14 @@ export type { HttpServiceSetup, HttpServiceStart, } from './src/http_contract'; + +export type { + AddVersionOpts, + VersionedRouteRequestValidation, + VersionedRouteResponseValidation, + ApiVersion, + VersionedRoute, + VersionedRouteConfig, + VersionedRouteRegistrar, + VersionedRouter, +} from './src/versioning'; diff --git a/packages/core/versioning/core-version-http-server/README.md b/packages/core/http/core-http-server/src/versioning/README.md similarity index 76% rename from packages/core/versioning/core-version-http-server/README.md rename to packages/core/http/core-http-server/src/versioning/README.md index b419f083b0d7e..60553d059ada9 100644 --- a/packages/core/versioning/core-version-http-server/README.md +++ b/packages/core/http/core-http-server/src/versioning/README.md @@ -1,6 +1,4 @@ -# @kbn/core-version-http-server - -This package contains types for sever-side HTTP versioning. +This folder contains types for sever-side HTTP versioning. ## Experimental diff --git a/packages/core/http/core-http-server/src/versioning/index.ts b/packages/core/http/core-http-server/src/versioning/index.ts new file mode 100644 index 0000000000000..1b2ee09771225 --- /dev/null +++ b/packages/core/http/core-http-server/src/versioning/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +export type { + ApiVersion, + AddVersionOpts, + VersionedRouteRequestValidation, + VersionedRouteResponseValidation, + VersionedRoute, + VersionedRouteConfig, + VersionedRouteRegistrar, + VersionedRouter, +} from './types'; diff --git a/packages/core/http/core-http-server/src/versioning/types.ts b/packages/core/http/core-http-server/src/versioning/types.ts new file mode 100644 index 0000000000000..bf5eec361018d --- /dev/null +++ b/packages/core/http/core-http-server/src/versioning/types.ts @@ -0,0 +1,225 @@ +/* + * 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 type { Type } from '@kbn/config-schema'; +import type { MaybePromise } from '@kbn/utility-types'; +import type { + RouteConfig, + RouteMethod, + RequestHandler, + IKibanaResponse, + RouteConfigOptions, + RouteValidatorFullConfig, + RequestHandlerContextBase, + RouteValidationFunction, +} from '../..'; + +type RqCtx = RequestHandlerContextBase; + +/** + * Assuming that version will be a monotonically increasing number where: version > 0. + * @experimental + */ +export type ApiVersion = `${number}`; + +/** + * Configuration for a versioned route + * @experimental + */ +export type VersionedRouteConfig = Omit< + RouteConfig, + 'validate' | 'options' +> & { + options?: Omit, 'access'>; + /** See {@link RouteConfigOptions['access']} */ + access: RouteConfigOptions['access']; +}; + +/** + * Create an {@link VersionedRoute | versioned route}. + * + * @param config - The route configuration + * @returns A versioned route + * @experimental + */ +export type VersionedRouteRegistrar = ( + config: VersionedRouteConfig +) => VersionedRoute; + +/** + * A router, very similar to {@link IRouter} that will return an {@link VersionedRoute} + * instead. + * + * @example + * const versionedRoute = versionedRouter + * .post({ + * access: 'internal', + * path: '/api/my-app/foo/{id?}', + * options: { timeout: { payload: 60000 } }, + * }) + * .addVersion( + * { + * version: '1', + * validate: { + * request: { + * query: schema.object({ + * name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + * }), + * params: schema.object({ + * id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + * }), + * body: schema.object({ foo: schema.string() }), + * }, + * response: { + * 200: { + * body: schema.object({ foo: schema.string() }), + * }, + * }, + * }, + * }, + * async (ctx, req, res) => { + * await ctx.fooService.create(req.body.foo, req.params.id, req.query.name); + * return res.ok({ body: { foo: req.body.foo } }); + * } + * ) + * // BREAKING CHANGE: { foo: string } => { fooString: string } in body + * .addVersion( + * { + * version: '2', + * validate: { + * request: { + * query: schema.object({ + * name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + * }), + * params: schema.object({ + * id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + * }), + * body: schema.object({ fooString: schema.string() }), + * }, + * response: { + * 200: { + * body: schema.object({ fooName: schema.string() }), + * }, + * }, + * }, + * }, + * async (ctx, req, res) => { + * await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); + * return res.ok({ body: { fooName: req.body.fooString } }); + * } + * ) + * // BREAKING CHANGES: Enforce min/max length on fooString + * .addVersion( + * { + * version: '3', + * validate: { + * request: { + * query: schema.object({ + * name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), + * }), + * params: schema.object({ + * id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), + * }), + * body: schema.object({ fooString: schema.string({ minLength: 0, maxLength: 1000 }) }), + * }, + * response: { + * 200: { + * body: schema.object({ fooName: schema.string() }), + * }, + * }, + * }, + * }, + * async (ctx, req, res) => { + * await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); + * return res.ok({ body: { fooName: req.body.fooString } }); + * } + * ); + + * @experimental + */ +export interface VersionedRouter { + /** @experimental */ + get: VersionedRouteRegistrar<'get', Ctx>; + /** @experimental */ + put: VersionedRouteRegistrar<'put', Ctx>; + /** @experimental */ + post: VersionedRouteRegistrar<'post', Ctx>; + /** @experimental */ + patch: VersionedRouteRegistrar<'patch', Ctx>; + /** @experimental */ + delete: VersionedRouteRegistrar<'delete', Ctx>; +} + +/** @experimental */ +export type VersionedRouteRequestValidation = RouteValidatorFullConfig; + +/** @experimental */ +export interface VersionedRouteResponseValidation { + [statusCode: number]: { body: RouteValidationFunction | Type }; + unsafe?: { body?: boolean }; +} + +/** + * Versioned route validation + * @experimental + */ +interface FullValidationConfig { + /** + * Validation to run against route inputs: params, query and body + * @experimental + */ + request?: VersionedRouteRequestValidation; + /** + * Validation to run against route output + * @note This validation is only intended to run in development. Do not use this + * for setting default values! + * @experimental + */ + response?: VersionedRouteResponseValidation; +} + +/** + * Options for a versioned route. Probably needs a lot more options like sunsetting + * of an endpoint etc. + * @experimental + */ +export interface AddVersionOpts { + /** + * Version to assign to this route + * @experimental + */ + version: ApiVersion; + /** + * Validation for this version of a route + * @experimental + */ + validate: false | FullValidationConfig; +} + +/** + * A versioned route + * @experimental + */ +export interface VersionedRoute< + Method extends RouteMethod = RouteMethod, + Ctx extends RqCtx = RqCtx +> { + /** + * Add a new version of this route + * @param opts {@link AddVersionOpts | Options} for this version of a route + * @param handler The request handler for this version of a route + * @returns A versioned route, allows for fluent chaining of version declarations + * @experimental + */ + addVersion

( + options: AddVersionOpts, + handler: ( + ...params: Parameters> + ) => MaybePromise> + ): VersionedRoute; +} diff --git a/packages/core/http/core-http-versioned-router-server-internal/README.md b/packages/core/http/core-http-versioned-router-server-internal/README.md new file mode 100644 index 0000000000000..81c803b2184f1 --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/README.md @@ -0,0 +1,7 @@ +# @kbn/core-http-versioned-router-server-internal + +This package contains the implementation for sever-side HTTP versioning. + +## Experimental + +See notes in `@kbn/core-http-server/src/versioning` \ No newline at end of file diff --git a/packages/core/versioning/core-version-http-server/index.ts b/packages/core/http/core-http-versioned-router-server-internal/index.ts similarity index 88% rename from packages/core/versioning/core-version-http-server/index.ts rename to packages/core/http/core-http-versioned-router-server-internal/index.ts index 2ed8fca6a33f4..3d92d3a8284ee 100644 --- a/packages/core/versioning/core-version-http-server/index.ts +++ b/packages/core/http/core-http-versioned-router-server-internal/index.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -// TODO: export once types are ready -export {}; +export { CoreVersionedRouter } from './src'; diff --git a/packages/core/versioning/core-version-http-server/jest.config.js b/packages/core/http/core-http-versioned-router-server-internal/jest.config.js similarity index 83% rename from packages/core/versioning/core-version-http-server/jest.config.js rename to packages/core/http/core-http-versioned-router-server-internal/jest.config.js index 7f87846044ae9..4d40fd48a54de 100644 --- a/packages/core/versioning/core-version-http-server/jest.config.js +++ b/packages/core/http/core-http-versioned-router-server-internal/jest.config.js @@ -9,5 +9,5 @@ module.exports = { preset: '@kbn/test/jest_node', rootDir: '../../../..', - roots: ['/packages/core/versioning/core-version-http-server'], + roots: ['/packages/core/http/core-http-versioned-router-server-internal'], }; diff --git a/packages/core/versioning/core-version-http-server/kibana.jsonc b/packages/core/http/core-http-versioned-router-server-internal/kibana.jsonc similarity index 52% rename from packages/core/versioning/core-version-http-server/kibana.jsonc rename to packages/core/http/core-http-versioned-router-server-internal/kibana.jsonc index 38c17bf30d2a5..788d77de31840 100644 --- a/packages/core/versioning/core-version-http-server/kibana.jsonc +++ b/packages/core/http/core-http-versioned-router-server-internal/kibana.jsonc @@ -1,5 +1,5 @@ { "type": "shared-common", - "id": "@kbn/core-version-http-server", + "id": "@kbn/core-http-versioned-router-server-internal", "owner": "@elastic/kibana-core" } diff --git a/packages/core/versioning/core-version-http-server/package.json b/packages/core/http/core-http-versioned-router-server-internal/package.json similarity index 65% rename from packages/core/versioning/core-version-http-server/package.json rename to packages/core/http/core-http-versioned-router-server-internal/package.json index 160267c76a449..3375523f5d77c 100644 --- a/packages/core/versioning/core-version-http-server/package.json +++ b/packages/core/http/core-http-versioned-router-server-internal/package.json @@ -1,5 +1,5 @@ { - "name": "@kbn/core-version-http-server", + "name": "@kbn/core-http-versioned-router-server-internal", "private": true, "version": "1.0.0", "author": "Kibana Core", diff --git a/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_route.test.ts b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_route.test.ts new file mode 100644 index 0000000000000..e51af21555e92 --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_route.test.ts @@ -0,0 +1,194 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { IRouter, RequestHandler } from '@kbn/core-http-server'; +import { httpServiceMock, httpServerMock } from '@kbn/core-http-server-mocks'; +import { VERSION_HEADER } from './core_versioned_route'; +import { CoreVersionedRouter } from '.'; +import { kibanaResponseFactory } from '@kbn/core-http-router-server-internal'; + +describe('Versioned route', () => { + let router: IRouter; + const handlerFn: RequestHandler = async (ctx, req, res) => res.ok({ body: { foo: 1 } }); + beforeEach(() => { + router = httpServiceMock.createRouter(); + }); + + it('can register multiple handlers', () => { + const versionedRouter = CoreVersionedRouter.from({ router }); + versionedRouter + .get({ path: '/test/{id}', access: 'internal' }) + .addVersion({ version: '1', validate: false }, handlerFn) + .addVersion({ version: '2', validate: false }, handlerFn) + .addVersion({ version: '3', validate: false }, handlerFn); + const routes = versionedRouter.getRoutes(); + expect(routes).toHaveLength(1); + const [route] = routes; + expect(route.handlers).toHaveLength(3); + // We only register one route with the underlying router + expect(router.get).toHaveBeenCalledTimes(1); + }); + + it('does not allow specifying a handler for the same version more than once', () => { + const versionedRouter = CoreVersionedRouter.from({ router }); + expect(() => + versionedRouter + .get({ path: '/test/{id}', access: 'internal' }) + .addVersion({ version: '1', validate: false }, handlerFn) + .addVersion({ version: '1', validate: false }, handlerFn) + .addVersion({ version: '3', validate: false }, handlerFn) + ).toThrowError( + `Version "1" handler has already been registered for the route [get] [/test/{id}]` + ); + }); + + it('runs request and response validations', async () => { + let handler: RequestHandler; + + let validatedBody = false; + let validatedParams = false; + let validatedQuery = false; + let validatedOutputBody = false; + + (router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn)); + const versionedRouter = CoreVersionedRouter.from({ router }); + versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion( + { + version: '1', + validate: { + request: { + body: schema.object({ + foo: schema.number({ + validate: () => { + validatedBody = true; + }, + }), + }), + params: schema.object({ + foo: schema.number({ + validate: () => { + validatedParams = true; + }, + }), + }), + query: schema.object({ + foo: schema.number({ + validate: () => { + validatedQuery = true; + }, + }), + }), + }, + response: { + 200: { + body: schema.object({ + foo: schema.number({ + validate: () => { + validatedOutputBody = true; + }, + }), + }), + }, + }, + }, + }, + handlerFn + ); + + const kibanaResponse = await handler!( + {} as any, + httpServerMock.createKibanaRequest({ + headers: { [VERSION_HEADER]: '1' }, + body: { foo: 1 }, + params: { foo: 1 }, + query: { foo: 1 }, + }), + kibanaResponseFactory + ); + + expect(kibanaResponse.status).toBe(200); + expect(validatedBody).toBe(true); + expect(validatedParams).toBe(true); + expect(validatedQuery).toBe(true); + expect(validatedOutputBody).toBe(true); + }); + + it('returns the expected output for non-existent versions', async () => { + let handler: RequestHandler; + const versionedRouter = CoreVersionedRouter.from({ router }); + (router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn)); + versionedRouter.post({ access: 'internal', path: '/test/{id}' }); + + await expect( + handler!( + {} as any, + httpServerMock.createKibanaRequest({ + headers: { [VERSION_HEADER]: '999' }, + }), + kibanaResponseFactory + ) + ).resolves.toEqual({ + options: {}, + payload: 'No version "999" available for [post] [/test/{id}]. Available versions are: "none"', + status: 406, + }); + }); + + it('returns the expected output if no version was provided to versioned route', async () => { + let handler: RequestHandler; + const versionedRouter = CoreVersionedRouter.from({ router }); + (router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn)); + + versionedRouter + .post({ access: 'internal', path: '/test/{id}' }) + .addVersion({ validate: false, version: '1' }, handlerFn); + + await expect( + handler!( + {} as any, + httpServerMock.createKibanaRequest({ + headers: {}, + }), + kibanaResponseFactory + ) + ).resolves.toEqual({ + options: {}, + payload: + 'Version expected at [post] [/test/{id}]. Please specify a version using the "Elastic-Api-Version" header. Available versions are: "1"', + status: 406, + }); + }); + it('returns the expected output for failed validation', async () => { + let handler: RequestHandler; + const versionedRouter = CoreVersionedRouter.from({ router }); + (router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn)); + + versionedRouter + .post({ access: 'internal', path: '/test/{id}' }) + .addVersion( + { validate: { request: { body: schema.object({ foo: schema.number() }) } }, version: '1' }, + handlerFn + ); + + await expect( + handler!( + {} as any, + httpServerMock.createKibanaRequest({ + headers: { [VERSION_HEADER]: '1' }, + body: {}, + }), + kibanaResponseFactory + ) + ).resolves.toEqual({ + options: {}, + payload: expect.any(String), + status: 400, + }); + }); +}); diff --git a/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_route.ts b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_route.ts new file mode 100644 index 0000000000000..fea7e947e2bed --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_route.ts @@ -0,0 +1,177 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { + RequestHandler, + IRouter, + RequestHandlerContextBase, + KibanaRequest, + KibanaResponseFactory, +} from '@kbn/core-http-server'; +import type { + ApiVersion, + AddVersionOpts, + VersionedRoute, + VersionedRouteConfig, +} from '@kbn/core-http-server'; +import type { CoreKibanaRequest } from '@kbn/core-http-router-server-internal'; +import type { Mutable } from 'utility-types'; +import type { Method } from './types'; + +import { validate } from './validate'; + +type Options = AddVersionOpts; + +/** @internal */ +export const VERSION_HEADER = 'Elastic-Api-Version'; + +// This validation is a pass-through so that we can apply our version-specific validation later +const passThroughValidation = { body: schema.any(), params: schema.any(), query: schema.any() }; + +export class CoreVersionedRoute implements VersionedRoute { + private readonly handlers = new Map< + ApiVersion, + { + fn: RequestHandler; + options: Options; + } + >(); + + public static from({ + router, + method, + path, + options, + }: { + router: IRouter; + method: Method; + path: string; + options: VersionedRouteConfig; + }) { + return new CoreVersionedRoute(router, method, path, options); + } + + private constructor( + private readonly router: IRouter, + public readonly method: Method, + public readonly path: string, + public readonly options: VersionedRouteConfig, + // TODO: Make "true" dev-only + private readonly validateResponses: boolean = true + ) { + this.router[this.method]( + { + path: this.path, + validate: passThroughValidation, + options: this.options, + }, + this.requestHandler + ); + } + + private getAvailableVersionsMessage(): string { + return `Available versions are: "${[...this.handlers.keys()].join(',') || 'none'}"`; + } + + /** This is where we must implement the versioned spec once it is available */ + private requestHandler = async ( + ctx: RequestHandlerContextBase, + req: KibanaRequest, + res: KibanaResponseFactory + ) => { + const version = req.headers[VERSION_HEADER] as undefined | ApiVersion; + if (!version) { + return res.custom({ + statusCode: 406, + body: `Version expected at [${this.method}] [${ + this.path + }]. Please specify a version using the "${VERSION_HEADER}" header. ${this.getAvailableVersionsMessage()}`, + }); + } + + const handler = this.handlers.get(version); + if (!handler) { + return res.custom({ + statusCode: 406, + body: `No version "${version}" available for [${this.method}] [${ + this.path + }]. ${this.getAvailableVersionsMessage()}`, + }); + } + + const validation = handler.options.validate || undefined; + + const mutableCoreKibanaRequest = req as Mutable; + if ( + validation?.request && + Boolean(validation.request.body || validation.request.params || validation.request.query) + ) { + try { + const { body, params, query } = validate( + mutableCoreKibanaRequest, + validation.request, + handler.options.version + ); + mutableCoreKibanaRequest.body = body; + mutableCoreKibanaRequest.params = params; + mutableCoreKibanaRequest.query = query; + } catch (e) { + return res.custom({ + statusCode: 400, + body: e.message, + }); + } + } else { + // Preserve behavior of not passing through unvalidated data + mutableCoreKibanaRequest.body = {}; + mutableCoreKibanaRequest.params = {}; + mutableCoreKibanaRequest.query = {}; + } + + const result = await handler.fn(ctx, mutableCoreKibanaRequest, res); + + if (this.validateResponses && validation?.response?.[result.status]) { + const responseValidation = validation.response[result.status]; + try { + validate( + req, + { body: responseValidation.body, unsafe: { body: validation.response.unsafe?.body } }, + handler.options.version + ); + } catch (e) { + return res.custom({ + statusCode: 500, + body: `Failed output validation: ${e.message}`, + }); + } + } + + return result; + }; + + public addVersion(options: Options, handler: RequestHandler): VersionedRoute { + if (this.handlers.has(options.version)) { + throw new Error( + `Version "${ + options.version + }" handler has already been registered for the route [${this.method.toLowerCase()}] [${ + this.path + }]"` + ); + } + + this.handlers.set(options.version, { fn: handler, options }); + + return this; + } + + public getHandlers(): Array<{ fn: RequestHandler; options: Options }> { + return [...this.handlers.values()]; + } +} diff --git a/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_router.test.ts b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_router.test.ts new file mode 100644 index 0000000000000..286d2499da944 --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_router.test.ts @@ -0,0 +1,26 @@ +/* + * 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 type { IRouter } from '@kbn/core-http-server'; +import { httpServiceMock } from '@kbn/core-http-server-mocks'; +import { CoreVersionedRouter } from '.'; + +describe('Versioned router', () => { + let router: IRouter; + beforeEach(() => { + router = httpServiceMock.createRouter(); + }); + + it('can register multiple routes', () => { + const versionedRouter = CoreVersionedRouter.from({ router }); + versionedRouter.get({ path: '/test/{id}', access: 'internal' }); + versionedRouter.post({ path: '/test', access: 'internal' }); + versionedRouter.delete({ path: '/test', access: 'internal' }); + expect(versionedRouter.getRoutes()).toHaveLength(3); + }); +}); diff --git a/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_router.ts b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_router.ts new file mode 100644 index 0000000000000..66d8f6c59093e --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/src/core_versioned_router.ts @@ -0,0 +1,50 @@ +/* + * 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 type { IRouter } from '@kbn/core-http-server'; +import type { VersionedRouter, VersionedRoute, VersionedRouteConfig } from '@kbn/core-http-server'; +import { CoreVersionedRoute } from './core_versioned_route'; +import { Method, VersionedRouterRoute } from './types'; + +export class CoreVersionedRouter implements VersionedRouter { + private readonly routes = new Set(); + public static from({ router }: { router: IRouter }) { + return new CoreVersionedRouter(router); + } + private constructor(private readonly router: IRouter) {} + + private registerVersionedRoute = + (routeMethod: Method) => + (options: VersionedRouteConfig): VersionedRoute => { + const route = CoreVersionedRoute.from({ + router: this.router, + method: routeMethod, + path: options.path, + options, + }); + this.routes.add(route); + return route; + }; + + public get = this.registerVersionedRoute('get'); + public delete = this.registerVersionedRoute('delete'); + public post = this.registerVersionedRoute('post'); + public patch = this.registerVersionedRoute('patch'); + public put = this.registerVersionedRoute('put'); + + public getRoutes(): VersionedRouterRoute[] { + return [...this.routes].map((route) => { + return { + path: route.path, + method: route.method, + options: route.options, + handlers: route.getHandlers(), + }; + }); + } +} diff --git a/packages/core/http/core-http-versioned-router-server-internal/src/index.ts b/packages/core/http/core-http-versioned-router-server-internal/src/index.ts new file mode 100644 index 0000000000000..a7d6e9c82fdb6 --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/src/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { CoreVersionedRouter } from './core_versioned_router'; diff --git a/packages/core/http/core-http-versioned-router-server-internal/src/types.ts b/packages/core/http/core-http-versioned-router-server-internal/src/types.ts new file mode 100644 index 0000000000000..f5f8d1c6f67dc --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/src/types.ts @@ -0,0 +1,31 @@ +/* + * 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 type { + AddVersionOpts, + RequestHandler, + RouteMethod, + VersionedRouteConfig, +} from '@kbn/core-http-server'; + +export type Method = Exclude; + +/** @experimental */ +export interface VersionedRouterRoute { + /** @experimental */ + method: string; + /** @experimental */ + path: string; + /** @experimental */ + options: VersionedRouteConfig; + /** @experimental */ + handlers: Array<{ + fn: RequestHandler; + options: AddVersionOpts; + }>; +} diff --git a/packages/core/http/core-http-versioned-router-server-internal/src/validate.ts b/packages/core/http/core-http-versioned-router-server-internal/src/validate.ts new file mode 100644 index 0000000000000..68754f614f09b --- /dev/null +++ b/packages/core/http/core-http-versioned-router-server-internal/src/validate.ts @@ -0,0 +1,25 @@ +/* + * 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 type { RouteValidatorFullConfig } from '@kbn/core-http-server'; +import type { ApiVersion } from '@kbn/core-http-server'; +import { RouteValidator } from '@kbn/core-http-router-server-internal'; + +/** Will throw if any of the validation checks fail */ +export function validate( + data: { body?: unknown; params?: unknown; query?: unknown }, + runtimeSchema: RouteValidatorFullConfig, + version: ApiVersion +): { body: unknown; params: unknown; query: unknown } { + const validator = RouteValidator.from(runtimeSchema); + return { + body: validator.getBody(data.body, `get ${version} body`), + params: validator.getParams(data.params, `get ${version} params`), + query: validator.getQuery(data.query, `get ${version} query`), + }; +} diff --git a/packages/core/versioning/core-version-http-server/tsconfig.json b/packages/core/http/core-http-versioned-router-server-internal/tsconfig.json similarity index 79% rename from packages/core/versioning/core-version-http-server/tsconfig.json rename to packages/core/http/core-http-versioned-router-server-internal/tsconfig.json index d0ff9556e176f..3551384bda8aa 100644 --- a/packages/core/versioning/core-version-http-server/tsconfig.json +++ b/packages/core/http/core-http-versioned-router-server-internal/tsconfig.json @@ -13,7 +13,8 @@ "kbn_references": [ "@kbn/config-schema", "@kbn/core-http-server", - "@kbn/utility-types", + "@kbn/core-http-server-mocks", + "@kbn/core-http-router-server-internal", ], "exclude": [ "target/**/*", diff --git a/packages/core/versioning/core-version-http-server/src/example.ts b/packages/core/versioning/core-version-http-server/src/example.ts deleted file mode 100644 index fc0a9b3e39aec..0000000000000 --- a/packages/core/versioning/core-version-http-server/src/example.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * 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 { schema } from '@kbn/config-schema'; -import type { IRouter, RequestHandlerContextBase } from '@kbn/core-http-server'; -import type { VersionHTTPToolkit } from './version_http_toolkit'; - -interface MyCustomContext extends RequestHandlerContextBase { - fooService: { create: (value: string, id: undefined | string, name?: string) => Promise }; -} -const vtk = {} as unknown as VersionHTTPToolkit; -const router = {} as unknown as IRouter; - -const versionedRouter = vtk.createVersionedRouter({ router }); - -// @ts-ignore unused variable -const versionedRoute = versionedRouter - .post({ - path: '/api/my-app/foo/{id?}', - options: { timeout: { payload: 60000 }, access: 'public' }, - }) - .addVersion( - { - version: '1', - validate: { - request: { - query: schema.object({ - name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), - }), - params: schema.object({ - id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), - }), - body: schema.object({ foo: schema.string() }), - }, - response: { - body: schema.object({ foo: schema.string() }), - }, - }, - }, - async (ctx, req, res) => { - await ctx.fooService.create(req.body.foo, req.params.id, req.query.name); - return res.ok({ body: { foo: req.body.foo } }); - } - ) - // BREAKING CHANGE: { foo: string } => { fooString: string } in body - .addVersion( - { - version: '2', - validate: { - request: { - query: schema.object({ - name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), - }), - params: schema.object({ - id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), - }), - body: schema.object({ fooString: schema.string() }), - }, - response: { - body: schema.object({ fooName: schema.string() }), - }, - }, - }, - async (ctx, req, res) => { - await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); - return res.ok({ body: { fooName: req.body.fooString } }); - } - ) - // BREAKING CHANGES: Enforce min/max length on fooString - .addVersion( - { - version: '3', - validate: { - request: { - query: schema.object({ - name: schema.maybe(schema.string({ minLength: 2, maxLength: 50 })), - }), - params: schema.object({ - id: schema.maybe(schema.string({ minLength: 10, maxLength: 13 })), - }), - body: schema.object({ fooString: schema.string({ minLength: 0, maxLength: 1000 }) }), - }, - response: { - body: schema.object({ fooName: schema.string() }), - }, - }, - }, - async (ctx, req, res) => { - await ctx.fooService.create(req.body.fooString, req.params.id, req.query.name); - return res.ok({ body: { fooName: req.body.fooString } }); - } - ); diff --git a/packages/core/versioning/core-version-http-server/src/version_http_toolkit.ts b/packages/core/versioning/core-version-http-server/src/version_http_toolkit.ts deleted file mode 100644 index 1488184aea7e0..0000000000000 --- a/packages/core/versioning/core-version-http-server/src/version_http_toolkit.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* - * 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 { Type } from '@kbn/config-schema'; -import type { WithRequiredProperty } from '@kbn/utility-types'; -import type { - IRouter, - RouteConfig, - RouteMethod, - RequestHandler, - IKibanaResponse, - RouteConfigOptions, - RouteValidatorFullConfig, - RequestHandlerContextBase, - RouteValidationFunction, -} from '@kbn/core-http-server'; - -type RqCtx = RequestHandlerContextBase; - -/** - * Assuming that version will be a monotonically increasing number where: version > 0. - * @experimental - */ -export type Version = `${number}`; - -/** - * Arguments to create a {@link VersionedRouter | versioned router}. - * @experimental - */ -export interface CreateVersionedRouterArgs { - /** - * A router instance - * @experimental - */ - router: IRouter; -} - -/** - * This interface is the starting point for creating versioned routers and routes - * - * @example see ./example.ts - * - * @experimental - */ -export interface VersionHTTPToolkit { - /** - * Create a versioned router - * @param args - The arguments to create a versioned router - * @returns A versioned router - * @experimental - */ - createVersionedRouter( - args: CreateVersionedRouterArgs - ): VersionedRouter; -} - -/** - * Versioned route access flag, required - * - '/api/foo' is 'public' - * - '/internal/my-foo' is 'internal' - * Required - */ -type VersionedRouteConfigOptions = WithRequiredProperty, 'access'>; -/** - * Configuration for a versioned route - * @experimental - */ -export type VersionedRouteConfig = Omit< - RouteConfig, - 'validate' | 'options' -> & { options: VersionedRouteConfigOptions }; - -/** - * Create an {@link VersionedRoute | versioned route}. - * - * @param config - The route configuration - * @returns A versioned route - * @experimental - */ -export type VersionedRouteRegistrar = ( - config: VersionedRouteConfig -) => VersionedRoute; - -/** - * A router, very similar to {@link IRouter} that will return an {@link VersionedRoute} - * instead. - * @experimental - */ -export interface VersionedRouter { - /** @experimental */ - get: VersionedRouteRegistrar<'get', Ctx>; - /** @experimental */ - put: VersionedRouteRegistrar<'put', Ctx>; - /** @experimental */ - post: VersionedRouteRegistrar<'post', Ctx>; - /** @experimental */ - patch: VersionedRouteRegistrar<'patch', Ctx>; - /** @experimental */ - delete: VersionedRouteRegistrar<'delete', Ctx>; - /** @experimental */ - options: VersionedRouteRegistrar<'options', Ctx>; -} - -/** @experimental */ -export type RequestValidation = RouteValidatorFullConfig; - -/** @experimental */ -export interface ResponseValidation { - body: RouteValidationFunction | Type; -} - -/** - * Versioned route validation - * @experimental - */ -interface FullValidationConfig { - /** - * Validation to run against route inputs: params, query and body - * @experimental - */ - request?: RequestValidation; - /** - * Validation to run against route output - * @note This validation is only intended to run in development. Do not use this - * for setting default values! - * @experimental - */ - response?: ResponseValidation; -} - -/** - * Options for a versioned route. Probably needs a lot more options like sunsetting - * of an endpoint etc. - * @experimental - */ -export interface AddVersionOpts { - /** - * Version to assign to this route - * @experimental - */ - version: Version; - /** - * Validation for this version of a route - * @experimental - */ - validate: false | FullValidationConfig; -} - -/** - * A versioned route - * @experimental - */ -export interface VersionedRoute< - Method extends RouteMethod = RouteMethod, - Ctx extends RqCtx = RqCtx -> { - /** - * Add a new version of this route - * @param opts {@link AddVersionOpts | Options} for this version of a route - * @param handler The request handler for this version of a route - * @returns A versioned route, allows for fluent chaining of version declarations - * @experimental - */ - addVersion( - options: AddVersionOpts, - handler: (...params: Parameters>) => Promise> - ): VersionedRoute; -} diff --git a/tsconfig.base.json b/tsconfig.base.json index 3254c680b59a2..eae5649b7f6b0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -344,6 +344,8 @@ "@kbn/core-http-server-internal/*": ["packages/core/http/core-http-server-internal/*"], "@kbn/core-http-server-mocks": ["packages/core/http/core-http-server-mocks"], "@kbn/core-http-server-mocks/*": ["packages/core/http/core-http-server-mocks/*"], + "@kbn/core-http-versioned-router-server-internal": ["packages/core/http/core-http-versioned-router-server-internal"], + "@kbn/core-http-versioned-router-server-internal/*": ["packages/core/http/core-http-versioned-router-server-internal/*"], "@kbn/core-i18n-browser": ["packages/core/i18n/core-i18n-browser"], "@kbn/core-i18n-browser/*": ["packages/core/i18n/core-i18n-browser/*"], "@kbn/core-i18n-browser-internal": ["packages/core/i18n/core-i18n-browser-internal"], @@ -562,8 +564,6 @@ "@kbn/core-usage-data-server-internal/*": ["packages/core/usage-data/core-usage-data-server-internal/*"], "@kbn/core-usage-data-server-mocks": ["packages/core/usage-data/core-usage-data-server-mocks"], "@kbn/core-usage-data-server-mocks/*": ["packages/core/usage-data/core-usage-data-server-mocks/*"], - "@kbn/core-version-http-server": ["packages/core/versioning/core-version-http-server"], - "@kbn/core-version-http-server/*": ["packages/core/versioning/core-version-http-server/*"], "@kbn/cross-cluster-replication-plugin": ["x-pack/plugins/cross_cluster_replication"], "@kbn/cross-cluster-replication-plugin/*": ["x-pack/plugins/cross_cluster_replication/*"], "@kbn/crypto": ["packages/kbn-crypto"], diff --git a/yarn.lock b/yarn.lock index 9f00e1ed2fb85..9473571ecf421 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3421,6 +3421,10 @@ version "0.0.0" uid "" +"@kbn/core-http-versioned-router-server-internal@link:packages/core/http/core-http-versioned-router-server-internal": + version "0.0.0" + uid "" + "@kbn/core-i18n-browser-internal@link:packages/core/i18n/core-i18n-browser-internal": version "0.0.0" uid "" @@ -3857,10 +3861,6 @@ version "0.0.0" uid "" -"@kbn/core-version-http-server@link:packages/core/versioning/core-version-http-server": - version "0.0.0" - uid "" - "@kbn/core@link:src/core": version "0.0.0" uid ""