diff --git a/src/core/packages/http/router-server-internal/index.ts b/src/core/packages/http/router-server-internal/index.ts index 0e048c59144df..f0cd26b1ba90b 100644 --- a/src/core/packages/http/router-server-internal/index.ts +++ b/src/core/packages/http/router-server-internal/index.ts @@ -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'; diff --git a/src/core/packages/http/router-server-internal/src/request.ts b/src/core/packages/http/router-server-internal/src/request.ts index 24c3aa7c61d3f..1eaae21c13259 100644 --- a/src/core/packages/http/router-server-internal/src/request.ts +++ b/src/core/packages/http/router-server-internal/src/request.ts @@ -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)); +} diff --git a/src/core/packages/http/router-server-internal/src/route.test.ts b/src/core/packages/http/router-server-internal/src/route.test.ts new file mode 100644 index 0000000000000..3e61235347ab4 --- /dev/null +++ b/src/core/packages/http/router-server-internal/src/route.test.ts @@ -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, + }); + } + }); +}); diff --git a/src/core/packages/http/router-server-internal/src/route.ts b/src/core/packages/http/router-server-internal/src/route.ts index 6faae2c1816b9..9afc11435eea7 100644 --- a/src/core/packages/http/router-server-internal/src/route.ts +++ b/src/core/packages/http/router-server-internal/src/route.ts @@ -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 = Omit< > & { security?: RouteSecurityGetter | RouteSecurity; }; + +/** @internal */ +interface Dependencies { + router: Router; + route: InternalRouteConfig; + handler: RequestHandlerEnhanced; + 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, route.options), + validationSchemas: route.validate, + isVersioned: false, + }; +} + +/** @internal */ +interface HandlerDependencies extends Dependencies { + routeSchemas?: RouteValidator; +} + +type RouteInfo = Pick, 'access' | 'httpResource' | 'deprecated'>; + +interface ValidationContext { + routeInfo: RouteInfo; + router: Router; + log: Logger; + routeSchemas?: RouteValidator; + 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; + 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( + route: InternalRouteConfig, + 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: routeInfo.access === 'public', + }; +} diff --git a/src/core/packages/http/router-server-internal/src/router.test.ts b/src/core/packages/http/router-server-internal/src/router.test.ts index f0eaa96879d42..ee1b3d234b71f 100644 --- a/src/core/packages/http/router-server-internal/src/router.test.ts +++ b/src/core/packages/http/router-server-internal/src/router.test.ts @@ -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', diff --git a/src/core/packages/http/router-server-internal/src/router.ts b/src/core/packages/http/router-server-internal/src/router.ts index 7c7a67facaddc..fcf70341b0c41 100644 --- a/src/core/packages/http/router-server-internal/src/router.ts +++ b/src/core/packages/http/router-server-internal/src/router.ts @@ -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) => RequestHandlerEnhanced; -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; /** - * 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( - route: InternalRouteConfig, - 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(...)`. + * + * 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: * - * @param method HTTP verb for these options - * @param routeConfig The route config definition + * 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 -) { - 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 & { + 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 = Omit< RouteConfig, @@ -171,13 +95,6 @@ export type VersionedRouteConfig = Omit< security?: RouteSecurityGetter; }; -/** @internal */ -export type InternalRegistrar = ( - route: InternalRouteConfig, - handler: RequestHandler, - internalOpts?: InternalRegistrarOptions -) => ReturnType>; - /** @internal */ type RouterEvents = /** Called after route validation, regardless of success or failure */ @@ -189,19 +106,24 @@ type RouterEvents = export class Router implements IRouter { - 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> = []; 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, + public readonly enhanceWithContext: ContextEnhancer, private readonly options: RouterOptions ) { this.pluginId = options.pluginId; @@ -209,40 +131,17 @@ export class Router(method: Method) => ( route: InternalRouteConfig, - handler: RequestHandler, - { isVersioned, events }: InternalRegistrarOptions = { isVersioned: false, events: true } + handler: RequestHandler ) => { - 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, 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 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 { const postValidate: RouterEvents = 'onPostValidate'; - Router.ee.emit(postValidate, request, postValidateConext); + Router.events.emit(postValidate, request, postValidateConext); }; - private async handle({ - 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; + 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) as KibanaRequest< - P, - Q, - B, - typeof request.method extends RouteMethod ? typeof request.method : any - >; - } 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, @@ -384,14 +215,14 @@ export class Router = T extends (first: any, ...rest: infer Params) => i ? (...rest: Params) => Return : never; -type RequestHandlerEnhanced = WithoutHeadArgument< +export type RequestHandlerEnhanced = WithoutHeadArgument< RequestHandler >; diff --git a/src/core/packages/http/router-server-internal/src/util.ts b/src/core/packages/http/router-server-internal/src/util.ts index 176d33b589880..b4027d9211890 100644 --- a/src/core/packages/http/router-server-internal/src/util.ts +++ b/src/core/packages/http/router-server-internal/src/util.ts @@ -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 +) { + 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 }; +} diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts index 9c09de9e45895..a9f90fdc0e0b2 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts @@ -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; + let versionedRouter: CoreVersionedRouter; let testValidation: ReturnType; 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[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 = { - 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, + } + ); + }); + }); }); diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.util.ts b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.util.ts index c7c8d30666990..58f29442e94da 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.util.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.util.ts @@ -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' }, + }); } diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.ts b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.ts index 49e386f73e675..04560c3e76e5a 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.ts @@ -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 extends VersionedRouteConfig { isDev: boolean; @@ -53,13 +51,6 @@ interface InternalVersionedRouteConfig 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; options: Options; } >(); public static from({ router, + log, method, path, options, }: { router: Router; + log: Logger; method: Method; path: string; options: InternalVersionedRouteConfig; }) { - return new CoreVersionedRoute(router, method, path, options); + return new CoreVersionedRoute(router, log, method, path, options); } public readonly options: VersionedRouteConfig; @@ -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 @@ -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 { @@ -167,94 +158,71 @@ export class CoreVersionedRoute implements VersionedRoute { return version; } - private requestHandler = async ( - ctx: RequestHandlerContextBase, - originalReq: KibanaRequest, - res: KibanaResponseFactory - ): Promise => { + private handle = async (hapiRequest: Request): Promise => { 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; - 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; + options: Options; + }> { return [...this.handlers.values()]; } diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.test.ts b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.test.ts index a3ffffc0ef219..7b9fbbf938807 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.test.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.test.ts @@ -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', diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.ts b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.ts index ef1f8255420ae..0570ac3cf099c 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_router.ts @@ -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): VersionedRoute => { const route = CoreVersionedRoute.from({ router: this.router, + log: this.log, method: routeMethod, path: options.path, options: { diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/mocks.ts b/src/core/packages/http/router-server-internal/src/versioned_router/mocks.ts index 36a672ca6a9f7..88e719b5033dc 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/mocks.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/mocks.ts @@ -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, diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.test.ts b/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.test.ts index da1e63c9ccca3..249867d2417c0 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.test.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.test.ts @@ -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', () => { diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.ts b/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.ts index 2b82dcc12acd2..e6151148928d2 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/route_version_utils.ts @@ -60,12 +60,14 @@ export interface RequestLike { } export function hasQueryVersion( - request: Mutable + request: RequestLike ): request is Mutable { return isObject(request.query) && ELASTIC_HTTP_VERSION_QUERY_PARAM in request.query; } -export function removeQueryVersion(request: Mutable): 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 { diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/validate.ts b/src/core/packages/http/router-server-internal/src/versioned_router/validate.ts deleted file mode 100644 index e1ed81a4ca2ac..0000000000000 --- a/src/core/packages/http/router-server-internal/src/versioned_router/validate.ts +++ /dev/null @@ -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 -): { 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'), - }; -} diff --git a/src/core/packages/http/server-internal/src/http_server.ts b/src/core/packages/http/server-internal/src/http_server.ts index 06b14911fd9bf..9f04230a0844b 100644 --- a/src/core/packages/http/server-internal/src/http_server.ts +++ b/src/core/packages/http/server-internal/src/http_server.ts @@ -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'; @@ -406,10 +407,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 }; diff --git a/src/core/packages/http/server/index.ts b/src/core/packages/http/server/index.ts index 7b79dfe313bd6..d0e9475cb1899 100644 --- a/src/core/packages/http/server/index.ts +++ b/src/core/packages/http/server/index.ts @@ -124,6 +124,7 @@ export type { InternalRouteSecurity, RouteDeprecationInfo, PostValidationMetadata, + AnyKibanaRequest, } from './src/router'; export { validBodyOutput, diff --git a/src/core/packages/http/server/src/router/index.ts b/src/core/packages/http/server/src/router/index.ts index 166fcad324953..278ab761cf8d2 100644 --- a/src/core/packages/http/server/src/router/index.ts +++ b/src/core/packages/http/server/src/router/index.ts @@ -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'; diff --git a/src/core/packages/http/server/src/router/request.ts b/src/core/packages/http/server/src/router/request.ts index 0a55cf022ab15..5019cce51c80d 100644 --- a/src/core/packages/http/server/src/router/request.ts +++ b/src/core/packages/http/server/src/router/request.ts @@ -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; diff --git a/src/core/packages/http/server/src/router/router.ts b/src/core/packages/http/server/src/router/router.ts index f770cfcc45e4b..f6a039a4130a9 100644 --- a/src/core/packages/http/server/src/router/router.ts +++ b/src/core/packages/http/server/src/router/router.ts @@ -139,7 +139,7 @@ export interface RouterRoute { req: Request, responseToolkit: ResponseToolkit ) => Promise>; - isVersioned: false; + isVersioned: boolean; } /** @public */ diff --git a/src/core/packages/http/server/src/versioning/types.ts b/src/core/packages/http/server/src/versioning/types.ts index 9b3480c554733..69f4c77d86c90 100644 --- a/src/core/packages/http/server/src/versioning/types.ts +++ b/src/core/packages/http/server/src/versioning/types.ts @@ -370,6 +370,6 @@ export interface VersionedRouterRoute

{ method: string; path: string; options: Omit, 'path'>; - handlers: Array<{ fn: RequestHandler; options: AddVersionOpts }>; + handlers: Array<{ fn: Function; options: AddVersionOpts }>; isVersioned: true; }