mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
9bfd8b6ecb
commit
ed56403817
38 changed files with 397 additions and 141 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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() : {}),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -17,4 +17,3 @@ export {
|
|||
isKibanaResponse,
|
||||
KibanaResponse,
|
||||
} from './src/response';
|
||||
export { RouteValidator } from './src/validator';
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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: {},
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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', () => {
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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: '',
|
||||
};
|
||||
}
|
|
@ -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(
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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()),
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -90,6 +90,7 @@ export type {
|
|||
RouteValidatorFullConfig,
|
||||
RouteValidatorOptions,
|
||||
IRouter,
|
||||
IRouterWithVersion,
|
||||
RouteRegistrar,
|
||||
RouterRoute,
|
||||
IKibanaSocket,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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`
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/core-http-versioned-router-server-internal",
|
||||
"owner": "@elastic/kibana-core"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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/**/*",
|
||||
]
|
||||
}
|
|
@ -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,
|
||||
|
|
108
src/core/server/integration_tests/http/versioned_router.test.ts
Normal file
108
src/core/server/integration_tests/http/versioned_router.test.ts
Normal 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/),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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"],
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue