From 6a7a400c708ce38351a686062323c5ff358a64af Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 19 Jul 2024 17:53:32 +0200 Subject: [PATCH] [HTTP/OAS] `zod` support (#186190) --- .github/CODEOWNERS | 1 + package.json | 3 +- .../src/router.test.ts | 2 +- .../src/router.ts | 5 +- .../src/util.ts | 3 +- .../src/validator.test.ts | 18 + .../src/validator.ts | 3 + .../tsconfig.json | 1 + .../src/router/route_validator.ts | 11 +- .../core/http/core-http-server/tsconfig.json | 3 +- .../__snapshots__/generate_oas.test.ts.snap | 36 +- .../src/generate_oas.test.fixture.ts | 460 ++++++++++++++++++ .../src/generate_oas.test.ts | 56 ++- .../src/generate_oas.test.util.ts | 34 +- .../src/oas_converter/index.ts | 7 +- .../src/oas_converter/zod/index.ts | 17 + .../src/oas_converter/zod/lib.test.ts | 145 ++++++ .../src/oas_converter/zod/lib.test.util.ts | 28 ++ .../src/oas_converter/zod/lib.ts | 215 ++++++++ .../kbn-router-to-openapispec/tsconfig.json | 3 +- packages/kbn-zod/README.md | 4 + packages/kbn-zod/index.ts | 11 + packages/kbn-zod/jest.config.js | 13 + packages/kbn-zod/kibana.jsonc | 5 + packages/kbn-zod/package.json | 6 + packages/kbn-zod/tsconfig.json | 17 + packages/kbn-zod/types.test.ts | 18 + packages/kbn-zod/types.ts | 11 + packages/kbn-zod/util.test.ts | 32 ++ packages/kbn-zod/util.ts | 13 + .../integration_tests/http/router.test.ts | 144 ++++-- src/core/tsconfig.json | 1 + tsconfig.base.json | 2 + yarn.lock | 12 +- 34 files changed, 1253 insertions(+), 87 deletions(-) create mode 100644 packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts create mode 100644 packages/kbn-router-to-openapispec/src/oas_converter/zod/index.ts create mode 100644 packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.ts create mode 100644 packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.util.ts create mode 100644 packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.ts create mode 100644 packages/kbn-zod/README.md create mode 100644 packages/kbn-zod/index.ts create mode 100644 packages/kbn-zod/jest.config.js create mode 100644 packages/kbn-zod/kibana.jsonc create mode 100644 packages/kbn-zod/package.json create mode 100644 packages/kbn-zod/tsconfig.json create mode 100644 packages/kbn-zod/types.test.ts create mode 100644 packages/kbn-zod/types.ts create mode 100644 packages/kbn-zod/util.test.ts create mode 100644 packages/kbn-zod/util.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d90fa97a0c9f2..e08bcbeff8ef6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -944,6 +944,7 @@ packages/kbn-web-worker-stub @elastic/kibana-operations packages/kbn-whereis-pkg-cli @elastic/kibana-operations packages/kbn-xstate-utils @elastic/obs-ux-logs-team packages/kbn-yarn-lock-validator @elastic/kibana-operations +packages/kbn-zod @elastic/kibana-core packages/kbn-zod-helpers @elastic/security-detection-rule-management #### ## Everything below this line overrides the default assignments for each package. diff --git a/package.json b/package.json index 0b99d219a968e..ea6a9f0bcbab2 100644 --- a/package.json +++ b/package.json @@ -937,6 +937,7 @@ "@kbn/visualizations-plugin": "link:src/plugins/visualizations", "@kbn/watcher-plugin": "link:x-pack/plugins/watcher", "@kbn/xstate-utils": "link:packages/kbn-xstate-utils", + "@kbn/zod": "link:packages/kbn-zod", "@kbn/zod-helpers": "link:packages/kbn-zod-helpers", "@langchain/community": "^0.2.4", "@langchain/core": "0.2.3", @@ -1762,7 +1763,7 @@ "xmlbuilder": "13.0.2", "yargs": "^15.4.1", "yarn-deduplicate": "^6.0.2", - "zod-to-json-schema": "^3.22.3" + "zod-to-json-schema": "^3.23.0" }, "packageManager": "yarn@1.22.21" } \ No newline at end of file diff --git a/packages/core/http/core-http-router-server-internal/src/router.test.ts b/packages/core/http/core-http-router-server-internal/src/router.test.ts index c9d9c28a88823..179b6a710359a 100644 --- a/packages/core/http/core-http-router-server-internal/src/router.test.ts +++ b/packages/core/http/core-http-router-server-internal/src/router.test.ts @@ -208,7 +208,7 @@ describe('Router', () => { (context, req, res) => res.ok({}) ) ).toThrowErrorMatchingInlineSnapshot( - `"Expected a valid validation logic declared with '@kbn/config-schema' package or a RouteValidationFunction at key: [params]."` + `"Expected a valid validation logic declared with '@kbn/config-schema' package, '@kbn/zod' package or a RouteValidationFunction at key: [params]."` ); }); diff --git a/packages/core/http/core-http-router-server-internal/src/router.ts b/packages/core/http/core-http-router-server-internal/src/router.ts index cd8fa84f662e3..3252e49977bb3 100644 --- a/packages/core/http/core-http-router-server-internal/src/router.ts +++ b/packages/core/http/core-http-router-server-internal/src/router.ts @@ -26,6 +26,7 @@ import type { VersionedRouter, RouteRegistrar, } from '@kbn/core-http-server'; +import { isZod } from '@kbn/zod'; import { validBodyOutput, getRequestValidation } from '@kbn/core-http-server'; import { RouteValidator } from './validator'; import { CoreVersionedRouter } from './versioned_router'; @@ -73,9 +74,9 @@ function routeSchemasFromRouteConfig( if (route.validate !== false) { const validation = getRequestValidation(route.validate); Object.entries(validation).forEach(([key, schema]) => { - if (!(isConfigSchema(schema) || typeof schema === 'function')) { + if (!(isConfigSchema(schema) || isZod(schema) || typeof schema === 'function')) { throw new Error( - `Expected a valid validation logic declared with '@kbn/config-schema' package or a RouteValidationFunction at key: [${key}].` + `Expected a valid validation logic declared with '@kbn/config-schema' package, '@kbn/zod' package or a RouteValidationFunction at key: [${key}].` ); } }); diff --git a/packages/core/http/core-http-router-server-internal/src/util.ts b/packages/core/http/core-http-router-server-internal/src/util.ts index 88bf7f7276116..75c18854f842f 100644 --- a/packages/core/http/core-http-router-server-internal/src/util.ts +++ b/packages/core/http/core-http-router-server-internal/src/util.ts @@ -14,13 +14,14 @@ import { type RouteValidator, } from '@kbn/core-http-server'; import type { ObjectType, Type } from '@kbn/config-schema'; +import type { ZodEsque } from '@kbn/zod'; function isStatusCode(key: string) { return !isNaN(parseInt(key, 10)); } interface ResponseValidation { - [statusCode: number]: { body: () => ObjectType | Type }; + [statusCode: number]: { body: () => ObjectType | Type | ZodEsque }; } export function prepareResponseValidation(validation: ResponseValidation): ResponseValidation { diff --git a/packages/core/http/core-http-router-server-internal/src/validator.test.ts b/packages/core/http/core-http-router-server-internal/src/validator.test.ts index 7da169f521ecd..b54eebd653ebe 100644 --- a/packages/core/http/core-http-router-server-internal/src/validator.test.ts +++ b/packages/core/http/core-http-router-server-internal/src/validator.test.ts @@ -7,6 +7,7 @@ */ import { schema, Type } from '@kbn/config-schema'; +import { z } from '@kbn/zod'; import { RouteValidationError } from '@kbn/core-http-server'; import { RouteValidator } from './validator'; @@ -80,6 +81,23 @@ describe('Router validator', () => { ); }); + it('should validate and infer the type from a zod-schema ObjectType', () => { + const schemaValidation = RouteValidator.from({ + params: z.object({ + foo: z.string(), + }), + }); + + expect(schemaValidation.getParams({ foo: 'bar' })).toStrictEqual({ foo: 'bar' }); + expect(schemaValidation.getParams({ foo: 'bar' }).foo.toUpperCase()).toBe('BAR'); // It knows it's a string! :) + expect(() => schemaValidation.getParams({ foo: 1 })).toThrowError( + /Expected string, received number/ + ); + expect(() => schemaValidation.getParams({})).toThrowError(/Required/); + expect(() => schemaValidation.getParams(undefined)).toThrowError(/Required/); + expect(() => schemaValidation.getParams({}, 'myField')).toThrowError(/Required/); + }); + it('should validate and infer the type from a config-schema non-ObjectType', () => { const schemaValidation = RouteValidator.from({ params: schema.buffer() }); diff --git a/packages/core/http/core-http-router-server-internal/src/validator.ts b/packages/core/http/core-http-router-server-internal/src/validator.ts index 80d6e96b2ccb0..22dce0329f6a3 100644 --- a/packages/core/http/core-http-router-server-internal/src/validator.ts +++ b/packages/core/http/core-http-router-server-internal/src/validator.ts @@ -7,6 +7,7 @@ */ import { Stream } from 'stream'; +import { isZod } from '@kbn/zod'; import { ValidationError, schema, isConfigSchema } from '@kbn/config-schema'; import type { RouteValidationSpec, @@ -119,6 +120,8 @@ export class RouteValidator

{ ): T { if (isConfigSchema(validationRule)) { return validationRule.validate(data, {}, namespace); + } else if (isZod(validationRule)) { + return validationRule.parse(data); } else if (typeof validationRule === 'function') { return this.validateFunction(validationRule, data, namespace); } else { diff --git a/packages/core/http/core-http-router-server-internal/tsconfig.json b/packages/core/http/core-http-router-server-internal/tsconfig.json index f14271e7bb53a..5224fd5db16b5 100644 --- a/packages/core/http/core-http-router-server-internal/tsconfig.json +++ b/packages/core/http/core-http-router-server-internal/tsconfig.json @@ -12,6 +12,7 @@ "@kbn/std", "@kbn/utility-types", "@kbn/config-schema", + "@kbn/zod", "@kbn/es-errors", "@kbn/core-http-server", "@kbn/hapi-mocks", diff --git a/packages/core/http/core-http-server/src/router/route_validator.ts b/packages/core/http/core-http-server/src/router/route_validator.ts index f628a5ddce5df..c1e78d02c9c6a 100644 --- a/packages/core/http/core-http-server/src/router/route_validator.ts +++ b/packages/core/http/core-http-server/src/router/route_validator.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { ObjectType, SchemaTypeError, Type } from '@kbn/config-schema'; +import { type ObjectType, SchemaTypeError, type Type } from '@kbn/config-schema'; +import type { ZodEsque } from '@kbn/zod'; /** * Error to return when the validation is not successful. @@ -79,7 +80,11 @@ export type RouteValidationFunction = ( * * @public */ -export type RouteValidationSpec = ObjectType | Type | RouteValidationFunction; +export type RouteValidationSpec = + | ObjectType + | Type + | ZodEsque + | RouteValidationFunction; /** * The configuration object to the RouteValidator class. @@ -208,4 +213,4 @@ export type RouteValidator = * @return A @kbn/config-schema schema * @public */ -export type LazyValidator = () => Type; +export type LazyValidator = () => Type | ZodEsque; diff --git a/packages/core/http/core-http-server/tsconfig.json b/packages/core/http/core-http-server/tsconfig.json index 737c4e54906f9..64b2dacf2f292 100644 --- a/packages/core/http/core-http-server/tsconfig.json +++ b/packages/core/http/core-http-server/tsconfig.json @@ -14,7 +14,8 @@ "@kbn/config-schema", "@kbn/utility-types", "@kbn/core-base-common", - "@kbn/core-http-common" + "@kbn/core-http-common", + "@kbn/zod" ], "exclude": [ "target/**/*", diff --git a/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap b/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap index be1b698f5cd9a..9a50453d972bd 100644 --- a/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap +++ b/packages/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap @@ -3,7 +3,25 @@ exports[`generateOpenApiDocument @kbn/config-schema generates references in the expected format 1`] = ` Object { "components": Object { - "schemas": Object {}, + "schemas": Object { + "foo": Object { + "additionalProperties": false, + "properties": Object { + "name": Object { + "minLength": 1, + "type": "string", + }, + "other": Object { + "type": "string", + }, + }, + "required": Array [ + "name", + "other", + ], + "type": "object", + }, + }, "securitySchemes": Object { "apiKeyAuth": Object { "in": "header", @@ -55,21 +73,7 @@ Object { "content": Object { "application/json; Elastic-Api-Version=2023-10-31": Object { "schema": Object { - "additionalProperties": false, - "properties": Object { - "name": Object { - "minLength": 1, - "type": "string", - }, - "other": Object { - "type": "string", - }, - }, - "required": Array [ - "name", - "other", - ], - "type": "object", + "$ref": "#/components/schemas/foo", }, }, }, diff --git a/packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts b/packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts new file mode 100644 index 0000000000000..603517be4eda9 --- /dev/null +++ b/packages/kbn-router-to-openapispec/src/generate_oas.test.fixture.ts @@ -0,0 +1,460 @@ +/* + * 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 'zod'; +import { schema } from '@kbn/config-schema'; + +export const sharedOas = { + components: { + schemas: {}, + securitySchemes: { + apiKeyAuth: { + in: 'header', + name: 'Authorization', + type: 'apiKey', + }, + basicAuth: { + scheme: 'basic', + type: 'http', + }, + }, + }, + info: { + title: 'test', + version: '99.99.99', + }, + openapi: '3.0.0', + paths: { + '/bar': { + get: { + deprecated: true, + operationId: '/bar#0', + parameters: [ + { + description: 'The version of the API to use', + in: 'header', + name: 'elastic-api-version', + schema: { + default: 'oas-test-version-2', + enum: ['oas-test-version-1', 'oas-test-version-2'], + type: 'string', + }, + }, + ], + requestBody: { + content: { + 'application/json; Elastic-Api-Version=oas-test-version-1': { + schema: { + additionalProperties: false, + properties: { + booleanDefault: { + default: true, + description: 'defaults to to true', + type: 'boolean', + }, + ipType: { + format: 'ipv4', + type: 'string', + }, + literalType: { + enum: ['literallythis'], + type: 'string', + }, + maybeNumber: { + maximum: 1000, + minimum: 1, + type: 'number', + }, + record: { + additionalProperties: { + type: 'string', + }, + type: 'object', + }, + string: { + maxLength: 10, + minLength: 1, + type: 'string', + }, + union: { + anyOf: [ + { + description: 'Union string', + maxLength: 1, + type: 'string', + }, + { + description: 'Union number', + minimum: 0, + type: 'number', + }, + ], + }, + uri: { + default: 'prototest://something', + format: 'uri', + type: 'string', + }, + }, + required: ['string', 'ipType', 'literalType', 'record', 'union'], + type: 'object', + }, + }, + 'application/json; Elastic-Api-Version=oas-test-version-2': { + schema: { + additionalProperties: false, + properties: { + foo: { + type: 'string', + }, + }, + required: ['foo'], + type: 'object', + }, + }, + }, + }, + responses: { + '200': { + content: { + 'application/json; Elastic-Api-Version=oas-test-version-1': { + schema: { + additionalProperties: false, + description: 'fooResponse', + properties: { + fooResponseWithDescription: { + type: 'string', + }, + }, + required: ['fooResponseWithDescription'], + type: 'object', + }, + }, + 'application/octet-stream; Elastic-Api-Version=oas-test-version-2': { + schema: { + description: 'stream response', + type: 'object', + }, + }, + }, + }, + }, + summary: 'versioned route', + tags: ['versioned'], + }, + }, + '/foo/{id}/{path*}': { + get: { + description: 'route description', + operationId: '/foo/{id}/{path*}#0', + parameters: [ + { + description: 'The version of the API to use', + in: 'header', + name: 'elastic-api-version', + schema: { + default: '2023-10-31', + enum: ['2023-10-31'], + type: 'string', + }, + }, + { + description: 'id', + in: 'path', + name: 'id', + required: true, + schema: { + maxLength: 36, + type: 'string', + }, + }, + { + description: 'path', + in: 'path', + name: 'path', + required: true, + schema: { + maxLength: 36, + type: 'string', + }, + }, + { + description: 'page', + in: 'query', + name: 'page', + required: false, + schema: { + default: 1, + maximum: 999, + minimum: 1, + type: 'number', + }, + }, + ], + requestBody: { + content: { + 'application/json; Elastic-Api-Version=2023-10-31': { + schema: { + additionalProperties: false, + properties: { + booleanDefault: { + default: true, + description: 'defaults to to true', + type: 'boolean', + }, + ipType: { + format: 'ipv4', + type: 'string', + }, + literalType: { + enum: ['literallythis'], + type: 'string', + }, + maybeNumber: { + maximum: 1000, + minimum: 1, + type: 'number', + }, + record: { + additionalProperties: { + type: 'string', + }, + type: 'object', + }, + string: { + maxLength: 10, + minLength: 1, + type: 'string', + }, + union: { + anyOf: [ + { + description: 'Union string', + maxLength: 1, + type: 'string', + }, + { + description: 'Union number', + minimum: 0, + type: 'number', + }, + ], + }, + uri: { + default: 'prototest://something', + format: 'uri', + type: 'string', + }, + }, + required: ['string', 'ipType', 'literalType', 'record', 'union'], + type: 'object', + }, + }, + }, + }, + responses: { + '200': { + content: { + 'application/json; Elastic-Api-Version=2023-10-31': { + schema: { + maxLength: 10, + minLength: 1, + type: 'string', + }, + }, + }, + }, + }, + summary: 'route summary', + tags: ['bar'], + }, + post: { + description: 'route description', + operationId: '/foo/{id}/{path*}#1', + parameters: [ + { + description: 'The version of the API to use', + in: 'header', + name: 'elastic-api-version', + schema: { + default: '2023-10-31', + enum: ['2023-10-31'], + type: 'string', + }, + }, + { + description: 'id', + in: 'path', + name: 'id', + required: true, + schema: { + maxLength: 36, + type: 'string', + }, + }, + { + description: 'path', + in: 'path', + name: 'path', + required: true, + schema: { + maxLength: 36, + type: 'string', + }, + }, + { + description: 'page', + in: 'query', + name: 'page', + required: false, + schema: { + default: 1, + maximum: 999, + minimum: 1, + type: 'number', + }, + }, + ], + requestBody: { + content: { + 'application/json; Elastic-Api-Version=2023-10-31': { + schema: { + additionalProperties: false, + properties: { + booleanDefault: { + default: true, + description: 'defaults to to true', + type: 'boolean', + }, + ipType: { + format: 'ipv4', + type: 'string', + }, + literalType: { + enum: ['literallythis'], + type: 'string', + }, + maybeNumber: { + maximum: 1000, + minimum: 1, + type: 'number', + }, + record: { + additionalProperties: { + type: 'string', + }, + type: 'object', + }, + string: { + maxLength: 10, + minLength: 1, + type: 'string', + }, + union: { + anyOf: [ + { + description: 'Union string', + maxLength: 1, + type: 'string', + }, + { + description: 'Union number', + minimum: 0, + type: 'number', + }, + ], + }, + uri: { + default: 'prototest://something', + format: 'uri', + type: 'string', + }, + }, + required: ['string', 'ipType', 'literalType', 'record', 'union'], + type: 'object', + }, + }, + }, + }, + responses: { + '200': { + content: { + 'application/json; Elastic-Api-Version=2023-10-31': { + schema: { + maxLength: 10, + minLength: 1, + type: 'string', + }, + }, + }, + }, + }, + summary: 'route summary', + tags: ['bar'], + }, + }, + }, + security: [ + { + basicAuth: [], + }, + ], + servers: [ + { + url: 'https://test.oas', + }, + ], + tags: [ + { + name: 'bar', + }, + { + name: 'versioned', + }, + ], +}; + +export function createSharedConfigSchema() { + return schema.object({ + string: schema.string({ maxLength: 10, minLength: 1 }), + maybeNumber: schema.maybe(schema.number({ max: 1000, min: 1 })), + booleanDefault: schema.boolean({ + defaultValue: true, + meta: { + description: 'defaults to to true', + }, + }), + ipType: schema.ip({ versions: ['ipv4'] }), + literalType: schema.literal('literallythis'), + record: schema.recordOf(schema.string(), schema.string()), + union: schema.oneOf([ + schema.string({ maxLength: 1, meta: { description: 'Union string' } }), + schema.number({ min: 0, meta: { description: 'Union number' } }), + ]), + uri: schema.uri({ + scheme: ['prototest'], + defaultValue: () => 'prototest://something', + }), + }); +} + +export function createSharedZodSchema() { + return z.object({ + string: z.string().max(10).min(1), + maybeNumber: z.number().max(1000).min(1).optional(), + booleanDefault: z.boolean({ description: 'defaults to to true' }).default(true), + ipType: z.string().ip({ version: 'v4' }), + literalType: z.literal('literallythis'), + record: z.record(z.string(), z.string()), + union: z.union([ + z.string({ description: 'Union string' }).max(1), + z.number({ description: 'Union number' }).min(0), + ]), + uri: z.string().url().default('prototest://something'), + }); +} diff --git a/packages/kbn-router-to-openapispec/src/generate_oas.test.ts b/packages/kbn-router-to-openapispec/src/generate_oas.test.ts index f37ba1dc87ef4..29e09125b0a22 100644 --- a/packages/kbn-router-to-openapispec/src/generate_oas.test.ts +++ b/packages/kbn-router-to-openapispec/src/generate_oas.test.ts @@ -6,9 +6,14 @@ * Side Public License, v 1. */ -import { generateOpenApiDocument } from './generate_oas'; import { schema, Type } from '@kbn/config-schema'; +import { generateOpenApiDocument } from './generate_oas'; import { createTestRouters, createRouter, createVersionedRouter } from './generate_oas.test.util'; +import { + sharedOas, + createSharedZodSchema, + createSharedConfigSchema, +} from './generate_oas.test.fixture'; interface RecursiveType { name: string; @@ -17,6 +22,27 @@ interface RecursiveType { describe('generateOpenApiDocument', () => { describe('@kbn/config-schema', () => { + it('generates the expected OpenAPI document for the shared schema', () => { + const [routers, versionedRouters] = createTestRouters({ + routers: { testRouter: { routes: [{ method: 'get' }, { method: 'post' }] } }, + versionedRouters: { testVersionedRouter: { routes: [{}] } }, + bodySchema: createSharedConfigSchema(), + }); + expect( + generateOpenApiDocument( + { + routers, + versionedRouters, + }, + { + title: 'test', + baseUrl: 'https://test.oas', + version: '99.99.99', + } + ) + ).toEqual(sharedOas); + }); + it('generates the expected OpenAPI document', () => { const [routers, versionedRouters] = createTestRouters({ routers: { testRouter: { routes: [{ method: 'get' }, { method: 'post' }] } }, @@ -40,7 +66,10 @@ describe('generateOpenApiDocument', () => { it('generates references in the expected format', () => { const sharedIdSchema = schema.string({ minLength: 1, meta: { description: 'test' } }); const sharedNameSchema = schema.string({ minLength: 1 }); - const otherSchema = schema.object({ name: sharedNameSchema, other: schema.string() }); + const otherSchema = schema.object( + { name: sharedNameSchema, other: schema.string() }, + { meta: { id: 'foo' } } + ); expect( generateOpenApiDocument( { @@ -126,6 +155,29 @@ describe('generateOpenApiDocument', () => { }); }); + describe('Zod', () => { + it('generates the expected OpenAPI document for the shared schema', () => { + const [routers, versionedRouters] = createTestRouters({ + routers: { testRouter: { routes: [{ method: 'get' }, { method: 'post' }] } }, + versionedRouters: { testVersionedRouter: { routes: [{}] } }, + bodySchema: createSharedZodSchema(), + }); + expect( + generateOpenApiDocument( + { + routers, + versionedRouters, + }, + { + title: 'test', + baseUrl: 'https://test.oas', + version: '99.99.99', + } + ) + ).toMatchObject(sharedOas); + }); + }); + describe('unknown schema/validation', () => { it('produces the expected output', () => { expect( diff --git a/packages/kbn-router-to-openapispec/src/generate_oas.test.util.ts b/packages/kbn-router-to-openapispec/src/generate_oas.test.util.ts index 5b28a7b9296c5..aeaf3aeb08a4b 100644 --- a/packages/kbn-router-to-openapispec/src/generate_oas.test.util.ts +++ b/packages/kbn-router-to-openapispec/src/generate_oas.test.util.ts @@ -5,13 +5,14 @@ * 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'; +import type { ZodType } from '@kbn/zod'; +import { schema, Type } from '@kbn/config-schema'; import type { CoreVersionedRouter, Router } from '@kbn/core-http-router-server-internal'; import { createLargeSchema } from './oas_converter/kbn_config_schema/lib.test.util'; type RoutesMeta = ReturnType[number]; type VersionedRoutesMeta = ReturnType[number]; +type RuntimeSchema = Type | ZodType; export const createRouter = (args: { routes: RoutesMeta[] }) => { return { @@ -24,7 +25,7 @@ export const createVersionedRouter = (args: { routes: VersionedRoutesMeta[] }) = } as unknown as CoreVersionedRouter; }; -export const getRouterDefaults = () => ({ +export const getRouterDefaults = (bodySchema?: RuntimeSchema) => ({ isVersioned: false, path: '/foo/{id}/{path*}', method: 'get', @@ -42,7 +43,7 @@ export const getRouterDefaults = () => ({ query: schema.object({ page: schema.number({ max: 999, min: 1, defaultValue: 1, meta: { description: 'page' } }), }), - body: createLargeSchema(), + body: bodySchema ?? createLargeSchema(), }, response: { 200: { @@ -54,7 +55,7 @@ export const getRouterDefaults = () => ({ handler: jest.fn(), }); -export const getVersionedRouterDefaults = () => ({ +export const getVersionedRouterDefaults = (bodySchema?: RuntimeSchema) => ({ method: 'get', path: '/bar', options: { @@ -71,12 +72,14 @@ export const getVersionedRouterDefaults = () => ({ options: { validate: { request: { - body: schema.object({ - foo: schema.string(), - deprecatedFoo: schema.maybe( - schema.string({ meta: { description: 'deprecated foo', deprecated: true } }) - ), - }), + body: + bodySchema ?? + schema.object({ + foo: schema.string(), + deprecatedFoo: schema.maybe( + schema.string({ meta: { description: 'deprecated foo', deprecated: true } }) + ), + }), }, response: { [200]: { @@ -115,10 +118,11 @@ interface CreatTestRouterArgs { versionedRouters?: { [routerId: string]: { routes: Array> }; }; + bodySchema?: RuntimeSchema; } export const createTestRouters = ( - { routers = {}, versionedRouters = {} }: CreatTestRouterArgs = { + { routers = {}, versionedRouters = {}, bodySchema }: CreatTestRouterArgs = { routers: { testRouter: { routes: [{}] } }, versionedRouters: { testVersionedRouter: { routes: [{}] } }, } @@ -126,13 +130,15 @@ export const createTestRouters = ( return [ [ ...Object.values(routers).map((rs) => - createRouter({ routes: rs.routes.map((r) => Object.assign(getRouterDefaults(), r)) }) + createRouter({ + routes: rs.routes.map((r) => Object.assign(getRouterDefaults(bodySchema), r)), + }) ), ], [ ...Object.values(versionedRouters).map((rs) => createVersionedRouter({ - routes: rs.routes.map((r) => Object.assign(getVersionedRouterDefaults(), r)), + routes: rs.routes.map((r) => Object.assign(getVersionedRouterDefaults(bodySchema), r)), }) ), ], diff --git a/packages/kbn-router-to-openapispec/src/oas_converter/index.ts b/packages/kbn-router-to-openapispec/src/oas_converter/index.ts index 92bdd7a795a03..c7f2e0e41691e 100644 --- a/packages/kbn-router-to-openapispec/src/oas_converter/index.ts +++ b/packages/kbn-router-to-openapispec/src/oas_converter/index.ts @@ -10,10 +10,15 @@ import type { OpenAPIV3 } from 'openapi-types'; import { KnownParameters, OpenAPIConverter } from '../type'; import { kbnConfigSchemaConverter } from './kbn_config_schema'; +import { zodConverter } from './zod'; import { catchAllConverter } from './catch_all'; export class OasConverter { - readonly #converters: OpenAPIConverter[] = [kbnConfigSchemaConverter, catchAllConverter]; + readonly #converters: OpenAPIConverter[] = [ + kbnConfigSchemaConverter, + zodConverter, + catchAllConverter, + ]; readonly #sharedSchemas = new Map(); #getConverter(schema: unknown) { diff --git a/packages/kbn-router-to-openapispec/src/oas_converter/zod/index.ts b/packages/kbn-router-to-openapispec/src/oas_converter/zod/index.ts new file mode 100644 index 0000000000000..48d2cb610d3f0 --- /dev/null +++ b/packages/kbn-router-to-openapispec/src/oas_converter/zod/index.ts @@ -0,0 +1,17 @@ +/* + * 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 type { OpenAPIConverter } from '../../type'; +import { is, convert, convertQuery, convertPathParameters } from './lib'; + +export const zodConverter: OpenAPIConverter = { + convertPathParameters, + convertQuery, + convert, + is, +}; diff --git a/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.ts b/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.ts new file mode 100644 index 0000000000000..bb1b31d75756e --- /dev/null +++ b/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.ts @@ -0,0 +1,145 @@ +/* + * 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 { convert, convertPathParameters, convertQuery } from './lib'; + +import { createLargeSchema } from './lib.test.util'; + +describe('zod', () => { + describe('convert', () => { + test('base case', () => { + expect(convert(createLargeSchema())).toEqual({ + schema: { + additionalProperties: false, + properties: { + any: { + description: 'any type', + }, + booleanDefault: { + default: true, + description: 'defaults to to true', + type: 'boolean', + }, + ipType: { + format: 'ipv4', + type: 'string', + }, + literalType: { + enum: ['literallythis'], + type: 'string', + }, + map: { + items: { + items: [ + { + type: 'string', + }, + { + type: 'string', + }, + ], + maxItems: 2, + minItems: 2, + type: 'array', + }, + maxItems: 125, + type: 'array', + }, + maybeNumber: { + maximum: 1000, + minimum: 1, + type: 'number', + }, + neverType: { + not: {}, + }, + record: { + additionalProperties: { + type: 'string', + }, + type: 'object', + }, + string: { + maxLength: 10, + minLength: 1, + type: 'string', + }, + union: { + anyOf: [ + { + description: 'Union string', + maxLength: 1, + type: 'string', + }, + { + description: 'Union number', + minimum: 0, + type: 'number', + }, + ], + }, + uri: { + default: 'prototest://something', + format: 'uri', + type: 'string', + }, + }, + required: ['string', 'ipType', 'literalType', 'neverType', 'map', 'record', 'union'], + type: 'object', + }, + shared: {}, + }); + }); + }); + + describe('convertPathParameters', () => { + test('base conversion', () => { + expect( + convertPathParameters(z.object({ a: z.string() }), { a: { optional: false } }) + ).toEqual({ + params: [ + { + in: 'path', + name: 'a', + required: true, + schema: { + type: 'string', + }, + }, + ], + shared: {}, + }); + }); + test('throws if known parameters not found', () => { + expect(() => + convertPathParameters(z.object({ b: z.string() }), { a: { optional: false } }) + ).toThrow( + 'Path expects key "a" from schema but it was not found. Existing schema keys are: b' + ); + }); + }); + + describe('convertQuery', () => { + test('base conversion', () => { + expect(convertQuery(z.object({ a: z.string() }))).toEqual({ + query: [ + { + in: 'query', + name: 'a', + required: true, + schema: { + type: 'string', + }, + }, + ], + shared: {}, + }); + }); + }); +}); diff --git a/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.util.ts b/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.util.ts new file mode 100644 index 0000000000000..6d069dc4a3600 --- /dev/null +++ b/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.test.util.ts @@ -0,0 +1,28 @@ +/* + * 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 function createLargeSchema() { + return z.object({ + string: z.string().max(10).min(1), + maybeNumber: z.number().max(1000).min(1).optional(), + booleanDefault: z.boolean({ description: 'defaults to to true' }).default(true), + ipType: z.string().ip({ version: 'v4' }), + literalType: z.literal('literallythis'), + neverType: z.never(), + map: z.map(z.string(), z.string()), + record: z.record(z.string(), z.string()), + union: z.union([ + z.string({ description: 'Union string' }).max(1), + z.number({ description: 'Union number' }).min(0), + ]), + uri: z.string().url().default('prototest://something'), + any: z.any({ description: 'any type' }), + }); +} diff --git a/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.ts b/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.ts new file mode 100644 index 0000000000000..abaa1357ef2cc --- /dev/null +++ b/packages/kbn-router-to-openapispec/src/oas_converter/zod/lib.ts @@ -0,0 +1,215 @@ +/* + * 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, isZod } from '@kbn/zod'; +import type { OpenAPIV3 } from 'openapi-types'; +// eslint-disable-next-line import/no-extraneous-dependencies +import zodToJsonSchema from 'zod-to-json-schema'; +import { KnownParameters } from '../../type'; +import { validatePathParameters } from '../common'; + +// Adapted from from https://github.com/jlalmes/trpc-openapi/blob/aea45441af785518df35c2bc173ae2ea6271e489/src/utils/zod.ts#L1 + +const createError = (message: string): Error => { + return new Error(`[Zod converter] ${message}`); +}; + +function assertInstanceOfZodType(schema: unknown): asserts schema is z.ZodTypeAny { + if (!isZod(schema)) { + throw createError('Expected schema to be an instance of Zod'); + } +} + +const instanceofZodTypeKind = ( + type: z.ZodTypeAny, + zodTypeKind: Z +): type is InstanceType => { + return type?._def?.typeName === zodTypeKind; +}; + +const instanceofZodTypeObject = (type: z.ZodTypeAny): type is z.ZodObject => { + return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodObject); +}; + +type ZodTypeLikeVoid = z.ZodVoid | z.ZodUndefined | z.ZodNever; + +const instanceofZodTypeLikeVoid = (type: z.ZodTypeAny): type is ZodTypeLikeVoid => { + return ( + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodVoid) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodUndefined) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNever) + ); +}; + +const unwrapZodType = (type: z.ZodTypeAny, unwrapPreprocess: boolean): z.ZodTypeAny => { + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodOptional)) { + return unwrapZodType(type.unwrap(), unwrapPreprocess); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodDefault)) { + return unwrapZodType(type.removeDefault(), unwrapPreprocess); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodLazy)) { + return unwrapZodType(type._def.getter(), unwrapPreprocess); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEffects)) { + if (type._def.effect.type === 'refinement') { + return unwrapZodType(type._def.schema, unwrapPreprocess); + } + if (type._def.effect.type === 'transform') { + return unwrapZodType(type._def.schema, unwrapPreprocess); + } + if (unwrapPreprocess && type._def.effect.type === 'preprocess') { + return unwrapZodType(type._def.schema, unwrapPreprocess); + } + } + return type; +}; + +interface NativeEnumType { + [k: string]: string | number; + [nu: number]: string; +} + +type ZodTypeLikeString = + | z.ZodString + | z.ZodOptional + | z.ZodDefault + | z.ZodEffects + | z.ZodUnion<[ZodTypeLikeString, ...ZodTypeLikeString[]]> + | z.ZodIntersection + | z.ZodLazy + | z.ZodLiteral + | z.ZodEnum<[string, ...string[]]> + | z.ZodNativeEnum; + +const instanceofZodTypeLikeString = (_type: z.ZodTypeAny): _type is ZodTypeLikeString => { + const type = unwrapZodType(_type, false); + + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEffects)) { + if (type._def.effect.type === 'preprocess') { + return true; + } + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodUnion)) { + return !type._def.options.some((option) => !instanceofZodTypeLikeString(option)); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodArray)) { + return instanceofZodTypeLikeString(type._def.type); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodIntersection)) { + return ( + instanceofZodTypeLikeString(type._def.left) && instanceofZodTypeLikeString(type._def.right) + ); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodLiteral)) { + return typeof type._def.value === 'string'; + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEnum)) { + return true; + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNativeEnum)) { + return !Object.values(type._def.values).some((value) => typeof value === 'number'); + } + return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodString); +}; + +const zodSupportsCoerce = 'coerce' in z; + +type ZodTypeCoercible = z.ZodNumber | z.ZodBoolean | z.ZodBigInt | z.ZodDate; + +const instanceofZodTypeCoercible = (_type: z.ZodTypeAny): _type is ZodTypeCoercible => { + const type = unwrapZodType(_type, false); + return ( + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNumber) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBoolean) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBigInt) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodDate) + ); +}; + +const convertObjectMembersToParameterObjects = ( + shape: z.ZodRawShape, + isRequired: boolean, + isPathParameter = false, + knownParameters: KnownParameters = {} +): OpenAPIV3.ParameterObject[] => { + return Object.entries(shape).map(([shapeKey, subShape]) => { + const isSubShapeRequired = !subShape.isOptional(); + + if (!instanceofZodTypeLikeString(subShape)) { + if (zodSupportsCoerce) { + if (!instanceofZodTypeCoercible(subShape)) { + throw createError( + `Input parser key: "${shapeKey}" must be ZodString, ZodNumber, ZodBoolean, ZodBigInt or ZodDate` + ); + } + } else { + throw createError(`Input parser key: "${shapeKey}" must be ZodString`); + } + } + + const { + schema: { description, ...openApiSchemaObject }, + } = convert(subShape); + + return { + name: shapeKey, + in: isPathParameter ? 'path' : 'query', + required: isPathParameter ? !knownParameters[shapeKey]?.optional : isSubShapeRequired, + schema: openApiSchemaObject, + description, + }; + }); +}; + +export const convertQuery = (schema: unknown) => { + assertInstanceOfZodType(schema); + const unwrappedSchema = unwrapZodType(schema, true); + if (!instanceofZodTypeObject(unwrappedSchema)) { + throw createError('Query schema must be an _object_ schema validator!'); + } + const shape = unwrappedSchema.shape; + const isRequired = !schema.isOptional(); + return { + query: convertObjectMembersToParameterObjects(shape, isRequired), + shared: {}, + }; +}; + +export const convertPathParameters = (schema: unknown, knownParameters: KnownParameters) => { + assertInstanceOfZodType(schema); + const unwrappedSchema = unwrapZodType(schema, true); + const paramKeys = Object.keys(knownParameters); + const paramsCount = paramKeys.length; + if (paramsCount === 0 && instanceofZodTypeLikeVoid(unwrappedSchema)) { + return { params: [], shared: {} }; + } + if (!instanceofZodTypeObject(unwrappedSchema)) { + throw createError('Parameters schema must be an _object_ schema validator!'); + } + const shape = unwrappedSchema.shape; + const schemaKeys = Object.keys(shape); + validatePathParameters(paramKeys, schemaKeys); + const isRequired = !schema.isOptional(); + return { + params: convertObjectMembersToParameterObjects(shape, isRequired, true), + shared: {}, + }; +}; + +export const convert = (schema: z.ZodTypeAny) => { + return { + shared: {}, + schema: zodToJsonSchema(schema, { + target: 'openApi3', + $refStrategy: 'none', + }) as OpenAPIV3.SchemaObject, + }; +}; + +export const is = isZod; diff --git a/packages/kbn-router-to-openapispec/tsconfig.json b/packages/kbn-router-to-openapispec/tsconfig.json index b157378320c79..d82ca0bf48910 100644 --- a/packages/kbn-router-to-openapispec/tsconfig.json +++ b/packages/kbn-router-to-openapispec/tsconfig.json @@ -15,7 +15,8 @@ ], "kbn_references": [ "@kbn/core-http-router-server-internal", - "@kbn/config-schema", "@kbn/core-http-server", + "@kbn/config-schema", + "@kbn/zod" ] } diff --git a/packages/kbn-zod/README.md b/packages/kbn-zod/README.md new file mode 100644 index 0000000000000..1c224fac857eb --- /dev/null +++ b/packages/kbn-zod/README.md @@ -0,0 +1,4 @@ +# `@kbn/zod` + +Kibana's `Zod` library. Exposes the `Zod` API with some Kibana-specific +improvements. \ No newline at end of file diff --git a/packages/kbn-zod/index.ts b/packages/kbn-zod/index.ts new file mode 100644 index 0000000000000..1a1778c37fa0b --- /dev/null +++ b/packages/kbn-zod/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export * from 'zod'; +export { isZod } from './util'; +export type { ZodEsque } from './types'; diff --git a/packages/kbn-zod/jest.config.js b/packages/kbn-zod/jest.config.js new file mode 100644 index 0000000000000..9cfaff7b171b1 --- /dev/null +++ b/packages/kbn-zod/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-zod'], +}; diff --git a/packages/kbn-zod/kibana.jsonc b/packages/kbn-zod/kibana.jsonc new file mode 100644 index 0000000000000..1e85fceb5528c --- /dev/null +++ b/packages/kbn-zod/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/zod", + "owner": "@elastic/kibana-core" +} diff --git a/packages/kbn-zod/package.json b/packages/kbn-zod/package.json new file mode 100644 index 0000000000000..3b5234d793e17 --- /dev/null +++ b/packages/kbn-zod/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/zod", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-zod/tsconfig.json b/packages/kbn-zod/tsconfig.json new file mode 100644 index 0000000000000..2f9ddddbeea23 --- /dev/null +++ b/packages/kbn-zod/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [] +} diff --git a/packages/kbn-zod/types.test.ts b/packages/kbn-zod/types.test.ts new file mode 100644 index 0000000000000..10c33853b77cd --- /dev/null +++ b/packages/kbn-zod/types.test.ts @@ -0,0 +1,18 @@ +/* + * 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 { expectAssignable } from 'tsd'; +import { ZodEsque } from './types'; +import { z } from '.'; + +describe('ZodEsque', () => { + it('correctly extracts generic from Zod values', () => { + const s = z.object({ n: z.number() }); + expectAssignable>(s); + }); +}); diff --git a/packages/kbn-zod/types.ts b/packages/kbn-zod/types.ts new file mode 100644 index 0000000000000..c330081aa596c --- /dev/null +++ b/packages/kbn-zod/types.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export interface ZodEsque { + _output: V; +} diff --git a/packages/kbn-zod/util.test.ts b/packages/kbn-zod/util.test.ts new file mode 100644 index 0000000000000..b434424c6d8fa --- /dev/null +++ b/packages/kbn-zod/util.test.ts @@ -0,0 +1,32 @@ +/* + * 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 '.'; +import { isZod } from './util'; + +describe('isZod', () => { + test.each([ + [{}, false], + [1, false], + [undefined, false], + [null, false], + [z.any(), true], + [z.object({}).default({}), true], + [z.never(), true], + [z.string(), true], + [z.number(), true], + [z.map(z.string(), z.number()), true], + [z.record(z.string(), z.number()), true], + [z.array(z.string()), true], + [z.object({}), true], + [z.union([z.string(), z.number()]), true], + [z.literal('yes').optional(), true], + ])('"is" correctly identifies %#', (value, result) => { + expect(isZod(value)).toBe(result); + }); +}); diff --git a/packages/kbn-zod/util.ts b/packages/kbn-zod/util.ts new file mode 100644 index 0000000000000..bf61592109547 --- /dev/null +++ b/packages/kbn-zod/util.ts @@ -0,0 +1,13 @@ +/* + * 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 '.'; + +export function isZod(value: unknown): value is z.ZodType { + return value instanceof z.Schema; +} diff --git a/src/core/server/integration_tests/http/router.test.ts b/src/core/server/integration_tests/http/router.test.ts index e7640e39c3263..643c44e491aac 100644 --- a/src/core/server/integration_tests/http/router.test.ts +++ b/src/core/server/integration_tests/http/router.test.ts @@ -12,6 +12,7 @@ import { Stream } from 'stream'; import Boom from '@hapi/boom'; import supertest from 'supertest'; import { schema } from '@kbn/config-schema'; +import { z } from '@kbn/zod'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; @@ -670,55 +671,118 @@ describe('Handler', () => { `); }); - it('returns 400 Bad request if request validation failed', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + describe('returns 400 Bad request if request validation failed', () => { + it('@kbn/config-schema', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); - router.get( - { - path: '/', - validate: { - query: schema.object({ - page: schema.number(), - }), + router.get( + { + path: '/', + validate: { + query: schema.object({ + page: schema.number(), + }), + }, }, - }, - (context, req, res) => res.noContent() - ); - await server.start(); + (context, req, res) => res.noContent() + ); + await server.start(); - const result = await supertest(innerServer.listener) - .get('/') - .query({ page: 'one' }) - .expect(400); + const result = await supertest(innerServer.listener) + .get('/') + .query({ page: 'one' }) + .expect(400); - expect(result.body).toEqual({ - error: 'Bad Request', - message: '[request query.page]: expected value of type [number] but got [string]', - statusCode: 400, - }); + expect(result.body).toEqual({ + error: 'Bad Request', + message: '[request query.page]: expected value of type [number] but got [string]', + statusCode: 400, + }); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "400 Bad Request", + Object { + "error": Object { + "message": "[request query.page]: expected value of type [number] but got [string]", + }, + "http": Object { + "request": Object { + "method": "get", + "path": "/", + }, + "response": Object { + "status_code": 400, + }, + }, + }, + ], + ] + `); + }); + + it('@kbn/zod', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + { + path: '/', + validate: { + query: z.object({ + page: z.number(), + }), + }, + }, + (context, req, res) => res.noContent() + ); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .query({ page: 'one' }) + .expect(400); + + expect(result.body).toEqual({ + error: 'Bad Request', + message: expect.stringMatching(/Expected number, received string/), + statusCode: 400, + }); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ - "400 Bad Request", - Object { - "error": Object { - "message": "[request query.page]: expected value of type [number] but got [string]", - }, - "http": Object { - "request": Object { - "method": "get", - "path": "/", + Array [ + "400 Bad Request", + Object { + "error": Object { + "message": "[ + { + \\"code\\": \\"invalid_type\\", + \\"expected\\": \\"number\\", + \\"received\\": \\"string\\", + \\"path\\": [ + \\"page\\" + ], + \\"message\\": \\"Expected number, received string\\" + } + ]", }, - "response": Object { - "status_code": 400, + "http": Object { + "request": Object { + "method": "get", + "path": "/", + }, + "response": Object { + "status_code": 400, + }, }, }, - }, - ], - ] - `); + ], + ] + `); + }); }); it('accept to receive an array payload', async () => { diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json index 017ffa69c3107..4b110a758543a 100644 --- a/src/core/tsconfig.json +++ b/src/core/tsconfig.json @@ -167,6 +167,7 @@ "@kbn/core-user-profile-server", "@kbn/core-user-profile-server-mocks", "@kbn/core-user-profile-browser", + "@kbn/zod", ], "exclude": [ "target/**/*", diff --git a/tsconfig.base.json b/tsconfig.base.json index e58e698258657..ab36ec64b4962 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1882,6 +1882,8 @@ "@kbn/xstate-utils/*": ["packages/kbn-xstate-utils/*"], "@kbn/yarn-lock-validator": ["packages/kbn-yarn-lock-validator"], "@kbn/yarn-lock-validator/*": ["packages/kbn-yarn-lock-validator/*"], + "@kbn/zod": ["packages/kbn-zod"], + "@kbn/zod/*": ["packages/kbn-zod/*"], "@kbn/zod-helpers": ["packages/kbn-zod-helpers"], "@kbn/zod-helpers/*": ["packages/kbn-zod-helpers/*"], // END AUTOMATED PACKAGE LISTING diff --git a/yarn.lock b/yarn.lock index 6a28104d98d00..6aa64691a85e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7018,6 +7018,10 @@ version "0.0.0" uid "" +"@kbn/zod@link:packages/kbn-zod": + version "0.0.0" + uid "" + "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -32637,10 +32641,10 @@ zip-stream@^4.1.0: compress-commons "^4.1.0" readable-stream "^3.6.0" -zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.22.5: - version "3.22.5" - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.22.5.tgz#3646e81cfc318dbad2a22519e5ce661615418673" - integrity sha512-+akaPo6a0zpVCCseDed504KBJUQpEW5QZw7RMneNmKw+fGaML1Z9tUNLnHHAC8x6dzVRO1eB2oEMyZRnuBZg7Q== +zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.22.5, zod-to-json-schema@^3.23.0: + version "3.23.0" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.23.0.tgz#4fc60e88d3c709eedbfaae3f92f8a7bf786469f2" + integrity sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag== zod@3.22.4, zod@^3.22.3, zod@^3.22.4: version "3.22.4"