From 8b13035de7de6786beb6647d0fab3a48449e3611 Mon Sep 17 00:00:00 2001 From: Giulio Canti Date: Sat, 1 Mar 2025 10:56:32 +0100 Subject: [PATCH] Add `additionalPropertiesStrategy` option to `OpenApi.fromApi`, closes #4531 --- .changeset/poor-years-hang.md | 199 ++++++++++++++++++ packages/effect/src/JSONSchema.ts | 70 ++++-- .../effect/test/Schema/JSONSchema.test.ts | 24 ++- packages/platform/src/OpenApi.ts | 16 +- packages/platform/src/OpenApiJsonSchema.ts | 22 +- packages/platform/test/OpenApi.test.ts | 90 ++++++-- .../platform/test/OpenApiJsonSchema.test.ts | 20 ++ 7 files changed, 397 insertions(+), 44 deletions(-) create mode 100644 .changeset/poor-years-hang.md diff --git a/.changeset/poor-years-hang.md b/.changeset/poor-years-hang.md new file mode 100644 index 00000000000..10b98cfcc14 --- /dev/null +++ b/.changeset/poor-years-hang.md @@ -0,0 +1,199 @@ +--- +"@effect/platform": patch +"effect": patch +--- + +Add `additionalPropertiesStrategy` option to `OpenApi.fromApi`, closes #4531. + +This update introduces the `additionalPropertiesStrategy` option in `OpenApi.fromApi`, allowing control over how additional properties are handled in the generated OpenAPI schema. + +- When `"strict"` (default), additional properties are disallowed (`"additionalProperties": false`). +- When `"allow"`, additional properties are allowed (`"additionalProperties": true`), making APIs more flexible. + +The `additionalPropertiesStrategy` option has also been added to: + +- `JSONSchema.fromAST` +- `OpenApiJsonSchema.makeWithDefs` + +**Example** + +```ts +import { + HttpApi, + HttpApiEndpoint, + HttpApiGroup, + OpenApi +} from "@effect/platform" +import { Schema } from "effect" + +const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/").addSuccess( + Schema.Struct({ a: Schema.String }) + ) + ) +) + +const schema = OpenApi.fromApi(api, { + additionalPropertiesStrategy: "allow" +}) + +console.log(JSON.stringify(schema, null, 2)) +/* +{ + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "0.0.1" + }, + "paths": { + "/": { + "get": { + "tags": [ + "group" + ], + "operationId": "group.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "a" + ], + "properties": { + "a": { + "type": "string" + } + }, + "additionalProperties": true + } + } + } + }, + "400": { + "description": "The request did not match the expected schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpApiDecodeError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "HttpApiDecodeError": { + "type": "object", + "required": [ + "issues", + "message", + "_tag" + ], + "properties": { + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + }, + "message": { + "type": "string" + }, + "_tag": { + "type": "string", + "enum": [ + "HttpApiDecodeError" + ] + } + }, + "additionalProperties": true, + "description": "The request did not match the expected schema" + }, + "Issue": { + "type": "object", + "required": [ + "_tag", + "path", + "message" + ], + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Pointer", + "Unexpected", + "Missing", + "Composite", + "Refinement", + "Transformation", + "Type", + "Forbidden" + ], + "description": "The tag identifying the type of parse issue" + }, + "path": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PropertyKey" + }, + "description": "The path to the property where the issue occurred" + }, + "message": { + "type": "string", + "description": "A descriptive message explaining the issue" + } + }, + "additionalProperties": true, + "description": "Represents an error encountered while parsing a value to match the schema" + }, + "PropertyKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "object", + "required": [ + "_tag", + "key" + ], + "properties": { + "_tag": { + "type": "string", + "enum": [ + "symbol" + ] + }, + "key": { + "type": "string" + } + }, + "additionalProperties": true, + "description": "an object to be decoded into a globally shared symbol" + } + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [ + { + "name": "group" + } + ] +} +*/ +``` diff --git a/packages/effect/src/JSONSchema.ts b/packages/effect/src/JSONSchema.ts index fcc03db8546..2eee0f524da 100644 --- a/packages/effect/src/JSONSchema.ts +++ b/packages/effect/src/JSONSchema.ts @@ -271,6 +271,8 @@ type Target = "jsonSchema7" | "jsonSchema2019-09" | "openApi3.1" type TopLevelReferenceStrategy = "skip" | "keep" +type AdditionalPropertiesStrategy = "allow" | "strict" + /** * Returns a JSON Schema with additional options and definitions. * @@ -278,7 +280,7 @@ type TopLevelReferenceStrategy = "skip" | "keep" * * This function is experimental and subject to change. * - * **Details** + * **Options** * * - `definitions`: A record of definitions that are included in the schema. * - `definitionPath`: The path to the definitions within the schema (defaults @@ -291,6 +293,9 @@ type TopLevelReferenceStrategy = "skip" | "keep" * reference. Possible values are: * - `"keep"`: Keep the top-level reference (default behavior). * - `"skip"`: Skip the top-level reference. + * - `additionalPropertiesStrategy`: Controls the handling of additional properties. Possible values are: + * - `"strict"`: Disallow additional properties (default behavior). + * - `"allow"`: Allow additional properties. * * @category encoding * @since 3.11.5 @@ -298,17 +303,20 @@ type TopLevelReferenceStrategy = "skip" | "keep" */ export const fromAST = (ast: AST.AST, options: { readonly definitions: Record - readonly definitionPath?: string - readonly target?: Target - readonly topLevelReferenceStrategy?: TopLevelReferenceStrategy + readonly definitionPath?: string | undefined + readonly target?: Target | undefined + readonly topLevelReferenceStrategy?: TopLevelReferenceStrategy | undefined + readonly additionalPropertiesStrategy?: AdditionalPropertiesStrategy | undefined }): JsonSchema7 => { const definitionPath = options.definitionPath ?? "#/$defs/" const getRef = (id: string) => definitionPath + id - const target: Target = options.target ?? "jsonSchema7" + const target = options.target ?? "jsonSchema7" const handleIdentifier = options.topLevelReferenceStrategy !== "skip" + const additionalPropertiesStrategy = options.additionalPropertiesStrategy ?? "strict" return go(ast, options.definitions, handleIdentifier, [], { getRef, - target + target, + additionalPropertiesStrategy }) } @@ -450,19 +458,51 @@ const mergeRefinements = (from: any, jsonSchema: any, annotations: any): any => return out } -type Options = { +type GoOptions = { readonly getRef: (id: string) => string readonly target: Target + readonly additionalPropertiesStrategy: AdditionalPropertiesStrategy } -type Path = ReadonlyArray - -const isContentSchemaSupported = (options: Options) => options.target !== "jsonSchema7" +function isContentSchemaSupported(options: GoOptions): boolean { + switch (options.target) { + case "jsonSchema7": + return false + case "jsonSchema2019-09": + case "openApi3.1": + return true + } +} -const isNullTypeKeywordSupported = (options: Options) => options.target !== "openApi3.1" +function isNullTypeKeywordSupported(options: GoOptions): boolean { + switch (options.target) { + case "jsonSchema7": + case "jsonSchema2019-09": + return true + case "openApi3.1": + return false + } +} // https://swagger.io/docs/specification/v3_0/data-models/data-types/#null -const isNullableKeywordSupported = (options: Options) => options.target === "openApi3.1" +function isNullableKeywordSupported(options: GoOptions): boolean { + switch (options.target) { + case "jsonSchema7": + case "jsonSchema2019-09": + return false + case "openApi3.1": + return true + } +} + +function getAdditionalProperties(options: GoOptions): boolean { + switch (options.additionalPropertiesStrategy) { + case "allow": + return true + case "strict": + return false + } +} const isNeverJSONSchema = (jsonSchema: JsonSchema7): jsonSchema is JsonSchema7Never => "$id" in jsonSchema && jsonSchema.$id === "/schemas/never" @@ -496,8 +536,8 @@ const go = ( ast: AST.AST, $defs: Record, handleIdentifier: boolean, - path: Path, - options: Options + path: ReadonlyArray, + options: GoOptions ): JsonSchema7 => { if (handleIdentifier) { const identifier = AST.getJSONIdentifier(ast) @@ -639,7 +679,7 @@ const go = ( type: "object", required: [], properties: {}, - additionalProperties: false + additionalProperties: getAdditionalProperties(options) } let patternProperties: JsonSchema7 | undefined = undefined let propertyNames: JsonSchema7 | undefined = undefined diff --git a/packages/effect/test/Schema/JSONSchema.test.ts b/packages/effect/test/Schema/JSONSchema.test.ts index 2ee5ea536a8..9e049ea154c 100644 --- a/packages/effect/test/Schema/JSONSchema.test.ts +++ b/packages/effect/test/Schema/JSONSchema.test.ts @@ -103,7 +103,7 @@ const expectError = (schema: Schema.Schema, message: string) => { // Using this instead of Schema.JsonNumber to avoid cluttering the output with unnecessary description and title const JsonNumber = Schema.Number.pipe(Schema.filter((n) => Number.isFinite(n), { jsonSchema: {} })) -describe("makeWithOptions", () => { +describe("fromAST", () => { it("definitionsPath", () => { const schema = Schema.String.annotations({ identifier: "08368672-2c02-4d6d-92b0-dd0019b33a7b" }) const definitions = {} @@ -601,6 +601,28 @@ describe("makeWithOptions", () => { deepStrictEqual(definitions, {}) }) }) + + describe("additionalPropertiesStrategy", () => { + it(`"allow"`, () => { + const schema = Schema.Struct({ a: Schema.String }) + const definitions = {} + const jsonSchema = JSONSchema.fromAST(schema.ast, { + definitions, + additionalPropertiesStrategy: "allow" + }) + deepStrictEqual(jsonSchema, { + "type": "object", + "properties": { + "a": { + "type": "string" + } + }, + "required": ["a"], + "additionalProperties": true + }) + deepStrictEqual(definitions, {}) + }) + }) }) describe("make", () => { diff --git a/packages/platform/src/OpenApi.ts b/packages/platform/src/OpenApi.ts index 371ddef1fab..c23b525ce2a 100644 --- a/packages/platform/src/OpenApi.ts +++ b/packages/platform/src/OpenApi.ts @@ -175,6 +175,8 @@ function processAnnotation( } } +type AdditionalPropertiesStrategy = "allow" | "strict" + /** * Converts an `HttpApi` instance into an OpenAPI Specification object. * @@ -192,6 +194,12 @@ function processAnnotation( * and overrides. Cached results are used for better performance when the same * `HttpApi` instance is processed multiple times. * + * **Options** + * + * - `additionalPropertiesStrategy`: Controls the handling of additional properties. Possible values are: + * - `"strict"`: Disallow additional properties (default behavior). + * - `"allow"`: Allow additional properties. + * * @example * ```ts * import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "@effect/platform" @@ -214,7 +222,10 @@ function processAnnotation( * @since 1.0.0 */ export const fromApi = ( - api: HttpApi.HttpApi + api: HttpApi.HttpApi, + options?: { + readonly additionalPropertiesStrategy?: AdditionalPropertiesStrategy | undefined + } | undefined ): OpenAPISpec => { const cached = apiCache.get(api) if (cached !== undefined) { @@ -238,7 +249,8 @@ export const fromApi = (schema: Schema.Schema): Root => { return out } +type TopLevelReferenceStrategy = "skip" | "keep" + +type AdditionalPropertiesStrategy = "allow" | "strict" + /** * Creates a schema with additional options and definitions. * + * **Options** + * * - `defs`: A record of definitions that are included in the schema. * - `defsPath`: The path to the definitions within the schema (defaults to "#/$defs/"). * - `topLevelReferenceStrategy`: Controls the handling of the top-level reference. Possible values are: * - `"keep"`: Keep the top-level reference (default behavior). * - `"skip"`: Skip the top-level reference. + * - `additionalPropertiesStrategy`: Controls the handling of additional properties. Possible values are: + * - `"strict"`: Disallow additional properties (default behavior). + * - `"allow"`: Allow additional properties. * * @category encoding * @since 1.0.0 */ export const makeWithDefs = (schema: Schema.Schema, options: { readonly defs: Record - readonly defsPath?: string - readonly topLevelReferenceStrategy?: "skip" | "keep" + readonly defsPath?: string | undefined + readonly topLevelReferenceStrategy?: TopLevelReferenceStrategy | undefined + readonly additionalPropertiesStrategy?: AdditionalPropertiesStrategy | undefined }): JsonSchema => fromAST(schema.ast, options) /** @internal */ export const fromAST = (ast: AST.AST, options: { readonly defs: Record - readonly defsPath?: string - readonly topLevelReferenceStrategy?: "skip" | "keep" + readonly defsPath?: string | undefined + readonly topLevelReferenceStrategy?: TopLevelReferenceStrategy | undefined + readonly additionalPropertiesStrategy?: AdditionalPropertiesStrategy | undefined }): JsonSchema => { const jsonSchema = JSONSchema.fromAST(ast, { definitions: options.defs, definitionPath: options.defsPath ?? "#/components/schemas/", target: "openApi3.1", - topLevelReferenceStrategy: options.topLevelReferenceStrategy ?? "keep" + topLevelReferenceStrategy: options.topLevelReferenceStrategy, + additionalPropertiesStrategy: options.additionalPropertiesStrategy }) return jsonSchema as JsonSchema } diff --git a/packages/platform/test/OpenApi.test.ts b/packages/platform/test/OpenApi.test.ts index a22a6972671..54de99fd6df 100644 --- a/packages/platform/test/OpenApi.test.ts +++ b/packages/platform/test/OpenApi.test.ts @@ -24,7 +24,7 @@ const HttpApiDecodeError = { } } -type Options = { +type Parts = { readonly paths: OpenApi.OpenAPISpec["paths"] readonly tags?: OpenApi.OpenAPISpec["tags"] | undefined readonly securitySchemes?: Record | undefined @@ -32,12 +32,19 @@ type Options = { readonly security?: Array | undefined } -const getSpec = (options: Options): OpenApi.OpenAPISpec => { +type AdditionalPropertiesStrategy = "allow" | "strict" + +type Options = { + readonly additionalPropertiesStrategy?: AdditionalPropertiesStrategy | undefined +} + +const getSpec = (parts: Parts, options?: Options): OpenApi.OpenAPISpec => { + const additionalPropertiesStrategy = (options?.additionalPropertiesStrategy ?? "strict") === "allow" return { "openapi": "3.1.0", "info": { "title": "Api", "version": "0.0.1" }, - "paths": options.paths, - "tags": options.tags ?? [{ "name": "group" }], + "paths": parts.paths, + "tags": parts.tags ?? [{ "name": "group" }], "components": { "schemas": { "HttpApiDecodeError": { @@ -58,7 +65,7 @@ const getSpec = (options: Options): OpenApi.OpenAPISpec => { ] } }, - "additionalProperties": false, + "additionalProperties": additionalPropertiesStrategy, "description": "The request did not match the expected schema" }, "Issue": { @@ -92,14 +99,14 @@ const getSpec = (options: Options): OpenApi.OpenAPISpec => { "description": "A descriptive message explaining the issue" } }, - "additionalProperties": false + "additionalProperties": additionalPropertiesStrategy }, "PropertyKey": { "anyOf": [ { "type": "string" }, { "type": "number" }, { - "additionalProperties": false, + "additionalProperties": additionalPropertiesStrategy, "description": "an object to be decoded into a globally shared symbol", "properties": { "_tag": { @@ -115,33 +122,35 @@ const getSpec = (options: Options): OpenApi.OpenAPISpec => { } ] }, - ...options.schemas + ...parts.schemas }, - "securitySchemes": options.securitySchemes ?? {} + "securitySchemes": parts.securitySchemes ?? {} }, - "security": options.security ?? [] + "security": parts.security ?? [] } } -const expectOptions = ( +const expectParts = ( api: HttpApi.HttpApi, - options: Options + parts: Parts ) => { - expectSpec(api, getSpec(options)) + expectSpec(api, getSpec(parts)) } const expectSpecPaths = ( api: HttpApi.HttpApi, - paths: OpenApi.OpenAPISpec["paths"] + paths: OpenApi.OpenAPISpec["paths"], + options?: Options ) => { - expectSpec(api, getSpec({ paths })) + expectSpec(api, getSpec({ paths }, options), options) } const expectSpec = ( api: HttpApi.HttpApi, - expected: OpenApi.OpenAPISpec + expected: OpenApi.OpenAPISpec, + options?: Options ) => { - const spec = OpenApi.fromApi(api) + const spec = OpenApi.fromApi(api, options) // console.log(JSON.stringify(spec.paths, null, 2)) // console.log(JSON.stringify(spec, null, 2)) deepStrictEqual(spec, expected) @@ -158,6 +167,45 @@ const expectPaths = { describe("fromApi", () => { describe("HttpApi", () => { + it(`additionalPropertiesStrategy: "allow"`, () => { + const api = HttpApi.make("api").add( + HttpApiGroup.make("group").add( + HttpApiEndpoint.get("get", "/") + .addSuccess(Schema.Struct({ a: Schema.String })) + ) + ) + expectSpecPaths(api, { + "/": { + "get": { + "tags": ["group"], + "operationId": "group.get", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "a": { + "type": "string" + } + }, + "required": ["a"], + "additionalProperties": true + } + } + } + }, + "400": HttpApiDecodeError + } + } + } + }, { additionalPropertiesStrategy: "allow" }) + }) + it("addHttpApi", () => { const anotherApi = HttpApi.make("api").add( HttpApiGroup.make("group").add( @@ -810,7 +858,7 @@ describe("OpenApi", () => { .addSuccess(User) ) ) - expectOptions(api, { + expectParts(api, { schemas: { "User": { "additionalProperties": false, @@ -1534,7 +1582,7 @@ describe("OpenApi", () => { ) // Or apply the middleware to the entire API .middleware(Authorization) - expectOptions(api, { + expectParts(api, { security: [{ "myBearer": [] }, { @@ -1745,7 +1793,7 @@ describe("OpenApi", () => { ) ) ) - expectOptions(api, { + expectParts(api, { schemas: { "PersistedFile": { "type": "string", @@ -2081,7 +2129,7 @@ describe("OpenApi", () => { .addError(err).addError(err) ).addError(err).addError(err) ).addError(err).addError(err) - expectOptions(api, { + expectParts(api, { tags: [{ name: "group1" }, { name: "group2" }], schemas: { "err": { diff --git a/packages/platform/test/OpenApiJsonSchema.test.ts b/packages/platform/test/OpenApiJsonSchema.test.ts index b67fd3bcb9e..7828622ae81 100644 --- a/packages/platform/test/OpenApiJsonSchema.test.ts +++ b/packages/platform/test/OpenApiJsonSchema.test.ts @@ -53,4 +53,24 @@ describe("OpenApiJsonSchema", () => { } }) }) + + it(`additionalPropertiesStrategy: "allow"`, () => { + const schema = Schema.Struct({ a: Schema.String }) + const defs: Record = {} + const jsonSchema = OpenApiJsonSchema.makeWithDefs(schema, { + defs, + additionalPropertiesStrategy: "allow" + }) + deepStrictEqual(jsonSchema, { + "type": "object", + "properties": { + "a": { + "type": "string" + } + }, + "required": ["a"], + "additionalProperties": true + }) + deepStrictEqual(defs, {}) + }) })