diff --git a/.changeset/thick-gifts-live.md b/.changeset/thick-gifts-live.md new file mode 100644 index 00000000000..9d4cb77ce7f --- /dev/null +++ b/.changeset/thick-gifts-live.md @@ -0,0 +1,8 @@ +--- +"effect": patch +--- + +JSON Schema generation: add `jsonSchema2020-12` target and fix tuple output for: + +- JSON Schema 2019-09 +- OpenAPI 3.1 diff --git a/packages/effect/src/JSONSchema.ts b/packages/effect/src/JSONSchema.ts index 70621786e64..1e9419e8883 100644 --- a/packages/effect/src/JSONSchema.ts +++ b/packages/effect/src/JSONSchema.ts @@ -163,7 +163,8 @@ export interface JsonSchema7Boolean extends JsonSchemaAnnotations { */ export interface JsonSchema7Array extends JsonSchemaAnnotations { type: "array" - items?: JsonSchema7 | Array + items?: JsonSchema7 | Array | false + prefixItems?: Array minItems?: number maxItems?: number additionalItems?: JsonSchema7 | boolean @@ -258,7 +259,7 @@ export const make = (schema: Schema.Schema): JsonSchema7Root = definitions }) const out: JsonSchema7Root = { - $schema, + $schema: getMetaSchemaUri("jsonSchema7"), $defs: {}, ...jsonSchema } @@ -270,12 +271,25 @@ export const make = (schema: Schema.Schema): JsonSchema7Root = return out } -type Target = "jsonSchema7" | "jsonSchema2019-09" | "openApi3.1" +type Target = "jsonSchema7" | "jsonSchema2019-09" | "openApi3.1" | "jsonSchema2020-12" type TopLevelReferenceStrategy = "skip" | "keep" type AdditionalPropertiesStrategy = "allow" | "strict" +/** @internal */ +export function getMetaSchemaUri(target: Target) { + switch (target) { + case "jsonSchema7": + return "http://json-schema.org/draft-07/schema#" + case "jsonSchema2019-09": + return "https://json-schema.org/draft/2019-09/schema" + case "jsonSchema2020-12": + case "openApi3.1": + return "https://json-schema.org/draft/2020-12/schema" + } +} + /** * Returns a JSON Schema with additional options and definitions. * @@ -365,8 +379,6 @@ const constEmptyStruct: JsonSchema7empty = { ] } -const $schema = "http://json-schema.org/draft-07/schema#" - function getRawDescription(annotated: AST.Annotated | undefined): string | undefined { if (annotated !== undefined) return Option.getOrUndefined(AST.getDescriptionAnnotation(annotated)) } @@ -529,6 +541,7 @@ function isContentSchemaSupported(options: GoOptions): boolean { case "jsonSchema7": return false case "jsonSchema2019-09": + case "jsonSchema2020-12": case "openApi3.1": return true } @@ -716,7 +729,11 @@ function go( const len = ast.elements.length if (len > 0) { output.minItems = len - ast.elements.filter((element) => element.isOptional).length - output.items = elements + if (options.target === "jsonSchema7") { + output.items = elements + } else { + output.prefixItems = elements + } } // --------------------------------------------- // handle rest element @@ -726,9 +743,18 @@ function go( const head = rest[0] const isHomogeneous = restLength === 1 && ast.elements.every((e) => e.type === ast.rest[0].type) if (isHomogeneous) { - output.items = head + if (options.target === "jsonSchema7") { + output.items = head + } else { + output.items = head + delete output.prefixItems + } } else { - output.additionalItems = head + if (options.target === "jsonSchema7") { + output.additionalItems = head + } else { + output.items = head + } } // --------------------------------------------- @@ -740,7 +766,11 @@ function go( } } else { if (len > 0) { - output.additionalItems = false + if (options.target === "jsonSchema7") { + output.additionalItems = false + } else { + output.items = false + } } else { output.maxItems = 0 } diff --git a/packages/effect/test/Schema/JSONSchema.new.test.ts b/packages/effect/test/Schema/JSONSchema.new.test.ts index f419586887d..55e9332b927 100644 --- a/packages/effect/test/Schema/JSONSchema.new.test.ts +++ b/packages/effect/test/Schema/JSONSchema.new.test.ts @@ -81,6 +81,24 @@ async function assertOpenApi3_1( return jsonSchema } +async function assertDraft2020_12( + schema: S, + expected: object +) { + const definitions = {} + const jsonSchema = JSONSchema.fromAST(schema.ast, { + definitions, + target: "jsonSchema2020-12" + }) + deepStrictEqual(jsonSchema, expected) + const valid = ajv2020.validateSchema(jsonSchema) + if (valid instanceof Promise) { + await valid + } + strictEqual(ajv2020.errors, null) + return jsonSchema +} + function assertAjvDraft7Success( schema: S, input: S["Type"] @@ -371,7 +389,7 @@ schema (SymbolKeyword): symbol` }) }) - describe("Draft 07", () => { + describe("jsonSchema7", () => { describe("nullable handling", () => { it("Null", async () => { const schema = Schema.Null @@ -4160,7 +4178,7 @@ schema (SymbolKeyword): symbol` }) }) - describe("Draft 2019-09", () => { + describe("jsonSchema2019-09", () => { describe("nullable handling", () => { it("Null", async () => { const schema = Schema.Null @@ -4279,7 +4297,7 @@ schema (SymbolKeyword): symbol` }) }) - describe("OpenAPI 3.1", () => { + describe("openApi3.1", () => { describe("nullable handling", () => { it("Null", async () => { const schema = Schema.Null @@ -4398,3 +4416,222 @@ schema (SymbolKeyword): symbol` }) }) }) + +describe("jsonSchema2020-12", () => { + describe("Tuple", () => { + it("empty tuple", async () => { + const schema = Schema.Tuple() + await assertDraft2020_12(schema, { + "type": "array", + "maxItems": 0 + }) + }) + + it("element", async () => { + const schema = Schema.Tuple(Schema.Number) + await assertDraft2020_12(schema, { + "type": "array", + "prefixItems": [{ + "type": "number" + }], + "minItems": 1, + "items": false + }) + }) + + it("element + inner annotations", async () => { + await assertDraft2020_12( + Schema.Tuple(Schema.Number.annotations({ description: "inner" })), + { + "type": "array", + "prefixItems": [{ + "type": "number", + "description": "inner" + }], + "minItems": 1, + "items": false + } + ) + }) + + it("element + outer annotations should override inner annotations", async () => { + await assertDraft2020_12( + Schema.Tuple( + Schema.element(Schema.Number.annotations({ description: "inner" })).annotations({ description: "outer" }) + ), + { + "type": "array", + "prefixItems": [{ + "type": "number", + "description": "outer" + }], + "minItems": 1, + "items": false + } + ) + }) + + it("optionalElement", async () => { + const schema = Schema.Tuple(Schema.optionalElement(Schema.Number)) + await assertDraft2020_12(schema, { + "type": "array", + "minItems": 0, + "prefixItems": [ + { + "type": "number" + } + ], + "items": false + }) + }) + + it("optionalElement + inner annotations", async () => { + await assertDraft2020_12( + Schema.Tuple(Schema.optionalElement(Schema.Number).annotations({ description: "inner" })), + { + "type": "array", + "minItems": 0, + "prefixItems": [ + { + "type": "number", + "description": "inner" + } + ], + "items": false + } + ) + }) + + it("optionalElement + outer annotations should override inner annotations", async () => { + await assertDraft2020_12( + Schema.Tuple( + Schema.optionalElement(Schema.Number).annotations({ description: "inner" }).annotations({ + description: "outer" + }) + ), + { + "type": "array", + "minItems": 0, + "prefixItems": [ + { + "type": "number", + "description": "outer" + } + ], + "items": false + } + ) + }) + + it("element + optionalElement", async () => { + const schema = Schema.Tuple( + Schema.element(Schema.String.annotations({ description: "inner" })).annotations({ description: "outer" }), + Schema.optionalElement(Schema.Number.annotations({ description: "inner?" })).annotations({ + description: "outer?" + }) + ) + await assertDraft2020_12(schema, { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "type": "string", + "description": "outer" + }, + { + "type": "number", + "description": "outer?" + } + ], + "items": false + }) + }) + + it("rest", async () => { + const schema = Schema.Array(Schema.Number) + await assertDraft2020_12(schema, { + "type": "array", + "items": { + "type": "number" + } + }) + }) + + it("rest + inner annotations", async () => { + await assertDraft2020_12(Schema.Array(Schema.Number.annotations({ description: "inner" })), { + "type": "array", + "items": { + "type": "number", + "description": "inner" + } + }) + }) + + it("optionalElement + rest + inner annotations", async () => { + const schema = Schema.Tuple( + [Schema.optionalElement(Schema.String)], + Schema.element(Schema.Number.annotations({ description: "inner" })) + ) + await assertDraft2020_12(schema, { + "type": "array", + "minItems": 0, + "prefixItems": [ + { + "type": "string" + } + ], + "items": { + "type": "number", + "description": "inner" + } + }) + }) + + it("optionalElement + rest + outer annotations should override inner annotations", async () => { + await assertDraft2020_12( + Schema.Tuple( + [Schema.optionalElement(Schema.String)], + Schema.element(Schema.Number.annotations({ description: "inner" })).annotations({ description: "outer" }) + ), + { + "type": "array", + "minItems": 0, + "prefixItems": [ + { + "type": "string" + } + ], + "items": { + "type": "number", + "description": "outer" + } + } + ) + }) + + it("element + rest", async () => { + const schema = Schema.Tuple([Schema.String], Schema.Number) + await assertDraft2020_12(schema, { + "type": "array", + "prefixItems": [{ + "type": "string" + }], + "minItems": 1, + "items": { + "type": "number" + } + }) + }) + + it("NonEmptyArray", async () => { + await assertDraft2020_12( + Schema.NonEmptyArray(Schema.String), + { + type: "array", + minItems: 1, + items: { type: "string" } + } + ) + }) + }) +})