kibana/packages/kbn-openapi-generator
Marshall Main 6073eb6dcd
[Security Solution] Swap rule unions out for discriminated unions to improve validation error messages (#171452)
**Epics:** https://github.com/elastic/security-team/issues/8058,
https://github.com/elastic/security-team/issues/6726 (internal)
**Partially addresses:**
https://github.com/elastic/security-team/issues/7991 (internal)

## Summary

The main benefit of this PR is shown in `rule_request_schema.test.ts`,
where the error messages are now more accurate and concise. With regular
unions, `zod` has to try validating the input against all schemas in the
union and reports the errors from every schema in the union. Switching
to discriminated unions, with `type` as the discriminator, allows `zod`
to pick the right rule type schema from the union and only validate
against that rule type. This means the error message reports that either
the discriminator is invalid, in any case where `type` is not valid, or
if `type` is valid but another field is wrong for that type of rule then
the error message is the validation result from only that rule type.

To make it possible to use discriminated unions, we need to switch from
using zod's `.and()` for intersections to `.merge()` because `.and()`
returns an intersection type that is incompatible with discriminated
unions in zod. Similarly, we need to remove the usage of `.transform()`
because it returns a ZodEffect that is incompatible with `.merge()`.

Instead of using `.transform()` to turn properties from optional to
possibly undefined, we can use `requiredOptional` explicitly in specific
places to convert the types. Similarly, the `RequiredOptional` type can
be used on the return type of conversion functions between API and
internal schemas to enforce that all properties are explicitly specified
in the conversion.

Future work:
- better alignment of codegen with OpenAPI definitions of anyOf/oneOf.
https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/#oneof
oneOf requires that the input match exactly one schema from the list,
which is different from z.union. anyOf should be z.union, oneOf should
be z.discriminatedUnion
- flatten the schema structure further to avoid `Type instantiation is
excessively deep and possibly infinite`. Seems to be a common issue with
zod (https://github.com/microsoft/TypeScript/issues/34933) Limiting the
number of `.merge` and other zod operations needed to build a particular
schema object seems to help resolve the error. Combining
`ResponseRequiredFields` and `ResponseOptionalFields` into a single
object rather than merging them solved the immediate problem. However,
we may still be near the depth limit. Changing `RuleResponse` as seen
below also solved the problem in testing, and may give us more headroom
for future changes if we apply techniques like this here and in other
places. The difference here is that `SharedResponseProps` is only
intersected with the type specific schemas after they're combined in a
discriminated union, whereas in `main` we merge `SharedResponseProps`
with each individual schema then merge them all together.
- combine other Required and Optional schemas, like
QueryRuleRequiredFields and QueryRuleOptionalFields

```ts
export type RuleResponse = z.infer<typeof RuleResponse>;
export const RuleResponse = SharedResponseProps.and(z.discriminatedUnion('type', [
  EqlRuleResponseFields,
  QueryRuleResponseFields,
  SavedQueryRuleResponseFields,
  ThresholdRuleResponseFields,
  ThreatMatchRuleResponseFields,
  MachineLearningRuleResponseFields,
  NewTermsRuleResponseFields,
  EsqlRuleResponseFields,
]));
```

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
2023-11-29 21:11:29 +01:00
..
src [Security Solution] Swap rule unions out for discriminated unions to improve validation error messages (#171452) 2023-11-29 21:11:29 +01:00
image.png [Security Solution] Extract OpenAPI codegen to a package (#166269) 2023-09-25 10:51:40 +02:00
index.ts [Security Solution] Extract OpenAPI codegen to a package (#166269) 2023-09-25 10:51:40 +02:00
jest.config.js [Security Solution] Extract OpenAPI codegen to a package (#166269) 2023-09-25 10:51:40 +02:00
kibana.jsonc [Security Solution] Migrate Rules schema to OpenAPI (#167999) 2023-10-27 18:00:10 +02:00
package.json [Security Solution] Migrate Rules schema to OpenAPI (#167999) 2023-10-27 18:00:10 +02:00
README.md Enable Security's Cypress tests on all PRs (#167516) 2023-10-06 17:58:31 +02:00
tsconfig.json [Security Solution] Extract OpenAPI codegen to a package (#166269) 2023-09-25 10:51:40 +02:00

OpenAPI Code Generator for Kibana

This code generator could be used to generate runtime types, documentation, server stub implementations, clients, and much more given OpenAPI specification.

Getting started

To start with code generation you should have OpenAPI specification describing your API endpoint request and response schemas along with common types used in your API. The code generation script supports OpenAPI 3.1.0, refer to https://swagger.io/specification/ for more details.

OpenAPI specification should be in YAML format and have .schema.yaml extension. Here's a simple example of OpenAPI specification:

openapi: 3.0.0
info:
  title: Install Prebuilt Rules API endpoint
  version: 2023-10-31
paths:
  /api/detection_engine/rules/prepackaged:
    put:
      operationId: InstallPrebuiltRules
      x-codegen-enabled: true
      summary: Installs all Elastic prebuilt rules and timelines
      tags:
        - Prebuilt Rules API
      responses:
        200:
          description: Indicates a successful call
          content:
            application/json:
              schema:
                type: object
                properties:
                  rules_installed:
                    type: integer
                    description: The number of rules installed
                    minimum: 0
                  rules_updated:
                    type: integer
                    description: The number of rules updated
                    minimum: 0
                  timelines_installed:
                    type: integer
                    description: The number of timelines installed
                    minimum: 0
                  timelines_updated:
                    type: integer
                    description: The number of timelines updated
                    minimum: 0
                required:
                  - rules_installed
                  - rules_updated
                  - timelines_installed
                  - timelines_updated

Put it anywhere in your plugin, the code generation script will traverse the whole plugin directory and find all .schema.yaml files.

Then to generate code run the following command:

node scripts/generate_openapi --rootDir ./x-pack/plugins/security_solution

Generator command output

By default it uses the zod_operation_schema template which produces runtime types for request and response schemas described in OpenAPI specification. The generated code will be placed adjacent to the .schema.yaml file and will have .gen.ts extension.

Example of generated code:

import { z } from 'zod';

/*
 * NOTICE: Do not edit this file manually.
 * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
 */

export type InstallPrebuiltRulesResponse = z.infer<typeof InstallPrebuiltRulesResponse>;
export const InstallPrebuiltRulesResponse = z.object({
  /**
   * The number of rules installed
   */
  rules_installed: z.number().int().min(0),
  /**
   * The number of rules updated
   */
  rules_updated: z.number().int().min(0),
  /**
   * The number of timelines installed
   */
  timelines_installed: z.number().int().min(0),
  /**
   * The number of timelines updated
   */
  timelines_updated: z.number().int().min(0),
});

Programmatic API

Alternatively, you can use the code generator programmatically. You can create a script file and run it with node command. This could be useful if you want to set up code generation in your CI pipeline. Here's an example of such script:

require('../../../../../src/setup_node_env');
const { generate } = require('@kbn/openapi-generator');
const { resolve } = require('path');

const SECURITY_SOLUTION_ROOT = resolve(__dirname, '../..');

generate({
  rootDir: SECURITY_SOLUTION_ROOT, // Path to the plugin root directory
  sourceGlob: './**/*.schema.yaml', // Glob pattern to find OpenAPI specification files
  templateName: 'zod_operation_schema', // Name of the template to use
});

CI integration

To make sure that generated code is always in sync with its OpenAPI specification it is recommended to add a command to your CI pipeline that will run code generation on every pull request and commit the changes if there are any.

First, create a script that will run code generation and commit the changes. See .buildkite/scripts/steps/code_generation/security_solution_codegen.sh for an example:

#!/usr/bin/env bash

set -euo pipefail

source .buildkite/scripts/common/util.sh

.buildkite/scripts/bootstrap.sh

echo --- Security Solution OpenAPI Code Generation

(cd x-pack/plugins/security_solution && yarn openapi:generate)
check_for_changed_files "yarn openapi:generate" true

This scripts sets up the minimal environment required fro code generation and runs the code generation script. Then it checks if there are any changes and commits them if there are any using the check_for_changed_files function.

Then add the code generation script to your plugin build pipeline. Open your plugin build pipeline, for example .buildkite/pipelines/pull_request/base.yml, and add the following command to the steps list adjusting the path to your code generation script:

- command: .buildkite/scripts/steps/code_generation/security_solution_codegen.sh
  label: 'Security Solution OpenAPI codegen'
  agents:
    queue: n2-2-spot
  timeout_in_minutes: 60
  parallelism: 1

Now on every pull request the code generation script will run and commit the changes if there are any.

OpenAPI Schema

The code generator supports the OpenAPI definitions described in the request, response, and component sections of the document.

For every API operation (GET, POST, etc) it is required to specify the operationId field. This field is used to generate the name of the generated types. For example, if the operationId is InstallPrebuiltRules then the generated types will be named InstallPrebuiltRulesResponse and InstallPrebuiltRulesRequest. If the operationId is not specified then the code generation will throw an error.

The x-codegen-enabled field is used to enable or disable code generation for the operation. If it is not specified then code generation is disabled by default. This field could be also used to disable code generation of common components described in the components section of the OpenAPI specification.

Keep in mind that disabling code generation for common components that are referenced by external OpenAPI specifications could lead to errors during code generation.

Schema files organization

It is recommended to limit the number of operations and components described in a single OpenAPI specification file. Having one HTTP operation in a single file will make it easier to maintain and will keep the generated artifacts granular for ease of reuse and better tree shaking. You can have as many OpenAPI specification files as you want.

Common components

It is common to have shared types that are used in multiple API operations. To avoid code duplication you can define common components in the components section of the OpenAPI specification and put them in a separate file. Then you can reference these components in the parameters and responses sections of the API operations.

Here's an example of the schema that references common components:

openapi: 3.0.0
info:
  title: Delete Rule API endpoint
  version: 2023-10-31
paths:
  /api/detection_engine/rules:
    delete:
      operationId: DeleteRule
      description: Deletes a single rule using the `rule_id` or `id` field.
      parameters:
        - name: id
          in: query
          required: false
          description: The rule's `id` value.
          schema:
            $ref: '../../../model/rule_schema/common_attributes.schema.yaml#/components/schemas/RuleSignatureId'
        - name: rule_id
          in: query
          required: false
          description: The rule's `rule_id` value.
          schema:
            $ref: '../../../model/rule_schema/common_attributes.schema.yaml#/components/schemas/RuleObjectId'
      responses:
        200:
          description: Indicates a successful call.
          content:
            application/json:
              schema:
                $ref: '../../../model/rule_schema/rule_schemas.schema.yaml#/components/schemas/RuleResponse'