[Http] Router refactor (#205502)

This commit is contained in:
Jean-Louis Leysens 2025-01-15 16:10:46 +01:00 committed by GitHub
parent 6429c53597
commit ca77772d2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 823 additions and 481 deletions

View file

@ -16,7 +16,7 @@ export {
type HandlerResolutionStrategy,
} from './src/versioned_router';
export { Router } from './src/router';
export type { RouterOptions, InternalRegistrar, InternalRegistrarOptions } from './src/router';
export type { RouterOptions } from './src/router';
export { isKibanaRequest, isRealRequest, ensureRawRequest, CoreKibanaRequest } from './src/request';
export { isSafeMethod } from './src/route';
export { HapiResponseAdapter } from './src/response_adapter';

View file

@ -191,7 +191,7 @@ export class CoreKibanaRequest<
enumerable: false,
});
this.httpVersion = isRealReq ? request.raw.req.httpVersion : '1.0';
this.httpVersion = isRealReq ? getHttpVersionFromRequest(request) : '1.0';
this.apiVersion = undefined;
this.protocol = getProtocolFromHttpVersion(this.httpVersion);
@ -418,3 +418,11 @@ function sanitizeRequest(req: Request): { query: unknown; params: unknown; body:
function getProtocolFromHttpVersion(httpVersion: string): HttpProtocol {
return httpVersion.split('.')[0] === '2' ? 'http2' : 'http1';
}
function getHttpVersionFromRequest(request: Request) {
return request.raw.req.httpVersion;
}
export function getProtocolFromRequest(request: Request) {
return getProtocolFromHttpVersion(getHttpVersionFromRequest(request));
}

View file

@ -0,0 +1,188 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { hapiMocks } from '@kbn/hapi-mocks';
import { validateHapiRequest, handle } from './route';
import { createRouter } from './versioned_router/mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { Logger } from '@kbn/logging';
import { RouteValidator } from './validator';
import { schema } from '@kbn/config-schema';
import { Router } from './router';
import { RouteAccess } from '@kbn/core-http-server';
import { createRequest } from './versioned_router/core_versioned_route.test.util';
import { kibanaResponseFactory } from './response';
describe('handle', () => {
let handler: jest.Func;
let log: Logger;
let router: Router;
beforeEach(() => {
router = createRouter();
handler = jest.fn(async () => kibanaResponseFactory.ok());
log = loggingSystemMock.createLogger();
});
describe('post validation events', () => {
it('emits with validation schemas provided', async () => {
const validate = { body: schema.object({ foo: schema.number() }) };
await handle(createRequest({ body: { foo: 1 } }), {
router,
handler,
log,
method: 'get',
route: { path: '/test', validate },
routeSchemas: RouteValidator.from(validate),
});
// Failure
await handle(createRequest({ body: { foo: 'bar' } }), {
router,
handler,
log,
method: 'get',
route: {
path: '/test',
validate,
options: {
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
},
},
routeSchemas: RouteValidator.from(validate),
});
expect(handler).toHaveBeenCalledTimes(1);
expect(router.emitPostValidate).toHaveBeenCalledTimes(2);
expect(router.emitPostValidate).toHaveBeenNthCalledWith(1, expect.any(Object), {
deprecated: undefined,
isInternalApiRequest: false,
isPublicAccess: false,
});
expect(router.emitPostValidate).toHaveBeenNthCalledWith(2, expect.any(Object), {
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
isInternalApiRequest: false,
isPublicAccess: false,
});
});
it('emits with no validation schemas provided', async () => {
await handle(createRequest({ body: { foo: 1 } }), {
router,
handler,
log,
method: 'get',
route: {
path: '/test',
validate: false,
options: {
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
},
},
routeSchemas: undefined,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(router.emitPostValidate).toHaveBeenCalledTimes(1);
expect(router.emitPostValidate).toHaveBeenCalledWith(expect.any(Object), {
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
isInternalApiRequest: false,
isPublicAccess: false,
});
});
});
});
describe('validateHapiRequest', () => {
let router: Router;
let log: Logger;
beforeEach(() => {
router = createRouter();
log = loggingSystemMock.createLogger();
});
it('validates hapi requests and returns kibana requests: ok case', () => {
const { ok, error } = validateHapiRequest(hapiMocks.createRequest({ payload: { ok: true } }), {
log,
routeInfo: { access: 'public', httpResource: false },
router,
routeSchemas: RouteValidator.from({ body: schema.object({ ok: schema.literal(true) }) }),
});
expect(ok?.body).toEqual({ ok: true });
expect(error).toBeUndefined();
expect(log.error).not.toHaveBeenCalled();
});
it('validates hapi requests and returns kibana requests: error case', () => {
const { ok, error } = validateHapiRequest(hapiMocks.createRequest({ payload: { ok: false } }), {
log,
routeInfo: { access: 'public', httpResource: false },
router,
routeSchemas: RouteValidator.from({ body: schema.object({ ok: schema.literal(true) }) }),
});
expect(ok).toBeUndefined();
expect(error?.status).toEqual(400);
expect(error?.payload).toMatch(/expected value to equal/);
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith('400 Bad Request', {
error: { message: '[request body.ok]: expected value to equal [true]' },
http: { request: { method: undefined, path: undefined }, response: { status_code: 400 } },
});
});
it('emits post validation events on the router', () => {
const deps = {
log,
routeInfo: { access: 'public' as RouteAccess, httpResource: false },
router,
routeSchemas: RouteValidator.from({ body: schema.object({ ok: schema.literal(true) }) }),
};
{
const { ok, error } = validateHapiRequest(
hapiMocks.createRequest({ payload: { ok: false } }),
deps
);
expect(ok).toBeUndefined();
expect(error).toBeDefined();
expect(router.emitPostValidate).toHaveBeenCalledTimes(1);
expect(router.emitPostValidate).toHaveBeenCalledWith(expect.any(Object), {
deprecated: undefined,
isInternalApiRequest: false,
isPublicAccess: true,
});
}
{
const { ok, error } = validateHapiRequest(
hapiMocks.createRequest({ payload: { ok: true } }),
deps
);
expect(ok).toBeDefined();
expect(error).toBeUndefined();
expect(router.emitPostValidate).toHaveBeenCalledTimes(2);
expect(router.emitPostValidate).toHaveBeenNthCalledWith(2, expect.any(Object), {
deprecated: undefined,
isInternalApiRequest: false,
isPublicAccess: true,
});
}
});
});

View file

@ -7,8 +7,40 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { RouteMethod, SafeRouteMethod, RouteConfig } from '@kbn/core-http-server';
import type { RouteSecurityGetter, RouteSecurity } from '@kbn/core-http-server';
import {
type RouteMethod,
type SafeRouteMethod,
type RouteConfig,
getRequestValidation,
} from '@kbn/core-http-server';
import type {
RouteSecurityGetter,
RouteSecurity,
AnyKibanaRequest,
IKibanaResponse,
RouteAccess,
RouteConfigOptions,
} from '@kbn/core-http-server';
import { isConfigSchema } from '@kbn/config-schema';
import { isZod } from '@kbn/zod';
import type { Logger } from '@kbn/logging';
import type { DeepPartial } from '@kbn/utility-types';
import { Request } from '@hapi/hapi';
import { Mutable } from 'utility-types';
import type { InternalRouterRoute, RequestHandlerEnhanced, Router } from './router';
import { CoreKibanaRequest } from './request';
import { RouteValidator } from './validator';
import { BASE_PUBLIC_VERSION } from './versioned_router';
import { kibanaResponseFactory } from './response';
import {
getVersionHeader,
injectVersionHeader,
formatErrorMeta,
getRouteFullPath,
validOptions,
prepareRouteConfigValidation,
} from './util';
import { validRouteSecurity } from './security_route_config_validator';
export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod {
return method === 'get' || method === 'options';
@ -21,3 +53,162 @@ export type InternalRouteConfig<P, Q, B, M extends RouteMethod> = Omit<
> & {
security?: RouteSecurityGetter | RouteSecurity;
};
/** @internal */
interface Dependencies {
router: Router;
route: InternalRouteConfig<unknown, unknown, unknown, RouteMethod>;
handler: RequestHandlerEnhanced<unknown, unknown, unknown, RouteMethod>;
log: Logger;
method: RouteMethod;
}
export function buildRoute({
handler,
log,
route,
router,
method,
}: Dependencies): InternalRouterRoute {
route = prepareRouteConfigValidation(route);
const routeSchemas = routeSchemasFromRouteConfig(route, method);
return {
handler: async (req) => {
return await handle(req, {
handler,
log,
method,
route,
router,
routeSchemas,
});
},
method,
path: getRouteFullPath(router.routerPath, route.path),
options: validOptions(method, route),
security: validRouteSecurity(route.security as DeepPartial<RouteSecurity>, route.options),
validationSchemas: route.validate,
isVersioned: false,
};
}
/** @internal */
interface HandlerDependencies extends Dependencies {
routeSchemas?: RouteValidator<unknown, unknown, unknown>;
}
type RouteInfo = Pick<RouteConfigOptions<RouteMethod>, 'access' | 'httpResource' | 'deprecated'>;
interface ValidationContext {
routeInfo: RouteInfo;
router: Router;
log: Logger;
routeSchemas?: RouteValidator<unknown, unknown, unknown>;
version?: string;
}
/** @internal */
export function validateHapiRequest(
request: Request,
{ routeInfo, router, log, routeSchemas, version }: ValidationContext
): { ok: AnyKibanaRequest; error?: never } | { ok?: never; error: IKibanaResponse } {
let kibanaRequest: Mutable<AnyKibanaRequest>;
try {
kibanaRequest = CoreKibanaRequest.from(request, routeSchemas);
kibanaRequest.apiVersion = version;
} catch (error) {
kibanaRequest = CoreKibanaRequest.from(request);
kibanaRequest.apiVersion = version;
log.error('400 Bad Request', formatErrorMeta(400, { request, error }));
const response = kibanaResponseFactory.badRequest({
body: error.message,
headers: isPublicAccessApiRoute(routeInfo)
? getVersionHeader(BASE_PUBLIC_VERSION)
: undefined,
});
return { error: response };
} finally {
router.emitPostValidate(
kibanaRequest!,
getPostValidateEventMetadata(kibanaRequest!, routeInfo)
);
}
return { ok: kibanaRequest };
}
/** @internal */
export const handle = async (
request: Request,
{ router, route, handler, routeSchemas, log }: HandlerDependencies
) => {
const { error, ok: kibanaRequest } = validateHapiRequest(request, {
routeInfo: {
access: route.options?.access,
httpResource: route.options?.httpResource,
deprecated: route.options?.deprecated,
},
router,
log,
routeSchemas,
});
if (error) {
return error;
}
const kibanaResponse = await handler(kibanaRequest, kibanaResponseFactory);
if (isPublicAccessApiRoute(route.options)) {
injectVersionHeader(BASE_PUBLIC_VERSION, kibanaResponse);
}
return kibanaResponse;
};
function isPublicAccessApiRoute({
access,
httpResource,
}: {
access?: RouteAccess;
httpResource?: boolean;
} = {}): boolean {
return !httpResource && access === 'public';
}
/**
* Create the validation schemas for a route
*
* @returns Route schemas if `validate` is specified on the route, otherwise
* undefined.
*/
function routeSchemasFromRouteConfig<P, Q, B>(
route: InternalRouteConfig<P, Q, B, typeof routeMethod>,
routeMethod: RouteMethod
) {
// The type doesn't allow `validate` to be undefined, but it can still
// happen when it's used from JavaScript.
if (route.validate === undefined) {
throw new Error(
`The [${routeMethod}] at [${route.path}] does not have a 'validate' specified. Use 'false' as the value if you want to bypass validation.`
);
}
if (route.validate !== false) {
const validation = getRequestValidation(route.validate);
Object.entries(validation).forEach(([key, schema]) => {
if (!(isConfigSchema(schema) || isZod(schema) || typeof schema === 'function')) {
throw new Error(
`Expected a valid validation logic declared with '@kbn/config-schema' package, '@kbn/zod' package or a RouteValidationFunction at key: [${key}].`
);
}
});
return RouteValidator.from(validation);
}
}
function getPostValidateEventMetadata(request: AnyKibanaRequest, routeInfo: RouteInfo) {
return {
deprecated: routeInfo.deprecated,
isInternalApiRequest: request.isInternalApiRequest,
isPublicAccess: isPublicAccessApiRoute(routeInfo),
};
}

View file

@ -95,14 +95,12 @@ describe('Router', () => {
it('can exclude versioned routes', () => {
const router = new Router('', logger, enhanceWithContext, routerOptions);
const validation = schema.object({ foo: schema.string() });
router.post(
{
router.versioned
.post({
path: '/versioned',
validate: { body: validation, query: validation, params: validation },
},
(context, req, res) => res.ok(),
{ isVersioned: true, events: false }
);
access: 'internal',
})
.addVersion({ version: '999', validate: false }, async (ctx, req, res) => res.ok());
router.get(
{
path: '/unversioned',

View file

@ -10,7 +10,6 @@
import { EventEmitter } from 'node:events';
import type { Request, ResponseToolkit } from '@hapi/hapi';
import apm from 'elastic-apm-node';
import { isConfigSchema } from '@kbn/config-schema';
import type { Logger } from '@kbn/logging';
import {
isUnauthorizedError as isElasticsearchUnauthorizedError,
@ -27,24 +26,18 @@ import type {
RequestHandler,
VersionedRouter,
RouteRegistrar,
RouteSecurity,
PostValidationMetadata,
IKibanaResponse,
} from '@kbn/core-http-server';
import { isZod } from '@kbn/zod';
import { validBodyOutput, getRequestValidation } from '@kbn/core-http-server';
import type { RouteSecurityGetter } from '@kbn/core-http-server';
import type { DeepPartial } from '@kbn/utility-types';
import { RouteValidator } from './validator';
import { BASE_PUBLIC_VERSION, CoreVersionedRouter } from './versioned_router';
import { CoreKibanaRequest } from './request';
import { CoreVersionedRouter } from './versioned_router';
import { CoreKibanaRequest, getProtocolFromRequest } from './request';
import { kibanaResponseFactory } from './response';
import { HapiResponseAdapter } from './response_adapter';
import { wrapErrors } from './error_wrapper';
import { Method } from './versioned_router/types';
import { getVersionHeader, injectVersionHeader, prepareRouteConfigValidation } from './util';
import { formatErrorMeta } from './util';
import { stripIllegalHttp2Headers } from './strip_illegal_http2_headers';
import { validRouteSecurity } from './security_route_config_validator';
import { InternalRouteConfig } from './route';
import { InternalRouteConfig, buildRoute } from './route';
export type ContextEnhancer<
P,
@ -54,86 +47,28 @@ export type ContextEnhancer<
Context extends RequestHandlerContextBase
> = (handler: RequestHandler<P, Q, B, Context, Method>) => RequestHandlerEnhanced<P, Q, B, Method>;
export function getRouteFullPath(routerPath: string, routePath: string) {
// If router's path ends with slash and route's path starts with slash,
// we should omit one of them to have a valid concatenated path.
const routePathStartIndex = routerPath.endsWith('/') && routePath.startsWith('/') ? 1 : 0;
return `${routerPath}${routePath.slice(routePathStartIndex)}`;
}
/** @internal */
export type InternalRouteHandler = (request: Request) => Promise<IKibanaResponse>;
/**
* Create the validation schemas for a route
* We have at least two implementations of InternalRouterRoutes:
* (1) Router route
* (2) Versioned router route {@link CoreVersionedRoute}
*
* @returns Route schemas if `validate` is specified on the route, otherwise
* undefined.
*/
function routeSchemasFromRouteConfig<P, Q, B>(
route: InternalRouteConfig<P, Q, B, typeof routeMethod>,
routeMethod: RouteMethod
) {
// The type doesn't allow `validate` to be undefined, but it can still
// happen when it's used from JavaScript.
if (route.validate === undefined) {
throw new Error(
`The [${routeMethod}] at [${route.path}] does not have a 'validate' specified. Use 'false' as the value if you want to bypass validation.`
);
}
if (route.validate !== false) {
const validation = getRequestValidation(route.validate);
Object.entries(validation).forEach(([key, schema]) => {
if (!(isConfigSchema(schema) || isZod(schema) || typeof schema === 'function')) {
throw new Error(
`Expected a valid validation logic declared with '@kbn/config-schema' package, '@kbn/zod' package or a RouteValidationFunction at key: [${key}].`
);
}
});
return RouteValidator.from(validation);
}
}
/**
* Create a valid options object with "sensible" defaults + adding some validation to the options fields
* The former registers internal handlers when users call `route.put(...)` while
* the latter registers an internal handler for `router.versioned.put(...)`.
*
* @param method HTTP verb for these options
* @param routeConfig The route config definition
* This enables us to expose internal details to each of these types routes so
* that implementation has freedom to change what it needs to in each case, like:
*
* validation: versioned routes only know what validation to run after inspecting
* special version values, whereas "regular" routes only ever have one validation
* that is predetermined to always run.
* @internal
*/
function validOptions(
method: RouteMethod,
routeConfig: InternalRouteConfig<unknown, unknown, unknown, typeof method>
) {
const shouldNotHavePayload = ['head', 'get'].includes(method);
const { options = {}, validate } = routeConfig;
const shouldValidateBody = (validate && !!getRequestValidation(validate).body) || !!options.body;
const { output } = options.body || {};
if (typeof output === 'string' && !validBodyOutput.includes(output)) {
throw new Error(
`[options.body.output: '${output}'] in route ${method.toUpperCase()} ${
routeConfig.path
} is not valid. Only '${validBodyOutput.join("' or '")}' are valid.`
);
}
// @ts-expect-error to eliminate problems with `security` in the options for route factories abstractions
if (options.security) {
throw new Error('`options.security` is not allowed in route config. Use `security` instead.');
}
const body = shouldNotHavePayload
? undefined
: {
// If it's not a GET (requires payload) but no body validation is required (or no body options are specified),
// We assume the route does not care about the body => use the memory-cheapest approach (stream and no parsing)
output: !shouldValidateBody ? ('stream' as const) : undefined,
parse: !shouldValidateBody ? false : undefined,
// User's settings should overwrite any of the "desired" values
...options.body,
};
return { ...options, body };
}
export type InternalRouterRoute = Omit<RouterRoute, 'handler'> & {
handler: InternalRouteHandler;
};
/** @internal */
export interface RouterOptions {
@ -152,17 +87,6 @@ export interface RouterOptions {
};
}
/** @internal */
export interface InternalRegistrarOptions {
/** @default false */
isVersioned: boolean;
/**
* Whether this route should emit "route events" like postValidate
* @default true
*/
events: boolean;
}
/** @internal */
export type VersionedRouteConfig<P, Q, B, M extends RouteMethod> = Omit<
RouteConfig<P, Q, B, M>,
@ -171,13 +95,6 @@ export type VersionedRouteConfig<P, Q, B, M extends RouteMethod> = Omit<
security?: RouteSecurityGetter;
};
/** @internal */
export type InternalRegistrar<M extends Method, C extends RequestHandlerContextBase> = <P, Q, B>(
route: InternalRouteConfig<P, Q, B, M>,
handler: RequestHandler<P, Q, B, C, M>,
internalOpts?: InternalRegistrarOptions
) => ReturnType<RouteRegistrar<M, C>>;
/** @internal */
type RouterEvents =
/** Called after route validation, regardless of success or failure */
@ -189,19 +106,24 @@ type RouterEvents =
export class Router<Context extends RequestHandlerContextBase = RequestHandlerContextBase>
implements IRouter<Context>
{
private static ee = new EventEmitter();
/**
* Used for global request events at the router level, similar to what we get from Hapi's request lifecycle events.
*
* See {@link RouterEvents}.
*/
private static events = new EventEmitter();
public routes: Array<Readonly<RouterRoute>> = [];
public pluginId?: symbol;
public get: InternalRegistrar<'get', Context>;
public post: InternalRegistrar<'post', Context>;
public delete: InternalRegistrar<'delete', Context>;
public put: InternalRegistrar<'put', Context>;
public patch: InternalRegistrar<'patch', Context>;
public get: RouteRegistrar<'get', Context>;
public post: RouteRegistrar<'post', Context>;
public delete: RouteRegistrar<'delete', Context>;
public put: RouteRegistrar<'put', Context>;
public patch: RouteRegistrar<'patch', Context>;
constructor(
public readonly routerPath: string,
private readonly log: Logger,
private readonly enhanceWithContext: ContextEnhancer<any, any, any, any, any>,
public readonly enhanceWithContext: ContextEnhancer<any, any, any, any, any>,
private readonly options: RouterOptions
) {
this.pluginId = options.pluginId;
@ -209,40 +131,17 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
<Method extends RouteMethod>(method: Method) =>
<P, Q, B>(
route: InternalRouteConfig<P, Q, B, Method>,
handler: RequestHandler<P, Q, B, Context, Method>,
{ isVersioned, events }: InternalRegistrarOptions = { isVersioned: false, events: true }
handler: RequestHandler<P, Q, B, Context, Method>
) => {
route = prepareRouteConfigValidation(route);
const routeSchemas = routeSchemasFromRouteConfig(route, method);
const isPublicUnversionedRoute =
!isVersioned &&
route.options?.access === 'public' &&
// We do not consider HTTP resource routes as APIs
route.options?.httpResource !== true;
this.routes.push({
handler: async (req, responseToolkit) => {
return await this.handle({
routeSchemas,
request: req,
responseToolkit,
isPublicUnversionedRoute,
handler: this.enhanceWithContext(handler),
emit: events ? { onPostValidation: this.emitPostValidate } : undefined,
});
},
method,
path: getRouteFullPath(this.routerPath, route.path),
options: validOptions(method, route),
// For the versioned route security is validated in the versioned router
security: isVersioned
? route.security
: validRouteSecurity(route.security as DeepPartial<RouteSecurity>, route.options),
validationSchemas: route.validate,
// @ts-expect-error using isVersioned: false in the type instead of boolean
// for typeguarding between versioned and unversioned RouterRoute types
isVersioned,
});
this.registerRoute(
buildRoute({
handler: this.enhanceWithContext(handler),
log: this.log,
method,
route,
router: this,
})
);
};
this.get = buildMethod('get');
@ -253,11 +152,11 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
}
public static on(event: RouterEvents, cb: (req: CoreKibanaRequest, ...args: any[]) => void) {
Router.ee.on(event, cb);
Router.events.on(event, cb);
}
public static off(event: RouterEvents, cb: (req: CoreKibanaRequest, ...args: any[]) => void) {
Router.ee.off(event, cb);
Router.events.off(event, cb);
}
public getRoutes({ excludeVersionedRoutes }: { excludeVersionedRoutes?: boolean } = {}) {
@ -269,27 +168,6 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
public handleLegacyErrors = wrapErrors;
private logError(
msg: string,
statusCode: number,
{
error,
request,
}: {
request: Request;
error: Error;
}
) {
this.log.error(msg, {
http: {
response: { status_code: statusCode },
request: { method: request.route?.method, path: request.route?.path },
},
error: { message: error.message },
});
}
/** Should be private, just exposed for convenience for the versioned router */
public emitPostValidate = (
request: KibanaRequest,
postValidateConext: PostValidationMetadata = {
@ -298,73 +176,31 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
}
) => {
const postValidate: RouterEvents = 'onPostValidate';
Router.ee.emit(postValidate, request, postValidateConext);
Router.events.emit(postValidate, request, postValidateConext);
};
private async handle<P, Q, B>({
routeSchemas,
/** @internal */
public registerRoute(route: InternalRouterRoute) {
this.routes.push({
...route,
handler: async (request, responseToolkit) =>
await this.handle({ request, responseToolkit, handler: route.handler }),
});
}
private async handle({
request,
responseToolkit,
emit,
isPublicUnversionedRoute,
handler,
}: {
request: Request;
responseToolkit: ResponseToolkit;
emit?: {
onPostValidation: (req: KibanaRequest, metadata: PostValidationMetadata) => void;
};
isPublicUnversionedRoute: boolean;
handler: RequestHandlerEnhanced<
P,
Q,
B,
// request.method's type contains way more verbs than we currently support
typeof request.method extends RouteMethod ? typeof request.method : any
>;
routeSchemas?: RouteValidator<P, Q, B>;
handler: InternalRouteHandler;
}) {
let kibanaRequest: KibanaRequest<
P,
Q,
B,
typeof request.method extends RouteMethod ? typeof request.method : any
>;
const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit);
try {
kibanaRequest = CoreKibanaRequest.from(request, routeSchemas);
} catch (error) {
this.logError('400 Bad Request', 400, { request, error });
const response = hapiResponseAdapter.toBadRequest(error.message);
if (isPublicUnversionedRoute) {
response.output.headers = {
...response.output.headers,
...getVersionHeader(BASE_PUBLIC_VERSION),
};
}
// Emit onPostValidation even if validation fails.
const req = CoreKibanaRequest.from(request);
emit?.onPostValidation(req, {
deprecated: req.route.options.deprecated,
isInternalApiRequest: req.isInternalApiRequest,
isPublicAccess: req.route.options.access === 'public',
});
return response;
}
emit?.onPostValidation(kibanaRequest, {
deprecated: kibanaRequest.route.options.deprecated,
isInternalApiRequest: kibanaRequest.isInternalApiRequest,
isPublicAccess: kibanaRequest.route.options.access === 'public',
});
try {
const kibanaResponse = await handler(kibanaRequest, kibanaResponseFactory);
if (isPublicUnversionedRoute) {
injectVersionHeader(BASE_PUBLIC_VERSION, kibanaResponse);
}
if (kibanaRequest.protocol === 'http2' && kibanaResponse.options.headers) {
const kibanaResponse = await handler(request);
if (getProtocolFromRequest(request) === 'http2' && kibanaResponse.options.headers) {
kibanaResponse.options.headers = stripIllegalHttp2Headers({
headers: kibanaResponse.options.headers,
isDev: this.options.isDev ?? false,
@ -379,14 +215,14 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
// forward 401 errors from ES client
if (isElasticsearchUnauthorizedError(error)) {
this.logError('401 Unauthorized', 401, { request, error });
this.log.error('401 Unauthorized', formatErrorMeta(401, { request, error }));
return hapiResponseAdapter.handle(
kibanaResponseFactory.unauthorized(convertEsUnauthorized(error))
);
}
// return a generic 500 to avoid error info / stack trace surfacing
this.logError('500 Server Error', 500, { request, error });
this.log.error('500 Server Error', formatErrorMeta(500, { request, error }));
return hapiResponseAdapter.toInternalError();
}
}
@ -398,6 +234,7 @@ export class Router<Context extends RequestHandlerContextBase = RequestHandlerCo
this.versionedRouter = CoreVersionedRouter.from({
router: this,
isDev: this.options.isDev,
log: this.log,
...this.options.versionedRouterOptions,
});
}
@ -424,6 +261,6 @@ type WithoutHeadArgument<T> = T extends (first: any, ...rest: infer Params) => i
? (...rest: Params) => Return
: never;
type RequestHandlerEnhanced<P, Q, B, Method extends RouteMethod> = WithoutHeadArgument<
export type RequestHandlerEnhanced<P, Q, B, Method extends RouteMethod> = WithoutHeadArgument<
RequestHandler<P, Q, B, RequestHandlerContextBase, Method>
>;

View file

@ -13,10 +13,13 @@ import {
type RouteValidatorFullConfigResponse,
type RouteMethod,
type RouteValidator,
getRequestValidation,
validBodyOutput,
} from '@kbn/core-http-server';
import type { Mutable } from 'utility-types';
import type { IKibanaResponse, ResponseHeaders } from '@kbn/core-http-server';
import type { IKibanaResponse, ResponseHeaders, SafeRouteMethod } from '@kbn/core-http-server';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { Request } from '@hapi/hapi';
import type { InternalRouteConfig } from './route';
function isStatusCode(key: string) {
@ -92,3 +95,76 @@ export function getVersionHeader(version: string): ResponseHeaders {
export function injectVersionHeader(version: string, response: IKibanaResponse): IKibanaResponse {
return injectResponseHeaders(getVersionHeader(version), response);
}
export function formatErrorMeta(
statusCode: number,
{
error,
request,
}: {
error: Error;
request: Request;
}
) {
return {
http: {
response: { status_code: statusCode },
request: { method: request.route?.method, path: request.route?.path },
},
error: { message: error.message },
};
}
export function getRouteFullPath(routerPath: string, routePath: string) {
// If router's path ends with slash and route's path starts with slash,
// we should omit one of them to have a valid concatenated path.
const routePathStartIndex = routerPath.endsWith('/') && routePath.startsWith('/') ? 1 : 0;
return `${routerPath}${routePath.slice(routePathStartIndex)}`;
}
export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod {
return method === 'get' || method === 'options';
}
/**
* Create a valid options object with "sensible" defaults + adding some validation to the options fields
*
* @param method HTTP verb for these options
* @param routeConfig The route config definition
*/
export function validOptions(
method: RouteMethod,
routeConfig: InternalRouteConfig<unknown, unknown, unknown, typeof method>
) {
const shouldNotHavePayload = ['head', 'get'].includes(method);
const { options = {}, validate } = routeConfig;
const shouldValidateBody = (validate && !!getRequestValidation(validate).body) || !!options.body;
const { output } = options.body || {};
if (typeof output === 'string' && !validBodyOutput.includes(output)) {
throw new Error(
`[options.body.output: '${output}'] in route ${method.toUpperCase()} ${
routeConfig.path
} is not valid. Only '${validBodyOutput.join("' or '")}' are valid.`
);
}
// @ts-expect-error to eliminate problems with `security` in the options for route factories abstractions
if (options.security) {
throw new Error('`options.security` is not allowed in route config. Use `security` instead.');
}
const body = shouldNotHavePayload
? undefined
: {
// If it's not a GET (requires payload) but no body validation is required (or no body options are specified),
// We assume the route does not care about the body => use the memory-cheapest approach (stream and no parsing)
output: !shouldValidateBody ? ('stream' as const) : undefined,
parse: !shouldValidateBody ? false : undefined,
// User's settings should overwrite any of the "desired" values
...options.body,
};
return { ...options, body };
}

View file

@ -9,43 +9,31 @@
import type { ApiVersion } from '@kbn/core-http-common';
import type {
KibanaResponseFactory,
RequestHandler,
RouteConfig,
VersionedRouteValidation,
RouteSecurity,
} from '@kbn/core-http-server';
import { Router } from '../router';
import { InternalRouteHandler, Router } from '../router';
import { createFooValidation } from '../router.test.util';
import { createRouter } from './mocks';
import { CoreVersionedRouter, unwrapVersionedResponseBodyValidation } from '.';
import { passThroughValidation } from './core_versioned_route';
import { Method } from './types';
import { createRequest } from './core_versioned_route.test.util';
import { isConfigSchema } from '@kbn/config-schema';
import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
describe('Versioned route', () => {
let router: Router;
let responseFactory: jest.Mocked<KibanaResponseFactory>;
let versionedRouter: CoreVersionedRouter;
let testValidation: ReturnType<typeof createFooValidation>;
const handlerFn: RequestHandler = async (ctx, req, res) => res.ok({ body: { foo: 1 } });
beforeEach(() => {
testValidation = createFooValidation();
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();
versionedRouter = CoreVersionedRouter.from({
router,
log: loggingSystemMock.createLogger(),
});
});
afterEach(() => {
@ -54,7 +42,6 @@ describe('Versioned route', () => {
describe('#getRoutes', () => {
it('returns the expected metadata', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
versionedRouter
.get({
path: '/test/{id}',
@ -93,7 +80,6 @@ describe('Versioned route', () => {
});
it('can register multiple handlers', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
versionedRouter
.get({ path: '/test/{id}', access: 'internal' })
.addVersion({ version: '1', validate: false }, handlerFn)
@ -104,11 +90,18 @@ describe('Versioned route', () => {
const [route] = routes;
expect(route.handlers).toHaveLength(3);
// We only register one route with the underlying router
expect(router.get).toHaveBeenCalledTimes(1);
expect(router.registerRoute).toHaveBeenCalledTimes(1);
expect(router.registerRoute).toHaveBeenCalledWith({
isVersioned: true,
handler: expect.any(Function),
security: expect.any(Function),
method: 'get',
options: { access: 'internal' },
path: '/test/{id}',
});
});
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' })
@ -121,7 +114,6 @@ describe('Versioned route', () => {
});
it('only allows versions that are numbers greater than 0 for internal APIs', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
expect(() =>
versionedRouter
.get({ path: '/test/{id}', access: 'internal' })
@ -145,7 +137,6 @@ describe('Versioned route', () => {
});
it('only allows correctly formatted version date strings for public APIs', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
expect(() =>
versionedRouter
.get({ path: '/test/{id}', access: 'public' })
@ -168,8 +159,7 @@ describe('Versioned route', () => {
).not.toThrow();
});
it('passes through the expected values to the IRouter registrar', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
it('passes through all expected values to the router registrar', () => {
const opts: Parameters<typeof versionedRouter.post>[0] = {
path: '/test/{id}',
access: 'internal',
@ -186,25 +176,29 @@ describe('Versioned route', () => {
};
versionedRouter.post(opts);
expect(router.post).toHaveBeenCalledTimes(1);
const { access, options } = opts;
const expectedRouteConfig: RouteConfig<unknown, unknown, unknown, Method> = {
path: opts.path,
options: { access, ...options },
validate: passThroughValidation,
};
expect(router.post).toHaveBeenCalledWith(
expect.objectContaining(expectedRouteConfig),
expect.any(Function),
{ isVersioned: true, events: false }
);
expect(router.registerRoute).toHaveBeenCalledTimes(1);
expect(router.registerRoute).toHaveBeenCalledWith({
handler: expect.any(Function),
isVersioned: true,
method: 'post',
options: {
access: 'internal',
authRequired: true,
excludeFromOAS: true,
httpResource: true,
tags: ['access:test'],
timeout: { idleSocket: 10_000, payload: 60_000 },
xsrfRequired: false,
},
path: '/test/{id}',
security: expect.any(Function),
});
});
it('allows public versions other than "2023-10-31"', () => {
expect(() =>
CoreVersionedRouter.from({ router, isDev: false })
CoreVersionedRouter.from({ router, log: loggingSystemMock.createLogger(), isDev: false })
.get({ access: 'public', path: '/foo' })
.addVersion({ version: '2023-01-31', validate: false }, (ctx, req, res) => res.ok())
).not.toThrow();
@ -213,12 +207,11 @@ describe('Versioned route', () => {
it.each([['static' as const], ['lazy' as const]])(
'runs %s request validations',
async (staticOrLazy) => {
let handler: RequestHandler;
let handler: InternalRouteHandler;
const { fooValidation, validateBodyFn, validateOutputFn, validateParamsFn, validateQueryFn } =
testValidation;
(router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn));
const versionedRouter = CoreVersionedRouter.from({ router });
(router.registerRoute as jest.Mock).mockImplementation((opts) => (handler = opts.handler));
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
@ -228,14 +221,12 @@ describe('Versioned route', () => {
);
const kibanaResponse = await handler!(
{} as any,
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
}),
responseFactory
})
);
expect(kibanaResponse.status).toBe(200);
@ -247,7 +238,7 @@ describe('Versioned route', () => {
);
it('constructs lazily provided validations once (idempotency)', async () => {
let handler: RequestHandler;
let handler: InternalRouteHandler;
const { fooValidation } = testValidation;
const response200 = fooValidation.response[200].body;
@ -258,8 +249,7 @@ describe('Versioned route', () => {
const lazyResponse404 = jest.fn(() => response404());
fooValidation.response[404].body = lazyResponse404;
(router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn));
const versionedRouter = CoreVersionedRouter.from({ router });
(router.registerRoute as jest.Mock).mockImplementation((opts) => (handler = opts.handler));
const lazyValidation = jest.fn(() => fooValidation);
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
@ -271,14 +261,12 @@ describe('Versioned route', () => {
for (let i = 0; i < 10; i++) {
const { status } = await handler!(
{} as any,
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
}),
responseFactory
})
);
const [route] = versionedRouter.getRoutes();
const [
@ -306,22 +294,28 @@ describe('Versioned route', () => {
});
describe('when in dev', () => {
beforeEach(() => {
versionedRouter = CoreVersionedRouter.from({
router,
isDev: true,
log: loggingSystemMock.createLogger(),
});
});
// NOTE: Temporary test to ensure single public API version is enforced
it('only allows "2023-10-31" as public route versions', () => {
expect(() =>
CoreVersionedRouter.from({ router, isDev: true })
versionedRouter
.get({ access: 'public', path: '/foo' })
.addVersion({ version: '2023-01-31', validate: false }, (ctx, req, res) => res.ok())
).toThrow(/Invalid public version/);
});
it('runs response validations', async () => {
let handler: RequestHandler;
let handler: InternalRouteHandler;
const { fooValidation, validateBodyFn, validateOutputFn, validateParamsFn, validateQueryFn } =
testValidation;
(router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn));
const versionedRouter = CoreVersionedRouter.from({ router, isDev: true });
(router.registerRoute as jest.Mock).mockImplementation((opts) => (handler = opts.handler));
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
@ -331,14 +325,12 @@ describe('Versioned route', () => {
);
const kibanaResponse = await handler!(
{} as any,
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
}),
responseFactory
})
);
expect(kibanaResponse.status).toBe(200);
@ -349,10 +341,14 @@ describe('Versioned route', () => {
});
it('handles "undefined" response schemas', async () => {
let handler: RequestHandler;
let handler: InternalRouteHandler;
(router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn));
const versionedRouter = CoreVersionedRouter.from({ router, isDev: true });
(router.registerRoute as jest.Mock).mockImplementation((opts) => (handler = opts.handler));
versionedRouter = CoreVersionedRouter.from({
router,
isDev: true,
log: loggingSystemMock.createLogger(),
});
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
@ -363,27 +359,29 @@ describe('Versioned route', () => {
await expect(
handler!(
{} as any,
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
}),
responseFactory
})
)
).resolves.not.toThrow();
});
it('runs custom response validations', async () => {
let handler: RequestHandler;
let handler: InternalRouteHandler;
const { fooValidation, validateBodyFn, validateOutputFn, validateParamsFn, validateQueryFn } =
testValidation;
const custom = jest.fn(() => ({ value: 1 }));
fooValidation.response[200].body = { custom } as any;
(router.post as jest.Mock).mockImplementation((opts: unknown, fn) => (handler = fn));
const versionedRouter = CoreVersionedRouter.from({ router, isDev: true });
(router.registerRoute as jest.Mock).mockImplementation((opts) => (handler = opts.handler));
versionedRouter = CoreVersionedRouter.from({
router,
isDev: true,
log: loggingSystemMock.createLogger(),
});
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
@ -393,14 +391,12 @@ describe('Versioned route', () => {
);
const kibanaResponse = await handler!(
{} as any,
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
}),
responseFactory
})
);
expect(kibanaResponse.status).toBe(200);
@ -413,15 +409,16 @@ describe('Versioned route', () => {
});
it('allows using default resolution for specific internal routes', async () => {
const versionedRouter = CoreVersionedRouter.from({
versionedRouter = CoreVersionedRouter.from({
router,
isDev: true,
log: loggingSystemMock.createLogger(),
useVersionResolutionStrategyForInternalPaths: ['/bypass_me/{id?}'],
});
let bypassVersionHandler: RequestHandler;
(router.post as jest.Mock).mockImplementation(
(opts: unknown, fn) => (bypassVersionHandler = fn)
let bypassVersionHandler: InternalRouteHandler;
(router.registerRoute as jest.Mock).mockImplementation(
(opts) => (bypassVersionHandler = opts.handler)
);
versionedRouter.post({ path: '/bypass_me/{id?}', access: 'internal' }).addVersion(
{
@ -431,8 +428,10 @@ describe('Versioned route', () => {
handlerFn
);
let doNotBypassHandler1: RequestHandler;
(router.put as jest.Mock).mockImplementation((opts: unknown, fn) => (doNotBypassHandler1 = fn));
let doNotBypassHandler1: InternalRouteHandler;
(router.registerRoute as jest.Mock).mockImplementation(
(opts) => (doNotBypassHandler1 = opts.handler)
);
versionedRouter.put({ path: '/do_not_bypass_me/{id}', access: 'internal' }).addVersion(
{
version: '1',
@ -441,8 +440,10 @@ describe('Versioned route', () => {
handlerFn
);
let doNotBypassHandler2: RequestHandler;
(router.get as jest.Mock).mockImplementation((opts: unknown, fn) => (doNotBypassHandler2 = fn));
let doNotBypassHandler2: InternalRouteHandler;
(router.registerRoute as jest.Mock).mockImplementation(
(opts) => (doNotBypassHandler2 = opts.handler)
);
versionedRouter.get({ path: '/do_not_bypass_me_either', access: 'internal' }).addVersion(
{
version: '1',
@ -452,22 +453,12 @@ describe('Versioned route', () => {
);
const byPassedVersionResponse = await bypassVersionHandler!(
{} as any,
createRequest({ version: undefined }),
responseFactory
createRequest({ version: undefined })
);
const doNotBypassResponse1 = await doNotBypassHandler1!(
{} as any,
createRequest({ version: undefined }),
responseFactory
);
const doNotBypassResponse1 = await doNotBypassHandler1!(createRequest({ version: undefined }));
const doNotBypassResponse2 = await doNotBypassHandler2!(
{} as any,
createRequest({ version: undefined }),
responseFactory
);
const doNotBypassResponse2 = await doNotBypassHandler2!(createRequest({ version: undefined }));
expect(byPassedVersionResponse.status).toBe(200);
expect(doNotBypassResponse1.status).toBe(400);
@ -477,7 +468,6 @@ describe('Versioned route', () => {
});
it('can register multiple handlers with different security configurations', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
const securityConfig1: RouteSecurity = {
authz: {
requiredPrivileges: ['foo'],
@ -533,11 +523,10 @@ describe('Versioned route', () => {
expect(route.handlers[0].options.security).toStrictEqual(securityConfig1);
expect(route.handlers[1].options.security).toStrictEqual(securityConfig2);
expect(route.handlers[2].options.security).toStrictEqual(securityConfig3);
expect(router.get).toHaveBeenCalledTimes(1);
expect(router.registerRoute).toHaveBeenCalledTimes(1);
});
it('falls back to default security configuration if it is not specified for specific version', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
const securityConfigDefault: RouteSecurity = {
authz: {
requiredPrivileges: ['foo', 'bar', 'baz'],
@ -613,11 +602,10 @@ describe('Versioned route', () => {
headers: { [ELASTIC_HTTP_VERSION_HEADER]: '99' },
})
).toStrictEqual(securityConfigDefault);
expect(router.get).toHaveBeenCalledTimes(1);
expect(router.registerRoute).toHaveBeenCalledTimes(1);
});
it('validates security configuration', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
const validSecurityConfig: RouteSecurity = {
authz: {
requiredPrivileges: ['foo'],
@ -668,7 +656,6 @@ describe('Versioned route', () => {
});
it('should correctly merge security configuration for versions', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
const validSecurityConfig: RouteSecurity = {
authz: {
requiredPrivileges: ['foo'],
@ -704,4 +691,99 @@ describe('Versioned route', () => {
expect(security.authz).toEqual({ requiredPrivileges: ['foo', 'bar'] });
});
describe('emits post validation events on the router', () => {
let handler: InternalRouteHandler;
it('for routes with validation', async () => {
const { fooValidation } = testValidation;
(router.registerRoute as jest.Mock).mockImplementation((opts) => (handler = opts.handler));
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
validate: fooValidation,
options: {
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
},
},
handlerFn
);
await handler!(
createRequest({
version: '1',
body: { foo: 1 },
params: { foo: 1 },
query: { foo: 1 },
})
);
// Failed validation
await handler!(createRequest({ version: '1' }));
expect(router.emitPostValidate).toHaveBeenCalledTimes(2);
expect(router.emitPostValidate).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ apiVersion: '1' }),
{
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
isInternalApiRequest: false,
isPublicAccess: false,
}
);
expect(router.emitPostValidate).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ apiVersion: '1' }),
{
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
isInternalApiRequest: false,
isPublicAccess: false,
}
);
});
it('for routes without validation', async () => {
(router.registerRoute as jest.Mock).mockImplementation((opts) => (handler = opts.handler));
versionedRouter.post({ path: '/test/{id}', access: 'internal' }).addVersion(
{
version: '1',
validate: false,
options: {
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
},
},
handlerFn
);
await handler!(createRequest({ version: '1' }));
expect(router.emitPostValidate).toHaveBeenCalledTimes(1);
expect(router.emitPostValidate).toHaveBeenCalledWith(
expect.objectContaining({ apiVersion: '1' }),
{
deprecated: {
severity: 'warning',
reason: { type: 'bump', newApiVersion: '123' },
documentationUrl: 'http://test.foo',
},
isInternalApiRequest: false,
isPublicAccess: false,
}
);
});
});
});

View file

@ -10,8 +10,6 @@
// eslint-disable-next-line @kbn/imports/no_boundary_crossing
import { hapiMocks } from '@kbn/hapi-mocks';
import { ApiVersion, ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common';
import { CoreKibanaRequest } from '../request';
import { passThroughValidation } from './core_versioned_route';
export function createRequest(
{
@ -19,18 +17,15 @@ export function createRequest(
body,
params,
query,
}: { version: undefined | ApiVersion; body?: object; params?: object; query?: object } = {
}: { version?: undefined | ApiVersion; body?: object; params?: object; query?: object } = {
version: '1',
}
) {
return CoreKibanaRequest.from(
hapiMocks.createRequest({
payload: body,
params,
query,
headers: { [ELASTIC_HTTP_VERSION_HEADER]: version },
app: { requestId: 'fakeId' },
}),
passThroughValidation
);
return hapiMocks.createRequest({
payload: body,
params,
query,
headers: { [ELASTIC_HTTP_VERSION_HEADER]: version },
app: { requestId: 'fakeId' },
});
}

View file

@ -7,16 +7,12 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { schema } from '@kbn/config-schema';
import {
ELASTIC_HTTP_VERSION_HEADER,
ELASTIC_HTTP_VERSION_QUERY_PARAM,
} from '@kbn/core-http-common';
import type {
RequestHandler,
RequestHandlerContextBase,
KibanaRequest,
KibanaResponseFactory,
ApiVersion,
VersionedRoute,
VersionedRouteConfig,
@ -26,12 +22,11 @@ import type {
RouteSecurity,
RouteMethod,
VersionedRouterRoute,
PostValidationMetadata,
} from '@kbn/core-http-server';
import type { Mutable } from 'utility-types';
import { Request } from '@hapi/hapi';
import { Logger } from '@kbn/logging';
import type { HandlerResolutionStrategy, Method, Options } from './types';
import { validate } from './validate';
import {
isAllowedPublicVersion,
isValidRouteVersion,
@ -39,13 +34,16 @@ import {
readVersion,
removeQueryVersion,
} from './route_version_utils';
import { getVersionHeader, injectVersionHeader } from '../util';
import { injectVersionHeader } from '../util';
import { validRouteSecurity } from '../security_route_config_validator';
import { resolvers } from './handler_resolvers';
import { prepareVersionedRouteValidation, unwrapVersionedResponseBodyValidation } from './util';
import type { RequestLike } from './route_version_utils';
import { Router } from '../router';
import { RequestHandlerEnhanced, Router } from '../router';
import { kibanaResponseFactory as responseFactory } from '../response';
import { validateHapiRequest } from '../route';
import { RouteValidator } from '../validator';
interface InternalVersionedRouteConfig<M extends RouteMethod> extends VersionedRouteConfig<M> {
isDev: boolean;
@ -53,13 +51,6 @@ interface InternalVersionedRouteConfig<M extends RouteMethod> extends VersionedR
defaultHandlerResolutionStrategy: HandlerResolutionStrategy;
}
// This validation is a pass-through so that we can apply our version-specific validation later
export const passThroughValidation = {
body: schema.nullable(schema.any()),
params: schema.nullable(schema.any()),
query: schema.nullable(schema.any()),
};
function extractValidationSchemaFromHandler(handler: VersionedRouterRoute['handlers'][0]) {
if (handler.options.validate === false) return undefined;
if (typeof handler.options.validate === 'function') return handler.options.validate();
@ -70,23 +61,25 @@ export class CoreVersionedRoute implements VersionedRoute {
public readonly handlers = new Map<
ApiVersion,
{
fn: RequestHandler;
fn: RequestHandlerEnhanced<unknown, unknown, unknown, RouteMethod>;
options: Options;
}
>();
public static from({
router,
log,
method,
path,
options,
}: {
router: Router;
log: Logger;
method: Method;
path: string;
options: InternalVersionedRouteConfig<Method>;
}) {
return new CoreVersionedRoute(router, method, path, options);
return new CoreVersionedRoute(router, log, method, path, options);
}
public readonly options: VersionedRouteConfig<Method>;
@ -99,6 +92,7 @@ export class CoreVersionedRoute implements VersionedRoute {
private defaultHandlerResolutionStrategy: HandlerResolutionStrategy;
private constructor(
private readonly router: Router,
private readonly log: Logger,
public readonly method: Method,
public readonly path: string,
internalOptions: InternalVersionedRouteConfig<Method>
@ -117,17 +111,14 @@ export class CoreVersionedRoute implements VersionedRoute {
this.enableQueryVersion = options.enableQueryVersion === true;
this.defaultSecurityConfig = validRouteSecurity(options.security, options.options);
this.options = options;
this.router[this.method](
{
path: this.path,
validate: passThroughValidation,
// @ts-expect-error upgrade typescript v5.1.6
options: this.getRouteConfigOptions(),
security: this.getSecurity,
},
this.requestHandler,
{ isVersioned: true, events: false }
);
this.router.registerRoute({
path: this.path,
options: this.getRouteConfigOptions(),
security: this.getSecurity,
handler: (request) => this.handle(request),
isVersioned: true,
method: this.method,
});
}
private getRouteConfigOptions(): RouteConfigOptions<Method> {
@ -167,94 +158,71 @@ export class CoreVersionedRoute implements VersionedRoute {
return version;
}
private requestHandler = async (
ctx: RequestHandlerContextBase,
originalReq: KibanaRequest,
res: KibanaResponseFactory
): Promise<IKibanaResponse> => {
private handle = async (hapiRequest: Request): Promise<IKibanaResponse> => {
if (this.handlers.size <= 0) {
return res.custom({
return responseFactory.custom({
statusCode: 500,
body: `No handlers registered for [${this.method}] [${this.path}].`,
});
}
const req = originalReq as Mutable<KibanaRequest>;
const version = this.getVersion(req);
req.apiVersion = version;
const version = this.getVersion(hapiRequest);
if (!version) {
return res.badRequest({
return responseFactory.badRequest({
body: `Please specify a version via ${ELASTIC_HTTP_VERSION_HEADER} header. Available versions: ${this.versionsToString()}`,
});
}
if (hasQueryVersion(req)) {
if (this.enableQueryVersion) {
// This endpoint has opted-in to query versioning, so we remove the query parameter as it is reserved
removeQueryVersion(req);
} else
return res.badRequest({
if (hasQueryVersion(hapiRequest)) {
if (!this.enableQueryVersion) {
return responseFactory.badRequest({
body: `Use of query parameter "${ELASTIC_HTTP_VERSION_QUERY_PARAM}" is not allowed. Please specify the API version using the "${ELASTIC_HTTP_VERSION_HEADER}" header.`,
});
}
removeQueryVersion(hapiRequest);
}
const invalidVersionMessage = isValidRouteVersion(this.isPublic, version);
if (invalidVersionMessage) {
return res.badRequest({ body: invalidVersionMessage });
return responseFactory.badRequest({ body: invalidVersionMessage });
}
const handler = this.handlers.get(version);
if (!handler) {
return res.badRequest({
return responseFactory.badRequest({
body: `No version "${version}" available for [${this.method}] [${
this.path
}]. Available versions are: ${this.versionsToString()}`,
});
}
const validation = extractValidationSchemaFromHandler(handler);
const postValidateMetadata: PostValidationMetadata = {
deprecated: handler.options.options?.deprecated,
isInternalApiRequest: req.isInternalApiRequest,
isPublicAccess: this.isPublic,
};
if (
validation?.request &&
Boolean(validation.request.body || validation.request.params || validation.request.query)
) {
try {
const { body, params, query } = validate(req, validation.request);
req.body = body;
req.params = params;
req.query = query;
} catch (e) {
// Emit onPostValidation even if validation fails.
this.router.emitPostValidate(req, postValidateMetadata);
return res.badRequest({ body: e.message, headers: getVersionHeader(version) });
}
} else {
// Preserve behavior of not passing through unvalidated data
req.body = {};
req.params = {};
req.query = {};
const { error, ok: kibanaRequest } = validateHapiRequest(hapiRequest, {
routeInfo: {
access: this.options.access,
httpResource: this.options.options?.httpResource,
deprecated: handler.options?.options?.deprecated,
},
router: this.router,
log: this.log,
routeSchemas: validation?.request ? RouteValidator.from(validation.request) : undefined,
version,
});
if (error) {
return injectVersionHeader(version, error);
}
this.router.emitPostValidate(req, postValidateMetadata);
const response = await handler.fn(ctx, req, res);
const response = await handler.fn(kibanaRequest, responseFactory);
if (this.isDev && validation?.response?.[response.status]?.body) {
const { [response.status]: responseValidation, unsafe } = validation.response;
try {
validate(
{ body: response.payload },
{
body: unwrapVersionedResponseBodyValidation(responseValidation.body!),
unsafe: { body: unsafe?.body },
}
);
const validator = RouteValidator.from({
body: unwrapVersionedResponseBodyValidation(responseValidation.body!),
unsafe: { body: unsafe?.body },
});
validator.getBody(response.payload, 'response body');
} catch (e) {
return res.custom({
return responseFactory.custom({
statusCode: 500,
body: `Failed output validation: ${e.message}`,
});
@ -292,13 +260,16 @@ export class CoreVersionedRoute implements VersionedRoute {
this.validateVersion(options.version);
options = prepareVersionedRouteValidation(options);
this.handlers.set(options.version, {
fn: handler,
fn: this.router.enhanceWithContext(handler),
options,
});
return this;
}
public getHandlers(): Array<{ fn: RequestHandler; options: Options }> {
public getHandlers(): Array<{
fn: RequestHandlerEnhanced<unknown, unknown, unknown, RouteMethod>;
options: Options;
}> {
return [...this.handlers.values()];
}

View file

@ -7,18 +7,24 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { Router } from '../router';
import { CoreVersionedRouter } from '.';
import { createRouter } from './mocks';
const pluginId = Symbol('test');
describe('Versioned router', () => {
let router: Router;
let versionedRouter: CoreVersionedRouter;
beforeEach(() => {
router = createRouter();
router = createRouter({ pluginId });
versionedRouter = CoreVersionedRouter.from({
router,
log: loggingSystemMock.createLogger(),
});
});
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' });
@ -26,13 +32,10 @@ describe('Versioned router', () => {
});
it('registers pluginId if router has one', () => {
const pluginId = Symbol('test');
const versionedRouter = CoreVersionedRouter.from({ router: createRouter({ pluginId }) });
expect(versionedRouter.pluginId).toBe(pluginId);
});
it('provides the expected metadata', () => {
const versionedRouter = CoreVersionedRouter.from({ router });
versionedRouter.get({
path: '/test/{id}',
access: 'internal',

View file

@ -14,13 +14,16 @@ import type {
VersionedRouterRoute,
} from '@kbn/core-http-server';
import { omit } from 'lodash';
import { Logger } from '@kbn/logging';
import { CoreVersionedRoute } from './core_versioned_route';
import type { HandlerResolutionStrategy, Method } from './types';
import { getRouteFullPath, type Router } from '../router';
import type { Router } from '../router';
import { getRouteFullPath } from '../util';
/** @internal */
export interface VersionedRouterArgs {
router: Router;
log: Logger;
/**
* Which route resolution algo to use.
* @note default to "oldest", but when running in dev default to "none"
@ -52,12 +55,14 @@ export class CoreVersionedRouter implements VersionedRouter {
public pluginId?: symbol;
public static from({
router,
log,
defaultHandlerResolutionStrategy,
isDev,
useVersionResolutionStrategyForInternalPaths,
}: VersionedRouterArgs) {
return new CoreVersionedRouter(
router,
log,
defaultHandlerResolutionStrategy,
isDev,
useVersionResolutionStrategyForInternalPaths
@ -65,6 +70,7 @@ export class CoreVersionedRouter implements VersionedRouter {
}
private constructor(
public readonly router: Router,
private readonly log: Logger,
public readonly defaultHandlerResolutionStrategy: HandlerResolutionStrategy = 'oldest',
public readonly isDev: boolean = false,
useVersionResolutionStrategyForInternalPaths: string[] = []
@ -80,6 +86,7 @@ export class CoreVersionedRouter implements VersionedRouter {
(options: VersionedRouteConfig<Method>): VersionedRoute<Method, any> => {
const route = CoreVersionedRoute.from({
router: this.router,
log: this.log,
method: routeMethod,
path: options.path,
options: {

View file

@ -21,6 +21,8 @@ export function createRouter(opts: CreateMockRouterOptions = {}) {
getRoutes: jest.fn(),
handleLegacyErrors: jest.fn(),
emitPostValidate: jest.fn(),
registerRoute: jest.fn(),
enhanceWithContext: jest.fn((fn) => fn.bind(null, {})),
patch: jest.fn(),
routerPath: '',
versioned: {} as any,

View file

@ -7,10 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { KibanaRequest } from '@kbn/core-http-server';
import { hapiMocks } from '@kbn/hapi-mocks';
import { CoreKibanaRequest } from '../request';
import { passThroughValidation } from './core_versioned_route';
import {
isValidRouteVersion,
isAllowedPublicVersion,
@ -65,9 +62,8 @@ describe('isValidRouteVersion', () => {
});
});
function getRequest(arg: { headers?: any; query?: any } = {}): KibanaRequest {
const request = hapiMocks.createRequest({ ...arg });
return CoreKibanaRequest.from(request, passThroughValidation);
function getRequest(arg: { headers?: any; query?: any } = {}) {
return hapiMocks.createRequest({ ...arg });
}
describe('readVersion', () => {

View file

@ -60,12 +60,14 @@ export interface RequestLike {
}
export function hasQueryVersion(
request: Mutable<KibanaRequest>
request: RequestLike
): request is Mutable<KibanaRequestWithQueryVersion> {
return isObject(request.query) && ELASTIC_HTTP_VERSION_QUERY_PARAM in request.query;
}
export function removeQueryVersion(request: Mutable<KibanaRequestWithQueryVersion>): void {
delete request.query[ELASTIC_HTTP_VERSION_QUERY_PARAM];
export function removeQueryVersion(request: RequestLike): void {
if (request.query) {
delete (request.query as { [key: string]: string })[ELASTIC_HTTP_VERSION_QUERY_PARAM];
}
}
function readQueryVersion(request: RequestLike): undefined | ApiVersion {

View file

@ -1,24 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { RouteValidatorFullConfigRequest } from '@kbn/core-http-server';
import { RouteValidator } from '../validator';
/** Will throw if any of the validation checks fail */
export function validate(
data: { body?: unknown; params?: unknown; query?: unknown },
runtimeSchema: RouteValidatorFullConfigRequest<unknown, unknown, unknown>
): { body: unknown; params: unknown; query: unknown } {
const validator = RouteValidator.from(runtimeSchema);
return {
params: validator.getParams(data.params, 'request params'),
query: validator.getQuery(data.query, 'request query'),
body: validator.getBody(data.body, 'request body'),
};
}

View file

@ -37,6 +37,7 @@ import type {
IAuthHeadersStorage,
RouterDeprecatedApiDetails,
RouteMethod,
VersionedRouterRoute,
} from '@kbn/core-http-server';
import { performance } from 'perf_hooks';
import { isBoom } from '@hapi/boom';
@ -410,10 +411,12 @@ export class HttpServer {
.map((route) => {
const access = route.options.access;
if (route.isVersioned === true) {
return [...route.handlers.entries()].map(([_, { options }]) => {
const deprecated = options.options?.deprecated;
return { route, version: `${options.version}`, deprecated, access };
});
return [...(route as VersionedRouterRoute).handlers.entries()].map(
([_, { options }]) => {
const deprecated = options.options?.deprecated;
return { route, version: `${options.version}`, deprecated, access };
}
);
}
return { route, version: undefined, deprecated: route.options.deprecated, access };

View file

@ -124,6 +124,7 @@ export type {
InternalRouteSecurity,
RouteDeprecationInfo,
PostValidationMetadata,
AnyKibanaRequest,
} from './src/router';
export {
validBodyOutput,

View file

@ -31,6 +31,7 @@ export type {
KibanaRouteOptions,
RouteSecurityGetter,
InternalRouteSecurity,
AnyKibanaRequest,
} from './request';
export type { RequestHandlerWrapper, RequestHandler } from './request_handler';
export type { RequestHandlerContextBase } from './request_handler_context';

View file

@ -214,3 +214,8 @@ export interface KibanaRequest<
*/
readonly body: Body;
}
/**
* @remark Convenience type, use when the concrete values of P, Q, B and route method do not matter.
*/
export type AnyKibanaRequest = KibanaRequest<unknown, unknown, unknown, RouteMethod>;

View file

@ -139,7 +139,7 @@ export interface RouterRoute {
req: Request,
responseToolkit: ResponseToolkit
) => Promise<ResponseObject | Boom.Boom<any>>;
isVersioned: false;
isVersioned: boolean;
}
/** @public */

View file

@ -370,6 +370,6 @@ export interface VersionedRouterRoute<P = unknown, Q = unknown, B = unknown> {
method: string;
path: string;
options: Omit<VersionedRouteConfig<RouteMethod>, 'path'>;
handlers: Array<{ fn: RequestHandler<P, Q, B>; options: AddVersionOpts<P, Q, B> }>;
handlers: Array<{ fn: Function; options: AddVersionOpts<P, Q, B> }>;
isVersioned: true;
}