Skip to content

Commit

Permalink
Add additionalPropertiesStrategy option to OpenApi.fromApi, closes
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti committed Mar 1, 2025
1 parent 367bb35 commit 8b13035
Show file tree
Hide file tree
Showing 7 changed files with 397 additions and 44 deletions.
199 changes: 199 additions & 0 deletions .changeset/poor-years-hang.md
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
*/
```
70 changes: 55 additions & 15 deletions packages/effect/src/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,16 @@ type Target = "jsonSchema7" | "jsonSchema2019-09" | "openApi3.1"

type TopLevelReferenceStrategy = "skip" | "keep"

type AdditionalPropertiesStrategy = "allow" | "strict"

/**
* Returns a JSON Schema with additional options and definitions.
*
* **Warning**
*
* 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
Expand All @@ -291,24 +293,30 @@ 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
* @experimental
*/
export const fromAST = (ast: AST.AST, options: {
readonly definitions: Record<string, JsonSchema7>
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
})
}

Expand Down Expand Up @@ -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<PropertyKey>

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"
Expand Down Expand Up @@ -496,8 +536,8 @@ const go = (
ast: AST.AST,
$defs: Record<string, JsonSchema7>,
handleIdentifier: boolean,
path: Path,
options: Options
path: ReadonlyArray<PropertyKey>,
options: GoOptions
): JsonSchema7 => {
if (handleIdentifier) {
const identifier = AST.getJSONIdentifier(ast)
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion packages/effect/test/Schema/JSONSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const expectError = <A, I>(schema: Schema.Schema<A, I>, 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 = {}
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading

0 comments on commit 8b13035

Please # to comment.