[kbn/server-route-repository] Add zod support (#190244)

This PR adds support for using `zod` as the validation library alongside
of `io-ts`.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Milton Hultgren 2024-08-14 18:00:07 +02:00 committed by GitHub
parent 4d0cfdf943
commit 0caa6cdfa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 829 additions and 332 deletions

View file

@ -24,4 +24,6 @@ export type {
DefaultClientOptions,
DefaultRouteCreateOptions,
DefaultRouteHandlerResources,
IoTsParamsObject,
ZodParamsObject,
} from './src/typings';

View file

@ -9,13 +9,14 @@
import type { HttpFetchOptions } from '@kbn/core-http-browser';
import type { IKibanaResponse } from '@kbn/core-http-server';
import type {
RequestHandlerContext,
Logger,
RouteConfigOptions,
RouteMethod,
KibanaRequest,
KibanaResponseFactory,
Logger,
RequestHandlerContext,
RouteConfigOptions,
RouteMethod,
} from '@kbn/core/server';
import { z } from '@kbn/zod';
import * as t from 'io-ts';
import { RequiredKeys } from 'utility-types';
@ -30,7 +31,13 @@ type WithoutIncompatibleMethods<T extends t.Any> = Omit<T, 'encode' | 'asEncoder
asEncoder: () => t.Encoder<any, any>;
};
export type RouteParamsRT = WithoutIncompatibleMethods<
export type ZodParamsObject = z.ZodObject<{
path?: any;
query?: any;
body?: any;
}>;
export type IoTsParamsObject = WithoutIncompatibleMethods<
t.Type<{
path?: any;
query?: any;
@ -38,6 +45,8 @@ export type RouteParamsRT = WithoutIncompatibleMethods<
}>
>;
export type RouteParamsRT = IoTsParamsObject | ZodParamsObject;
export interface RouteState {
[endpoint: string]: ServerRoute<any, any, any, any, any>;
}
@ -82,6 +91,10 @@ type ClientRequestParamsOfType<TRouteParamsRT extends RouteParamsRT> =
? MaybeOptional<{
params: t.OutputOf<TRouteParamsRT>;
}>
: TRouteParamsRT extends z.Schema
? MaybeOptional<{
params: z.TypeOf<TRouteParamsRT>;
}>
: {};
type DecodedRequestParamsOfType<TRouteParamsRT extends RouteParamsRT> =
@ -89,6 +102,10 @@ type DecodedRequestParamsOfType<TRouteParamsRT extends RouteParamsRT> =
? MaybeOptional<{
params: t.TypeOf<TRouteParamsRT>;
}>
: TRouteParamsRT extends z.Schema
? MaybeOptional<{
params: z.TypeOf<TRouteParamsRT>;
}>
: {};
export type EndpointOf<TServerRouteRepository extends ServerRouteRepository> =

View file

@ -19,5 +19,6 @@
"@kbn/core-http-browser",
"@kbn/core-http-server",
"@kbn/core",
"@kbn/zod",
]
}

View file

@ -127,22 +127,22 @@ The client translates the endpoint and the options (including request parameters
## Request parameter validation
When creating your routes, you can also provide an `io-ts` codec to be used when validating incoming requests.
When creating your routes, you can provide either a `zod` schema or an `io-ts` codec to be used when validating incoming requests.
```javascript
import * as t from 'io-ts';
import { z } from '@kbn/zod';
const myRoute = createMyPluginServerRoute({
endpoint: 'GET /internal/my_plugin/route/{my_path_param}',
params: t.type({
path: t.type({
my_path_param: t.string,
endpoint: 'POST /internal/my_plugin/route/{my_path_param}',
params: z.object({
path: z.object({
my_path_param: z.string(),
}),
query: t.type({
my_query_param: t.string,
query: z.object({
my_query_param: z.string(),
}),
body: t.type({
my_body_param: t.string,
body: z.object({
my_body_param: z.string(),
}),
}),
handler: async (resources) => {
@ -162,7 +162,7 @@ The `params` object is added to the route resources.
When calling this endpoint, it will look like this:
```javascript
client('GET /internal/my_plugin/route/{my_path_param}', {
client('POST /internal/my_plugin/route/{my_path_param}', {
params: {
path: {
my_path_param: 'some_path_value',
@ -179,6 +179,9 @@ client('GET /internal/my_plugin/route/{my_path_param}', {
Where the shape of `params` is typed to match the expected shape, meaning you don't need to manually use the codec when calling the route.
> When using `zod` you also opt into the Kibana platforms automatic OpenAPI specification generation tooling.
> By adding `server.oas.enabled: true` to your `kibana.yml` and visiting `/api/oas?pluginId=yourPluginId` you can see the generated specification.
## Public routes
To define a public route, you need to change the endpoint path and add a version.

View file

@ -9,7 +9,8 @@
export { formatRequest, parseEndpoint } from '@kbn/server-route-repository-utils';
export { createServerRouteFactory } from './src/create_server_route_factory';
export { decodeRequestParams } from './src/decode_request_params';
export { routeValidationObject } from './src/route_validation_object';
export { stripNullishRequestParameters } from './src/strip_nullish_request_parameters';
export { passThroughValidationObject } from './src/validation_objects';
export { registerRoutes } from './src/register_routes';
export type {
@ -24,4 +25,5 @@ export type {
RouteState,
DefaultRouteCreateOptions,
DefaultRouteHandlerResources,
IoTsParamsObject,
} from '@kbn/server-route-repository-utils';

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { jsonRt } from '@kbn/io-ts-utils';
import * as t from 'io-ts';
import { decodeRequestParams } from './decode_request_params';
@ -14,10 +14,9 @@ describe('decodeRequestParams', () => {
const decode = () => {
return decodeRequestParams(
{
params: {
path: {
serviceName: 'opbeans-java',
},
body: null,
query: {
start: '',
},
@ -48,11 +47,10 @@ describe('decodeRequestParams', () => {
const decode = () => {
return decodeRequestParams(
{
params: {
path: {
serviceName: 'opbeans-java',
extraKey: '',
},
body: null,
query: {
start: '',
},
@ -74,81 +72,4 @@ describe('decodeRequestParams', () => {
path.extraKey"
`);
});
it('returns the decoded output', () => {
const decode = () => {
return decodeRequestParams(
{
params: {},
query: {
_inspect: 'true',
},
body: null,
},
t.type({
query: t.type({
_inspect: jsonRt.pipe(t.boolean),
}),
})
);
};
expect(decode).not.toThrow();
expect(decode()).toEqual({
query: {
_inspect: true,
},
});
});
it('strips empty params', () => {
const decode = () => {
return decodeRequestParams(
{
params: {},
query: {},
body: {},
},
t.type({
body: t.any,
})
);
};
expect(decode).not.toThrow();
expect(decode()).toEqual({});
});
it('allows excess keys in an any type', () => {
const decode = () => {
return decodeRequestParams(
{
params: {},
query: {},
body: {
body: {
query: 'foo',
},
},
},
t.type({
body: t.type({
body: t.any,
}),
})
);
};
expect(decode).not.toThrow();
expect(decode()).toEqual({
body: {
body: {
query: 'foo',
},
},
});
});
});

View file

@ -7,32 +7,16 @@
*/
import Boom from '@hapi/boom';
import { formatErrors, strictKeysRt } from '@kbn/io-ts-utils';
import { RouteParamsRT } from '@kbn/server-route-repository-utils';
import { IoTsParamsObject } from '@kbn/server-route-repository-utils';
import { isLeft } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import { isEmpty, isPlainObject, omitBy } from 'lodash';
interface KibanaRequestParams {
body: unknown;
query: unknown;
params: unknown;
}
export function decodeRequestParams<T extends RouteParamsRT>(
params: KibanaRequestParams,
export function decodeRequestParams<T extends IoTsParamsObject>(
params: Partial<{ path: any; query: any; body: any }>,
paramsRt: T
): t.OutputOf<T> {
const paramMap = omitBy(
{
path: params.params,
body: params.body,
query: params.query,
},
(val) => val === null || val === undefined || (isPlainObject(val) && isEmpty(val))
);
// decode = validate
const result = strictKeysRt(paramsRt).decode(paramMap);
const result = strictKeysRt(paramsRt).decode(params);
if (isLeft(result)) {
throw Boom.badRequest(formatErrors(result.left));

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { z } from '@kbn/zod';
import { makeZodValidationObject } from './make_zod_validation_object';
import { noParamsValidationObject } from './validation_objects';
describe('makeZodValidationObject', () => {
it('translate path to params', () => {
const schema = z.object({
path: z.object({}),
});
expect(makeZodValidationObject(schema)).toMatchObject({
params: expect.anything(),
});
});
it('makes all object types strict', () => {
const schema = z.object({
path: z.object({}),
query: z.object({}),
body: z.string(),
});
const pathStrictSpy = jest.spyOn(schema.shape.path, 'strict');
const queryStrictSpy = jest.spyOn(schema.shape.query, 'strict');
expect(makeZodValidationObject(schema)).toEqual({
params: pathStrictSpy.mock.results[0].value,
query: queryStrictSpy.mock.results[0].value,
body: schema.shape.body,
});
});
it('sets key to strict empty if schema is missing key', () => {
const schema = z.object({});
expect(makeZodValidationObject(schema)).toStrictEqual({
params: noParamsValidationObject.params,
query: noParamsValidationObject.query,
body: noParamsValidationObject.body,
});
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ZodObject, ZodAny } from '@kbn/zod';
import { ZodParamsObject } from '@kbn/server-route-repository-utils';
import { noParamsValidationObject } from './validation_objects';
export function makeZodValidationObject(params: ZodParamsObject) {
return {
params: params.shape.path ? asStrict(params.shape.path) : noParamsValidationObject.params,
query: params.shape.query ? asStrict(params.shape.query) : noParamsValidationObject.query,
body: params.shape.body ? asStrict(params.shape.body) : noParamsValidationObject.body,
};
}
function asStrict(schema: ZodAny) {
if (schema instanceof ZodObject) {
return schema.strict();
} else {
return schema;
}
}

View file

@ -6,59 +6,39 @@
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { CoreSetup, kibanaResponseFactory } from '@kbn/core/server';
import { loggerMock } from '@kbn/logging-mocks';
import { registerRoutes } from './register_routes';
import { routeValidationObject } from './route_validation_object';
import { z } from '@kbn/zod';
import * as t from 'io-ts';
import { NEVER } from 'rxjs';
import * as makeZodValidationObject from './make_zod_validation_object';
import { registerRoutes } from './register_routes';
import { passThroughValidationObject, noParamsValidationObject } from './validation_objects';
describe('registerRoutes', () => {
const get = jest.fn();
const getAddVersion = jest.fn();
const getWithVersion = jest.fn((_options) => {
const post = jest.fn();
const postAddVersion = jest.fn();
const postWithVersion = jest.fn((_options) => {
return {
addVersion: getAddVersion,
addVersion: postAddVersion,
};
});
const createRouter = jest.fn().mockReturnValue({
get,
post,
versioned: {
get: getWithVersion,
post: postWithVersion,
},
});
const internalOptions = {
internal: true,
};
const publicOptions = {
public: true,
};
const internalHandler = jest.fn().mockResolvedValue('internal');
const publicHandler = jest
.fn()
.mockResolvedValue(
kibanaResponseFactory.custom({ statusCode: 201, body: { message: 'public' } })
);
const errorHandler = jest.fn().mockRejectedValue(new Error('error'));
const coreSetup = {
http: {
createRouter,
},
} as unknown as CoreSetup;
const mockLogger = loggerMock.create();
const mockService = jest.fn();
const mockContext = {};
const mockRequest = {
body: {
bodyParam: 'body',
},
query: {
queryParam: 'query',
},
params: {
pathParam: 'path',
},
events: {
aborted$: NEVER,
},
@ -66,14 +46,279 @@ describe('registerRoutes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const coreSetup = {
http: {
createRouter,
it('creates a router and defines the routes', () => {
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
handler: jest.fn(),
options: {
internal: true,
},
},
} as unknown as CoreSetup;
'POST /api/public_route version': {
endpoint: 'POST /api/public_route version',
handler: jest.fn(),
options: {
public: true,
},
},
});
const paramsRt = t.type({
expect(createRouter).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
const [internalRoute] = post.mock.calls[0];
expect(internalRoute.path).toEqual('/internal/route');
expect(internalRoute.options).toEqual({
internal: true,
});
expect(internalRoute.validate).toEqual(noParamsValidationObject);
expect(postWithVersion).toHaveBeenCalledTimes(1);
const [publicRoute] = postWithVersion.mock.calls[0];
expect(publicRoute.path).toEqual('/api/public_route');
expect(publicRoute.options).toEqual({
public: true,
});
expect(publicRoute.access).toEqual('public');
expect(postAddVersion).toHaveBeenCalledTimes(1);
const [versionedRoute] = postAddVersion.mock.calls[0];
expect(versionedRoute.version).toEqual('version');
expect(versionedRoute.validate).toEqual({
request: noParamsValidationObject,
});
});
it('does not allow any params if no schema is provided', () => {
const pathDoesNotAllowExcessKeys = () => {
noParamsValidationObject.params.parse({
unexpectedKey: 'not_allowed',
});
};
const queryDoesNotAllowExcessKeys = () => {
noParamsValidationObject.query.parse({
unexpectedKey: 'not_allowed',
});
};
const bodyDoesNotAllowExcessKeys = () => {
noParamsValidationObject.body.parse({
unexpectedKey: 'not_allowed',
});
};
expect(pathDoesNotAllowExcessKeys).toThrowErrorMatchingInlineSnapshot(`
"[
{
\\"code\\": \\"unrecognized_keys\\",
\\"keys\\": [
\\"unexpectedKey\\"
],
\\"path\\": [],
\\"message\\": \\"Unrecognized key(s) in object: 'unexpectedKey'\\"
}
]"
`);
expect(queryDoesNotAllowExcessKeys).toThrowErrorMatchingInlineSnapshot(`
"[
{
\\"code\\": \\"unrecognized_keys\\",
\\"keys\\": [
\\"unexpectedKey\\"
],
\\"path\\": [],
\\"message\\": \\"Unrecognized key(s) in object: 'unexpectedKey'\\"
}
]"
`);
expect(bodyDoesNotAllowExcessKeys).toThrowErrorMatchingInlineSnapshot(`
"[
{
\\"code\\": \\"unrecognized_keys\\",
\\"keys\\": [
\\"unexpectedKey\\"
],
\\"path\\": [],
\\"message\\": \\"Unrecognized key(s) in object: 'unexpectedKey'\\"
}
]"
`);
});
it('calls the route handler with all dependencies', async () => {
const handler = jest.fn();
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
handler,
},
});
const [_, wrappedHandler] = post.mock.calls[0];
await wrappedHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(handler).toBeCalledTimes(1);
const [args] = handler.mock.calls[0];
expect(Object.keys(args).sort()).toEqual(
['aService', 'request', 'response', 'context', 'params', 'logger'].sort()
);
const { params, logger, aService, request, response, context } = args;
expect(params).toEqual(undefined);
expect(request).toBe(mockRequest);
expect(response).toBe(kibanaResponseFactory);
expect(context).toBe(mockContext);
expect(aService).toBe(mockService);
expect(logger).toBe(mockLogger);
});
it('wraps a plain route handler result into a response', async () => {
const handler = jest.fn().mockResolvedValue('result');
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
handler,
},
});
const [_, wrappedHandler] = post.mock.calls[0];
const result = await wrappedHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({
status: 200,
payload: 'result',
options: { body: 'result' },
});
});
it('allows for route handlers to define a custom response', async () => {
const handler = jest
.fn()
.mockResolvedValue(
kibanaResponseFactory.custom({ statusCode: 201, body: { message: 'result' } })
);
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
handler,
},
});
const [_, wrappedHandler] = post.mock.calls[0];
const result = await wrappedHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ status: 201, payload: { message: 'result' }, options: {} });
});
it('translates errors thrown in a route handler to an error response', async () => {
const handler = jest.fn().mockRejectedValue(new Error('error'));
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
handler,
},
});
const [_, wrappedHandler] = post.mock.calls[0];
const error = await wrappedHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(handler).toHaveBeenCalledTimes(1);
expect(error).toEqual({
status: 500,
payload: { message: 'error', attributes: { data: {} } },
options: {},
});
});
describe('when using zod', () => {
const makeZodValidationObjectSpy = jest.spyOn(
makeZodValidationObject,
'makeZodValidationObject'
);
const zodParamsRt = z.object({
body: z.object({
bodyParam: z.string(),
}),
query: z.object({
queryParam: z.string(),
}),
path: z.object({
pathParam: z.string(),
}),
});
it('uses Core validation', () => {
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
params: zodParamsRt,
handler: jest.fn,
},
});
const [internalRoute] = post.mock.calls[0];
expect(makeZodValidationObjectSpy).toHaveBeenCalledWith(zodParamsRt);
expect(internalRoute.validate).toEqual(makeZodValidationObjectSpy.mock.results[0].value);
});
it('passes on params', async () => {
const handler = jest.fn();
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
params: zodParamsRt,
handler,
},
});
const [_, wrappedHandler] = post.mock.calls[0];
await wrappedHandler(
mockContext,
{
...mockRequest,
params: {
pathParam: 'path',
},
query: {
queryParam: 'query',
},
body: {
bodyParam: 'body',
},
},
kibanaResponseFactory
);
expect(handler).toBeCalledTimes(1);
const [args] = handler.mock.calls[0];
const { params } = args;
expect(params).toEqual({
path: {
pathParam: 'path',
},
query: {
queryParam: 'query',
},
body: {
bodyParam: 'body',
},
});
});
});
describe('when using io-ts', () => {
const iotsParamsRt = t.type({
body: t.type({
bodyParam: t.string,
}),
@ -85,120 +330,73 @@ describe('registerRoutes', () => {
}),
});
it('bypasses Core validation', () => {
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
params: iotsParamsRt,
handler: jest.fn,
},
});
const [internalRoute] = post.mock.calls[0];
expect(internalRoute.validate).toEqual(passThroughValidationObject);
});
it('decodes params', async () => {
const handler = jest.fn();
callRegisterRoutes({
'POST /internal/route': {
endpoint: 'POST /internal/route',
params: iotsParamsRt,
handler,
},
});
const [_, wrappedHandler] = post.mock.calls[0];
await wrappedHandler(
mockContext,
{
...mockRequest,
params: {
pathParam: 'path',
},
query: {
queryParam: 'query',
},
body: {
bodyParam: 'body',
},
},
kibanaResponseFactory
);
expect(handler).toBeCalledTimes(1);
const [args] = handler.mock.calls[0];
const { params } = args;
expect(params).toEqual({
path: {
pathParam: 'path',
},
query: {
queryParam: 'query',
},
body: {
bodyParam: 'body',
},
});
});
});
function callRegisterRoutes(repository: any) {
registerRoutes({
core: coreSetup,
repository: {
'GET /internal/app/feature': {
endpoint: 'GET /internal/app/feature',
handler: internalHandler,
params: paramsRt,
options: internalOptions,
},
'GET /api/app/feature version': {
endpoint: 'GET /api/app/feature version',
handler: publicHandler,
params: paramsRt,
options: publicOptions,
},
'GET /internal/app/feature/error': {
endpoint: 'GET /internal/app/feature/error',
handler: errorHandler,
params: paramsRt,
options: internalOptions,
},
},
logger: mockLogger,
dependencies: {
aService: mockService,
},
logger: mockLogger,
repository,
});
});
it('creates a router and defines the routes', () => {
expect(createRouter).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledTimes(2);
const [internalRoute] = get.mock.calls[0];
expect(internalRoute.path).toEqual('/internal/app/feature');
expect(internalRoute.options).toEqual(internalOptions);
expect(internalRoute.validate).toEqual(routeValidationObject);
expect(getWithVersion).toHaveBeenCalledTimes(1);
const [publicRoute] = getWithVersion.mock.calls[0];
expect(publicRoute.path).toEqual('/api/app/feature');
expect(publicRoute.options).toEqual(publicOptions);
expect(publicRoute.access).toEqual('public');
expect(getAddVersion).toHaveBeenCalledTimes(1);
const [versionedRoute] = getAddVersion.mock.calls[0];
expect(versionedRoute.version).toEqual('version');
expect(versionedRoute.validate).toEqual({
request: routeValidationObject,
});
});
it('calls the route handler with all dependencies', async () => {
const [_, internalRouteHandler] = get.mock.calls[0];
await internalRouteHandler(mockContext, mockRequest, kibanaResponseFactory);
const [args] = internalHandler.mock.calls[0];
expect(Object.keys(args).sort()).toEqual(
['aService', 'request', 'response', 'context', 'params', 'logger'].sort()
);
const { params, logger, aService, request, response, context } = args;
expect(params).toEqual({
body: {
bodyParam: 'body',
},
query: {
queryParam: 'query',
},
path: {
pathParam: 'path',
},
});
expect(request).toBe(mockRequest);
expect(response).toBe(kibanaResponseFactory);
expect(context).toBe(mockContext);
expect(aService).toBe(mockService);
expect(logger).toBe(mockLogger);
});
it('wraps a plain route handler result into a response', async () => {
const [_, internalRouteHandler] = get.mock.calls[0];
const internalResult = await internalRouteHandler(
mockContext,
mockRequest,
kibanaResponseFactory
);
expect(internalHandler).toHaveBeenCalledTimes(1);
expect(internalResult).toEqual({
status: 200,
payload: 'internal',
options: { body: 'internal' },
});
});
it('allows for route handlers to define a custom response', async () => {
const [_, publicRouteHandler] = getAddVersion.mock.calls[0];
const publicResult = await publicRouteHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(publicHandler).toHaveBeenCalledTimes(1);
expect(publicResult).toEqual({ status: 201, payload: { message: 'public' }, options: {} });
});
it('translates errors thrown in a route handler to an error response', async () => {
const [_, errorRouteHandler] = get.mock.calls[1];
const errorResult = await errorRouteHandler(mockContext, mockRequest, kibanaResponseFactory);
expect(errorHandler).toHaveBeenCalledTimes(1);
expect(errorResult).toEqual({
status: 500,
payload: { message: 'error', attributes: { data: {} } },
options: {},
});
});
}
});

View file

@ -5,6 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { errors } from '@elastic/elasticsearch';
import { isBoom } from '@hapi/boom';
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
@ -12,15 +13,17 @@ import type { KibanaRequest, KibanaResponseFactory } from '@kbn/core-http-server
import { isKibanaResponse } from '@kbn/core-http-server';
import type { CoreSetup } from '@kbn/core-lifecycle-server';
import type { Logger } from '@kbn/logging';
import * as t from 'io-ts';
import { merge, pick } from 'lodash';
import {
ServerRoute,
ServerRouteCreateOptions,
ZodParamsObject,
parseEndpoint,
} from '@kbn/server-route-repository-utils';
import { decodeRequestParams } from './decode_request_params';
import { routeValidationObject } from './route_validation_object';
import { isZod } from '@kbn/zod';
import { merge } from 'lodash';
import { passThroughValidationObject, noParamsValidationObject } from './validation_objects';
import { validateAndDecodeParams } from './validate_and_decode_params';
import { makeZodValidationObject } from './make_zod_validation_object';
const CLIENT_CLOSED_REQUEST = {
statusCode: 499,
@ -55,12 +58,7 @@ export function registerRoutes<TDependencies extends Record<string, any>>({
response: KibanaResponseFactory
) => {
try {
const runtimeType = params || t.strict({});
const validatedParams = decodeRequestParams(
pick(request, 'params', 'body', 'query'),
runtimeType
);
const validatedParams = validateAndDecodeParams(request, params);
const { aborted, result } = await Promise.race([
handler({
@ -122,12 +120,21 @@ export function registerRoutes<TDependencies extends Record<string, any>>({
logger.debug(`Registering endpoint ${endpoint}`);
let validationObject;
if (params === undefined) {
validationObject = noParamsValidationObject;
} else if (isZod(params)) {
validationObject = makeZodValidationObject(params as ZodParamsObject);
} else {
validationObject = passThroughValidationObject;
}
if (!version) {
router[method](
{
path: pathname,
options,
validate: routeValidationObject,
validate: validationObject,
},
wrappedHandler
);
@ -140,7 +147,7 @@ export function registerRoutes<TDependencies extends Record<string, any>>({
{
version,
validate: {
request: routeValidationObject,
request: validationObject,
},
},
wrappedHandler

View file

@ -1,20 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
const anyObject = schema.object({}, { unknowns: 'allow' });
export const routeValidationObject = {
// `body` can be null, but `validate` expects non-nullable types
// if any validation is defined. Not having validation currently
// means we don't get the payload. See
// https://github.com/elastic/kibana/issues/50179
body: schema.nullable(schema.oneOf([anyObject, schema.string()])),
params: anyObject,
query: anyObject,
};

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { stripNullishRequestParameters } from './strip_nullish_request_parameters';
describe('stripNullishRequestParameters', () => {
it('translate params to path', () => {
expect(
stripNullishRequestParameters({
params: {
something: 'test',
},
})
).toEqual({
path: {
something: 'test',
},
});
});
it('removes invalid values', () => {
expect(
stripNullishRequestParameters({
params: undefined,
query: null,
body: {},
})
).toEqual({});
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { omitBy, isPlainObject, isEmpty } from 'lodash';
interface KibanaRequestParams {
body?: unknown;
query?: unknown;
params?: unknown;
}
export function stripNullishRequestParameters(params: KibanaRequestParams) {
return omitBy<{ path: any; body: any; query: any }>(
{
path: params.params,
query: params.query,
body: params.body,
},
(val) => val === null || val === undefined || (isPlainObject(val) && isEmpty(val))
);
}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { z } from '@kbn/zod';
import { kibanaResponseFactory } from '@kbn/core/server';
import { EndpointOf, ReturnOf, RouteRepositoryClient } from '@kbn/server-route-repository-utils';
import { createServerRouteFactory } from './create_server_route_factory';
@ -39,6 +40,18 @@ createServerRouteFactory<{}, {}>()({
},
});
createServerRouteFactory<{}, {}>()({
endpoint: 'GET /internal/endpoint_with_params',
params: z.object({
path: z.object({
serviceName: z.string(),
}),
}),
handler: async (resources) => {
assertType<{ params: { path: { serviceName: string } } }>(resources);
},
});
// Resources should be passed to the request handler.
createServerRouteFactory<{ context: { getSpaceId: () => string } }, {}>()({
endpoint: 'GET /internal/endpoint_with_params',
@ -53,6 +66,19 @@ createServerRouteFactory<{ context: { getSpaceId: () => string } }, {}>()({
},
});
createServerRouteFactory<{ context: { getSpaceId: () => string } }, {}>()({
endpoint: 'GET /internal/endpoint_with_params',
params: z.object({
path: z.object({
serviceName: z.string(),
}),
}),
handler: async ({ context }) => {
const spaceId = context.getSpaceId();
assertType<string>(spaceId);
},
});
// Create options are available when registering a route.
createServerRouteFactory<{}, { options: { tags: string[] } }>()({
endpoint: 'GET /internal/endpoint_with_params',
@ -125,6 +151,36 @@ const repository = {
};
},
}),
...createServerRoute({
endpoint: 'GET /internal/endpoint_with_params_zod',
params: z.object({
path: z.object({
serviceName: z.string(),
}),
}),
handler: async () => {
return {
yesParamsForMe: true,
};
},
}),
...createServerRoute({
endpoint: 'GET /internal/endpoint_with_optional_params_zod',
params: z
.object({
path: z
.object({
serviceName: z.string(),
})
.partial(),
})
.partial(),
handler: async () => {
return {
someParamsForMe: true,
};
},
}),
...createServerRoute({
endpoint: 'GET /internal/endpoint_returning_result',
handler: async () => {
@ -153,6 +209,10 @@ assertType<Array<EndpointOf<TestRepository>>>([
'GET /internal/endpoint_with_params',
'GET /internal/endpoint_without_params',
'GET /internal/endpoint_with_optional_params',
'GET /internal/endpoint_with_params_zod',
'GET /internal/endpoint_with_optional_params_zod',
'GET /internal/endpoint_returning_result',
'GET /internal/endpoint_returning_kibana_response',
]);
// @ts-expect-error Type '"this_endpoint_does_not_exist"' is not assignable to type '"endpoint_without_params" | "endpoint_with_params" | "endpoint_with_optional_params"'
@ -208,11 +268,23 @@ client('GET /internal/endpoint_with_params', {
timeout: 1,
});
client('GET /internal/endpoint_with_params_zod', {
params: {
// @ts-expect-error property 'serviceName' is missing in type '{}'
path: {},
},
timeout: 1,
});
// Params are optional if the codec has no required keys
client('GET /internal/endpoint_with_optional_params', {
timeout: 1,
});
client('GET /internal/endpoint_with_optional_params_zod', {
timeout: 1,
});
// If optional, an error will still occur if the params do not match
client('GET /internal/endpoint_with_optional_params', {
timeout: 1,
@ -222,6 +294,14 @@ client('GET /internal/endpoint_with_optional_params', {
},
});
client('GET /internal/endpoint_with_optional_params_zod', {
timeout: 1,
params: {
// @ts-expect-error Object literal may only specify known properties, and 'path' does not exist in type
path: '',
},
});
// The return type is correctly inferred
client('GET /internal/endpoint_with_params', {
params: {
@ -241,6 +321,24 @@ client('GET /internal/endpoint_with_params', {
}>(res);
});
client('GET /internal/endpoint_with_params_zod', {
params: {
path: {
serviceName: '',
},
},
timeout: 1,
}).then((res) => {
assertType<{
noParamsForMe: boolean;
// @ts-expect-error Property 'noParamsForMe' is missing in type
}>(res);
assertType<{
yesParamsForMe: boolean;
}>(res);
});
client('GET /internal/endpoint_returning_result', {
timeout: 1,
}).then((res) => {
@ -261,7 +359,7 @@ client('GET /internal/endpoint_returning_kibana_response', {
assertType<{ path: { serviceName: string } }>(
decodeRequestParams(
{
params: {
path: {
serviceName: 'serviceName',
},
body: undefined,
@ -275,7 +373,7 @@ assertType<{ path: { serviceName: boolean } }>(
// @ts-expect-error The types of 'path.serviceName' are incompatible between these types.
decodeRequestParams(
{
params: {
path: {
serviceName: 'serviceName',
},
body: undefined,

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KibanaRequest } from '@kbn/core-http-server';
import { z } from '@kbn/zod';
import * as t from 'io-ts';
import { validateAndDecodeParams } from './validate_and_decode_params';
describe('validateAndDecodeParams', () => {
it('does nothing if no schema is provided', () => {
const request = {} as KibanaRequest;
expect(validateAndDecodeParams(request, undefined)).toEqual(undefined);
});
it('only does formatting when using zod', () => {
const request = {
params: {
my_path_param: 'test',
},
query: {},
} as KibanaRequest;
expect(validateAndDecodeParams(request, z.object({}))).toEqual({
path: {
my_path_param: 'test',
},
});
});
it('additionally performs validation when using zod', () => {
const schema = t.type({
path: t.type({
my_path_param: t.string,
}),
});
const validRequest = {
params: {
my_path_param: 'test',
},
query: {},
} as KibanaRequest;
expect(validateAndDecodeParams(validRequest, schema)).toEqual({
path: {
my_path_param: 'test',
},
});
const invalidRequest = {
params: {
my_unexpected_param: 'test',
},
} as KibanaRequest;
const shouldThrow = () => {
return validateAndDecodeParams(invalidRequest, schema);
};
expect(shouldThrow).toThrowErrorMatchingInlineSnapshot(`
"Failed to validate:
in /path/my_path_param: undefined does not match expected type string"
`);
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KibanaRequest } from '@kbn/core-http-server';
import { ZodParamsObject, IoTsParamsObject } from '@kbn/server-route-repository-utils';
import { isZod } from '@kbn/zod';
import { decodeRequestParams } from './decode_request_params';
import { stripNullishRequestParameters } from './strip_nullish_request_parameters';
export function validateAndDecodeParams(
request: KibanaRequest,
paramsSchema: ZodParamsObject | IoTsParamsObject | undefined
) {
if (paramsSchema === undefined) {
return undefined;
}
const params = stripNullishRequestParameters({
params: request.params,
body: request.body,
query: request.query,
});
if (isZod(paramsSchema)) {
// Already validated by platform
return params;
}
return decodeRequestParams(params, paramsSchema);
}

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { z } from '@kbn/zod';
export const passThroughValidationObject = {
body: z.any(),
params: z.any(),
query: z.any(),
};
export const noParamsValidationObject = {
params: z.object({}).strict(),
query: z.object({}).strict(),
body: z.union([
// If the route uses POST, the body should be empty object or null
z.object({}).strict(),
z.null(),
// If the route uses GET, body is undefined,
z.undefined(),
]),
};

View file

@ -12,7 +12,6 @@
"**/*.ts"
],
"kbn_references": [
"@kbn/config-schema",
"@kbn/io-ts-utils",
"@kbn/core-http-request-handler-context-server",
"@kbn/core-http-server",
@ -21,6 +20,7 @@
"@kbn/core",
"@kbn/logging-mocks",
"@kbn/server-route-repository-utils",
"@kbn/zod",
],
"exclude": [
"target/**/*",

View file

@ -10,12 +10,16 @@ import * as t from 'io-ts';
import { Logger, KibanaRequest, KibanaResponseFactory, RouteRegistrar } from '@kbn/core/server';
import { errors } from '@elastic/elasticsearch';
import agent from 'elastic-apm-node';
import { ServerRouteRepository } from '@kbn/server-route-repository';
import {
IoTsParamsObject,
ServerRouteRepository,
stripNullishRequestParameters,
} from '@kbn/server-route-repository';
import { merge } from 'lodash';
import {
decodeRequestParams,
parseEndpoint,
routeValidationObject,
passThroughValidationObject,
} from '@kbn/server-route-repository';
import { jsonRt, mergeRt } from '@kbn/io-ts-utils';
import { InspectResponse } from '@kbn/observability-plugin/typings/common';
@ -24,7 +28,6 @@ import { VersionedRouteRegistrar } from '@kbn/core-http-server';
import { IRuleDataClient } from '@kbn/rule-registry-plugin/server';
import type { APMIndices } from '@kbn/apm-data-access-plugin/server';
import { ApmFeatureFlags } from '../../../common/apm_feature_flags';
import { pickKeys } from '../../../common/utils/pick_keys';
import type {
APMCore,
MinimalApmPluginRequestHandlerContext,
@ -94,10 +97,14 @@ export function registerRoutes({
inspectableEsQueriesMap.set(request, []);
try {
const runtimeType = params ? mergeRt(params, inspectRt) : inspectRt;
const runtimeType = params ? mergeRt(params as IoTsParamsObject, inspectRt) : inspectRt;
const validatedParams = decodeRequestParams(
pickKeys(request, 'params', 'body', 'query'),
stripNullishRequestParameters({
params: request.params,
body: request.body,
query: request.query,
}),
runtimeType
);
@ -210,7 +217,7 @@ export function registerRoutes({
{
path: pathname,
options,
validate: routeValidationObject,
validate: passThroughValidationObject,
},
wrappedHandler
);
@ -228,7 +235,7 @@ export function registerRoutes({
{
version,
validate: {
request: routeValidationObject,
request: passThroughValidationObject,
},
},
wrappedHandler

View file

@ -8,10 +8,12 @@ import { errors } from '@elastic/elasticsearch';
import Boom from '@hapi/boom';
import { CoreSetup, Logger, RouteRegistrar } from '@kbn/core/server';
import {
IoTsParamsObject,
ServerRouteRepository,
decodeRequestParams,
stripNullishRequestParameters,
parseEndpoint,
routeValidationObject,
passThroughValidationObject,
} from '@kbn/server-route-repository';
import * as t from 'io-ts';
import { DatasetQualityRequestHandlerContext } from '../types';
@ -43,18 +45,18 @@ export function registerRoutes({
(router[method] as RouteRegistrar<typeof method, DatasetQualityRequestHandlerContext>)(
{
path: pathname,
validate: routeValidationObject,
validate: passThroughValidationObject,
options,
},
async (context, request, response) => {
try {
const decodedParams = decodeRequestParams(
{
stripNullishRequestParameters({
params: request.params,
body: request.body,
query: request.query,
},
params ?? t.strict({})
}),
(params as IoTsParamsObject) ?? t.strict({})
);
const data = (await handler({

View file

@ -10,9 +10,11 @@ import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { CoreSetup, KibanaRequest, Logger, RouteRegistrar } from '@kbn/core/server';
import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server';
import {
IoTsParamsObject,
decodeRequestParams,
stripNullishRequestParameters,
parseEndpoint,
routeValidationObject,
passThroughValidationObject,
} from '@kbn/server-route-repository';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import axios from 'axios';
@ -57,18 +59,18 @@ export function registerRoutes({ config, repository, core, logger, dependencies
(router[method] as RouteRegistrar<typeof method, ObservabilityRequestHandlerContext>)(
{
path: pathname,
validate: routeValidationObject,
validate: passThroughValidationObject,
options,
},
async (context, request, response) => {
try {
const decodedParams = decodeRequestParams(
{
stripNullishRequestParameters({
params: request.params,
body: request.body,
query: request.query,
},
params ?? t.strict({})
}),
(params as IoTsParamsObject) ?? t.strict({})
);
const data = await handler({

View file

@ -9,10 +9,12 @@ import Boom from '@hapi/boom';
import type { IKibanaResponse } from '@kbn/core/server';
import { CoreSetup, Logger, RouteRegistrar } from '@kbn/core/server';
import {
IoTsParamsObject,
ServerRouteRepository,
decodeRequestParams,
stripNullishRequestParameters,
parseEndpoint,
routeValidationObject,
passThroughValidationObject,
} from '@kbn/server-route-repository';
import * as t from 'io-ts';
import { ObservabilityOnboardingConfig } from '..';
@ -52,18 +54,18 @@ export function registerRoutes({
(router[method] as RouteRegistrar<typeof method, ObservabilityOnboardingRequestHandlerContext>)(
{
path: pathname,
validate: routeValidationObject,
validate: passThroughValidationObject,
options,
},
async (context, request, response) => {
try {
const decodedParams = decodeRequestParams(
{
stripNullishRequestParameters({
params: request.params,
body: request.body,
query: request.query,
},
params ?? t.strict({})
}),
(params as IoTsParamsObject) ?? t.strict({})
);
const data = (await handler({

View file

@ -14,9 +14,11 @@ import {
RuleRegistryPluginSetupContract,
} from '@kbn/rule-registry-plugin/server';
import {
IoTsParamsObject,
decodeRequestParams,
stripNullishRequestParameters,
parseEndpoint,
routeValidationObject,
passThroughValidationObject,
} from '@kbn/server-route-repository';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import axios from 'axios';
@ -59,18 +61,18 @@ export function registerRoutes({ config, repository, core, logger, dependencies
(router[method] as RouteRegistrar<typeof method, SloRequestHandlerContext>)(
{
path: pathname,
validate: routeValidationObject,
validate: passThroughValidationObject,
options,
},
async (context, request, response) => {
try {
const decodedParams = decodeRequestParams(
{
stripNullishRequestParameters({
params: request.params,
body: request.body,
query: request.query,
},
params ?? t.strict({})
}),
(params as IoTsParamsObject) ?? t.strict({})
);
const data = await handler({