mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Http] Router refactor (#205502)
This commit is contained in:
parent
6429c53597
commit
ca77772d2a
22 changed files with 823 additions and 481 deletions
|
@ -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';
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
188
src/core/packages/http/router-server-internal/src/route.test.ts
Normal file
188
src/core/packages/http/router-server-internal/src/route.test.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
>;
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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()];
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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'),
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -124,6 +124,7 @@ export type {
|
|||
InternalRouteSecurity,
|
||||
RouteDeprecationInfo,
|
||||
PostValidationMetadata,
|
||||
AnyKibanaRequest,
|
||||
} from './src/router';
|
||||
export {
|
||||
validBodyOutput,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -139,7 +139,7 @@ export interface RouterRoute {
|
|||
req: Request,
|
||||
responseToolkit: ResponseToolkit
|
||||
) => Promise<ResponseObject | Boom.Boom<any>>;
|
||||
isVersioned: false;
|
||||
isVersioned: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue