Skip to content

Commit

Permalink
[kbn/server-route-repository] Add zod support (#190244)
Browse files Browse the repository at this point in the history
This PR adds support for using `zod` as the validation library alongside
of `io-ts`.

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
miltonhultgren and kibanamachine authored Aug 14, 2024
1 parent 4d0cfdf commit 0caa6cd
Show file tree
Hide file tree
Showing 24 changed files with 800 additions and 303 deletions.
2 changes: 2 additions & 0 deletions packages/kbn-server-route-repository-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ export type {
DefaultClientOptions,
DefaultRouteCreateOptions,
DefaultRouteHandlerResources,
IoTsParamsObject,
ZodParamsObject,
} from './src/typings';
25 changes: 21 additions & 4 deletions packages/kbn-server-route-repository-utils/src/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
import type { HttpFetchOptions } from '@kbn/core-http-browser';
import type { IKibanaResponse } from '@kbn/core-http-server';
import type {
RequestHandlerContext,
KibanaRequest,
KibanaResponseFactory,
Logger,
RequestHandlerContext,
RouteConfigOptions,
RouteMethod,
KibanaRequest,
KibanaResponseFactory,
} from '@kbn/core/server';
import { z } from '@kbn/zod';
import * as t from 'io-ts';
import { RequiredKeys } from 'utility-types';

Expand All @@ -30,14 +31,22 @@ 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;
body?: any;
}>
>;

export type RouteParamsRT = IoTsParamsObject | ZodParamsObject;

export interface RouteState {
[endpoint: string]: ServerRoute<any, any, any, any, any>;
}
Expand Down Expand Up @@ -82,13 +91,21 @@ type ClientRequestParamsOfType<TRouteParamsRT extends RouteParamsRT> =
? MaybeOptional<{
params: t.OutputOf<TRouteParamsRT>;
}>
: TRouteParamsRT extends z.Schema
? MaybeOptional<{
params: z.TypeOf<TRouteParamsRT>;
}>
: {};

type DecodedRequestParamsOfType<TRouteParamsRT extends RouteParamsRT> =
TRouteParamsRT extends t.Mixed
? MaybeOptional<{
params: t.TypeOf<TRouteParamsRT>;
}>
: TRouteParamsRT extends z.Schema
? MaybeOptional<{
params: z.TypeOf<TRouteParamsRT>;
}>
: {};

export type EndpointOf<TServerRouteRepository extends ServerRouteRepository> =
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-server-route-repository-utils/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
"@kbn/core-http-browser",
"@kbn/core-http-server",
"@kbn/core",
"@kbn/zod",
]
}
25 changes: 14 additions & 11 deletions packages/kbn-server-route-repository/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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',
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packages/kbn-server-route-repository/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -24,4 +25,5 @@ export type {
RouteState,
DefaultRouteCreateOptions,
DefaultRouteHandlerResources,
IoTsParamsObject,
} from '@kbn/server-route-repository-utils';
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -14,10 +14,9 @@ describe('decodeRequestParams', () => {
const decode = () => {
return decodeRequestParams(
{
params: {
path: {
serviceName: 'opbeans-java',
},
body: null,
query: {
start: '',
},
Expand Down Expand Up @@ -48,11 +47,10 @@ describe('decodeRequestParams', () => {
const decode = () => {
return decodeRequestParams(
{
params: {
path: {
serviceName: 'opbeans-java',
extraKey: '',
},
body: null,
query: {
start: '',
},
Expand All @@ -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',
},
},
});
});
});
24 changes: 4 additions & 20 deletions packages/kbn-server-route-repository/src/decode_request_params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 0caa6cd

Please sign in to comment.