[HTTP] Expose versioned router (#153858)

## Summary

Now that we merged https://github.com/elastic/kibana/pull/153543, this
PR exposes the versioned router for teams to start using. The versioned
router will be available on `IRouter` under a new `versioned` property.

Primary benefit of this approach is that plugin developers will not need
to do anything other than "get" the `versioned` property to get a
versioned router.

Drawback is that this precludes us from passing in additional
configuration, like a version, to scope the versioned router instance.
For that we would need some kind of `createVersionedRouter({ version:
... })`. At this point it is not clear this is necessary, we could
revisit this decision based on actual usage. Plugin developers could
also do something like:

```ts
// common const
const MY_API_VERSION: ApiVersion = '1';

// in routes
import {MY_API_VERSION} from '../from/common';
router.versioned.get({ path: ... })
  .addVersion({ version: MY_API_VERSION });
```

In this way they could get many of the same benefits of a version-scoped
version router, with the drawback that they need to pass this in for
every route.

### TODO

- [x] Add an integration test for the versioned router

### Future work

* We still need to consider revisiting some of the router design to
better support internal cases like adding support for registering a
handler for a version range and adding a default version to continue
supporting on-prem where introducing versions will be a breaking change

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2023-04-03 18:13:12 +02:00 committed by GitHub
parent 9bfd8b6ecb
commit ed56403817
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 397 additions and 141 deletions

1
.github/CODEOWNERS vendored
View file

@ -176,7 +176,6 @@ 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

View file

@ -251,7 +251,6 @@
"@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",

View file

@ -16,6 +16,7 @@ import type { HttpResponse, HttpFetchOptionsWithPath } from '@kbn/core-http-brow
import { Fetch } from './fetch';
import { BasePath } from './base_path';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
function delay<T>(duration: number) {
return new Promise<T>((r) => setTimeout(r, duration));
@ -479,6 +480,14 @@ describe('Fetch', () => {
expect(ndjson).toEqual(content);
});
it('should pass through version as a header', async () => {
fetchMock.get('*', { body: {} });
await fetchInstance.fetch('/my/path', { asResponse: true, version: '99' });
expect(fetchMock.lastOptions()!.headers).toEqual(
expect.objectContaining({ [ELASTIC_HTTP_VERSION_HEADER.toLowerCase()]: '99' })
);
});
});
describe('interception', () => {

View file

@ -19,6 +19,7 @@ import type {
HttpResponse,
HttpFetchOptionsWithPath,
} from '@kbn/core-http-browser';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { HttpFetchError } from './http_fetch_error';
import { HttpInterceptController } from './http_intercept_controller';
import { interceptRequest, interceptResponse } from './intercept';
@ -110,6 +111,7 @@ export class Fetch {
private createRequest(options: HttpFetchOptionsWithPath): Request {
const context = this.params.executionContext.withGlobalContext(options.context);
const { version } = options;
// Merge and destructure options out that are not applicable to the Fetch API.
const {
query,
@ -128,6 +130,7 @@ export class Fetch {
'Content-Type': 'application/json',
...options.headers,
'kbn-version': this.params.kibanaVersion,
[ELASTIC_HTTP_VERSION_HEADER]: version,
...(!isEmpty(context) ? new ExecutionContextContainer(context).toHeader() : {}),
}),
};

View file

@ -9,6 +9,7 @@
import type { Observable } from 'rxjs';
import type { MaybePromise } from '@kbn/utility-types';
import type { KibanaExecutionContext } from '@kbn/core-execution-context-common';
import type { ApiVersion } from '@kbn/core-http-common';
/** @public */
export interface HttpSetup {
@ -280,6 +281,9 @@ export interface HttpFetchOptions extends HttpRequestInit {
asResponse?: boolean;
context?: KibanaExecutionContext;
/** @experimental */
version?: ApiVersion;
}
/**

View file

@ -12,7 +12,8 @@
],
"kbn_references": [
"@kbn/utility-types",
"@kbn/core-execution-context-common"
"@kbn/core-execution-context-common",
"@kbn/core-http-common"
],
"exclude": [
"target/**/*",

View file

@ -7,3 +7,6 @@
*/
export type { IExternalUrlPolicy } from './src/external_url_policy';
export type { ApiVersion } from './src/versioning';
export { ELASTIC_HTTP_VERSION_HEADER } from './src/versioning';

View file

@ -6,8 +6,12 @@
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/http/core-http-versioned-router-server-internal'],
};
/**
* A Kibana HTTP API version
* @note assumption that version will be monotonically increasing number where: version > 0.
* @experimental
*/
export type ApiVersion = `${number}`;
/** @internal */
export const ELASTIC_HTTP_VERSION_HEADER = 'elastic-api-version' as const;

View file

@ -17,4 +17,3 @@ export {
isKibanaResponse,
KibanaResponse,
} from './src/response';
export { RouteValidator } from './src/validator';

View file

@ -29,9 +29,9 @@ import {
RawRequest,
FakeRawRequest,
} from '@kbn/core-http-server';
import { RouteValidator } from './validator';
import { isSafeMethod } from './route';
import { KibanaSocket } from './socket';
import { RouteValidator } from './validator';
const requestSymbol = Symbol('request');

View file

@ -22,13 +22,16 @@ import type {
RouterRoute,
IRouter,
RequestHandler,
VersionedRouter,
IRouterWithVersion,
} from '@kbn/core-http-server';
import { validBodyOutput } from '@kbn/core-http-server';
import { RouteValidator } from './validator';
import { CoreVersionedRouter } from './versioned_router';
import { CoreKibanaRequest } from './request';
import { kibanaResponseFactory } from './response';
import { HapiResponseAdapter } from './response_adapter';
import { wrapErrors } from './error_wrapper';
import { RouteValidator } from './validator';
export type ContextEnhancer<
P,
@ -120,7 +123,7 @@ function validOptions(
* @internal
*/
export class Router<Context extends RequestHandlerContextBase = RequestHandlerContextBase>
implements IRouter<Context>
implements IRouterWithVersion<Context>
{
public routes: Array<Readonly<RouterRoute>> = [];
public get: IRouter<Context>['get'];
@ -202,6 +205,14 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
return hapiResponseAdapter.toInternalError();
}
}
private versionedRouter: undefined | VersionedRouter<Context> = undefined;
public get versioned(): VersionedRouter<Context> {
if (this.versionedRouter === undefined) {
this.versionedRouter = CoreVersionedRouter.from({ router: this });
}
return this.versionedRouter;
}
}
const convertEsUnauthorized = (e: EsNotAuthorizedError): ErrorHttpResponseOptions => {

View file

@ -6,18 +6,56 @@
* Side Public License, v 1.
*/
import { hapiMocks } from '@kbn/hapi-mocks';
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 type { ApiVersion } from '@kbn/core-http-common';
import type { IRouter, KibanaResponseFactory, RequestHandler } from '@kbn/core-http-server';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { createRouter } from './mocks';
import { CoreVersionedRouter } from '.';
import { kibanaResponseFactory } from '@kbn/core-http-router-server-internal';
import { passThroughValidation } from './core_versioned_route';
import { CoreKibanaRequest } from '../request';
const createRequest = (
{
version,
body,
params,
query,
}: { version: undefined | ApiVersion; body?: object; params?: object; query?: object } = {
version: '1',
}
) =>
CoreKibanaRequest.from(
hapiMocks.createRequest({
payload: body,
params,
query,
headers: { [ELASTIC_HTTP_VERSION_HEADER]: version },
app: { requestId: 'fakeId' },
}),
passThroughValidation
);
describe('Versioned route', () => {
let router: IRouter;
let responseFactory: jest.Mocked<KibanaResponseFactory>;
const handlerFn: RequestHandler = async (ctx, req, res) => res.ok({ body: { foo: 1 } });
beforeEach(() => {
router = httpServiceMock.createRouter();
responseFactory = {
custom: jest.fn(({ body, statusCode }) => ({
options: {},
status: statusCode,
payload: body,
})),
badRequest: jest.fn(({ body }) => ({ status: 400, payload: body, options: {} })),
ok: jest.fn(({ body } = {}) => ({
options: {},
status: 200,
payload: body,
})),
} as any;
router = createRouter();
});
it('can register multiple handlers', () => {
@ -48,6 +86,31 @@ describe('Versioned route', () => {
);
});
it('only allows versions that are numbers greater than 0', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
expect(() =>
versionedRouter
.get({ path: '/test/{id}', access: 'internal' })
.addVersion({ version: 'foo' as ApiVersion, validate: false }, handlerFn)
).toThrowError(
`Invalid version number. Received "foo", expected any finite, whole number greater than 0.`
);
expect(() =>
versionedRouter
.get({ path: '/test/{id}', access: 'internal' })
.addVersion({ version: '-1', validate: false }, handlerFn)
).toThrowError(
`Invalid version number. Received "-1", expected any finite, whole number greater than 0.`
);
expect(() =>
versionedRouter
.get({ path: '/test/{id}', access: 'internal' })
.addVersion({ version: '1.1', validate: false }, handlerFn)
).toThrowError(
`Invalid version number. Received "1.1", expected any finite, whole number greater than 0.`
);
});
it('runs request and response validations', async () => {
let handler: RequestHandler;
@ -57,7 +120,7 @@ describe('Versioned route', () => {
let validatedOutputBody = false;
(router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn));
const versionedRouter = CoreVersionedRouter.from({ router });
const versionedRouter = CoreVersionedRouter.from({ router, validateResponses: true });
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
@ -103,13 +166,13 @@ describe('Versioned route', () => {
const kibanaResponse = await handler!(
{} as any,
httpServerMock.createKibanaRequest({
headers: { [VERSION_HEADER]: '1' },
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
}),
kibanaResponseFactory
responseFactory
);
expect(kibanaResponse.status).toBe(200);
@ -126,18 +189,14 @@ describe('Versioned route', () => {
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,
});
handler!({} as any, createRequest({ version: '999' }), responseFactory)
).resolves.toEqual(
expect.objectContaining({
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 () => {
@ -150,17 +209,10 @@ describe('Versioned route', () => {
.addVersion({ validate: false, version: '1' }, handlerFn);
await expect(
handler!(
{} as any,
httpServerMock.createKibanaRequest({
headers: {},
}),
kibanaResponseFactory
)
handler!({} as any, createRequest({ version: undefined }), responseFactory)
).resolves.toEqual({
options: {},
payload:
'Version expected at [post] [/test/{id}]. Please specify a version using the "Elastic-Api-Version" header. Available versions are: "1"',
payload: `Version expected at [post] [/test/{id}]. Please specify a version using the "${ELASTIC_HTTP_VERSION_HEADER}" header. Available versions are: [1]`,
status: 406,
});
});
@ -179,11 +231,11 @@ describe('Versioned route', () => {
await expect(
handler!(
{} as any,
httpServerMock.createKibanaRequest({
headers: { [VERSION_HEADER]: '1' },
createRequest({
version: '1',
body: {},
}),
kibanaResponseFactory
responseFactory
)
).resolves.toEqual({
options: {},

View file

@ -7,32 +7,32 @@
*/
import { schema } from '@kbn/config-schema';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
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';
import { isValidRouteVersion } from './is_valid_route_version';
type Options = AddVersionOpts<unknown, unknown, unknown, unknown>;
/** @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 const passThroughValidation = {
body: schema.nullable(schema.any()),
params: schema.nullable(schema.any()),
query: schema.nullable(schema.any()),
};
export class CoreVersionedRoute implements VersionedRoute {
private readonly handlers = new Map<
@ -48,13 +48,15 @@ export class CoreVersionedRoute implements VersionedRoute {
method,
path,
options,
validateResponses = false,
}: {
router: IRouter;
method: Method;
path: string;
options: VersionedRouteConfig<Method>;
validateResponses?: boolean;
}) {
return new CoreVersionedRoute(router, method, path, options);
return new CoreVersionedRoute(router, method, path, options, validateResponses);
}
private constructor(
@ -62,8 +64,7 @@ export class CoreVersionedRoute implements VersionedRoute {
public readonly method: Method,
public readonly path: string,
public readonly options: VersionedRouteConfig<Method>,
// TODO: Make "true" dev-only
private readonly validateResponses: boolean = true
private readonly validateResponses: boolean = false
) {
this.router[this.method](
{
@ -76,7 +77,10 @@ export class CoreVersionedRoute implements VersionedRoute {
}
private getAvailableVersionsMessage(): string {
return `Available versions are: "${[...this.handlers.keys()].join(',') || 'none'}"`;
const versions = [...this.handlers.keys()];
return `Available versions are: ${
versions.length ? '[' + [...versions].join(', ') + ']' : '<none>'
}`;
}
/** This is where we must implement the versioned spec once it is available */
@ -85,13 +89,13 @@ export class CoreVersionedRoute implements VersionedRoute {
req: KibanaRequest,
res: KibanaResponseFactory
) => {
const version = req.headers[VERSION_HEADER] as undefined | ApiVersion;
const version = req.headers?.[ELASTIC_HTTP_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()}`,
}]. Please specify a version using the "${ELASTIC_HTTP_VERSION_HEADER}" header. ${this.getAvailableVersionsMessage()}`,
});
}
@ -107,7 +111,7 @@ export class CoreVersionedRoute implements VersionedRoute {
const validation = handler.options.validate || undefined;
const mutableCoreKibanaRequest = req as Mutable<CoreKibanaRequest>;
const mutableCoreKibanaRequest = req as Mutable<KibanaRequest>;
if (
validation?.request &&
Boolean(validation.request.body || validation.request.params || validation.request.query)
@ -122,8 +126,7 @@ export class CoreVersionedRoute implements VersionedRoute {
mutableCoreKibanaRequest.params = params;
mutableCoreKibanaRequest.query = query;
} catch (e) {
return res.custom({
statusCode: 400,
return res.badRequest({
body: e.message,
});
}
@ -140,7 +143,7 @@ export class CoreVersionedRoute implements VersionedRoute {
const responseValidation = validation.response[result.status];
try {
validate(
req,
{ body: result.payload },
{ body: responseValidation.body, unsafe: { body: validation.response.unsafe?.body } },
handler.options.version
);
@ -155,19 +158,25 @@ export class CoreVersionedRoute implements VersionedRoute {
return result;
};
public addVersion(options: Options, handler: RequestHandler<any, any, any, any>): VersionedRoute {
if (this.handlers.has(options.version)) {
private validateVersion(version: string) {
if (!isValidRouteVersion(version)) {
throw new Error(
`Version "${
options.version
}" handler has already been registered for the route [${this.method.toLowerCase()}] [${
`Invalid version number. Received "${version}", expected any finite, whole number greater than 0.`
);
}
if (this.handlers.has(version as ApiVersion)) {
throw new Error(
`Version "${version}" handler has already been registered for the route [${this.method.toLowerCase()}] [${
this.path
}]"`
);
}
}
public addVersion(options: Options, handler: RequestHandler<any, any, any, any>): VersionedRoute {
this.validateVersion(options.version);
this.handlers.set(options.version, { fn: handler, options });
return this;
}

View file

@ -7,13 +7,13 @@
*/
import type { IRouter } from '@kbn/core-http-server';
import { httpServiceMock } from '@kbn/core-http-server-mocks';
import { createRouter } from './mocks';
import { CoreVersionedRouter } from '.';
describe('Versioned router', () => {
let router: IRouter;
beforeEach(() => {
router = httpServiceMock.createRouter();
router = createRouter();
});
it('can register multiple routes', () => {

View file

@ -13,10 +13,19 @@ import { Method, VersionedRouterRoute } from './types';
export class CoreVersionedRouter implements VersionedRouter {
private readonly routes = new Set<CoreVersionedRoute>();
public static from({ router }: { router: IRouter }) {
return new CoreVersionedRouter(router);
public static from({
router,
validateResponses,
}: {
router: IRouter;
validateResponses?: boolean;
}) {
return new CoreVersionedRouter(router, validateResponses);
}
private constructor(private readonly router: IRouter) {}
private constructor(
private readonly router: IRouter,
private readonly validateResponses: boolean = false
) {}
private registerVersionedRoute =
(routeMethod: Method) =>
@ -26,6 +35,7 @@ export class CoreVersionedRouter implements VersionedRouter {
method: routeMethod,
path: options.path,
options,
validateResponses: this.validateResponses,
});
this.routes.add(route);
return route;

View file

@ -0,0 +1,19 @@
/*
* 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 { isValidRouteVersion } from './is_valid_route_version';
describe('isValidRouteVersion', () => {
test('valid numbers return "true"', () => {
expect(isValidRouteVersion('1')).toBe(true);
});
test.each([['1.1'], [''], ['abc']])('%p returns "false"', (value: string) => {
expect(isValidRouteVersion(value)).toBe(false);
});
});

View file

@ -6,4 +6,7 @@
* Side Public License, v 1.
*/
export { CoreVersionedRouter } from './src';
export function isValidRouteVersion(version: string): boolean {
const float = parseFloat(version);
return isFinite(float) && !isNaN(float) && float > 0 && Math.round(float) === float;
}

View file

@ -0,0 +1,22 @@
/*
* 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 { IRouter } from '@kbn/core-http-server';
export function createRouter(): jest.Mocked<IRouter> {
return {
delete: jest.fn(),
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
getRoutes: jest.fn(),
handleLegacyErrors: jest.fn(),
patch: jest.fn(),
routerPath: '',
};
}

View file

@ -8,7 +8,7 @@
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';
import { RouteValidator } from '../validator';
/** Will throw if any of the validation checks fail */
export function validate(

View file

@ -7,9 +7,7 @@
"node"
]
},
"include": [
"**/*.ts"
],
"include": [ "**/*.ts" ],
"kbn_references": [
"@kbn/std",
"@kbn/utility-types",
@ -19,6 +17,7 @@
"@kbn/hapi-mocks",
"@kbn/core-logging-server-mocks",
"@kbn/logging",
"@kbn/core-http-common",
],
"exclude": [
"target/**/*",

View file

@ -7,4 +7,5 @@
*/
export { mockRouter } from './src/router.mock';
export { createVersionedRouterMock } from './src/versioned_router.mock';
export type { RouterMock, RequestFixtureOptions } from './src/router.mock';

View file

@ -12,7 +12,7 @@ import { stringify } from 'query-string';
import { hapiMocks } from '@kbn/hapi-mocks';
import { schema } from '@kbn/config-schema';
import type {
IRouter,
IRouterWithVersion,
KibanaRequest,
RouteMethod,
RouteValidationSpec,
@ -21,8 +21,9 @@ import type {
KibanaResponseFactory,
} from '@kbn/core-http-server';
import { CoreKibanaRequest } from '@kbn/core-http-router-server-internal';
import { createVersionedRouterMock } from './versioned_router.mock';
export type RouterMock = jest.Mocked<IRouter<any>>;
export type RouterMock = jest.Mocked<IRouterWithVersion<any>>;
function createRouterMock({ routerPath = '' }: { routerPath?: string } = {}): RouterMock {
return {
@ -34,6 +35,7 @@ function createRouterMock({ routerPath = '' }: { routerPath?: string } = {}): Ro
patch: jest.fn(),
getRoutes: jest.fn(),
handleLegacyErrors: jest.fn().mockImplementation((handler) => handler),
versioned: createVersionedRouterMock(),
};
}

View file

@ -0,0 +1,22 @@
/*
* 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 { VersionedRouter, VersionedRoute } from '@kbn/core-http-server';
const createMockVersionedRoute = (): VersionedRoute => {
const api: VersionedRoute = { addVersion: jest.fn(() => api) };
return api;
};
export const createVersionedRouterMock = (): jest.Mocked<VersionedRouter> => ({
delete: jest.fn((_) => createMockVersionedRoute()),
get: jest.fn((_) => createMockVersionedRoute()),
patch: jest.fn((_) => createMockVersionedRoute()),
post: jest.fn((_) => createMockVersionedRoute()),
put: jest.fn((_) => createMockVersionedRoute()),
});

View file

@ -9,6 +9,7 @@
import type { PluginOpaqueId } from '@kbn/core-base-common';
import type {
IRouter,
IRouterWithVersion,
RequestHandlerContextBase,
IContextProvider,
IAuthHeadersStorage,
@ -49,7 +50,7 @@ export interface InternalHttpServiceSetup
createRouter: <Context extends RequestHandlerContextBase = RequestHandlerContextBase>(
path: string,
plugin?: PluginOpaqueId
) => IRouter<Context>;
) => IRouterWithVersion<Context>;
registerRouterAfterListening: (router: IRouter) => void;
registerStaticDir: (path: string, dirPath: string) => void;
authRequestHeaders: IAuthHeadersStorage;

View file

@ -90,6 +90,7 @@ export type {
RouteValidatorFullConfig,
RouteValidatorOptions,
IRouter,
IRouterWithVersion,
RouteRegistrar,
RouterRoute,
IKibanaSocket,

View file

@ -61,7 +61,7 @@ export type {
RouteValidatorOptions,
} from './route_validator';
export { RouteValidationError } from './route_validator';
export type { IRouter, RouteRegistrar, RouterRoute } from './router';
export type { IRouter, IRouterWithVersion, RouteRegistrar, RouterRoute } from './router';
export type { IKibanaSocket } from './socket';
export type {
KibanaErrorResponseFactory,

View file

@ -8,6 +8,7 @@
import type { Request, ResponseObject, ResponseToolkit } from '@hapi/hapi';
import type Boom from '@hapi/boom';
import type { VersionedRouter } from '../versioning';
import type { RouteConfig, RouteMethod } from './route';
import type { RequestHandler, RequestHandlerWrapper } from './request_handler';
import type { RequestHandlerContextBase } from './request_handler_context';
@ -87,6 +88,30 @@ export interface IRouter<Context extends RequestHandlerContextBase = RequestHand
getRoutes: () => RouterRoute[];
}
export interface IRouterWithVersion<
Context extends RequestHandlerContextBase = RequestHandlerContextBase
> extends IRouter<Context> {
/**
* An instance very similar to {@link IRouter} that can be used for versioning HTTP routes
* following the Elastic versioning specification.
*
* @example
* const router = core.http.createRouter();
* router.versioned.get({ path: '/api/my-path', access: 'public' }).addVersion(
* {
* version: '1',
* validate: false,
* },
* async (ctx, req, res) => {
* return res.ok();
* }
* );
*
* @experimental
*/
versioned: VersionedRouter<Context>;
}
/** @public */
export interface RouterRoute {
method: RouteMethod;

View file

@ -7,6 +7,7 @@
*/
import type { Type } from '@kbn/config-schema';
import type { ApiVersion } from '@kbn/core-http-common';
import type { MaybePromise } from '@kbn/utility-types';
import type {
RouteConfig,
@ -21,11 +22,7 @@ import type {
type RqCtx = RequestHandlerContextBase;
/**
* Assuming that version will be a monotonically increasing number where: version > 0.
* @experimental
*/
export type ApiVersion = `${number}`;
export type { ApiVersion };
/**
* Configuration for a versioned route
@ -159,8 +156,8 @@ export interface VersionedRouter<Ctx extends RqCtx = RqCtx> {
export type VersionedRouteRequestValidation<P, Q, B> = RouteValidatorFullConfig<P, Q, B>;
/** @experimental */
export interface VersionedRouteResponseValidation<R> {
[statusCode: number]: { body: RouteValidationFunction<R> | Type<R> };
export interface VersionedRouteResponseValidation {
[statusCode: number]: { body: RouteValidationFunction<unknown> | Type<unknown> };
unsafe?: { body?: boolean };
}
@ -168,7 +165,7 @@ export interface VersionedRouteResponseValidation<R> {
* Versioned route validation
* @experimental
*/
interface FullValidationConfig<P, Q, B, R> {
interface FullValidationConfig<P, Q, B> {
/**
* Validation to run against route inputs: params, query and body
* @experimental
@ -180,7 +177,7 @@ interface FullValidationConfig<P, Q, B, R> {
* for setting default values!
* @experimental
*/
response?: VersionedRouteResponseValidation<R>;
response?: VersionedRouteResponseValidation;
}
/**
@ -198,7 +195,7 @@ export interface AddVersionOpts<P, Q, B, R> {
* Validation for this version of a route
* @experimental
*/
validate: false | FullValidationConfig<P, Q, B, R>;
validate: false | FullValidationConfig<P, Q, B>;
}
/**

View file

@ -1,7 +0,0 @@
# @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`

View file

@ -1,5 +0,0 @@
{
"type": "shared-common",
"id": "@kbn/core-http-versioned-router-server-internal",
"owner": "@elastic/kibana-core"
}

View file

@ -1,7 +0,0 @@
{
"name": "@kbn/core-http-versioned-router-server-internal",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -1,22 +0,0 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts"
],
"kbn_references": [
"@kbn/config-schema",
"@kbn/core-http-server",
"@kbn/core-http-server-mocks",
"@kbn/core-http-router-server-internal",
],
"exclude": [
"target/**/*",
]
}

View file

@ -10,7 +10,7 @@ import { shareReplay } from 'rxjs/operators';
import type { CoreContext } from '@kbn/core-base-server-internal';
import type { PluginOpaqueId } from '@kbn/core-base-common';
import type { NodeInfo } from '@kbn/core-node-server';
import type { IRouter, IContextProvider } from '@kbn/core-http-server';
import type { IContextProvider, IRouterWithVersion } from '@kbn/core-http-server';
import { PluginInitializerContext, PluginManifest } from '@kbn/core-plugins-server';
import { CorePreboot, CoreSetup, CoreStart } from '@kbn/core-lifecycle-server';
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
@ -220,7 +220,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
provider: IContextProvider<Context, ContextName>
) => deps.http.registerRouteHandlerContext(plugin.opaqueId, contextName, provider),
createRouter: <Context extends RequestHandlerContext = RequestHandlerContext>() =>
router as IRouter<Context>,
router as IRouterWithVersion<Context>,
resources: deps.httpResources.createRegistrar(router),
registerOnPreRouting: deps.http.registerOnPreRouting,
registerOnPreAuth: deps.http.registerOnPreAuth,

View file

@ -0,0 +1,108 @@
/*
* 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 Supertest from 'supertest';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks';
import { contextServiceMock } from '@kbn/core-http-context-server-mocks';
import { createHttpServer } from '@kbn/core-http-server-mocks';
import type { HttpService } from '@kbn/core-http-server-internal';
let server: HttpService;
let logger: ReturnType<typeof loggingSystemMock.create>;
const contextSetup = contextServiceMock.createSetupContract();
const setupDeps = {
context: contextSetup,
executionContext: executionContextServiceMock.createInternalSetupContract(),
};
beforeEach(async () => {
logger = loggingSystemMock.create();
server = createHttpServer({ logger });
await server.preboot({ context: contextServiceMock.createPrebootContract() });
});
afterEach(async () => {
await server.stop();
});
describe('Routing versioned requests', () => {
it('routes requests to the expected handlers', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
const supertest = Supertest(innerServer.listener);
router.versioned
.get({ path: '/my-path', access: 'internal' })
.addVersion({ validate: false, version: '1' }, async (ctx, req, res) => {
return res.ok({ body: { v: '1' } });
})
.addVersion({ validate: false, version: '2' }, async (ctx, req, res) => {
return res.ok({ body: { v: '2' } });
});
await server.start();
await expect(
supertest
.get('/my-path')
.set('Elastic-Api-Version', '1')
.expect(200)
.then(({ body: { v } }) => v)
).resolves.toBe('1');
await expect(
supertest
.get('/my-path')
.set('Elastic-Api-Version', '2')
.expect(200)
.then(({ body: { v } }) => v)
).resolves.toBe('2');
});
it('handles non-existent version', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
const supertest = Supertest(innerServer.listener);
router.versioned.get({ path: '/my-path', access: 'internal' }); // do not actually register any versions
await server.start();
await supertest.get('/my-path').set('Elastic-Api-Version', '2').expect(406);
});
it('handles missing version header', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('/');
const supertest = Supertest(innerServer.listener);
router.versioned
.get({ path: '/my-path', access: 'internal' })
.addVersion({ validate: false, version: '1' }, async (ctx, req, res) => {
return res.ok({ body: { v: '1' } });
})
.addVersion({ validate: false, version: '2' }, async (ctx, req, res) => {
return res.ok({ body: { v: '2' } });
});
await server.start();
await expect(
supertest
.get('/my-path')
.expect(406)
.then(({ body }) => body)
).resolves.toEqual(
expect.objectContaining({
message: expect.stringMatching(/Version expected at/),
})
);
});
});

View file

@ -346,8 +346,6 @@
"@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"],

View file

@ -3425,10 +3425,6 @@
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 ""