Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Add additionalPropertiesStrategy option to OpenApi.fromApi, close… #4540

Merged
merged 1 commit into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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