Skip to content
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
8 changes: 8 additions & 0 deletions .changeset/thick-gifts-live.md
Original file line number Diff line number Diff line change
@@ -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
48 changes: 39 additions & 9 deletions packages/effect/src/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ export interface JsonSchema7Boolean extends JsonSchemaAnnotations {
*/
export interface JsonSchema7Array extends JsonSchemaAnnotations {
type: "array"
items?: JsonSchema7 | Array<JsonSchema7>
items?: JsonSchema7 | Array<JsonSchema7> | false
prefixItems?: Array<JsonSchema7>
minItems?: number
maxItems?: number
additionalItems?: JsonSchema7 | boolean
Expand Down Expand Up @@ -258,7 +259,7 @@ export const make = <A, I, R>(schema: Schema.Schema<A, I, R>): JsonSchema7Root =
definitions
})
const out: JsonSchema7Root = {
$schema,
$schema: getMetaSchemaUri("jsonSchema7"),
$defs: {},
...jsonSchema
}
Expand All @@ -270,12 +271,25 @@ export const make = <A, I, R>(schema: Schema.Schema<A, I, R>): 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.
*
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -529,6 +541,7 @@ function isContentSchemaSupported(options: GoOptions): boolean {
case "jsonSchema7":
return false
case "jsonSchema2019-09":
case "jsonSchema2020-12":
case "openApi3.1":
return true
}
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}

// ---------------------------------------------
Expand All @@ -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
}
Expand Down
243 changes: 240 additions & 3 deletions packages/effect/test/Schema/JSONSchema.new.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ async function assertOpenApi3_1<S extends Schema.Schema.All>(
return jsonSchema
}

async function assertDraft2020_12<S extends Schema.Schema.All>(
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<S extends Schema.Schema.Any>(
schema: S,
input: S["Type"]
Expand Down Expand Up @@ -371,7 +389,7 @@ schema (SymbolKeyword): symbol`
})
})

describe("Draft 07", () => {
describe("jsonSchema7", () => {
describe("nullable handling", () => {
it("Null", async () => {
const schema = Schema.Null
Expand Down Expand Up @@ -4160,7 +4178,7 @@ schema (SymbolKeyword): symbol`
})
})

describe("Draft 2019-09", () => {
describe("jsonSchema2019-09", () => {
describe("nullable handling", () => {
it("Null", async () => {
const schema = Schema.Null
Expand Down Expand Up @@ -4279,7 +4297,7 @@ schema (SymbolKeyword): symbol`
})
})

describe("OpenAPI 3.1", () => {
describe("openApi3.1", () => {
describe("nullable handling", () => {
it("Null", async () => {
const schema = Schema.Null
Expand Down Expand Up @@ -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" }
}
)
})
})
})