mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
4d0cfdf943
commit
0caa6cdfa7
24 changed files with 829 additions and 332 deletions
|
@ -24,4 +24,6 @@ export type {
|
|||
DefaultClientOptions,
|
||||
DefaultRouteCreateOptions,
|
||||
DefaultRouteHandlerResources,
|
||||
IoTsParamsObject,
|
||||
ZodParamsObject,
|
||||
} from './src/typings';
|
||||
|
|
|
@ -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> =
|
||||
|
|
|
@ -19,5 +19,6 @@
|
|||
"@kbn/core-http-browser",
|
||||
"@kbn/core-http-server",
|
||||
"@kbn/core",
|
||||
"@kbn/zod",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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: {},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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({});
|
||||
});
|
||||
});
|
|
@ -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))
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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(),
|
||||
]),
|
||||
};
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue