diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 460d8e5094af1..542c81dd107f5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -443,6 +443,7 @@ x-pack/examples/gen_ai_streaming_response_example @elastic/response-ops packages/kbn-generate @elastic/kibana-operations packages/kbn-generate-console-definitions @elastic/platform-deployment-management packages/kbn-generate-csv @elastic/appex-sharedux +packages/kbn-generate-oas @elastic/kibana-core packages/kbn-get-repo-files @elastic/kibana-operations x-pack/plugins/global_search_bar @elastic/appex-sharedux x-pack/plugins/global_search @elastic/appex-sharedux @@ -889,6 +890,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 161bf56a9e883..68526464f06fa 100644 --- a/package.json +++ b/package.json @@ -476,6 +476,7 @@ "@kbn/gen-ai-streaming-response-example-plugin": "link:x-pack/examples/gen_ai_streaming_response_example", "@kbn/generate-console-definitions": "link:packages/kbn-generate-console-definitions", "@kbn/generate-csv": "link:packages/kbn-generate-csv", + "@kbn/generate-oas": "link:packages/kbn-generate-oas", "@kbn/global-search-bar-plugin": "link:x-pack/plugins/global_search_bar", "@kbn/global-search-plugin": "link:x-pack/plugins/global_search", "@kbn/global-search-providers-plugin": "link:x-pack/plugins/global_search_providers", @@ -874,6 +875,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", "@loaders.gl/core": "^3.4.7", "@loaders.gl/json": "^3.4.7", @@ -999,6 +1001,7 @@ "ipaddr.js": "2.0.0", "isbinaryfile": "4.0.2", "joi": "^17.7.1", + "joi-to-json": "^4.2.0", "jquery": "^3.5.0", "js-levenshtein": "^1.1.6", "js-search": "^1.4.3", @@ -1147,7 +1150,8 @@ "xterm": "^5.1.0", "yauzl": "^2.10.0", "yazl": "^2.5.1", - "zod": "^3.22.3" + "zod": "^3.22.3", + "zod-to-json-schema": "^3.22.3" }, "devDependencies": { "@apidevtools/swagger-parser": "^10.0.3", diff --git a/packages/core/http/core-http-router-server-internal/index.ts b/packages/core/http/core-http-router-server-internal/index.ts index 1081af2c4aa70..8b241472801c6 100644 --- a/packages/core/http/core-http-router-server-internal/index.ts +++ b/packages/core/http/core-http-router-server-internal/index.ts @@ -7,6 +7,8 @@ */ export { filterHeaders } from './src/headers'; +export { versionHandlerResolvers } from './src/versioned_router'; +export { CoreVersionedRouter } from './src/versioned_router'; export { Router, type RouterOptions } from './src/router'; export type { HandlerResolutionStrategy } from './src/versioned_router'; export { isKibanaRequest, isRealRequest, ensureRawRequest, CoreKibanaRequest } from './src/request'; 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 b4089008593a1..2558ea04b6083 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 @@ -24,6 +24,7 @@ import type { IRouter, RequestHandler, VersionedRouter, + RouteRegistrar, } from '@kbn/core-http-server'; import { validBodyOutput } from '@kbn/core-http-server'; import { RouteValidator } from './validator'; @@ -32,6 +33,7 @@ import { CoreKibanaRequest } from './request'; import { kibanaResponseFactory } from './response'; import { HapiResponseAdapter } from './response_adapter'; import { wrapErrors } from './error_wrapper'; +import { Method } from './versioned_router/types'; export type ContextEnhancer< P, @@ -133,18 +135,35 @@ export interface RouterOptions { }; } +/** @internal */ +interface InternalRegistrarOptions { + isVersioned: boolean; +} + +/** @internal */ +type InternalRegistrar = ( + route: RouteConfig, + handler: RequestHandler, + internalOpts?: InternalRegistrarOptions +) => ReturnType>; + +/** @internal */ +interface InternalRouterRoute extends RouterRoute { + readonly isVersioned: boolean; +} + /** * @internal */ export class Router implements IRouter { - public routes: Array> = []; - public get: IRouter['get']; - public post: IRouter['post']; - public delete: IRouter['delete']; - public put: IRouter['put']; - public patch: IRouter['patch']; + public routes: Array> = []; + public get: InternalRegistrar<'get', Context>; + public post: InternalRegistrar<'post', Context>; + public delete: InternalRegistrar<'delete', Context>; + public put: InternalRegistrar<'put', Context>; + public patch: InternalRegistrar<'patch', Context>; constructor( public readonly routerPath: string, @@ -156,7 +175,8 @@ export class Router(method: Method) => ( route: RouteConfig, - handler: RequestHandler + handler: RequestHandler, + internalOptions: { isVersioned: boolean } = { isVersioned: false } ) => { const routeSchemas = routeSchemasFromRouteConfig(route, method); @@ -171,6 +191,8 @@ export class Router !route.isVersioned); + } return [...this.routes]; } diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts index dbfb59d459cc7..0978ea2e2c7e8 100644 --- a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts +++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_route.ts @@ -23,7 +23,7 @@ import type { RouteConfigOptions, } from '@kbn/core-http-server'; import type { Mutable } from 'utility-types'; -import type { Method } from './types'; +import type { Method, VersionedRouterRoute } from './types'; import type { CoreVersionedRouter } from './core_versioned_router'; import { validate } from './validate'; @@ -47,6 +47,12 @@ export const passThroughValidation = { query: schema.nullable(schema.any()), }; +function extractValidationSchemaFromHandler(handler: VersionedRouterRoute['handlers'][0]) { + if (handler.options.validate === false) return undefined; + if (typeof handler.options.validate === 'function') return handler.options.validate(); + return handler.options.validate; +} + export class CoreVersionedRoute implements VersionedRoute { private readonly handlers = new Map< ApiVersion, @@ -88,7 +94,8 @@ export class CoreVersionedRoute implements VersionedRoute { validate: passThroughValidation, options: this.getRouteConfigOptions(), }, - this.requestHandler + this.requestHandler, + { isVersioned: true } ); } @@ -156,7 +163,7 @@ export class CoreVersionedRoute implements VersionedRoute { }]. Available versions are: ${this.versionsToString()}`, }); } - const validation = handler.options.validate || undefined; + const validation = extractValidationSchemaFromHandler(handler); if ( validation?.request && Boolean(validation.request.body || validation.request.params || validation.request.query) diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_router.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_router.ts index a22222d2de85e..08f4fcc0e67c0 100644 --- a/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_router.ts +++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/core_versioned_router.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import type { IRouter } from '@kbn/core-http-server'; import type { VersionedRouter, VersionedRoute, VersionedRouteConfig } from '@kbn/core-http-server'; +import type { Router } from '../router'; import { CoreVersionedRoute } from './core_versioned_route'; import type { HandlerResolutionStrategy, Method, VersionedRouterRoute } from './types'; /** @internal */ export interface VersionedRouterArgs { - router: IRouter; + router: Router; /** * Which route resolution algo to use. * @note default to "oldest", but when running in dev default to "none" @@ -56,7 +56,7 @@ export class CoreVersionedRouter implements VersionedRouter { ); } private constructor( - public readonly router: IRouter, + public readonly router: Router, public readonly defaultHandlerResolutionStrategy: HandlerResolutionStrategy = 'oldest', public readonly isDev: boolean = false, useVersionResolutionStrategyForInternalPaths: string[] = [] diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/index.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/index.ts index 941b6b5e5706f..1567574a3ba46 100644 --- a/packages/core/http/core-http-router-server-internal/src/versioned_router/index.ts +++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/index.ts @@ -5,6 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +export { resolvers as versionHandlerResolvers } from './handler_resolvers'; export { CoreVersionedRouter } from './core_versioned_router'; export type { HandlerResolutionStrategy } from './types'; diff --git a/packages/core/http/core-http-router-server-internal/src/versioned_router/validate.ts b/packages/core/http/core-http-router-server-internal/src/versioned_router/validate.ts index 37443209e4508..80c32bab8ee55 100644 --- a/packages/core/http/core-http-router-server-internal/src/versioned_router/validate.ts +++ b/packages/core/http/core-http-router-server-internal/src/versioned_router/validate.ts @@ -6,17 +6,50 @@ * Side Public License, v 1. */ -import type { RouteValidatorFullConfig } from '@kbn/core-http-server'; +import { + type VersionedRouteRequestValidation, + type VersionedSpecValidation, + type RouteValidationFunction, + RouteValidationError, +} from '@kbn/core-http-server'; +import { z, extractErrorMessage } from '@kbn/zod'; import type { ApiVersion } from '@kbn/core-http-server'; +import { instanceofZodType } from '@kbn/zod'; +import type { Type } from '@kbn/config-schema'; import { RouteValidator } from '../validator'; +function makeValidationFunction(schema: z.ZodTypeAny): RouteValidationFunction { + return (data: unknown) => { + const result = schema.safeParse(data); + if (!result.success) { + return { + error: new RouteValidationError(extractErrorMessage(result.error)), + value: undefined, + }; + } + return { error: undefined, value: result.data }; + }; +} + +function getValidator( + handler?: VersionedSpecValidation +): RouteValidationFunction | Type | undefined { + return instanceofZodType(handler) + ? makeValidationFunction(handler) + : (handler as RouteValidationFunction | Type | undefined); +} + /** Will throw if any of the validation checks fail */ export function validate( data: { body?: unknown; params?: unknown; query?: unknown }, - runtimeSchema: RouteValidatorFullConfig, + runtimeSchema: VersionedRouteRequestValidation, version: ApiVersion ): { body: unknown; params: unknown; query: unknown } { - const validator = RouteValidator.from(runtimeSchema); + const validator = RouteValidator.from({ + body: getValidator(runtimeSchema.body), + params: getValidator(runtimeSchema.params), + query: getValidator(runtimeSchema.query), + }); return { params: validator.getParams(data.params, 'request params'), query: validator.getQuery(data.query, 'request query'), 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 e4a70cbcddeec..c7659f6c5ccc2 100644 --- a/packages/core/http/core-http-router-server-internal/tsconfig.json +++ b/packages/core/http/core-http-router-server-internal/tsconfig.json @@ -7,7 +7,7 @@ "node" ] }, - "include": [ "**/*.ts" ], + "include": [ "**/*.ts", "src/versioned_router/oas_poc/run.js", "src/versioned_router/oas_poc/generate.js" ], "kbn_references": [ "@kbn/std", "@kbn/utility-types", @@ -17,7 +17,8 @@ "@kbn/hapi-mocks", "@kbn/core-logging-server-mocks", "@kbn/logging", - "@kbn/core-http-common" + "@kbn/core-http-common", + "@kbn/zod" ], "exclude": [ "target/**/*", diff --git a/packages/core/http/core-http-server-internal/src/http_server.ts b/packages/core/http/core-http-server-internal/src/http_server.ts index ae9025d5cd9a7..e47fe01ad86c3 100644 --- a/packages/core/http/core-http-server-internal/src/http_server.ts +++ b/packages/core/http/core-http-server-internal/src/http_server.ts @@ -8,6 +8,7 @@ import { Server, Request } from '@hapi/hapi'; import HapiStaticFiles from '@hapi/inert'; +import { generateOpenApiDocument } from '@kbn/generate-oas'; import url from 'url'; import { v4 as uuidv4 } from 'uuid'; import { @@ -26,7 +27,7 @@ import apm from 'elastic-apm-node'; import Brok from 'brok'; import type { Logger, LoggerFactory } from '@kbn/logging'; import type { InternalExecutionContextSetup } from '@kbn/core-execution-context-server-internal'; -import { isSafeMethod } from '@kbn/core-http-router-server-internal'; +import { CoreVersionedRouter, isSafeMethod, Router } from '@kbn/core-http-router-server-internal'; import type { IRouter, RouteConfigOptions, @@ -204,6 +205,10 @@ export class HttpServer { return this.server !== undefined && this.server.listener.listening; } + public getRegisteredRouters() { + return [...this.registeredRouters]; + } + private registerRouter(router: IRouter) { if (this.isListening()) { throw new Error('Routers can be registered only when HTTP server is stopped.'); @@ -330,6 +335,8 @@ export class HttpServer { } } + this.registerOasEndpoint(); + await this.server.start(); const serverPath = this.config && this.config.rewriteBasePath && this.config.basePath !== undefined @@ -623,6 +630,51 @@ export class HttpServer { }); } + private oasCache: undefined | object; + private registerOasEndpoint() { + this.server!.route({ + path: '/api/oas', + method: 'GET', + handler: (req, h) => { + // TODO cache the result of generating OAS + // if (this.oasCache) return h.response(this.oasCache); + + const pathStartsWith = req.query?.pathStartsWith; + try { + const routers = this.getRegisteredRouters().flatMap((r) => { + const rs: Array = []; + if ((r as Router).getRoutes(true).length > 0) { + rs.push(r as Router); + } + const versionedRouter = r.versioned as CoreVersionedRouter; + if (versionedRouter.getRoutes().length > 0) { + rs.push(versionedRouter); + } + return rs; + }); + this.oasCache = generateOpenApiDocument(routers, { + baseUrl: 'todo', + title: 'todo', + version: '0.0.0', + pathStartsWith, + }); + return h.response(this.oasCache); + } catch (e) { + this.log.error(e); + return h.response({ message: e.message }).code(500); + } + }, + options: { + app: { access: 'public' }, + auth: false, + cache: { + privacy: 'public', + otherwise: 'must-revalidate', + }, + }, + }); + } + private registerStaticDir(path: string, dirPath: string) { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); diff --git a/packages/core/http/core-http-server-internal/src/http_service.ts b/packages/core/http/core-http-server-internal/src/http_service.ts index 3dcab5be510e9..f07ebb8a056f3 100644 --- a/packages/core/http/core-http-server-internal/src/http_service.ts +++ b/packages/core/http/core-http-server-internal/src/http_service.ts @@ -81,6 +81,10 @@ export class HttpService this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } + public getRegisteredRouters(): IRouter[] { + return this.httpServer.getRegisteredRouters(); + } + public async preboot(deps: PrebootDeps): Promise { this.log.debug('setting up preboot server'); const config = await firstValueFrom(this.config$); @@ -195,6 +199,10 @@ export class HttpService contextName: ContextName, provider: IContextProvider ) => this.requestHandlerContext!.registerContext(pluginOpaqueId, contextName, provider), + + registerPrebootRoutes: this.internalPreboot!.registerRoutes, + + getRegisteredRouters: this.getRegisteredRouters.bind(this), }; return this.internalSetup; diff --git a/packages/core/http/core-http-server-internal/src/types.ts b/packages/core/http/core-http-server-internal/src/types.ts index 72dde630e03db..9f930ee8b3f0c 100644 --- a/packages/core/http/core-http-server-internal/src/types.ts +++ b/packages/core/http/core-http-server-internal/src/types.ts @@ -64,6 +64,9 @@ export interface InternalHttpServiceSetup contextName: ContextName, provider: IContextProvider ) => IContextContainer; + + registerPrebootRoutes(path: string, callback: (router: IRouter) => void): void; + getRegisteredRouters: () => IRouter[]; } /** @internal */ diff --git a/packages/core/http/core-http-server-internal/tsconfig.json b/packages/core/http/core-http-server-internal/tsconfig.json index 7c52ff584a532..7ddb1b40f43d1 100644 --- a/packages/core/http/core-http-server-internal/tsconfig.json +++ b/packages/core/http/core-http-server-internal/tsconfig.json @@ -33,6 +33,7 @@ "@kbn/core-execution-context-server-mocks", "@kbn/core-http-context-server-mocks", "@kbn/logging-mocks", + "@kbn/generate-oas", "@kbn/core-base-server-mocks", ], "exclude": [ diff --git a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts index 7172accf98a9f..b890b0e3b0a89 100644 --- a/packages/core/http/core-http-server-mocks/src/http_service.mock.ts +++ b/packages/core/http/core-http-server-mocks/src/http_service.mock.ts @@ -183,6 +183,8 @@ const createInternalSetupContractMock = () => { authRequestHeaders: createAuthHeaderStorageMock(), getServerInfo: jest.fn(), registerRouterAfterListening: jest.fn(), + registerPrebootRoutes: jest.fn(), + getRegisteredRouters: jest.fn(), }; mock.createCookieSessionStorageFactory.mockResolvedValue(sessionStorageMock.createFactory()); mock.createRouter.mockImplementation(() => mockRouter.create()); @@ -260,6 +262,7 @@ const createHttpServiceMock = () => { getStartContract: jest.fn(), start: jest.fn(), stop: jest.fn(), + getRegisteredRouters: jest.fn(), }; mocked.preboot.mockResolvedValue(createInternalPrebootContractMock()); mocked.setup.mockResolvedValue(createInternalSetupContractMock()); diff --git a/packages/core/http/core-http-server/index.ts b/packages/core/http/core-http-server/index.ts index 82022ce77d6e3..2ff209e8cec5a 100644 --- a/packages/core/http/core-http-server/index.ts +++ b/packages/core/http/core-http-server/index.ts @@ -142,6 +142,7 @@ export type { VersionedRouteConfig, VersionedRouteRegistrar, VersionedRouter, + VersionedSpecValidation, } from './src/versioning'; export type { IStaticAssets } from './src/static_assets'; diff --git a/packages/core/http/core-http-server/src/router/route.ts b/packages/core/http/core-http-server/src/router/route.ts index 62ec27dbc12b0..8586480274023 100644 --- a/packages/core/http/core-http-server/src/router/route.ts +++ b/packages/core/http/core-http-server/src/router/route.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { Type } from '@kbn/config-schema'; import type { RouteValidatorFullConfig } from './route_validator'; /** @@ -159,6 +160,17 @@ export interface RouteConfigOptions { */ idleSocket?: number; }; + + /** Human-friendly description of this endpoint */ + description?: string; + + /** + * Responses grouped by status code expressed using runtime validation schemas. + * The mechanism used to express request inputs (params, query, body). + */ + responses?: { + [statusCode: number]: { body: Type }; + }; } /** diff --git a/packages/core/http/core-http-server/src/router/router.ts b/packages/core/http/core-http-server/src/router/router.ts index cf1f87a9f86bb..8e143a78a64ad 100644 --- a/packages/core/http/core-http-server/src/router/router.ts +++ b/packages/core/http/core-http-server/src/router/router.ts @@ -13,6 +13,7 @@ import type { RouteConfig, RouteMethod } from './route'; import type { RequestHandler, RequestHandlerWrapper } from './request_handler'; import type { RequestHandlerContextBase } from './request_handler_context'; import type { RouteConfigOptions } from './route'; +import { RouteValidatorFullConfig } from './route_validator'; /** * Route handler common definition @@ -123,6 +124,7 @@ export interface RouterRoute { method: RouteMethod; path: string; options: RouteConfigOptions; + validationSchemas?: RouteValidatorFullConfig | false; handler: ( req: Request, responseToolkit: ResponseToolkit diff --git a/packages/core/http/core-http-server/src/versioning/index.ts b/packages/core/http/core-http-server/src/versioning/index.ts index 2f3dd64637a47..c06da507ece09 100644 --- a/packages/core/http/core-http-server/src/versioning/index.ts +++ b/packages/core/http/core-http-server/src/versioning/index.ts @@ -16,4 +16,5 @@ export type { VersionedRouteConfig, VersionedRouteRegistrar, VersionedRouter, + VersionedSpecValidation, } from './types'; diff --git a/packages/core/http/core-http-server/src/versioning/types.ts b/packages/core/http/core-http-server/src/versioning/types.ts index f295fbaf0e534..08991270947bc 100644 --- a/packages/core/http/core-http-server/src/versioning/types.ts +++ b/packages/core/http/core-http-server/src/versioning/types.ts @@ -32,9 +32,11 @@ export type VersionedRouteConfig = Omit< RouteConfig, 'validate' | 'options' > & { - options?: Omit, 'access'>; + options?: Omit, 'access' | 'oas'>; /** See {@link RouteConfigOptions['access']} */ access: Exclude['access'], undefined>; + /** A human-readable description of this route */ + description?: string; /** * When enabled, the router will also check for the presence of an `apiVersion` * query parameter to determine the route version to resolve to: @@ -185,12 +187,33 @@ export interface VersionedRouter { delete: VersionedRouteRegistrar<'delete', Ctx>; } +interface ZodEsque { + _output: V; +} + +/** + * We accept three different types of validation for maximum flexibility and to enable + * teams to decide when or if they would like to opt into OAS spec generation for their + * public routes via zod. + */ +export type VersionedSpecValidation = + | ZodEsque + | RouteValidationFunction + | Type; + /** @public */ -export type VersionedRouteRequestValidation = RouteValidatorFullConfig; +export type VersionedRouteRequestValidation = Omit< + RouteValidatorFullConfig, + 'params' | 'query' | 'body' +> & { + params?: VersionedSpecValidation

; + query?: VersionedSpecValidation; + body?: VersionedSpecValidation; +}; /** @public */ export interface VersionedRouteResponseValidation { - [statusCode: number]: { body: RouteValidationFunction | Type }; + [statusCode: number]: { body: VersionedSpecValidation }; unsafe?: { body?: boolean }; } @@ -228,7 +251,7 @@ export interface AddVersionOpts { * Validation for this version of a route * @public */ - validate: false | FullValidationConfig; + validate: false | FullValidationConfig | (() => FullValidationConfig); // Provide a way to lazily load validation schemas } /** diff --git a/packages/core/http/core-http-server/tsconfig.json b/packages/core/http/core-http-server/tsconfig.json index 737c4e54906f9..a59056ce63bb2 100644 --- a/packages/core/http/core-http-server/tsconfig.json +++ b/packages/core/http/core-http-server/tsconfig.json @@ -14,7 +14,7 @@ "@kbn/config-schema", "@kbn/utility-types", "@kbn/core-base-common", - "@kbn/core-http-common" + "@kbn/core-http-common", ], "exclude": [ "target/**/*", diff --git a/packages/kbn-config-schema/src/types/maybe_type.ts b/packages/kbn-config-schema/src/types/maybe_type.ts index e90434077cc36..d8b13ee94f28c 100644 --- a/packages/kbn-config-schema/src/types/maybe_type.ts +++ b/packages/kbn-config-schema/src/types/maybe_type.ts @@ -8,6 +8,13 @@ import { Type, ExtendsDeepOptions } from './type'; +/** + * Used to explicitly mark a field as optional in @kbn/config-schema. + * + * Especially for introspection on schemas when generating OAS. + */ +const META_FIELD_X_OAS_OPTIONAL = 'x-oas-optional'; + export class MaybeType extends Type { private readonly maybeType: Type; @@ -16,6 +23,7 @@ export class MaybeType extends Type { type .getSchema() .optional() + .meta({ [META_FIELD_X_OAS_OPTIONAL]: true }) .default(() => undefined) ); this.maybeType = type; diff --git a/packages/kbn-config-schema/src/types/type.ts b/packages/kbn-config-schema/src/types/type.ts index 8023da2dda920..db05cf8e60dc4 100644 --- a/packages/kbn-config-schema/src/types/type.ts +++ b/packages/kbn-config-schema/src/types/type.ts @@ -13,6 +13,7 @@ import { Reference } from '../references'; export interface TypeOptions { defaultValue?: T | Reference | (() => T); validate?: (value: T) => string | void; + description?: string; } export interface SchemaStructureEntry { @@ -86,6 +87,10 @@ export abstract class Type { schema = schema.custom(convertValidationFunction(options.validate)); } + if (options.description) { + schema = schema.description(options.description); + } + // Attach generic error handler only if it hasn't been attached yet since // only the last error handler is counted. if (schema.$_getFlag('error') === undefined) { diff --git a/packages/kbn-generate-oas/README.md b/packages/kbn-generate-oas/README.md new file mode 100644 index 0000000000000..bdd05e958e2bc --- /dev/null +++ b/packages/kbn-generate-oas/README.md @@ -0,0 +1,3 @@ +# @kbn/generate-oas + +Generate OAS from router definitions. \ No newline at end of file diff --git a/packages/kbn-generate-oas/index.ts b/packages/kbn-generate-oas/index.ts new file mode 100644 index 0000000000000..d597f10bfb3f8 --- /dev/null +++ b/packages/kbn-generate-oas/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { generateOpenApiDocument } from './src'; diff --git a/packages/kbn-generate-oas/jest.config.js b/packages/kbn-generate-oas/jest.config.js new file mode 100644 index 0000000000000..7ea46d1e61ffb --- /dev/null +++ b/packages/kbn-generate-oas/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/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-generate-oas'], +}; diff --git a/packages/kbn-generate-oas/kibana.jsonc b/packages/kbn-generate-oas/kibana.jsonc new file mode 100644 index 0000000000000..aeed736876090 --- /dev/null +++ b/packages/kbn-generate-oas/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/generate-oas", + "owner": "@elastic/kibana-core" +} diff --git a/packages/kbn-generate-oas/package.json b/packages/kbn-generate-oas/package.json new file mode 100644 index 0000000000000..5d5624e8ed4a7 --- /dev/null +++ b/packages/kbn-generate-oas/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/generate-oas", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-generate-oas/src/generate_oas.ts b/packages/kbn-generate-oas/src/generate_oas.ts new file mode 100644 index 0000000000000..3c7f1f7b179bd --- /dev/null +++ b/packages/kbn-generate-oas/src/generate_oas.ts @@ -0,0 +1,287 @@ +/* + * 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. + */ + +/** + * Heavily adapted version of https://github.com/jlalmes/trpc-openapi + */ + +import type { OpenAPIV3 } from 'openapi-types'; + +import { CoreVersionedRouter, Router } from '@kbn/core-http-router-server-internal'; +import { versionHandlerResolvers } from '@kbn/core-http-router-server-internal'; +import { VersionedRouterRoute } from '@kbn/core-http-router-server-internal/src/versioned_router/types'; +import { + getPathParameters, + extractValidationSchemaFromVersionedHandler, + getVersionedContentString, + extractValidationSchemaFromRoute, + getJSONContentString, +} from './util'; + +import { convert, convertPathParameters, convertQuery } from './oas_converters'; + +export const openApiVersion = '3.0.0'; + +export interface GenerateOpenApiDocumentOptions { + title: string; + description?: string; + version: string; + baseUrl: string; + docsUrl?: string; + tags?: string[]; + pathStartsWith?: string; +} + +export const generateOpenApiDocument = ( + appRouters: Array, + opts: GenerateOpenApiDocumentOptions +): OpenAPIV3.Document => { + const paths: OpenAPIV3.PathsObject = {}; + for (const appRouter of appRouters) { + Object.assign(paths, getOpenApiPathsObject(appRouter, opts.pathStartsWith)); + } + return { + openapi: openApiVersion, + info: { + title: opts.title, + description: opts.description, + version: opts.version, + }, + servers: [ + { + url: opts.baseUrl, + }, + ], + paths, + security: [ + { + basicAuth: [], + }, + { + apiKeyAuth: [], + }, + ], + tags: opts.tags?.map((tag) => ({ name: tag })), + externalDocs: opts.docsUrl ? { url: opts.docsUrl } : undefined, + }; +}; + +const operationIdCounters = new Map(); +const getOperationId = (name: string): string => { + // Aliases an operationId to ensure it is unique across + // multiple method+path combinations sharing a name. + // "search" -> "search#0", "search#1", etc. + const operationIdCount = operationIdCounters.get(name) ?? 0; + const aliasedName = name + '#' + operationIdCount.toString(); + operationIdCounters.set(name, operationIdCount + 1); + return aliasedName; +}; + +const extractRequestBody = ( + route: VersionedRouterRoute +): OpenAPIV3.RequestBodyObject['content'] => { + return route.handlers.reduce((acc, handler) => { + const schemas = extractValidationSchemaFromVersionedHandler(handler); + if (!schemas?.request) return acc; + const schema = convert(schemas.request.body); + return { + ...acc, + [getVersionedContentString(handler.options.version)]: { + schema, + }, + }; + }, {} as OpenAPIV3.RequestBodyObject['content']); +}; +const extractVersionedResponses = (route: VersionedRouterRoute): OpenAPIV3.ResponsesObject => { + return route.handlers.reduce((acc, handler) => { + const schemas = extractValidationSchemaFromVersionedHandler(handler); + if (!schemas?.response) return acc; + const statusCodes = Object.keys(schemas.response); + for (const statusCode of statusCodes) { + const maybeSchema = schemas.response[statusCode as unknown as number].body; + const schema = convert(maybeSchema); + acc[statusCode] = { + ...acc[statusCode], + description: route.options.description ?? 'No description', + content: { + ...((acc[statusCode] ?? {}) as OpenAPIV3.ResponseObject).content, + [getVersionedContentString(handler.options.version)]: { + schema, + }, + }, + }; + } + return acc; + }, {}); +}; + +const prepareRoutes = ( + routes: R[], + pathStartsWith?: string +): R[] => { + return ( + routes + // TODO: Make this smarter? + .filter(pathStartsWith ? (route) => route.path.startsWith(pathStartsWith) : () => true) + // TODO: Figure out how we can scope which routes we generate OAS for + // .filter((route) => route.options.access === 'public') + ); +}; + +const processVersionedRouter = ( + appRouter: CoreVersionedRouter, + pathStartsWith?: string +): OpenAPIV3.PathsObject => { + const routes = prepareRoutes(appRouter.getRoutes(), pathStartsWith); + const paths: OpenAPIV3.PathsObject = {}; + for (const route of routes) { + const pathParams = getPathParameters(route.path); + /** + * Note: for a given route we accept that route params and query params remain BWC + * so we only take the latest version of the params and query params, we also + * assume at this point that we are generating for serverless. + */ + let pathObjects: OpenAPIV3.ParameterObject[] = []; + let queryObjects: OpenAPIV3.ParameterObject[] = []; + const version = versionHandlerResolvers.newest( + route.handlers.map(({ options: { version: v } }) => v) + ); + const handler = route.handlers.find(({ options: { version: v } }) => v === version); + const schemas = handler ? extractValidationSchemaFromVersionedHandler(handler) : undefined; + + try { + if (handler && schemas) { + const params = schemas.request?.params as unknown; + if (params) { + pathObjects = convertPathParameters(params, pathParams); + } + const query = schemas.request?.query as unknown; + if (query) { + queryObjects = convertQuery(query); + } + } + + const hasBody = Boolean( + handler && extractValidationSchemaFromVersionedHandler(handler)?.request?.body + ); + const path: OpenAPIV3.PathItemObject = { + [route.method]: { + requestBody: hasBody + ? { + content: extractRequestBody(route), + } + : undefined, + responses: extractVersionedResponses(route), + parameters: pathObjects.concat(queryObjects), + operationId: getOperationId(route.path), + }, + }; + + assignToPathsObject(paths, route.path, path); + } catch (e) { + // TODO there is a bug here, keeps trying to generate the wrong routes + + // Enrich the error message with a bit more context + // e.message = `Error generating OpenAPI for route '${route.path}' using version '${version}': ${e.message}`; + // throw e; + } + } + return paths; +}; + +type InternalRouterRoute = ReturnType[0]; + +const extractResponses = (route: InternalRouterRoute): OpenAPIV3.ResponsesObject => { + return !!route.options?.responses + ? Object.entries(route.options.responses).reduce( + (acc, [statusCode, schema]) => { + const oasSchema = convert(schema.body); + acc[statusCode] = { + ...acc[statusCode], + description: route.options.description ?? 'No description', + content: { + ...((acc[statusCode] ?? {}) as OpenAPIV3.ResponseObject).content, + [getJSONContentString()]: { + schema: oasSchema, + }, + }, + }; + return acc; + }, + {} + ) + : {}; +}; + +const processRouter = (appRouter: Router, pathStartsWith?: string): OpenAPIV3.PathsObject => { + const routes = prepareRoutes(appRouter.getRoutes(true), pathStartsWith); + + const paths: OpenAPIV3.PathsObject = {}; + for (const route of routes) { + const pathParams = getPathParameters(route.path); + const validationSchemas = extractValidationSchemaFromRoute(route); + + let pathObjects: OpenAPIV3.ParameterObject[] = []; + let queryObjects: OpenAPIV3.ParameterObject[] = []; + + try { + if (validationSchemas) { + const params = validationSchemas.params as unknown; + if (params) { + pathObjects = convertPathParameters(params, pathParams); + } + const query = validationSchemas.query as unknown; + if (query) { + queryObjects = convertQuery(query); + } + } + + const path: OpenAPIV3.PathItemObject = { + [route.method]: { + requestBody: !!validationSchemas?.body + ? { + content: { + [getJSONContentString()]: { + schema: convert(validationSchemas.body), + }, + }, + } + : undefined, + responses: extractResponses(route), + parameters: pathObjects.concat(queryObjects), + operationId: getOperationId(route.path), + }, + }; + assignToPathsObject(paths, route.path, path); + } catch (e) { + // Enrich the error message with a bit more context + e.message = `Error generating OpenAPI for route '${route.path}': ${e.message}`; + throw e; + } + } + return paths; +}; + +const assignToPathsObject = ( + paths: OpenAPIV3.PathsObject, + path: string, + pathObject: OpenAPIV3.PathItemObject +): void => { + const pathName = path.replace('?', ''); + paths[pathName] = { ...paths[pathName], ...pathObject }; +}; + +const getOpenApiPathsObject = ( + appRouter: Router | CoreVersionedRouter, + pathStartsWith?: string +): OpenAPIV3.PathsObject => { + if (appRouter instanceof CoreVersionedRouter) { + return processVersionedRouter(appRouter, pathStartsWith); + } + return processRouter(appRouter, pathStartsWith); +}; diff --git a/packages/kbn-generate-oas/src/index.ts b/packages/kbn-generate-oas/src/index.ts new file mode 100644 index 0000000000000..2c25468ea1901 --- /dev/null +++ b/packages/kbn-generate-oas/src/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { generateOpenApiDocument } from './generate_oas'; diff --git a/packages/kbn-generate-oas/src/oas_converters/catch_all.ts b/packages/kbn-generate-oas/src/oas_converters/catch_all.ts new file mode 100644 index 0000000000000..365111576bcf6 --- /dev/null +++ b/packages/kbn-generate-oas/src/oas_converters/catch_all.ts @@ -0,0 +1,24 @@ +/* + * 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'; + +export const catchAllConverter: OpenAPIConverter = { + convertPathParameters: () => { + return []; + }, + convertQuery: () => { + return []; + }, + convert: () => { + return {}; + }, + is: () => { + return true; + }, +}; diff --git a/packages/kbn-generate-oas/src/oas_converters/common.ts b/packages/kbn-generate-oas/src/oas_converters/common.ts new file mode 100644 index 0000000000000..7115a9537dc60 --- /dev/null +++ b/packages/kbn-generate-oas/src/oas_converters/common.ts @@ -0,0 +1,36 @@ +/* + * 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 { OpenAPIV3 } from 'openapi-types'; + +const trimTrailingStar = (str: string) => str.replace(/\*$/, ''); + +export const validatePathParameters = (pathParameters: string[], schemaKeys: string[]) => { + if (pathParameters.length !== schemaKeys.length) { + throw new Error( + `Schema expects [${schemaKeys.join(', ')}], but path contains [${pathParameters.join(', ')}]` + ); + } + + for (let pathParameter of pathParameters) { + pathParameter = trimTrailingStar(pathParameter); + if (!schemaKeys.includes(pathParameter)) { + throw new Error( + `Path expects key "${pathParameter}" from schema but it was not found. Existing schema keys are: ${schemaKeys.join( + ', ' + )}` + ); + } + } +}; + +export const isReferenceObject = (schema: unknown): schema is OpenAPIV3.ReferenceObject => { + return ( + Object.prototype.toString.call(schema) === '[object Object]' && '$ref' in (schema as object) + ); +}; diff --git a/packages/kbn-generate-oas/src/oas_converters/index.ts b/packages/kbn-generate-oas/src/oas_converters/index.ts new file mode 100644 index 0000000000000..7434ed4f60b75 --- /dev/null +++ b/packages/kbn-generate-oas/src/oas_converters/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { OpenAPIV3 } from 'openapi-types'; +import { KnownParameters, OpenAPIConverter } from '../type'; + +import { zodConverter } from './zod'; +import { kbnConfigSchemaConverter } from './kbn_config_schema/kbn_config_schema'; +import { catchAllConverter } from './catch_all'; + +const CONVERTERS: OpenAPIConverter[] = [kbnConfigSchemaConverter, zodConverter, catchAllConverter]; +const getConverter = (schema: unknown): OpenAPIConverter => { + return CONVERTERS.find((c) => c.is(schema))!; +}; + +export const convert = (schema: unknown): OpenAPIV3.SchemaObject => { + return getConverter(schema).convert(schema); +}; + +export const convertPathParameters = ( + schema: unknown, + pathParameters: KnownParameters +): OpenAPIV3.ParameterObject[] => { + return getConverter(schema).convertPathParameters(schema, pathParameters); +}; + +export const convertQuery = (schema: unknown): OpenAPIV3.ParameterObject[] => { + return getConverter(schema).convertQuery(schema); +}; diff --git a/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/index.ts b/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/index.ts new file mode 100644 index 0000000000000..58ac585d7c9f7 --- /dev/null +++ b/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { kbnConfigSchemaConverter } from './kbn_config_schema'; diff --git a/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/kbn_config_schema.ts b/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/kbn_config_schema.ts new file mode 100644 index 0000000000000..66c9a76b0704a --- /dev/null +++ b/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/kbn_config_schema.ts @@ -0,0 +1,196 @@ +/* + * 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 joiToJsonParse from 'joi-to-json'; +import joi from 'joi'; +import { isConfigSchema, Type } from '@kbn/config-schema'; +import { get } from 'lodash'; +import type { OpenAPIV3 } from 'openapi-types'; +import type { KnownParameters, OpenAPIConverter } from '../../type'; +import { isReferenceObject } from '../common'; +import * as mutations from './post_process_mutations'; + +const parse = (schema: joi.Schema) => { + const result = joiToJsonParse(schema, 'open-api'); + postProcess(result); + return result; +}; + +const isObjectType = (schema: joi.Schema | joi.Description): boolean => { + return schema.type === 'object'; +}; + +const isRecordType = (schema: joi.Schema | joi.Description): boolean => { + return schema.type === 'record'; +}; + +// See the `schema.nullable` type in @kbn/config-schema +// TODO: we need to generate better OAS for Kibana config schema nullable type +const isNullableObjectType = (schema: joi.Schema | joi.Description): boolean => { + if (schema.type === 'alternatives') { + const { matches } = joi.isSchema(schema) ? schema.describe() : schema; + return ( + matches.length === 2 && + matches.every( + (match: { schema: joi.Description }) => + match.schema.type === 'object' || + (match.schema.type === 'any' && + get(match, 'schema.flags.only') === true && + get(match, 'schema.allow')?.length === 1 && + get(match, 'schema.allow.0') === null) + ) + ); + } + return false; +}; + +const isEmptyObjectAllowsUnknowns = (schema: joi.Description) => { + return ( + isObjectType(schema) && + Object.keys(schema.keys).length === 0 && + get(schema, 'flags.unknown') === true + ); +}; + +const createError = (message: string): Error => { + return new Error(`[@kbn/config-schema converter] ${message}`); +}; + +function assertInstanceOfKbnConfigSchema(schema: unknown): asserts schema is Type { + if (!is(schema)) { + throw createError('Expected schema to be an instance of @kbn/config-schema'); + } +} + +const unwrapKbnConfigSchema = (schema: unknown): joi.Schema => { + assertInstanceOfKbnConfigSchema(schema); + return schema.getSchema(); +}; + +const isSchemaRequired = (schema: joi.Schema | joi.Description): boolean => { + if (joi.isSchema(schema)) { + return schema._flags?.presence === 'required'; + } + return 'required' === get(schema, 'flags.presence'); +}; + +const arrayContainers: Array = ['allOf', 'oneOf', 'anyOf']; + +const walkSchema = (schema: OpenAPIV3.SchemaObject): void => { + if (schema.type === 'array') { + walkSchema(schema.items as OpenAPIV3.SchemaObject); + } else if (schema.type === 'object') { + mutations.processObject(schema); + if (schema.properties) { + Object.values(schema.properties!).forEach((obj) => walkSchema(obj as OpenAPIV3.SchemaObject)); + } + } else if ((schema.type as string) === 'record') { + mutations.replaceRecordType(schema); + } else if (schema.type) { + // Do nothing + } else { + for (const arrayContainer of arrayContainers) { + if (schema[arrayContainer]) { + schema[arrayContainer].forEach(walkSchema); + break; + } + } + } +}; + +const postProcess = (oasSchema: OpenAPIV3.SchemaObject) => { + if (!oasSchema) return; + walkSchema(oasSchema); +}; + +const convert = (kbnConfigSchema: unknown): OpenAPIV3.BaseSchemaObject => { + const schema = unwrapKbnConfigSchema(kbnConfigSchema); + return parse(schema) as OpenAPIV3.SchemaObject; +}; + +const convertObjectMembersToParameterObjects = ( + schema: joi.Schema, + knownParameters: KnownParameters = {}, + isPathParameter = false +): OpenAPIV3.ParameterObject[] => { + let properties: Exclude; + if (isNullableObjectType(schema)) { + const { anyOf }: { anyOf: OpenAPIV3.SchemaObject[] } = parse(schema); + properties = anyOf.find((s) => s.type === 'object')!.properties!; + } else if (isObjectType(schema)) { + ({ properties } = parse(schema)); + } else if (isRecordType(schema)) { + return []; + } else { + throw createError(`Expected record, object or nullable object type, but got '${schema.type}'`); + } + + return Object.entries(properties).map(([schemaKey, schemaObject]) => { + const isSubSchemaRequired = isSchemaRequired(schemaObject); + if (isReferenceObject(schemaObject)) { + throw createError( + `Expected schema but got reference object: ${JSON.stringify(schemaObject, null, 2)}` + ); + } + const { description, ...openApiSchemaObject } = schemaObject; + return { + name: schemaKey, + in: isPathParameter ? 'path' : 'query', + required: isPathParameter ? !knownParameters[schemaKey].optional : isSubSchemaRequired, + schema: openApiSchemaObject, + description, + }; + }); +}; + +const convertQuery = (kbnConfigSchema: unknown): OpenAPIV3.ParameterObject[] => { + const schema = unwrapKbnConfigSchema(kbnConfigSchema); + return convertObjectMembersToParameterObjects(schema, {}, false); +}; + +const convertPathParameters = ( + kbnConfigSchema: unknown, + knownParameters: { [paramName: string]: { optional: boolean } } +): OpenAPIV3.ParameterObject[] => { + const schema = unwrapKbnConfigSchema(kbnConfigSchema); + + if (isObjectType(schema)) { + // TODO: Revisit this validation logic + // const schemaDescription = schema.describe(); + // const schemaKeys = Object.keys(schemaDescription.keys); + // validatePathParameters(pathParameters, schemaKeys); + } else if (isNullableObjectType(schema)) { + // nothing to do for now... + } else { + throw createError('Input parser for path params expected to be an object schema'); + } + + return convertObjectMembersToParameterObjects(schema, knownParameters, true); +}; + +const is = (schema: unknown): boolean => { + if (isConfigSchema(schema)) { + const description = schema.getSchema().describe(); + // We ignore "any" @kbn/config-schema for the purposes of OAS generation... + if ( + description.type === 'any' || + (!isSchemaRequired(description) && isEmptyObjectAllowsUnknowns(description)) + ) { + return false; + } + return true; + } + return false; +}; + +export const kbnConfigSchemaConverter: OpenAPIConverter = { + convertQuery, + convertPathParameters, + convert, + is, +}; diff --git a/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/post_process_mutations.ts b/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/post_process_mutations.ts new file mode 100644 index 0000000000000..cb9957a1201a0 --- /dev/null +++ b/packages/kbn-generate-oas/src/oas_converters/kbn_config_schema/post_process_mutations.ts @@ -0,0 +1,67 @@ +/* + * 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 { OpenAPIV3 } from 'openapi-types'; + +const stripDefaultDeep = (schema: OpenAPIV3.SchemaObject): void => { + if (schema.default?.special === 'deep') { + if (Object.keys(schema.default).length === 1) { + delete schema.default; + } else { + delete schema.default.special; + } + } +}; + +const isNullable = (schema: OpenAPIV3.SchemaObject): boolean => { + return schema.nullable === true; +}; + +const metaOptional = 'x-oas-optional'; +const populateRequiredFields = (schema: OpenAPIV3.SchemaObject): void => { + if (!schema.properties) return; + const required: string[] = []; + + const entries = Object.entries(schema.properties); + for (let x = 0; x < entries.length; x++) { + const entry = entries[x]; + const key: string = entry[0]; + const value: OpenAPIV3.SchemaObject = entry[1] as OpenAPIV3.SchemaObject; + if ((value as Record)[metaOptional]) { + delete (value as Record)[metaOptional]; + } else if ( + Boolean(value.default != null) || + Boolean(value.anyOf && value.anyOf.some((v) => isNullable(v as OpenAPIV3.SchemaObject))) + ) { + // Do not add any of the above to the required array + } else { + required.push(key); + } + } + + schema.required = required; +}; + +const removeNeverType = (schema: OpenAPIV3.SchemaObject): void => { + if (!schema.properties) return; + for (const [key, value] of Object.entries(schema.properties)) { + if (Object.keys(value).length === 1 && 'not' in value) { + delete schema.properties[key]; + } + } +}; + +export const processObject = (schema: OpenAPIV3.SchemaObject): void => { + stripDefaultDeep(schema); + removeNeverType(schema); + populateRequiredFields(schema); +}; + +export const replaceRecordType = (schema: OpenAPIV3.SchemaObject): void => { + schema.type = 'object'; +}; diff --git a/packages/kbn-generate-oas/src/oas_converters/zod.ts b/packages/kbn-generate-oas/src/oas_converters/zod.ts new file mode 100644 index 0000000000000..5678974418485 --- /dev/null +++ b/packages/kbn-generate-oas/src/oas_converters/zod.ts @@ -0,0 +1,219 @@ +/* + * 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 type { OpenAPIV3 } from 'openapi-types'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { KnownParameters, OpenAPIConverter } from '../type'; +import { validatePathParameters } from './common'; + +// Adapted from from https://github.com/jlalmes/trpc-openapi/blob/aea45441af785518df35c2bc173ae2ea6271e489/src/utils/zod.ts#L1 + +const instanceofZodType = (type: any): type is z.ZodTypeAny => { + return !!type?._def?.typeName; +}; + +const createError = (message: string): Error => { + return new Error(`[Zod converter] ${message}`); +}; + +function assertInstanceOfZodType(schema: unknown): asserts schema is z.ZodTypeAny { + if (!instanceofZodType(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 instanceofZodTypeOptional = (type: z.ZodTypeAny): type is z.ZodOptional => { + return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodOptional); +}; + +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 { description, ...openApiSchemaObject } = convert(subShape); + + return { + name: shapeKey, + in: isPathParameter ? 'path' : 'query', + required: isPathParameter ? !knownParameters[shapeKey]?.optional : isSubShapeRequired, + schema: openApiSchemaObject, + description, + }; + }); +}; + +const convertQuery = (schema: unknown): OpenAPIV3.ParameterObject[] => { + 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 convertObjectMembersToParameterObjects(shape, isRequired); +}; + +const convertPathParameters = ( + schema: unknown, + knownParameters: KnownParameters +): OpenAPIV3.ParameterObject[] => { + assertInstanceOfZodType(schema); + const unwrappedSchema = unwrapZodType(schema, true); + const paramKeys = Object.keys(knownParameters); + const paramsCount = paramKeys.length; + if (paramsCount === 0 && instanceofZodTypeLikeVoid(unwrappedSchema)) { + return []; + } + 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 convertObjectMembersToParameterObjects(shape, isRequired, true); +}; + +const convert = (schema: z.ZodTypeAny): OpenAPIV3.SchemaObject => { + /* For this PoC we assume that we are able to get JSONSchema from our runtime validation types */ + return zodToJsonSchema(schema, { target: 'openApi3', $refStrategy: 'none' }) as any; +}; + +const is = instanceofZodType; + +export const zodConverter: OpenAPIConverter = { + convertPathParameters, + convertQuery, + convert, + is, +}; diff --git a/packages/kbn-generate-oas/src/type.ts b/packages/kbn-generate-oas/src/type.ts new file mode 100644 index 0000000000000..21284ebf053ba --- /dev/null +++ b/packages/kbn-generate-oas/src/type.ts @@ -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 type { OpenAPIV3 } from 'openapi-types'; + +export interface KnownParameters { + [paramName: string]: { optional: boolean }; +} + +export interface OpenAPIConverter { + convertPathParameters( + schema: unknown, + knownPathParameters: KnownParameters + ): OpenAPIV3.ParameterObject[]; + + convertQuery(schema: unknown): OpenAPIV3.ParameterObject[]; + + convert(schema: unknown): OpenAPIV3.BaseSchemaObject; + + is(type: unknown): boolean; +} diff --git a/packages/kbn-generate-oas/src/util.ts b/packages/kbn-generate-oas/src/util.ts new file mode 100644 index 0000000000000..010dbc36e647e --- /dev/null +++ b/packages/kbn-generate-oas/src/util.ts @@ -0,0 +1,42 @@ +/* + * 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 { VersionedRouterRoute } from '@kbn/core-http-router-server-internal/src/versioned_router/types'; +import { RouterRoute, RouteValidatorConfig } from '@kbn/core-http-server'; +import { KnownParameters } from './type'; + +export const getPathParameters = (path: string): KnownParameters => { + return Array.from(path.matchAll(/\{(.+?)\}/g)).reduce((acc, [_, key]) => { + const optional = key.endsWith('?'); + acc[optional ? key.slice(0, key.length - 1) : key] = { optional }; + return acc; + }, {}); +}; + +export const extractValidationSchemaFromVersionedHandler = ( + handler: VersionedRouterRoute['handlers'][0] +) => { + if (handler.options.validate === false) return undefined; + if (typeof handler.options.validate === 'function') return handler.options.validate(); + return handler.options.validate; +}; + +export const getVersionedContentString = (version: string): string => { + return `application/json; Elastic-Api-Version=${version}`; +}; + +export const getJSONContentString = () => { + return 'application/json'; +}; + +export const extractValidationSchemaFromRoute = ( + route: RouterRoute +): undefined | RouteValidatorConfig => { + if (route.validationSchemas === false) return undefined; + return route.validationSchemas; +}; diff --git a/packages/kbn-generate-oas/tsconfig.json b/packages/kbn-generate-oas/tsconfig.json new file mode 100644 index 0000000000000..213ed93e0a1be --- /dev/null +++ b/packages/kbn-generate-oas/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-http-router-server-internal", + "@kbn/config-schema", + "@kbn/zod", + "@kbn/core-http-server", + ] +} diff --git a/packages/kbn-rule-data-utils/index.ts b/packages/kbn-rule-data-utils/index.ts index 3e9787891be05..91dc6e09f54e4 100644 --- a/packages/kbn-rule-data-utils/index.ts +++ b/packages/kbn-rule-data-utils/index.ts @@ -15,3 +15,4 @@ export * from './src/alerts_as_data_status'; export * from './src/alerts_as_data_cases'; export * from './src/routes/stack_rule_paths'; export * from './src/rule_types'; +export * from './src/rule_params_schemas'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/custom_threshold/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/custom_threshold/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/custom_threshold/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/custom_threshold/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/custom_threshold/v1.ts new file mode 100644 index 0000000000000..8450f38412fcf --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/custom_threshold/v1.ts @@ -0,0 +1,224 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as z from 'zod'; +import { + validateKQLStringFilterV1, + validateIsOneOfLiteralsV1, +} from '..'; + +const Comparators = { + GT: '>', + LT: '<', + GT_OR_EQ: '>=', + LT_OR_EQ: '<=', + BETWEEN: 'between', + OUTSIDE_RANGE: 'outside', +} as const + +const Aggregators = { + COUNT: 'count', + AVERAGE: 'avg', + SUM: 'sum', + MIN: 'min', + MAX: 'max', + CARDINALITY: 'cardinality', + RATE: 'rate', + P95: 'p95', + P99: 'p99', +} as const; + +const RUNTIME_FIELD_TYPES2 = [ + 'keyword', + 'long', + 'double', + 'date', + 'ip', + 'boolean', + 'geo_point', +] as const; + +export const oneOfLiterals = (arrayOfLiterals: Readonly) =>{ + return z.string().superRefine(validateIsOneOfLiteralsV1(arrayOfLiterals)); +}; + +const serializedFieldFormatSchema = z.object({ + id: z.optional(z.string()), + params: z.optional(z.any()), +}); + +const primitiveRuntimeFieldSchemaShared = { + script: z.optional( + z.object({ + source: z.string(), + }) + ), + format: z.optional(serializedFieldFormatSchema), + customLabel: z.optional(z.string()), + popularity: z.optional(z.number().min(0)), +}; + +const runtimeFieldNonCompositeFieldsSpecTypeSchema = z.enum( + RUNTIME_FIELD_TYPES2 +); + +const compositeRuntimeFieldSchemaShared = { + script: z.optional( + z.object({ + source: z.string(), + }) + ), + fields: z.optional( + z.record( + z.object({ + type: runtimeFieldNonCompositeFieldsSpecTypeSchema, + format: z.optional(serializedFieldFormatSchema), + customLabel: z.optional(z.string()), + popularity: z.optional(z.number().min(0)), + }) + ) + ), +}; + +const primitiveRuntimeFieldSchema = z.object({ + type: runtimeFieldNonCompositeFieldsSpecTypeSchema, + ...primitiveRuntimeFieldSchemaShared, +}); + +const compositeRuntimeFieldSchema = z.object({ + type: z.literal('composite'), + ...compositeRuntimeFieldSchemaShared, +}); + +const runtimeFieldSchema = z.union([ + primitiveRuntimeFieldSchema, + compositeRuntimeFieldSchema, +]); + +const fieldSpecSchemaFields = { + name: z.string().max(1000), + type: z.string().max(1000).default('string'), + count: z.optional(z.number().min(0)), + script: z.optional(z.string().max(1000000)), + format: z.optional(serializedFieldFormatSchema), + esTypes: z.optional(z.array(z.string())), + scripted: z.optional(z.boolean()), + subType: z.optional( + z.object({ + multi: z.optional( + z.object({ + parent: z.string(), + }) + ), + nested: z.optional( + z.object({ + path: z.string(), + }) + ), + }) + ), + customLabel: z.optional(z.string()), + shortDotsEnable: z.optional(z.boolean()), + searchable: z.optional(z.boolean()), + aggregatable: z.optional(z.boolean()), + readFromDocValues: z.optional(z.boolean()), + runtimeField: z.optional(runtimeFieldSchema), +}; + +// Allow and ignore unknowns to make fields transient. +// Because `fields` have a bunch of calculated fields +// this allows to retrieve an index pattern and then to re-create by using the retrieved payload +const fieldSpecSchema = z.object(fieldSpecSchemaFields).nonstrict(); + +const dataViewSpecSchema = z.object({ + title: z.string(), + version: z.optional(z.string()), + id: z.optional(z.string()), + type: z.optional(z.string()), + timeFieldName: z.optional(z.string()), + sourceFilters: z.optional( + z.array( + z.object({ + value: z.string(), + clientId: z.optional(z.union([z.string(), z.number()])), + }) + ) + ), + fields: z.optional(z.record(fieldSpecSchema)), + typeMeta: z.optional(z.object({}).passthrough()), + fieldFormats: z.optional(z.record(serializedFieldFormatSchema)), + fieldAttrs: z.optional( + z.record( + z.object({ + customLabel: z.optional(z.string()), + count: z.optional(z.number()), + }) + ) + ), + allowNoIndex: z.optional(z.boolean()), + runtimeFieldMap: z.optional(z.record(runtimeFieldSchema)), + name: z.optional(z.string()), + namespaces: z.optional(z.array(z.string())), + allowHidden: z.optional(z.boolean()), +}); + +const searchConfigurationSchema = z.object({ + index: z.union([z.string(), dataViewSpecSchema]), + query: z.object({ + language: z.string().superRefine(validateKQLStringFilterV1), + query: z.string(), + }), + filter: z.optional( + z.array( + z.object({ + query: z.optional(z.record(z.any())), + meta: z.record(z.any()), + }) + ) + ), +}); + +const baseCriterion = { + threshold: z.array(z.number()), + comparator: oneOfLiterals(Object.values(Comparators)), + timeUnit: z.string(), + timeSize: z.number(), +}; +const allowedAggregators = Object.values(Aggregators); +allowedAggregators.splice(Object.values(Aggregators).indexOf(Aggregators.COUNT), 1); + +const customCriterion = z.object({ + ...baseCriterion, + aggType: z.optional(z.literal('custom')), + metric: z.never(), + metrics: z.array( + z.union([ + z.object({ + name: z.string(), + aggType: oneOfLiterals(allowedAggregators), + field: z.string(), + filter: z.never(), + }), + z.object({ + name: z.string(), + aggType: z.literal('count'), + filter: z.optional(z.string().superRefine(validateKQLStringFilterV1)), + field: z.never(), + }), + ]) + ), + equation: z.optional(z.string()), + label: z.optional(z.string()), +}); + +export const customThresholdZodParamsSchema = z.object({ + criteria: z.array(customCriterion), + groupBy: z.optional(z.union([z.string(), z.array(z.string())])), + alertOnNoData: z.optional(z.boolean()), + alertOnGroupDisappear: z.optional(z.boolean()), + searchConfiguration: searchConfigurationSchema, +}).passthrough(); diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/es_query/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/es_query/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/es_query/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/es_query/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/es_query/v1.ts new file mode 100644 index 0000000000000..a59fd2609a4ed --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/es_query/v1.ts @@ -0,0 +1,88 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as z from 'zod'; +import { + validateIsOneOfLiteralsV1, + validateTimeWindowUnitsV1, + validateGroupByV1, + validationAggregationTypeV1, +} from '../'; + +const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000; +const MAX_SELECTABLE_GROUP_BY_TERMS = 4; +const MAX_SELECTABLE_SOURCE_FIELDS = 5; + +const comparators = { + GT: '>', + LT: '<', + GT_OR_EQ: '>=', + LT_OR_EQ: '<=', + BETWEEN: 'between', + NOT_BETWEEN: 'notBetween', +} as const; + +const oneOfLiterals = (arrayOfLiterals: Readonly) =>{ + return z.string().superRefine(validateIsOneOfLiteralsV1(arrayOfLiterals)); +}; + +const baseParams = z.object({ + size: z.number().min(0).max(ES_QUERY_MAX_HITS_PER_EXECUTION), + timeWindowSize: z.number().min(1), + excludeHitsFromPreviousRun: z.boolean().default(true), + timeWindowUnit: z.string().superRefine(validateTimeWindowUnitsV1), + threshold: z.array(z.number()).min(1).max(2), + thresholdComparator: oneOfLiterals(Object.values(comparators)), + // aggregation type + aggType: z.string().default('count').superRefine(validationAggregationTypeV1), + // aggregation field + aggField: z.optional(z.string().min(1)), + // how to group + groupBy: z.string().default('all').superRefine(validateGroupByV1), + // field to group on (for groupBy: top) + termField: z.optional( + z.union([ + z.string().min(1), + z.array(z.string()).min(2).max(MAX_SELECTABLE_GROUP_BY_TERMS), + ]) + ), + sourceFields: z.optional( + z.array( + z.object({ + label: z.string(), + searchPath: z.string(), + }) + ).max(MAX_SELECTABLE_SOURCE_FIELDS) + ), +}) + +const searchSourceParams = z.object({ + searchType: z.literal('searchSource'), + timeField: z.optional(z.string().min(1)), + searchConfiguration: z.object({}).passthrough(), +}); + +const esQueryParams = z.object({ + searchType: z.literal('esQuery'), + timeField: z.string().min(1), + esQuery: z.string().min(1), + index: z.array(z.string().min(1)).min(1), +}); + +const esqlQuery = z.object({ + searchType: z.literal('esqlQuery'), + timeField: z.optional(z.string().min(1)), + esqlQuery: z.object({ + esql: z.string().min(1) + }), +}); + +export const esQueryZodParamsSchema = z.discriminatedUnion('searchType', [ + baseParams.merge(searchSourceParams), + baseParams.merge(esQueryParams), + baseParams.merge(esqlQuery), +]); diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/index.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/index.ts new file mode 100644 index 0000000000000..ebcd7e8148ae5 --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/index.ts @@ -0,0 +1,39 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { validateIsOneOfLiterals } from './validation/validate_is_one_of_literals/latest'; +export { + validateIsStringElasticsearchJSONFilter, +} from './validation/validate_is_string_elasticsearch_json_filter/latest'; +export { validateKQLStringFilter } from './validation/validate_kql_string_filter/latest'; +export { validateTimeWindowUnits } from './validation/validate_time_window_unit/latest'; +export { validationAggregationType } from './validation/validate_aggregation_type/latest'; +export { validateGroupBy } from './validation/validate_group_by/latest'; + +export { + validateIsOneOfLiterals as validateIsOneOfLiteralsV1, +} from './validation/validate_is_one_of_literals/v1'; +export { + validateIsStringElasticsearchJSONFilter as validateIsStringElasticsearchJSONFilterV1, +} from './validation/validate_is_string_elasticsearch_json_filter/v1'; +export { + validateKQLStringFilter as validateKQLStringFilterV1, +} from './validation/validate_kql_string_filter/v1'; +export { + validateTimeWindowUnits as validateTimeWindowUnitsV1, +} from './validation/validate_time_window_unit/v1'; +export { validationAggregationType as validationAggregationTypeV1 } from './validation/validate_aggregation_type/v1'; +export { validateGroupBy as validateGroupByV1 } from './validation/validate_group_by/v1'; + + +export { customThresholdZodParamsSchema } from './custom_threshold/latest'; +export { metricThresholdZodParamsSchema } from './metric_threshold/latest'; +export { esQueryZodParamsSchema } from './es_query/latest'; + +export { customThresholdZodParamsSchema as customThresholdZodParamsSchemaV1 } from './custom_threshold/v1'; +export { metricThresholdZodParamsSchema as metricThresholdZodParamsSchemaV1 } from './metric_threshold/v1'; +export { esQueryZodParamsSchema as esQueryZodParamsSchemaV1 } from './es_query/v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/metric_threshold/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/metric_threshold/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/metric_threshold/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/metric_threshold/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/metric_threshold/v1.ts new file mode 100644 index 0000000000000..4c731887eca99 --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/metric_threshold/v1.ts @@ -0,0 +1,101 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as z from 'zod'; +import { + validateIsOneOfLiteralsV1, + validateIsStringElasticsearchJSONFilterV1, +} from '../'; + +const comparators = { + GT: '>', + LT: '<', + GT_OR_EQ: '>=', + LT_OR_EQ: '<=', + BETWEEN: 'between', + OUTSIDE_RANGE: 'outside', +} as const; + +const aggregations = { + COUNT: 'count', + AVERAGE: 'avg', + SUM: 'sum', + MIN: 'min', + MAX: 'max', + CARDINALITY: 'cardinality', + RATE: 'rate', + P95: 'p95', + P99: 'p99', + CUSTOM: 'custom' +} as const; + +const oneOfLiterals = (arrayOfLiterals: Readonly) =>{ + return z.string().superRefine(validateIsOneOfLiteralsV1(arrayOfLiterals)); +}; + +const baseCriterion = { + threshold: z.array(z.number()), + comparator: oneOfLiterals(Object.values(comparators)), + timeUnit: z.string(), + timeSize: z.number(), + warningThreshold: z.optional(z.array(z.number())), + warningComparator: z.optional(oneOfLiterals(Object.values(comparators))), +}; + +const nonCountCriterion = z.object({ + ...baseCriterion, + metric: z.string(), + aggType: oneOfLiterals(Object.values(aggregations)), + customMetrics: z.never(), + equation: z.never(), + label: z.never(), +}); + +const countCriterion = z.object({ + ...baseCriterion, + aggType: z.literal('count'), + metric: z.never(), + customMetrics: z.never(), + equation: z.never(), + label: z.never(), +}); + +const customCriterion = z.object({ + ...baseCriterion, + aggType: z.literal('custom'), + metric: z.never(), + customMetrics: z.array( + z.union([ + z.object({ + name: z.string(), + aggType: oneOfLiterals(['avg', 'sum', 'max', 'min', 'cardinality']), + field: z.string(), + filter: z.never(), + }), + z.object({ + name: z.string(), + aggType: z.literal('count'), + filter: z.optional(z.string()), + field: z.never(), + }), + ]) + ), + equation: z.optional(z.string()), + label: z.optional(z.string()), +}); + +export const metricThresholdZodParamsSchema = z.object({ + criteria: z.array( + z.union([countCriterion, nonCountCriterion, customCriterion]) + ), + groupBy: z.optional(z.union([z.string(), z.array(z.string())])), + filterQuery: z.optional( + z.string().superRefine(validateIsStringElasticsearchJSONFilterV1) + ), + sourceId: z.string(), + alertOnNoData: z.optional(z.boolean()), + alertOnGroupDisappear: z.optional(z.boolean()), +}).passthrough(); diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_aggregation_type/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_aggregation_type/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_aggregation_type/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_aggregation_type/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_aggregation_type/v1.ts new file mode 100644 index 0000000000000..1d34d3f3a78d2 --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_aggregation_type/v1.ts @@ -0,0 +1,33 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; + +const AggTypes = new Set(['count', 'avg', 'min', 'max', 'sum']); + +export const validationAggregationType = ( + aggType: string, + zodRefinementCtx: RefinementCtx, +) => { + if (AggTypes.has(aggType)) { + return; + } + + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message: i18n.translate('xpack.triggersActionsUI.data.coreQueryParams.invalidAggTypeErrorMessage', { + defaultMessage: 'invalid aggType: "{aggType}"', + values: { + aggType, + }, + }), + fatal: true, + }); + + return NEVER; +} diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_group_by/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_group_by/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_group_by/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_group_by/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_group_by/v1.ts new file mode 100644 index 0000000000000..d85d780c3193f --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_group_by/v1.ts @@ -0,0 +1,31 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; + +export const validateGroupBy = ( + groupBy: string, + zodRefinementCtx: RefinementCtx, +) => { + if (groupBy === 'all' || groupBy === 'top') { + return; + } + + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message: i18n.translate('xpack.triggersActionsUI.data.coreQueryParams.invalidGroupByErrorMessage', { + defaultMessage: 'invalid groupBy: "{groupBy}"', + values: { + groupBy, + }, + }), + fatal: true, + }); + + return NEVER; +} diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_one_of_literals/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_one_of_literals/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_one_of_literals/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_one_of_literals/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_one_of_literals/v1.ts new file mode 100644 index 0000000000000..ba67149744fd4 --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_one_of_literals/v1.ts @@ -0,0 +1,25 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; + +export const validateIsOneOfLiterals = ( + arrayOfLiterals: Readonly +) => ( + value: string, + zodRefinementCtx: RefinementCtx +) =>{ + if (!arrayOfLiterals.includes(value)) { + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message: `must be one of ${arrayOfLiterals.join(' | ')}`, + fatal: true, + }); + + return NEVER; + } +}; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_string_elasticsearch_json_filter/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_string_elasticsearch_json_filter/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_string_elasticsearch_json_filter/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_string_elasticsearch_json_filter/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_string_elasticsearch_json_filter/v1.ts new file mode 100644 index 0000000000000..4554cfdbb253f --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_is_string_elasticsearch_json_filter/v1.ts @@ -0,0 +1,40 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash'; +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; + +export const validateIsStringElasticsearchJSONFilter = ( + value: string, + zodRefinementCtx: RefinementCtx, +) => { + if (value === '') { + // Allow clearing the filter. + return; + } + + const message = 'filterQuery must be a valid Elasticsearch filter expressed in JSON'; + try { + const parsedValue = JSON.parse(value); + if (!isEmpty(parsedValue.bool)) { + return; + } + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message, + fatal: true, + }); + return NEVER; + } catch (e) { + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message, + fatal: true, + }); + return NEVER; + } +}; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_kql_string_filter/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_kql_string_filter/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_kql_string_filter/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_kql_string_filter/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_kql_string_filter/v1.ts new file mode 100644 index 0000000000000..89d4ed20c55e5 --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_kql_string_filter/v1.ts @@ -0,0 +1,33 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildEsQuery as kbnBuildEsQuery } from '@kbn/es-query'; +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; +import { i18n } from '@kbn/i18n'; + +export const validateKQLStringFilter = ( + value: string, + zodRefinementCtx: RefinementCtx, +) => { + if (value === '') { + // Allow clearing the filter. + return; + } + + try { + kbnBuildEsQuery(undefined, [{ query: value, language: 'kuery' }], []); + } catch (e) { + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message: i18n.translate('xpack.observability.customThreshold.rule.schema.invalidFilterQuery', { + defaultMessage: 'filterQuery must be a valid KQL filter', + }), + fatal: true, + }); + return NEVER; + } +}; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_time_window_unit/latest.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_time_window_unit/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_time_window_unit/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_time_window_unit/v1.ts b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_time_window_unit/v1.ts new file mode 100644 index 0000000000000..e1a8b81e7e6a0 --- /dev/null +++ b/packages/kbn-rule-data-utils/src/rule_params_schemas/validation/validate_time_window_unit/v1.ts @@ -0,0 +1,36 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; + +const TimeWindowUnits = new Set(['s', 'm', 'h', 'd']); + +export const validateTimeWindowUnits = ( + timeWindowUnit: string, + zodRefinementCtx: RefinementCtx, +) => { + if (TimeWindowUnits.has(timeWindowUnit)) { + return; + } + + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message: i18n.translate( + 'xpack.triggersActionsUI.data.coreQueryParams.invalidTimeWindowUnitsErrorMessage', + { + defaultMessage: 'invalid timeWindowUnit: "{timeWindowUnit}"', + values: { + timeWindowUnit, + }, + } + ), + fatal: true, + }); + + return NEVER; +} diff --git a/packages/kbn-rule-data-utils/tsconfig.json b/packages/kbn-rule-data-utils/tsconfig.json index 77352c4f44209..5fc846755e8ba 100644 --- a/packages/kbn-rule-data-utils/tsconfig.json +++ b/packages/kbn-rule-data-utils/tsconfig.json @@ -12,6 +12,7 @@ ], "kbn_references": [ "@kbn/es-query", + "@kbn/i18n", ], "exclude": [ "target/**/*", diff --git a/packages/kbn-zod/CONTRIBUTE.md b/packages/kbn-zod/CONTRIBUTE.md new file mode 100644 index 0000000000000..7d792ed5b8c4d --- /dev/null +++ b/packages/kbn-zod/CONTRIBUTE.md @@ -0,0 +1,9 @@ +# How to contribute + +This is Kibana's slightly customised version of `zod`. We use this library to wrap +`zod` types with certain useful functionality like printing error messages in a +certain format or introducing custom types for all Kibana to use. + +If you want to update an error message add a new type to `./src` and then make +sure that your customized version of the `zod` type is mixed-in to our internal +`zod` namespace. See `./src/zod.ts`. diff --git a/packages/kbn-zod/README.md b/packages/kbn-zod/README.md new file mode 100644 index 0000000000000..5a80c576a62f1 --- /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. diff --git a/packages/kbn-zod/index.ts b/packages/kbn-zod/index.ts new file mode 100644 index 0000000000000..ea19ad9e2814d --- /dev/null +++ b/packages/kbn-zod/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { z, instanceofZodType, extractErrorMessage } from './src'; 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..3fb32e74620fc --- /dev/null +++ b/packages/kbn-zod/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/zod", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "author": "Kibana Core", + "private": true +} \ No newline at end of file diff --git a/packages/kbn-zod/src/index.ts b/packages/kbn-zod/src/index.ts new file mode 100644 index 0000000000000..71ed233b23ffa --- /dev/null +++ b/packages/kbn-zod/src/index.ts @@ -0,0 +1,43 @@ +/* + * 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 typeDetect from 'type-detect'; +import * as z from './zod'; + +const globalErrorMap: z.ZodErrorMap = (issue, ctx) => { + if (issue.code === z.ZodIssueCode.invalid_type) { + return { + message: `expected value of type [${issue.expected}] but got [${typeDetect(ctx.data)}]`, + }; + } + return { message: ctx.defaultError }; +}; +z.setErrorMap(globalErrorMap); + +export function instanceofZodType(type: any): type is z.ZodTypeAny { + return !!type?._def?.typeName; +} + +function prefixPath(path: Array, message: string): string { + return path.length ? `[${path.join('.')}]: ${message}` : message; +} + +export function extractErrorMessage(error: z.ZodError): string { + let message: string = ''; + if (error.issues.length > 1) { + error.issues.forEach((issue) => { + message = `${message ? message + '\n' : message} - ${prefixPath(issue.path, issue.message)}`; + }); + } else { + const [issue] = error.issues; + message = prefixPath(issue.path, issue.message); + } + return message; +} + +export { z }; diff --git a/packages/kbn-zod/src/types/array_type.ts b/packages/kbn-zod/src/types/array_type.ts new file mode 100644 index 0000000000000..51fea7f80b77b --- /dev/null +++ b/packages/kbn-zod/src/types/array_type.ts @@ -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 z from 'zod'; + +const errorMap: z.ZodErrorMap = (issue, ctx) => { + const value = ctx.data as unknown[]; + if (issue.code === z.ZodIssueCode.too_small) { + return { + message: `array size is [${value.length}], but cannot be smaller than [${issue.minimum}]`, + }; + } + if (issue.code === z.ZodIssueCode.too_big) { + return { + message: `array size is [${value.length}], but cannot be greater than [${issue.maximum}]`, + }; + } + return { message: ctx.defaultError }; +}; + +export const array: typeof z.array = (schema, options) => z.array(schema, { errorMap, ...options }); diff --git a/packages/kbn-zod/src/types/index.ts b/packages/kbn-zod/src/types/index.ts new file mode 100644 index 0000000000000..dc83719612830 --- /dev/null +++ b/packages/kbn-zod/src/types/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { array } from './array_type'; +export { literal } from './literal_type'; +export { number } from './number_type'; +export { object } from './object_type'; +export { string } from './string_type'; +export { union } from './union_type'; +export { never } from './never_type'; diff --git a/packages/kbn-zod/src/types/literal_type.ts b/packages/kbn-zod/src/types/literal_type.ts new file mode 100644 index 0000000000000..5e81b328e7e7b --- /dev/null +++ b/packages/kbn-zod/src/types/literal_type.ts @@ -0,0 +1,19 @@ +/* + * 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'; + +const errorMap: z.ZodErrorMap = (issue, ctx) => { + if (issue.code === z.ZodIssueCode.invalid_literal) { + return { message: `expected value to equal [${issue.expected}]` }; + } + return { message: ctx.defaultError }; +}; + +export const literal: typeof z.literal = (value, options) => + z.literal(value, { errorMap, ...options }); diff --git a/packages/kbn-zod/src/types/never_type.ts b/packages/kbn-zod/src/types/never_type.ts new file mode 100644 index 0000000000000..a9403036f5dec --- /dev/null +++ b/packages/kbn-zod/src/types/never_type.ts @@ -0,0 +1,15 @@ +/* + * 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'; + +export const never: typeof z.never = (options) => + z.never({ + errorMap: () => ({ message: `a value wasn't expected to be present` }), + ...options, + }); diff --git a/packages/kbn-zod/src/types/number_type.ts b/packages/kbn-zod/src/types/number_type.ts new file mode 100644 index 0000000000000..62c78df0aed94 --- /dev/null +++ b/packages/kbn-zod/src/types/number_type.ts @@ -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 'zod'; + +const errorMap: z.ZodErrorMap = (issue, ctx) => { + if (issue.code === z.ZodIssueCode.too_small) { + return { + message: `Value must be equal to or greater than [${issue.minimum}].`, + }; + } + if (issue.code === z.ZodIssueCode.too_big) { + return { + message: `Value must be equal to or lower than [${issue.maximum}].`, + }; + } + return { + message: ctx.defaultError, + }; +}; + +export const number: typeof z.number = (options) => z.number({ errorMap, ...options }); diff --git a/packages/kbn-zod/src/types/object_type.ts b/packages/kbn-zod/src/types/object_type.ts new file mode 100644 index 0000000000000..4822d75b0689f --- /dev/null +++ b/packages/kbn-zod/src/types/object_type.ts @@ -0,0 +1,24 @@ +/* + * 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'; + +const errorMap: z.ZodErrorMap = (issue, ctx) => { + if (issue.code === z.ZodIssueCode.unrecognized_keys) { + if (issue.keys.length === 1) { + return { + message: `definition for [${issue.keys[0]}] key is missing`, + }; + } + return { message: `definition for these keys is missing: [${issue.keys.join(', ')}]` }; + } + return { message: ctx.defaultError }; +}; + +export const object: typeof z.object = (schema, options) => + z.object(schema, { errorMap, ...options }); diff --git a/packages/kbn-zod/src/types/string_type.ts b/packages/kbn-zod/src/types/string_type.ts new file mode 100644 index 0000000000000..c7f27cd4d29bb --- /dev/null +++ b/packages/kbn-zod/src/types/string_type.ts @@ -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 z from 'zod'; + +const errorMap: z.ZodErrorMap = (issue, ctx) => { + const value = ctx.data as string; + if (issue.code === z.ZodIssueCode.too_small) { + return { + message: `value has length [${value.length}] but it must have a minimum length of [${issue.minimum}].`, + }; + } + if (issue.code === z.ZodIssueCode.too_big) { + return { + message: `value has length [${value.length}] but it must have a maximum length of [${issue.maximum}].`, + }; + } + return { message: ctx.defaultError }; +}; + +export const string: typeof z.string = (options) => z.string({ errorMap, ...options }); diff --git a/packages/kbn-zod/src/types/union_type.test.ts b/packages/kbn-zod/src/types/union_type.test.ts new file mode 100644 index 0000000000000..7298317dad667 --- /dev/null +++ b/packages/kbn-zod/src/types/union_type.test.ts @@ -0,0 +1,39 @@ +/* + * 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, extractErrorMessage } from '..'; + +test('formats error message as expected with object types', () => { + const type = z.union([z.object({ age: z.number() }), z.string()]); + const result = type.safeParse({ age: 'foo' }); + if (result.success) fail('expected validation to fail!'); + expect(extractErrorMessage(result.error)).toMatchInlineSnapshot(` + "expected one of: + | { [age]: number } but got { [age]: string } + | [string] but got [object] + " + `); +}); + +test('formats error message as expected for nested unions', () => { + const type = z.union([ + z.union([z.boolean(), z.string()]), + z.union([z.union([z.object({ foo: z.string() }), z.number()]), z.never()]), + ]); + const result = type.safeParse({ age: 'foo' }); + if (result.success) fail('expected validation to fail!'); + expect(extractErrorMessage(result.error)).toMatchInlineSnapshot(` + "expected one of: + | [boolean] but got [object] + | [string] but got [object] + | { [foo]: string } but got { [foo]: undefined } + | [number] but got [object] + | [never] but got [object] + " + `); +}); diff --git a/packages/kbn-zod/src/types/union_type.ts b/packages/kbn-zod/src/types/union_type.ts new file mode 100644 index 0000000000000..8b01dd19362dc --- /dev/null +++ b/packages/kbn-zod/src/types/union_type.ts @@ -0,0 +1,45 @@ +/* + * 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'; + +export function extractExpectedUnionValues( + issue: z.ZodInvalidUnionIssue, + values: string[] = [] +): string[] { + issue.unionErrors.forEach((e) => { + e.issues.forEach((i) => { + if (i.code === z.ZodIssueCode.invalid_type || i.code === z.ZodIssueCode.invalid_literal) { + if (i.path.length) { + const path = i.path.join('.'); + values.push(`{ [${path}]: ${i.expected} } but got { [${path}]: ${i.received} }`); + } else { + values.push(`[${i.expected}] but got [${i.received}]`); + } + } else if (i.code === z.ZodIssueCode.invalid_union) { + extractExpectedUnionValues(i, values); + } + }); + }); + return values; +} + +const errorMap: z.ZodErrorMap = (issue, ctx) => { + if (issue.code === z.ZodIssueCode.invalid_union) { + return { + message: `expected one of: + ${extractExpectedUnionValues(issue) + .map((s) => `| ${s}`) + .join('\n ')} +`, + }; + } + return { message: ctx.defaultError }; +}; + +export const union: typeof z.union = (types, options) => z.union(types, { errorMap, ...options }); diff --git a/packages/kbn-zod/src/zod.test.ts b/packages/kbn-zod/src/zod.test.ts new file mode 100644 index 0000000000000..c392a85bc226a --- /dev/null +++ b/packages/kbn-zod/src/zod.test.ts @@ -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 * as z from './zod'; +import { instanceofZodType } from '.'; + +interface MyDef extends z.ZodTypeDef { + typeName: 'myType'; +} +class MyType extends z.ZodType { + constructor(def: MyDef) { + super(def); + } + _parse = (): any => {}; +} + +describe('instanceofZodType', () => { + test('returns true for zod types', () => { + const myType = new MyType({ typeName: 'myType' }); + expect(instanceofZodType(myType)).toBe(true); + }); +}); diff --git a/packages/kbn-zod/src/zod.ts b/packages/kbn-zod/src/zod.ts new file mode 100644 index 0000000000000..bee9bfd798175 --- /dev/null +++ b/packages/kbn-zod/src/zod.ts @@ -0,0 +1,12 @@ +/* + * 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'; + +// Override zod namespace types with our own below, this file must be imported like `import * as z from './zod'` +export { array, literal, never, number, object, string, union } from './types'; diff --git a/packages/kbn-zod/tsconfig.json b/packages/kbn-zod/tsconfig.json new file mode 100644 index 0000000000000..ffb5b09f86995 --- /dev/null +++ b/packages/kbn-zod/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "stripInternal": true, + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 03bf4e8e55309..a15c819bc4378 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -880,6 +880,8 @@ "@kbn/generate-console-definitions/*": ["packages/kbn-generate-console-definitions/*"], "@kbn/generate-csv": ["packages/kbn-generate-csv"], "@kbn/generate-csv/*": ["packages/kbn-generate-csv/*"], + "@kbn/generate-oas": ["packages/kbn-generate-oas"], + "@kbn/generate-oas/*": ["packages/kbn-generate-oas/*"], "@kbn/get-repo-files": ["packages/kbn-get-repo-files"], "@kbn/get-repo-files/*": ["packages/kbn-get-repo-files/*"], "@kbn/global-search-bar-plugin": ["x-pack/plugins/global_search_bar"], @@ -1772,6 +1774,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/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts b/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts index 093299dbe66f2..9dc14a5364b45 100644 --- a/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts +++ b/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts @@ -8,7 +8,9 @@ export { filterStateStore } from './constants/latest'; export type { FilterStateStore } from './constants/latest'; export { alertsFilterQuerySchema } from './schemas/latest'; +export { alertsFilterQueryZodSchema } from './zod_schemas/latest'; export { filterStateStore as filterStateStoreV1 } from './constants/v1'; export type { FilterStateStore as FilterStateStoreV1 } from './constants/v1'; export { alertsFilterQuerySchema as alertsFilterQuerySchemaV1 } from './schemas/v1'; +export { alertsFilterQueryZodSchema as alertsFilterQueryZodSchemaV1 } from './zod_schemas/v1'; diff --git a/x-pack/plugins/alerting/common/routes/alerts_filter_query/zod_schemas/latest.ts b/x-pack/plugins/alerting/common/routes/alerts_filter_query/zod_schemas/latest.ts new file mode 100644 index 0000000000000..4de99c5962678 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/alerts_filter_query/zod_schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { alertsFilterQueryZodSchema } from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/alerts_filter_query/zod_schemas/v1.ts b/x-pack/plugins/alerting/common/routes/alerts_filter_query/zod_schemas/v1.ts new file mode 100644 index 0000000000000..a252f4c5e39e2 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/alerts_filter_query/zod_schemas/v1.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as z from 'zod'; +import { filterStateStore } from '..'; + +export const alertsFilterQueryZodSchema = z.object({ + kql: z.string(), + filters: z.array( + z.object({ + query: z.optional(z.record(z.any())), + meta: z.record(z.any()), + $state: z.optional( + z.object({ + store: z.union([ + z.literal(filterStateStore.APP_STATE), + z.literal(filterStateStore.GLOBAL_STATE), + ]), + }) + ), + }) + ), + dsl: z.optional(z.string()), +}); diff --git a/x-pack/plugins/alerting/common/routes/r_rule/index.ts b/x-pack/plugins/alerting/common/routes/r_rule/index.ts index 6062cbd1fac59..11dd0c0074cdb 100644 --- a/x-pack/plugins/alerting/common/routes/r_rule/index.ts +++ b/x-pack/plugins/alerting/common/routes/r_rule/index.ts @@ -7,10 +7,14 @@ export { rRuleRequestSchema } from './request/schemas/latest'; export { rRuleResponseSchema } from './response/schemas/latest'; +export { rRuleResponseZodSchema } from './response/zod_schemas/latest'; + export type { RRuleRequest } from './request/types/latest'; export type { RRuleResponse } from './response/types/latest'; export { rRuleRequestSchema as rRuleRequestSchemaV1 } from './request/schemas/v1'; export { rRuleResponseSchema as rRuleResponseSchemaV1 } from './response/schemas/v1'; +export { rRuleResponseZodSchema as rRuleResponseZodSchemaV1 } from './response/zod_schemas/v1'; + export type { RRuleRequest as RRuleRequestV1 } from './request/types/v1'; export type { RRuleResponse as RRuleResponseV1 } from './response/types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/r_rule/response/zod_schemas/latest.ts b/x-pack/plugins/alerting/common/routes/r_rule/response/zod_schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/r_rule/response/zod_schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/r_rule/response/zod_schemas/v1.ts b/x-pack/plugins/alerting/common/routes/r_rule/response/zod_schemas/v1.ts new file mode 100644 index 0000000000000..ecb7b00ce54ec --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/r_rule/response/zod_schemas/v1.ts @@ -0,0 +1,47 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as z from 'zod'; + +export const rRuleResponseZodSchema = z.object({ + dtstart: z.string(), + tzid: z.string(), + freq: z.optional( + z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + ]) + ), + until: z.optional(z.string()), + count: z.optional(z.number()), + interval: z.optional(z.number()), + wkst: z.optional( + z.union([ + z.literal('MO'), + z.literal('TU'), + z.literal('WE'), + z.literal('TH'), + z.literal('FR'), + z.literal('SA'), + z.literal('SU'), + ]) + ), + byweekday: z.optional(z.array(z.union([z.string(), z.number()]))), + bymonth: z.optional(z.array(z.number())), + bysetpos: z.optional(z.array(z.number())), + bymonthday: z.optional(z.array(z.number())), + byyearday: z.optional(z.array(z.number())), + byweekno: z.optional(z.array(z.number())), + byhour: z.optional(z.array(z.number())), + byminute: z.optional(z.array(z.number())), + bysecond: z.optional(z.array(z.number())), +}); diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/create/index.ts b/x-pack/plugins/alerting/common/routes/rule/apis/create/index.ts index b7d96ceed715c..a1d47f4149f9a 100644 --- a/x-pack/plugins/alerting/common/routes/rule/apis/create/index.ts +++ b/x-pack/plugins/alerting/common/routes/rule/apis/create/index.ts @@ -13,6 +13,14 @@ export { createBodySchema, } from './schemas/latest'; +export { + actionFrequencyZodSchema, + actionAlertsFilterZodSchema, + actionZodSchema, + createParamsZodSchema, + createBodyZodSchema, +} from './zod_schemas/latest'; + export type { CreateRuleAction, CreateRuleActionFrequency, @@ -29,6 +37,14 @@ export { createBodySchema as createBodySchemaV1, } from './schemas/v1'; +export { + actionFrequencyZodSchema as actionFrequencyZodSchemaV1, + actionAlertsFilterZodSchema as actionAlertsFilterZodSchemaV1, + actionZodSchema as actionZodSchemaV1, + createParamsZodSchema as createParamsZodSchemaV1, + createBodyZodSchema as createBodyZodSchemaV1, +} from './zod_schemas/v1'; + export type { CreateRuleAction as CreateRuleActionV1, CreateRuleActionFrequency as CreateRuleActionFrequencyV1, diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/create/zod_schemas/latest.ts b/x-pack/plugins/alerting/common/routes/rule/apis/create/zod_schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/create/zod_schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/create/zod_schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/create/zod_schemas/v1.ts new file mode 100644 index 0000000000000..93b3ad218cfbd --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/apis/create/zod_schemas/v1.ts @@ -0,0 +1,83 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as z from 'zod'; +import { + customThresholdZodParamsSchemaV1, + metricThresholdZodParamsSchemaV1, + esQueryZodParamsSchemaV1, +} from '@kbn/rule-data-utils'; +import { validateDurationV1, validateHoursV1, validateTimezoneV1 } from '../../../validation'; +import { notifyWhenZodSchemaV1, alertDelayZodSchemaV1 } from '../../../response'; +import { alertsFilterQueryZodSchemaV1 } from '../../../../alerts_filter_query'; + +export const ruleParamsZodSchema = z.union([ + customThresholdZodParamsSchemaV1.describe('Custom threshold rule type params'), + metricThresholdZodParamsSchemaV1.describe('Metric threshold rule type params'), + esQueryZodParamsSchemaV1.describe('ES query rule type params'), +]); + +export const actionFrequencyZodSchema = z.object({ + summary: z.boolean(), + notify_when: notifyWhenZodSchemaV1, + throttle: z.nullable(z.string().superRefine(validateDurationV1)), +}); + +export const actionAlertsFilterZodSchema = z.object({ + query: z.optional(alertsFilterQueryZodSchemaV1), + timeframe: z.optional( + z.object({ + days: z.array( + z.union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + z.literal(7), + ]) + ), + hours: z.object({ + start: z.string().superRefine(validateHoursV1), + end: z.string().superRefine(validateHoursV1), + }), + timezone: z.string().superRefine(validateTimezoneV1), + }) + ), +}); + +export const actionZodSchema = z.object({ + uuid: z.optional(z.string()), + group: z.string(), + id: z.string(), + actionTypeId: z.optional(z.string()), + params: z.record(z.optional(z.any())).default({}), + frequency: z.optional(actionFrequencyZodSchema), + alerts_filter: z.optional(actionAlertsFilterZodSchema), + use_alert_data_for_template: z.optional(z.boolean()), +}); + +export const createBodyZodSchema = z.object({ + name: z.string(), + rule_type_id: z.string(), + enabled: z.boolean().default(true), + consumer: z.string(), + tags: z.array(z.string()).default([]), + throttle: z.optional(z.nullable(z.string().superRefine(validateDurationV1))), + params: ruleParamsZodSchema, + schedule: z.object({ + interval: z.string().superRefine(validateDurationV1), + }), + actions: z.array(actionZodSchema).default([]), + notify_when: z.optional(z.nullable(notifyWhenZodSchemaV1)), + alert_delay: z.optional(alertDelayZodSchemaV1), +}); + +export const createParamsZodSchema = z.object({ + id: z.optional(z.string()), +}); diff --git a/x-pack/plugins/alerting/common/routes/rule/response/index.ts b/x-pack/plugins/alerting/common/routes/rule/response/index.ts index 8c784e744d473..4a5fd623fcee1 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/index.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/index.ts @@ -16,8 +16,23 @@ export { ruleSnoozeScheduleSchema, notifyWhenSchema, scheduleIdsSchema, + alertDelaySchema, } from './schemas/latest'; +export { + ruleParamsZodSchema, + actionParamsZodSchema, + mappedParamsZodSchema, + ruleExecutionStatusZodSchema, + ruleLastRunZodSchema, + monitoringZodSchema, + ruleResponseZodSchema, + ruleSnoozeScheduleZodSchema, + notifyWhenZodSchema, + scheduleIdsZodSchema, + alertDelayZodSchema, +} from './zod_schemas/latest'; + export type { RuleParams, RuleResponse, @@ -40,6 +55,20 @@ export { alertDelaySchema as alertDelaySchemaV1, } from './schemas/v1'; +export { + ruleParamsZodSchema as ruleParamsZodSchemaV1, + actionParamsZodSchema as actionParamsZodSchemaV1, + mappedParamsZodSchema as mappedParamsZodSchemaV1, + ruleExecutionStatusZodSchema as ruleExecutionStatusZodSchemaV1, + ruleLastRunZodSchema as ruleLastRunZodSchemaV1, + monitoringZodSchema as monitoringZodSchemaV1, + ruleResponseZodSchema as ruleResponseZodSchemaV1, + ruleSnoozeScheduleZodSchema as ruleSnoozeScheduleZodSchemaV1, + notifyWhenZodSchema as notifyWhenZodSchemaV1, + scheduleIdsZodSchema as scheduleIdsZodSchemaV1, + alertDelayZodSchema as alertDelayZodSchemaV1, +} from './zod_schemas/v1'; + export type { RuleParams as RuleParamsV1, RuleResponse as RuleResponseV1, diff --git a/x-pack/plugins/alerting/common/routes/rule/response/zod_schemas/latest.ts b/x-pack/plugins/alerting/common/routes/rule/response/zod_schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/response/zod_schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/rule/response/zod_schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/zod_schemas/v1.ts new file mode 100644 index 0000000000000..c996c79ff84ff --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/rule/response/zod_schemas/v1.ts @@ -0,0 +1,230 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as z from 'zod'; +import { + customThresholdZodParamsSchemaV1, + metricThresholdZodParamsSchemaV1, + esQueryZodParamsSchemaV1, +} from '@kbn/rule-data-utils'; +import { rRuleResponseZodSchemaV1 } from '../../../r_rule'; +import { alertsFilterQueryZodSchemaV1 } from '../../../alerts_filter_query'; +import { + ruleNotifyWhen as ruleNotifyWhenV1, + ruleExecutionStatusValues as ruleExecutionStatusValuesV1, + ruleExecutionStatusErrorReason as ruleExecutionStatusErrorReasonV1, + ruleExecutionStatusWarningReason as ruleExecutionStatusWarningReasonV1, + ruleLastRunOutcomeValues as ruleLastRunOutcomeValuesV1, +} from '../../common/constants/v1'; +import { validateNotifyWhenV1 } from '../../validation'; + +export const ruleParamsZodSchema = z.union([ + customThresholdZodParamsSchemaV1.describe('Custom threshold rule type params'), + metricThresholdZodParamsSchemaV1.describe('Metric threshold rule type params'), + esQueryZodParamsSchemaV1.describe('ES query rule type params'), +]); +export const actionParamsZodSchema = z.record(z.string(), z.optional(z.any())); +export const mappedParamsZodSchema = z.record(z.string(), z.optional(z.any())); + +export const notifyWhenZodSchema = z.union([ + z.literal(ruleNotifyWhenV1.CHANGE), + z.literal(ruleNotifyWhenV1.ACTIVE), + z.literal(ruleNotifyWhenV1.THROTTLE), +]).superRefine(validateNotifyWhenV1); + +const intervalScheduleZodSchema = z.object({ + interval: z.string(), +}); + +const actionFrequencyZodSchema = z.object({ + summary: z.boolean(), + notify_when: notifyWhenZodSchema, + throttle: z.nullable(z.string()), +}); + +const actionAlertsFilterZodSchema = z.object({ + query: z.optional(alertsFilterQueryZodSchemaV1), + timeframe: z.optional( + z.object({ + days: z.array( + z.union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + z.literal(7), + ]) + ), + hours: z.object({ + start: z.string(), + end: z.string(), + }), + timezone: z.string(), + }) + ), +}); + +const actionZodSchema = z.object({ + uuid: z.optional(z.string()), + group: z.string(), + id: z.string(), + connector_type_id: z.string(), + params: actionParamsZodSchema, + frequency: z.optional(actionFrequencyZodSchema), + alerts_filter: z.optional(actionAlertsFilterZodSchema), +}); + +export const ruleExecutionStatusZodSchema = z.object({ + status: z.union([ + z.literal(ruleExecutionStatusValuesV1.OK), + z.literal(ruleExecutionStatusValuesV1.ACTIVE), + z.literal(ruleExecutionStatusValuesV1.ERROR), + z.literal(ruleExecutionStatusValuesV1.WARNING), + z.literal(ruleExecutionStatusValuesV1.PENDING), + z.literal(ruleExecutionStatusValuesV1.UNKNOWN), + ]), + last_execution_date: z.string(), + last_duration: z.optional(z.number()), + error: z.optional( + z.object({ + reason: z.union([ + z.literal(ruleExecutionStatusErrorReasonV1.READ), + z.literal(ruleExecutionStatusErrorReasonV1.DECRYPT), + z.literal(ruleExecutionStatusErrorReasonV1.EXECUTE), + z.literal(ruleExecutionStatusErrorReasonV1.UNKNOWN), + z.literal(ruleExecutionStatusErrorReasonV1.LICENSE), + z.literal(ruleExecutionStatusErrorReasonV1.TIMEOUT), + z.literal(ruleExecutionStatusErrorReasonV1.DISABLED), + z.literal(ruleExecutionStatusErrorReasonV1.VALIDATE), + ]), + message: z.string(), + }) + ), + warning: z.optional( + z.object({ + reason: z.union([ + z.literal(ruleExecutionStatusWarningReasonV1.MAX_EXECUTABLE_ACTIONS), + z.literal(ruleExecutionStatusWarningReasonV1.MAX_ALERTS), + z.literal(ruleExecutionStatusWarningReasonV1.MAX_QUEUED_ACTIONS), + ]), + message: z.string(), + }) + ), +}); + +export const ruleLastRunZodSchema = z.object({ + outcome: z.union([ + z.literal(ruleLastRunOutcomeValuesV1.SUCCEEDED), + z.literal(ruleLastRunOutcomeValuesV1.WARNING), + z.literal(ruleLastRunOutcomeValuesV1.FAILED), + ]), + outcome_order: z.optional(z.number()), + warning: z.optional( + z.nullable( + z.union([ + z.literal(ruleExecutionStatusErrorReasonV1.READ), + z.literal(ruleExecutionStatusErrorReasonV1.DECRYPT), + z.literal(ruleExecutionStatusErrorReasonV1.EXECUTE), + z.literal(ruleExecutionStatusErrorReasonV1.UNKNOWN), + z.literal(ruleExecutionStatusErrorReasonV1.LICENSE), + z.literal(ruleExecutionStatusErrorReasonV1.TIMEOUT), + z.literal(ruleExecutionStatusErrorReasonV1.DISABLED), + z.literal(ruleExecutionStatusErrorReasonV1.VALIDATE), + z.literal(ruleExecutionStatusWarningReasonV1.MAX_EXECUTABLE_ACTIONS), + z.literal(ruleExecutionStatusWarningReasonV1.MAX_ALERTS), + z.literal(ruleExecutionStatusWarningReasonV1.MAX_QUEUED_ACTIONS), + ]) + ) + ), + outcome_msg: z.optional(z.nullable(z.array(z.string()))), + alerts_count: z.object({ + active: z.optional(z.nullable(z.number())), + new: z.optional(z.nullable(z.number())), + recovered: z.optional(z.nullable(z.number())), + ignored: z.optional(z.nullable(z.number())), + }), +}); + +export const monitoringZodSchema = z.object({ + run: z.object({ + history: z.array( + z.object({ + success: z.boolean(), + timestamp: z.number(), + duration: z.optional(z.number()), + outcome: z.optional(ruleLastRunZodSchema), + }) + ), + calculated_metrics: z.object({ + p50: z.optional(z.number()), + p95: z.optional(z.number()), + p99: z.optional(z.number()), + success_ratio: z.number(), + }), + last_run: z.object({ + timestamp: z.string(), + metrics: z.object({ + duration: z.optional(z.number()), + total_search_duration_ms: z.optional(z.nullable(z.number())), + total_indexing_duration_ms: z.optional(z.nullable(z.number())), + total_alerts_detected: z.optional(z.nullable(z.number())), + total_alerts_created: z.optional(z.nullable(z.number())), + gap_duration_s: z.optional(z.nullable(z.number())), + }), + }), + }), +}); + +export const ruleSnoozeScheduleZodSchema = z.object({ + id: z.optional(z.string()), + duration: z.number(), + rRule: rRuleResponseZodSchemaV1, + skipRecurrences: z.optional(z.array(z.string())), +}); + +export const alertDelayZodSchema = z.object({ + active: z.number(), +}); + +export const ruleResponseZodSchema = z.object({ + id: z.string(), + enabled: z.boolean(), + name: z.string(), + tags: z.array(z.string()), + rule_type_id: z.string(), + consumer: z.string(), + schedule: intervalScheduleZodSchema, + actions: z.array(actionZodSchema), + params: ruleParamsZodSchema, + mapped_params: z.optional(mappedParamsZodSchema), + scheduled_task_id: z.optional(z.string()), + created_by: z.nullable(z.string()), + updated_by: z.nullable(z.string()), + created_at: z.string(), + updated_at: z.string(), + api_key_owner: z.nullable(z.string()), + api_key_created_by_user: z.optional(z.nullable(z.boolean())), + throttle: z.optional(z.nullable(z.string())), + mute_all: z.boolean(), + notify_when: z.optional(z.nullable(notifyWhenZodSchema)), + muted_alert_ids: z.array(z.string()), + execution_status: ruleExecutionStatusZodSchema, + monitoring: z.optional(monitoringZodSchema), + snooze_schedule: z.optional(z.array(ruleSnoozeScheduleZodSchema)), + active_snoozes: z.optional(z.array(z.string())), + is_snoozed_until: z.optional(z.nullable(z.string())), + last_run: z.optional(z.nullable(ruleLastRunZodSchema)), + next_run: z.optional(z.nullable(z.string())), + revision: z.number(), + running: z.optional(z.nullable(z.boolean())), + view_in_app_relative_url: z.optional(z.nullable(z.string())), + alert_delay: z.optional(alertDelayZodSchema), +}); + +export const scheduleIdsZodSchema = z.optional(z.array(z.string())); diff --git a/x-pack/plugins/alerting/common/routes/rule/validation/validate_duration/v1.ts b/x-pack/plugins/alerting/common/routes/rule/validation/validate_duration/v1.ts index d07d710687cc0..97527b9ddbc0b 100644 --- a/x-pack/plugins/alerting/common/routes/rule/validation/validate_duration/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/validation/validate_duration/v1.ts @@ -5,12 +5,14 @@ * 2.0. */ +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; + const SECONDS_REGEX = /^[1-9][0-9]*s$/; const MINUTES_REGEX = /^[1-9][0-9]*m$/; const HOURS_REGEX = /^[1-9][0-9]*h$/; const DAYS_REGEX = /^[1-9][0-9]*d$/; -export function validateDuration(duration: string) { +export function validateDuration(duration: string, zodRefinementCtx?: RefinementCtx) { if (duration.match(SECONDS_REGEX)) { return; } @@ -23,5 +25,17 @@ export function validateDuration(duration: string) { if (duration.match(DAYS_REGEX)) { return; } - return 'string is not a valid duration: ' + duration; + + const message = `string is not a valid duration: ${duration}`; + + if (zodRefinementCtx) { + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message, + fatal: true, + }); + + return NEVER; + } + return message; } diff --git a/x-pack/plugins/alerting/common/routes/rule/validation/validate_hours/v1.ts b/x-pack/plugins/alerting/common/routes/rule/validation/validate_hours/v1.ts index 5c4fcd264ca04..1d359110ad865 100644 --- a/x-pack/plugins/alerting/common/routes/rule/validation/validate_hours/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/validation/validate_hours/v1.ts @@ -5,9 +5,22 @@ * 2.0. */ -export function validateHours(time: string) { +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; + +export const validateHours = (time: string, zodRefinementCtx?: RefinementCtx) => { if (/^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/.test(time)) { return; } - return 'string is not a valid time in HH:mm format ' + time; + const message = `string is not a valid time in HH:mm format ${time}`; + + if (zodRefinementCtx) { + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message, + fatal: true, + }); + + return NEVER; + } + return message; } diff --git a/x-pack/plugins/alerting/common/routes/rule/validation/validate_notify_when/v1.ts b/x-pack/plugins/alerting/common/routes/rule/validation/validate_notify_when/v1.ts index 38ccd877c49ae..3b88a4e04b592 100644 --- a/x-pack/plugins/alerting/common/routes/rule/validation/validate_notify_when/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/validation/validate_notify_when/v1.ts @@ -5,11 +5,24 @@ * 2.0. */ +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; import { ruleNotifyWhenV1, RuleNotifyWhenV1 } from '../../common'; -export function validateNotifyWhen(notifyWhen: string) { +export function validateNotifyWhen(notifyWhen: string, zodRefinementCtx?: RefinementCtx) { if (Object.values(ruleNotifyWhenV1).includes(notifyWhen as RuleNotifyWhenV1)) { return; } - return `string is not a valid RuleNotifyWhenType: ${notifyWhen}`; + + const message = `string is not a valid RuleNotifyWhenType: ${notifyWhen}`; + + if (zodRefinementCtx) { + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message, + fatal: true, + }); + + return NEVER; + } + return message; } diff --git a/x-pack/plugins/alerting/common/routes/rule/validation/validate_timezone/v1.ts b/x-pack/plugins/alerting/common/routes/rule/validation/validate_timezone/v1.ts index 68b095f4378d0..335dc94d090d8 100644 --- a/x-pack/plugins/alerting/common/routes/rule/validation/validate_timezone/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/validation/validate_timezone/v1.ts @@ -4,12 +4,25 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import moment from 'moment'; import 'moment-timezone'; +import { NEVER, RefinementCtx, ZodIssueCode } from 'zod'; -export function validateTimezone(timezone: string) { +export function validateTimezone(timezone: string, zodRefinementCtx?: RefinementCtx) { if (moment.tz.names().includes(timezone)) { return; } - return 'string is not a valid timezone: ' + timezone; + const message = `string is not a valid timezone: ${timezone}`; + + if (zodRefinementCtx) { + zodRefinementCtx.addIssue({ + code: ZodIssueCode.custom, + message, + fatal: true, + }); + + return NEVER; + } + return message; } diff --git a/x-pack/plugins/alerting/server/routes/get_rule.ts b/x-pack/plugins/alerting/server/routes/get_rule.ts index bc7983f6acdf1..6c100002bfe41 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.ts @@ -5,9 +5,11 @@ * 2.0. */ +import { z } from '@kbn/zod'; import { omit } from 'lodash'; -import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; +import { ruleResponseZodSchemaV1 } from '../../common/routes/rule/response'; + import { ILicenseState } from '../lib'; import { verifyAccessAndContext, @@ -23,10 +25,6 @@ import { SanitizedRule, } from '../types'; -const paramSchema = schema.object({ - id: schema.string(), -}); - const rewriteBodyRes: RewriteResponseCase> = ({ alertTypeId, createdBy, @@ -85,28 +83,44 @@ const buildGetRuleRoute = ({ router, excludeFromPublicApi = false, }: BuildGetRulesRouteParams) => { - router.get( - { + router + .versioned + .get({ path, - validate: { - params: paramSchema, + access: excludeFromPublicApi ? 'internal' : 'public', + description: 'Get a rule', + }) + .addVersion( + { + version: excludeFromPublicApi ? '1' : '2023-10-31', + validate: { + request: { + params: z.object({ + id: z.string(), + }), + }, + response: { + 200: { + body: ruleResponseZodSchemaV1, + }, + }, + }, }, - }, - router.handleLegacyErrors( - verifyAccessAndContext(licenseState, async function (context, req, res) { - const rulesClient = (await context.alerting).getRulesClient(); - const { id } = req.params; - const rule = await rulesClient.get({ - id, - excludeFromPublicApi, - includeSnoozeData: true, - }); - return res.ok({ - body: rewriteBodyRes(rule), - }); - }) - ) - ); + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + const { id } = req.params; + const rule = await rulesClient.get({ + id, + excludeFromPublicApi, + includeSnoozeData: true, + }); + return res.ok({ + body: rewriteBodyRes(rule), + }); + }) + ), + ); }; export const getRuleRoute = ( @@ -116,7 +130,7 @@ export const getRuleRoute = ( buildGetRuleRoute({ excludeFromPublicApi: true, licenseState, - path: `${BASE_ALERTING_API_PATH}/rule/{id}`, + path: `${BASE_ALERTING_API_PATH}/internal/alerting`, router, }); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts index 6b28b64284904..631eb9dbb6d76 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts @@ -19,60 +19,75 @@ import type { CreateRuleResponseV1, } from '../../../../../common/routes/rule/apis/create'; import { - createBodySchemaV1, - createParamsSchemaV1, + createBodyZodSchemaV1, + createParamsZodSchemaV1, } from '../../../../../common/routes/rule/apis/create'; +import { ruleResponseZodSchemaV1 } from '../../../../../common/routes/rule/response'; import type { RuleParamsV1 } from '../../../../../common/routes/rule/response'; import { Rule } from '../../../../application/rule/types'; import { transformCreateBodyV1 } from './transforms'; import { transformRuleToRuleResponseV1 } from '../../transforms'; export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOptions) => { - router.post( - { + router + .versioned + .post({ path: `${BASE_ALERTING_API_PATH}/rule/{id?}`, - validate: { - body: createBodySchemaV1, - params: createParamsSchemaV1, + access: 'public', + description: 'Create a rule', + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + body: createBodyZodSchemaV1, + params: createParamsZodSchemaV1, + }, + response: { + 200: { + body: ruleResponseZodSchemaV1 + }, + }, + }, }, - }, - handleDisabledApiKeysError( - router.handleLegacyErrors( - verifyAccessAndContext(licenseState, async function (context, req, res) { - const rulesClient = (await context.alerting).getRulesClient(); - - // Assert versioned inputs - const createRuleData: CreateRuleRequestBodyV1 = req.body; - const params: CreateRuleRequestParamsV1 = req.params; - - countUsageOfPredefinedIds({ - predefinedId: params?.id, - spaceId: rulesClient.getSpaceId(), - usageCounter, - }); - - try { - // TODO (http-versioning): Remove this cast, this enables us to move forward - // without fixing all of other solution types - const createdRule: Rule = (await rulesClient.create({ - data: transformCreateBodyV1(createRuleData), - options: { id: params?.id }, - })) as Rule; - - // Assert versioned response type - const response: CreateRuleResponseV1 = { - body: transformRuleToRuleResponseV1(createdRule), - }; - - return res.ok(response); - } catch (e) { - if (e instanceof RuleTypeDisabledError) { - return e.sendResponse(res); + handleDisabledApiKeysError( + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + + // Assert versioned inputs + const createRuleData: CreateRuleRequestBodyV1 = req.body; + const params: CreateRuleRequestParamsV1 = req.params; + + countUsageOfPredefinedIds({ + predefinedId: params?.id, + spaceId: rulesClient.getSpaceId(), + usageCounter, + }); + + try { + // TODO (http-versioning): Remove this cast, this enables us to move forward + // without fixing all of other solution types + const createdRule: Rule = (await rulesClient.create({ + data: transformCreateBodyV1(createRuleData), + options: { id: params?.id }, + })) as Rule; + + // Assert versioned response type + const response: CreateRuleResponseV1 = { + body: transformRuleToRuleResponseV1(createdRule), + }; + + return res.ok(response); + } catch (e) { + if (e instanceof RuleTypeDisabledError) { + return e.sendResponse(res); + } + throw e; } - throw e; - } - }) + }) + ) ) - ) - ); + ); }; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index eeb13576ce39d..3b89df3b6d6b8 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -293,7 +293,10 @@ export interface RuleType< params?: | { type: 'zod'; - schema: z.ZodObject | z.ZodIntersection; + schema: + z.ZodObject | + z.ZodIntersection | + z.ZodDiscriminatedUnion[]>; } | { type: 'config-schema'; diff --git a/x-pack/plugins/alerting/tsconfig.json b/x-pack/plugins/alerting/tsconfig.json index ada9116393748..13a5012046a89 100644 --- a/x-pack/plugins/alerting/tsconfig.json +++ b/x-pack/plugins/alerting/tsconfig.json @@ -66,7 +66,8 @@ "@kbn/core-http-browser", "@kbn/core-saved-objects-api-server-mocks", "@kbn/core-ui-settings-server-mocks", - "@kbn/core-test-helpers-kbn-server" + "@kbn/core-test-helpers-kbn-server", + "@kbn/zod" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/observability_solution/infra/common/index.ts b/x-pack/plugins/observability_solution/infra/common/index.ts new file mode 100644 index 0000000000000..971caaeacc292 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/common/index.ts @@ -0,0 +1,9 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { metricThresholdParamsSchema } from './alerting/metrics/metric_threshold_params_schema'; +export { METRIC_THRESHOLD_ALERT_TYPE_ID} from './alerting/metrics'; diff --git a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts index 108bf49102393..4b86894d1a8ab 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/register_metric_threshold_rule_type.ts @@ -6,7 +6,6 @@ */ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; -import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { ActionGroupIdsOf } from '@kbn/alerting-plugin/common'; import { @@ -16,9 +15,13 @@ import { RuleType, } from '@kbn/alerting-plugin/server'; import { observabilityPaths } from '@kbn/observability-plugin/common'; +import { + metricThresholdZodParamsSchema, + metricThresholdZodParamsSchemaV1, +} from '@kbn/rule-data-utils'; + import type { InfraConfig } from '../../../../common/plugin_config_types'; -import { Comparator, METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; -import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api'; +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; import { InfraBackendLibs } from '../../infra_types'; import { alertDetailUrlActionVariableDescription, @@ -39,7 +42,6 @@ import { valueActionVariableDescription, viewInAppUrlActionVariableDescription, } from '../common/messages'; -import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; import { createMetricThresholdExecutor, FIRED_ACTIONS, @@ -66,57 +68,6 @@ export async function registerMetricThresholdRuleType( return; } - const baseCriterion = { - threshold: schema.arrayOf(schema.number()), - comparator: oneOfLiterals(Object.values(Comparator)), - timeUnit: schema.string(), - timeSize: schema.number(), - warningThreshold: schema.maybe(schema.arrayOf(schema.number())), - warningComparator: schema.maybe(oneOfLiterals(Object.values(Comparator))), - }; - - const nonCountCriterion = schema.object({ - ...baseCriterion, - metric: schema.string(), - aggType: oneOfLiterals(METRIC_EXPLORER_AGGREGATIONS), - customMetrics: schema.never(), - equation: schema.never(), - label: schema.never(), - }); - - const countCriterion = schema.object({ - ...baseCriterion, - aggType: schema.literal('count'), - metric: schema.never(), - customMetrics: schema.never(), - equation: schema.never(), - label: schema.never(), - }); - - const customCriterion = schema.object({ - ...baseCriterion, - aggType: schema.literal('custom'), - metric: schema.never(), - customMetrics: schema.arrayOf( - schema.oneOf([ - schema.object({ - name: schema.string(), - aggType: oneOfLiterals(['avg', 'sum', 'max', 'min', 'cardinality']), - field: schema.string(), - filter: schema.never(), - }), - schema.object({ - name: schema.string(), - aggType: schema.literal('count'), - filter: schema.maybe(schema.string()), - field: schema.never(), - }), - ]) - ), - equation: schema.maybe(schema.string()), - label: schema.maybe(schema.string()), - }); - const groupActionVariableDescription = i18n.translate( 'xpack.infra.metrics.alerting.groupActionVariableDescription', { @@ -132,23 +83,14 @@ export async function registerMetricThresholdRuleType( }), fieldsForAAD: O11Y_AAD_FIELDS, validate: { - params: schema.object( - { - criteria: schema.arrayOf( - schema.oneOf([countCriterion, nonCountCriterion, customCriterion]) - ), - groupBy: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), - filterQuery: schema.maybe( - schema.string({ - validate: validateIsStringElasticsearchJSONFilter, - }) - ), - sourceId: schema.string(), - alertOnNoData: schema.maybe(schema.boolean()), - alertOnGroupDisappear: schema.maybe(schema.boolean()), + params: { + validate: (object: unknown) => { + return metricThresholdZodParamsSchema.parse(object); }, - { unknowns: 'allow' } - ), + }, + }, + schemas: { + params: { type: 'zod', schema: metricThresholdZodParamsSchemaV1 }, }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS, WARNING_ACTIONS, NO_DATA_ACTIONS], diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts index 93286a2988e04..f857d085ab951 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts @@ -13,12 +13,16 @@ import { i18n } from '@kbn/i18n'; import { IRuleTypeAlerts, GetViewInAppRelativeUrlFnOpts } from '@kbn/alerting-plugin/server'; import { IBasePath, Logger } from '@kbn/core/server'; import { legacyExperimentalFieldMap } from '@kbn/alerts-as-data-utils'; -import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils'; +import { + OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + customThresholdZodParamsSchema, + customThresholdZodParamsSchemaV1, +} from '@kbn/rule-data-utils'; import { createLifecycleExecutor, IRuleDataClient } from '@kbn/rule-registry-plugin/server'; import { LicenseType } from '@kbn/licensing-plugin/server'; import { EsQueryRuleParamsExtractedParams } from '@kbn/stack-alerts-plugin/server/rule_types/es_query/rule_type_params'; import { observabilityFeatureId, observabilityPaths } from '../../../../common'; -import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types'; +import { Aggregators } from '../../../../common/custom_threshold_rule/types'; import { THRESHOLD_RULE_REGISTRATION_CONTEXT } from '../../../common/constants'; import { @@ -35,7 +39,7 @@ import { valueActionVariableDescription, viewInAppUrlActionVariableDescription, } from './translations'; -import { oneOfLiterals, validateKQLStringFilter } from './utils'; +import { validateKQLStringFilter } from './utils'; import { createCustomThresholdExecutor, CustomThresholdLocators, @@ -78,54 +82,10 @@ export function thresholdRuleType( ruleDataClient: IRuleDataClient, locators: CustomThresholdLocators ) { - const baseCriterion = { - threshold: schema.arrayOf(schema.number()), - comparator: oneOfLiterals(Object.values(Comparator)), - timeUnit: schema.string(), - timeSize: schema.number(), - }; + const allowedAggregators = Object.values(Aggregators); allowedAggregators.splice(Object.values(Aggregators).indexOf(Aggregators.COUNT), 1); - const customCriterion = schema.object({ - ...baseCriterion, - aggType: schema.maybe(schema.literal('custom')), - metric: schema.never(), - metrics: schema.arrayOf( - schema.oneOf([ - schema.object({ - name: schema.string(), - aggType: oneOfLiterals(allowedAggregators), - field: schema.string(), - filter: schema.never(), - }), - schema.object({ - name: schema.string(), - aggType: schema.literal('count'), - filter: schema.maybe( - schema.string({ - validate: validateKQLStringFilter, - }) - ), - field: schema.never(), - }), - ]) - ), - equation: schema.maybe(schema.string()), - label: schema.maybe(schema.string()), - }); - - const paramsSchema = schema.object( - { - criteria: schema.arrayOf(customCriterion), - groupBy: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), - alertOnNoData: schema.maybe(schema.boolean()), - alertOnGroupDisappear: schema.maybe(schema.boolean()), - searchConfiguration: searchConfigurationSchema, - }, - { unknowns: 'allow' } - ); - return { id: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, name: i18n.translate('xpack.observability.threshold.ruleName', { @@ -133,12 +93,16 @@ export function thresholdRuleType( }), fieldsForAAD: CUSTOM_THRESHOLD_AAD_FIELDS, validate: { - params: paramsSchema, + params: { + validate: (object: unknown) => { + return customThresholdZodParamsSchema.parse(object) + }, + }, }, schemas: { params: { - type: 'config-schema' as const, - schema: paramsSchema, + type: 'zod', + schema: customThresholdZodParamsSchemaV1, }, }, defaultActionGroupId: FIRED_ACTION.id, diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts index b6d0439e3de0d..add507886bdee 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts @@ -8,14 +8,18 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; -import { ES_QUERY_ID, STACK_ALERTS_FEATURE_ID } from '@kbn/rule-data-utils'; +import { + ES_QUERY_ID, + STACK_ALERTS_FEATURE_ID, + esQueryZodParamsSchema, + esQueryZodParamsSchemaV1 +} from '@kbn/rule-data-utils'; import { STACK_ALERTS_AAD_CONFIG } from '..'; import { RuleType } from '../../types'; import { ActionContext } from './action_context'; import { EsQueryRuleParams, EsQueryRuleParamsExtractedParams, - EsQueryRuleParamsSchema, EsQueryRuleState, } from './rule_type_params'; import { ExecutorOptions } from './types'; @@ -152,12 +156,16 @@ export function getRuleType( actionGroups: [{ id: ActionGroupId, name: actionGroupName }], defaultActionGroupId: ActionGroupId, validate: { - params: EsQueryRuleParamsSchema, + params: { + validate: (object: unknown) => { + return esQueryZodParamsSchema.parse(object) as EsQueryRuleParams; + }, + }, }, schemas: { params: { - type: 'config-schema', - schema: EsQueryRuleParamsSchema, + type: 'zod', + schema: esQueryZodParamsSchemaV1, }, }, actionVariables: { diff --git a/yarn.lock b/yarn.lock index 8fcbda1afc1e9..37745eeecb7e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4829,6 +4829,10 @@ version "0.0.0" uid "" +"@kbn/generate-oas@link:packages/kbn-generate-oas": + version "0.0.0" + uid "" + "@kbn/generate@link:packages/kbn-generate": version "0.0.0" uid "" @@ -6621,6 +6625,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" @@ -13670,6 +13678,11 @@ colorspace@1.1.x: color "3.0.x" text-hex "1.0.x" +combinations@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/combinations/-/combinations-1.0.0.tgz#d6bd9ce6468a13eba4651c85b57db12547892b1c" + integrity sha512-aVgTfI/dewHblSn4gF+NZHvS7wtwg9YAPF2EknHMdH+xLsXLLIMpmHkSj64Zxs/R2m9VAAgn3bENjssrn7V4vQ== + combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz" @@ -20703,6 +20716,15 @@ jest@^29.6.1: import-local "^3.0.2" jest-cli "^29.6.1" +joi-to-json@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/joi-to-json/-/joi-to-json-4.2.0.tgz#189407a2a9be6e74c39c33d6b7845d6c8cdcd423" + integrity sha512-BYC66pwElodr/yOvGbyk0jYi48LIbtbdcwHntZIizcEVnRS7auMa5kmkhVaKB95c0xCpDG8EedbQp/Tvqx5WLw== + dependencies: + combinations "^1.0.0" + lodash "^4.17.21" + semver-compare "^1.0.0" + joi@^17.3.0, joi@^17.7.1: version "17.7.1" resolved "https://registry.yarnpkg.com/joi/-/joi-17.7.1.tgz#854fc85c7fa3cfc47c91124d30bffdbb58e06cec" @@ -27608,6 +27630,11 @@ selfsigned@^2.0.1: dependencies: node-forge "^1" +semver-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" + integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== + semver-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-4.0.0.tgz#3afcf5ed6d62259f5c72d0d5d50dffbdc9680df5"