diff --git a/.changeset/five-cats-brake.md b/.changeset/five-cats-brake.md
new file mode 100644
index 0000000000..937cae5603
--- /dev/null
+++ b/.changeset/five-cats-brake.md
@@ -0,0 +1,5 @@
+---
+"@effect/platform": patch
+---
+
+add OpenApiJsonSchema module
diff --git a/packages/platform-node/test/fixtures/openapi.json b/packages/platform-node/test/fixtures/openapi.json
index 4113da7634..152f353808 100644
--- a/packages/platform-node/test/fixtures/openapi.json
+++ b/packages/platform-node/test/fixtures/openapi.json
@@ -14,6 +14,8 @@
"name": "id",
"in": "path",
"schema": {
+ "description": "a number",
+ "title": "number",
"type": "string"
},
"required": true
@@ -30,16 +32,18 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of Group",
+ "title": "Group",
"type": "object",
"required": ["id", "name"],
"properties": {
- "name": {
- "type": "string"
- },
"id": {
"type": "integer",
"description": "an integer",
"title": "Int"
+ },
+ "name": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -52,24 +56,17 @@
"content": {
"application/json": {
"schema": {
+ "description": "HttpApiDecodeError: The request did not match the expected schema",
+ "title": "HttpApiDecodeError",
"type": "object",
"required": ["issues", "message", "_tag"],
"properties": {
- "_tag": {
- "enum": ["HttpApiDecodeError"]
- },
- "message": {
- "type": "string"
- },
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["_tag", "path", "message"],
"properties": {
- "message": {
- "type": "string"
- },
"_tag": {
"enum": [
"Pointer",
@@ -94,10 +91,19 @@
}
]
}
+ },
+ "message": {
+ "type": "string"
}
},
"additionalProperties": false
}
+ },
+ "message": {
+ "type": "string"
+ },
+ "_tag": {
+ "enum": ["HttpApiDecodeError"]
}
},
"additionalProperties": false
@@ -110,6 +116,8 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of GlobalError",
+ "title": "GlobalError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -144,16 +152,18 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of Group",
+ "title": "Group",
"type": "object",
"required": ["id", "name"],
"properties": {
- "name": {
- "type": "string"
- },
"id": {
"type": "integer",
"description": "an integer",
"title": "Int"
+ },
+ "name": {
+ "type": "string"
}
},
"additionalProperties": false
@@ -166,24 +176,17 @@
"content": {
"application/json": {
"schema": {
+ "description": "HttpApiDecodeError: The request did not match the expected schema",
+ "title": "HttpApiDecodeError",
"type": "object",
"required": ["issues", "message", "_tag"],
"properties": {
- "_tag": {
- "enum": ["HttpApiDecodeError"]
- },
- "message": {
- "type": "string"
- },
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["_tag", "path", "message"],
"properties": {
- "message": {
- "type": "string"
- },
"_tag": {
"enum": [
"Pointer",
@@ -208,10 +211,19 @@
}
]
}
+ },
+ "message": {
+ "type": "string"
}
},
"additionalProperties": false
}
+ },
+ "message": {
+ "type": "string"
+ },
+ "_tag": {
+ "enum": ["HttpApiDecodeError"]
}
},
"additionalProperties": false
@@ -224,6 +236,8 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of GlobalError",
+ "title": "GlobalError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -268,6 +282,8 @@
"name": "id",
"in": "path",
"schema": {
+ "description": "a number",
+ "title": "number",
"type": "string"
},
"required": true
@@ -280,18 +296,21 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of User",
+ "title": "User",
"type": "object",
"required": ["id", "name", "createdAt"],
"properties": {
- "name": {
- "type": "string"
- },
"id": {
"type": "integer",
"description": "an integer",
"title": "Int"
},
+ "name": {
+ "type": "string"
+ },
"createdAt": {
+ "description": "a DateTime.Utc instance",
"type": "string"
}
},
@@ -305,24 +324,17 @@
"content": {
"application/json": {
"schema": {
+ "description": "HttpApiDecodeError: The request did not match the expected schema",
+ "title": "HttpApiDecodeError",
"type": "object",
"required": ["issues", "message", "_tag"],
"properties": {
- "_tag": {
- "enum": ["HttpApiDecodeError"]
- },
- "message": {
- "type": "string"
- },
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["_tag", "path", "message"],
"properties": {
- "message": {
- "type": "string"
- },
"_tag": {
"enum": [
"Pointer",
@@ -347,10 +359,19 @@
}
]
}
+ },
+ "message": {
+ "type": "string"
}
},
"additionalProperties": false
}
+ },
+ "message": {
+ "type": "string"
+ },
+ "_tag": {
+ "enum": ["HttpApiDecodeError"]
}
},
"additionalProperties": false
@@ -363,6 +384,8 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of GlobalError",
+ "title": "GlobalError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -390,18 +413,21 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of User",
+ "title": "User",
"type": "object",
"required": ["id", "name", "createdAt"],
"properties": {
- "name": {
- "type": "string"
- },
"id": {
"type": "integer",
"description": "an integer",
"title": "Int"
},
+ "name": {
+ "type": "string"
+ },
"createdAt": {
+ "description": "a DateTime.Utc instance",
"type": "string"
}
},
@@ -417,24 +443,17 @@
"schema": {
"anyOf": [
{
+ "description": "HttpApiDecodeError: The request did not match the expected schema",
+ "title": "HttpApiDecodeError",
"type": "object",
"required": ["issues", "message", "_tag"],
"properties": {
- "_tag": {
- "enum": ["HttpApiDecodeError"]
- },
- "message": {
- "type": "string"
- },
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["_tag", "path", "message"],
"properties": {
- "message": {
- "type": "string"
- },
"_tag": {
"enum": [
"Pointer",
@@ -459,15 +478,26 @@
}
]
}
+ },
+ "message": {
+ "type": "string"
}
},
"additionalProperties": false
}
+ },
+ "message": {
+ "type": "string"
+ },
+ "_tag": {
+ "enum": ["HttpApiDecodeError"]
}
},
"additionalProperties": false
},
{
+ "description": "an instance of UserError",
+ "title": "UserError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -487,6 +517,8 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of GlobalError",
+ "title": "GlobalError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -526,6 +558,8 @@
"name": "page",
"in": "header",
"schema": {
+ "description": "a number",
+ "title": "number",
"type": "string"
},
"required": false
@@ -540,18 +574,21 @@
"schema": {
"type": "array",
"items": {
+ "description": "an instance of User",
+ "title": "User",
"type": "object",
"required": ["id", "name", "createdAt"],
"properties": {
- "name": {
- "type": "string"
- },
"id": {
"type": "integer",
"description": "an integer",
"title": "Int"
},
+ "name": {
+ "type": "string"
+ },
"createdAt": {
+ "description": "a DateTime.Utc instance",
"type": "string"
}
},
@@ -566,24 +603,17 @@
"content": {
"application/json": {
"schema": {
+ "description": "HttpApiDecodeError: The request did not match the expected schema",
+ "title": "HttpApiDecodeError",
"type": "object",
"required": ["issues", "message", "_tag"],
"properties": {
- "_tag": {
- "enum": ["HttpApiDecodeError"]
- },
- "message": {
- "type": "string"
- },
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["_tag", "path", "message"],
"properties": {
- "message": {
- "type": "string"
- },
"_tag": {
"enum": [
"Pointer",
@@ -608,10 +638,19 @@
}
]
}
+ },
+ "message": {
+ "type": "string"
}
},
"additionalProperties": false
}
+ },
+ "message": {
+ "type": "string"
+ },
+ "_tag": {
+ "enum": ["HttpApiDecodeError"]
}
},
"additionalProperties": false
@@ -624,6 +663,8 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of GlobalError",
+ "title": "GlobalError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -641,6 +682,8 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of NoStatusError",
+ "title": "NoStatusError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -690,24 +733,17 @@
"content": {
"application/json": {
"schema": {
+ "description": "HttpApiDecodeError: The request did not match the expected schema",
+ "title": "HttpApiDecodeError",
"type": "object",
"required": ["issues", "message", "_tag"],
"properties": {
- "_tag": {
- "enum": ["HttpApiDecodeError"]
- },
- "message": {
- "type": "string"
- },
"issues": {
"type": "array",
"items": {
"type": "object",
"required": ["_tag", "path", "message"],
"properties": {
- "message": {
- "type": "string"
- },
"_tag": {
"enum": [
"Pointer",
@@ -732,10 +768,19 @@
}
]
}
+ },
+ "message": {
+ "type": "string"
}
},
"additionalProperties": false
}
+ },
+ "message": {
+ "type": "string"
+ },
+ "_tag": {
+ "enum": ["HttpApiDecodeError"]
}
},
"additionalProperties": false
@@ -748,6 +793,8 @@
"content": {
"application/json": {
"schema": {
+ "description": "an instance of GlobalError",
+ "title": "GlobalError",
"type": "object",
"required": ["_tag"],
"properties": {
@@ -769,14 +816,14 @@
"required": ["file"],
"properties": {
"file": {
- "description": "an array of exactly 1 item(s)",
+ "type": "array",
"items": {
- "format": "binary",
- "type": "string"
+ "type": "string",
+ "format": "binary"
},
- "maxItems": 1,
+ "description": "an array of exactly 1 item(s)",
"minItems": 1,
- "type": "array"
+ "maxItems": 1
}
},
"additionalProperties": false
diff --git a/packages/platform/package.json b/packages/platform/package.json
index f0164621ca..aea9a95d2e 100644
--- a/packages/platform/package.json
+++ b/packages/platform/package.json
@@ -49,6 +49,7 @@
},
"devDependencies": {
"@effect/schema": "workspace:^",
+ "ajv": "^8.17.1",
"effect": "workspace:^"
}
}
diff --git a/packages/platform/src/OpenApi.ts b/packages/platform/src/OpenApi.ts
index c95e254f03..2e51812423 100644
--- a/packages/platform/src/OpenApi.ts
+++ b/packages/platform/src/OpenApi.ts
@@ -2,7 +2,6 @@
* @since 1.0.0
*/
import * as AST from "@effect/schema/AST"
-import * as JSONSchema from "@effect/schema/JSONSchema"
import * as Schema from "@effect/schema/Schema"
import * as Context from "effect/Context"
import { dual } from "effect/Function"
@@ -13,6 +12,7 @@ import * as HttpApi from "./HttpApi.js"
import * as HttpApiSchema from "./HttpApiSchema.js"
import type { HttpApiSecurity } from "./HttpApiSecurity.js"
import * as HttpMethod from "./HttpMethod.js"
+import * as JsonSchema from "./OpenApiJsonSchema.js"
/**
* @since 1.0.0
@@ -234,7 +234,7 @@ export const fromApi = (api: A): OpenAPISpec => {
op.requestBody = {
content: {
[HttpApiSchema.getMultipart(schema.ast) ? "multipart/form-data" : "application/json"]: {
- schema: makeJsonSchema(schema)
+ schema: JsonSchema.make(schema)
}
},
required: true
@@ -245,13 +245,13 @@ export const fromApi = (api: A): OpenAPISpec => {
Option.map((ast) => {
op.responses![successStatus].content = {
[successEncoding.contentType]: {
- schema: makeJsonSchema(Schema.make(ast))
+ schema: JsonSchema.make(Schema.make(ast))
}
}
})
)
if (Option.isSome(endpoint.pathSchema)) {
- const schema = makeJsonSchema(endpoint.pathSchema.value) as JSONSchema.JsonSchema7Object
+ const schema = JsonSchema.make(endpoint.pathSchema.value) as JsonSchema.Object
if ("properties" in schema) {
Object.entries(schema.properties).forEach(([name, jsonSchema]) => {
op.parameters!.push({
@@ -264,7 +264,7 @@ export const fromApi = (api: A): OpenAPISpec => {
}
}
if (!HttpMethod.hasBody(endpoint.method) && Option.isSome(endpoint.payloadSchema)) {
- const schema = makeJsonSchema(endpoint.payloadSchema.value) as JSONSchema.JsonSchema7Object
+ const schema = JsonSchema.make(endpoint.payloadSchema.value) as JsonSchema.Object
if ("properties" in schema) {
Object.entries(schema.properties).forEach(([name, jsonSchema]) => {
op.parameters!.push({
@@ -277,7 +277,7 @@ export const fromApi = (api: A): OpenAPISpec => {
}
}
if (Option.isSome(endpoint.headersSchema)) {
- const schema = makeJsonSchema(endpoint.headersSchema.value) as JSONSchema.JsonSchema7Object
+ const schema = JsonSchema.make(endpoint.headersSchema.value) as JsonSchema.Object
if ("properties" in schema) {
Object.entries(schema.properties).forEach(([name, jsonSchema]) => {
op.parameters!.push({
@@ -299,7 +299,7 @@ export const fromApi = (api: A): OpenAPISpec => {
Option.map((ast) => {
op.responses![status].content = {
"application/json": {
- schema: makeJsonSchema(Schema.make(ast))
+ schema: JsonSchema.make(Schema.make(ast))
}
}
})
@@ -361,12 +361,6 @@ const getDescriptionOrIdentifier = (ast: Option.Option {
- const jsonSchema = JSONSchema.make(schema as any)
- delete jsonSchema.$schema
- return jsonSchema
-}
-
/**
* @category models
* @since 1.0.0
@@ -478,12 +472,6 @@ export type OpenAPISpecPathItem =
readonly parameters?: Array
}
-/**
- * @category models
- * @since 1.0.0
- */
-export type OpenAPIJSONSchema = JSONSchema.JsonSchema7
-
/**
* @category models
* @since 1.0.0
@@ -491,7 +479,7 @@ export type OpenAPIJSONSchema = JSONSchema.JsonSchema7
export interface OpenAPISpecParameter {
readonly name: string
readonly in: "query" | "header" | "path" | "cookie"
- readonly schema: OpenAPIJSONSchema
+ readonly schema: JsonSchema.JsonSchema
readonly description?: string
readonly required?: boolean
readonly deprecated?: boolean
@@ -524,7 +512,7 @@ export type OpenApiSpecContent = {
*/
export interface OpenApiSpecResponseHeader {
readonly description?: string
- readonly schema: OpenAPIJSONSchema
+ readonly schema: JsonSchema.JsonSchema
}
/**
@@ -551,7 +539,7 @@ export interface OpenApiSpecResponse {
* @since 1.0.0
*/
export interface OpenApiSpecMediaType {
- readonly schema?: OpenAPIJSONSchema
+ readonly schema?: JsonSchema.JsonSchema
readonly example?: object
readonly description?: string
}
@@ -571,7 +559,7 @@ export interface OpenAPISpecRequestBody {
* @since 1.0.0
*/
export interface OpenAPIComponents {
- readonly schemas?: ReadonlyRecord
+ readonly schemas?: ReadonlyRecord
readonly securitySchemes?: ReadonlyRecord
}
diff --git a/packages/platform/src/OpenApiJsonSchema.ts b/packages/platform/src/OpenApiJsonSchema.ts
new file mode 100644
index 0000000000..abf72c72f7
--- /dev/null
+++ b/packages/platform/src/OpenApiJsonSchema.ts
@@ -0,0 +1,696 @@
+/**
+ * @since 1.0.0
+ */
+import * as AST from "@effect/schema/AST"
+import type * as ParseResult from "@effect/schema/ParseResult"
+import type * as Schema from "@effect/schema/Schema"
+import * as Arr from "effect/Array"
+import * as Option from "effect/Option"
+import * as Predicate from "effect/Predicate"
+import * as Record from "effect/Record"
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Annotations {
+ title?: string
+ description?: string
+ default?: unknown
+ examples?: globalThis.Array
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Any extends Annotations {
+ $id: "/schemas/any"
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Unknown extends Annotations {
+ $id: "/schemas/unknown"
+}
+
+/**
+ * @category model
+ * @since 0.69.0
+ */
+export interface Void extends Annotations {
+ $id: "/schemas/void"
+}
+
+/**
+ * @category model
+ * @since 0.71.0
+ */
+export interface AnyObject extends Annotations {
+ $id: "/schemas/object"
+ anyOf: [
+ { type: "object" },
+ { type: "array" }
+ ]
+}
+
+/**
+ * @category model
+ * @since 0.71.0
+ */
+export interface Empty extends Annotations {
+ $id: "/schemas/{}"
+ anyOf: [
+ { type: "object" },
+ { type: "array" }
+ ]
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Ref extends Annotations {
+ $ref: string
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface String extends Annotations {
+ type: "string"
+ minLength?: number
+ maxLength?: number
+ pattern?: string
+ contentEncoding?: string
+ contentMediaType?: string
+ contentSchema?: JsonSchema
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Numeric extends Annotations {
+ minimum?: number
+ exclusiveMinimum?: number
+ maximum?: number
+ exclusiveMaximum?: number
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Number extends Numeric {
+ type: "number"
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Integer extends Numeric {
+ type: "integer"
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Boolean extends Annotations {
+ type: "boolean"
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Array extends Annotations {
+ type: "array"
+ items?: JsonSchema | globalThis.Array
+ minItems?: number
+ maxItems?: number
+ additionalItems?: JsonSchema | boolean
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Enum extends Annotations {
+ enum: globalThis.Array
+}
+
+/**
+ * @category model
+ * @since 0.71.0
+ */
+export interface Enums extends Annotations {
+ $comment: "/schemas/enums"
+ anyOf: globalThis.Array<{
+ title: string
+ enum: [string | number]
+ }>
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface AnyOf extends Annotations {
+ anyOf: globalThis.Array
+}
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export interface Object extends Annotations {
+ type: "object"
+ required: globalThis.Array
+ properties: Record
+ additionalProperties?: boolean | JsonSchema
+ patternProperties?: Record
+ propertyNames?: JsonSchema
+}
+
+/**
+ * @category model
+ * @since 0.71.0
+ */
+export type JsonSchema =
+ | Any
+ | Unknown
+ | Void
+ | AnyObject
+ | Empty
+ | Ref
+ | String
+ | Number
+ | Integer
+ | Boolean
+ | Array
+ | Enum
+ | Enums
+ | AnyOf
+ | Object
+
+/**
+ * @category model
+ * @since 1.0.0
+ */
+export type Root = JsonSchema & {
+ $defs?: Record
+}
+
+/**
+ * @category encoding
+ * @since 1.0.0
+ */
+export const make = (schema: Schema.Schema): Root => {
+ const $defs: Record = {}
+ const out = go(schema.ast, $defs, true, []) as Root
+ // clean up self-referencing entries
+ for (const id in $defs) {
+ if ($defs[id]["$ref"] === get$ref(id)) {
+ delete $defs[id]
+ }
+ }
+ if (!Record.isEmptyRecord($defs)) {
+ out.$defs = $defs
+ }
+ return out
+}
+
+const constAny: JsonSchema = { $id: "/schemas/any" }
+
+const constUnknown: JsonSchema = { $id: "/schemas/unknown" }
+
+const constVoid: JsonSchema = { $id: "/schemas/void" }
+
+const constAnyObject: JsonSchema = {
+ "$id": "/schemas/object",
+ "anyOf": [
+ { "type": "object" },
+ { "type": "array" }
+ ]
+}
+
+const constEmpty: JsonSchema = {
+ "$id": "/schemas/{}",
+ "anyOf": [
+ { "type": "object" },
+ { "type": "array" }
+ ]
+}
+
+const getJsonSchemaAnnotations = (annotated: AST.Annotated): Annotations =>
+ Record.getSomes({
+ description: AST.getDescriptionAnnotation(annotated),
+ title: AST.getTitleAnnotation(annotated),
+ examples: AST.getExamplesAnnotation(annotated),
+ default: AST.getDefaultAnnotation(annotated)
+ })
+
+const pruneUndefinedKeyword = (ps: AST.PropertySignature): AST.AST | undefined => {
+ const type = ps.type
+ if (AST.isUnion(type) && Option.isNone(AST.getJSONSchemaAnnotation(type))) {
+ const types = type.types.filter((type) => !AST.isUndefinedKeyword(type))
+ if (types.length < type.types.length) {
+ return AST.Union.make(types, type.annotations)
+ }
+ }
+}
+
+const DEFINITION_PREFIX = "#/$defs/"
+
+const get$ref = (id: string): string => `${DEFINITION_PREFIX}${id}`
+
+const getRefinementInnerTransformation = (ast: AST.Refinement): AST.AST | undefined => {
+ switch (ast.from._tag) {
+ case "Transformation":
+ return ast.from
+ case "Refinement":
+ return getRefinementInnerTransformation(ast.from)
+ case "Suspend": {
+ const from = ast.from.f()
+ if (AST.isRefinement(from)) {
+ return getRefinementInnerTransformation(from)
+ }
+ }
+ }
+}
+
+const isParseJsonTransformation = (ast: AST.AST): boolean => ast.annotations[AST.TypeAnnotationId] === ParseJsonTypeId
+
+const isOverrideAnnotation = (jsonSchema: JsonSchema): boolean => {
+ return ("type" in jsonSchema) || ("oneOf" in jsonSchema) || ("anyOf" in jsonSchema) || ("const" in jsonSchema) ||
+ ("enum" in jsonSchema) || ("$ref" in jsonSchema)
+}
+
+const go = (
+ ast: AST.AST,
+ $defs: Record,
+ handleIdentifier: boolean,
+ path: ReadonlyArray
+): JsonSchema => {
+ const hook = AST.getJSONSchemaAnnotation(ast)
+ if (Option.isSome(hook)) {
+ const handler = hook.value as JsonSchema
+ if (AST.isRefinement(ast)) {
+ const t = getRefinementInnerTransformation(ast)
+ if (t === undefined) {
+ try {
+ return {
+ ...go(ast.from, $defs, true, path),
+ ...getJsonSchemaAnnotations(ast),
+ ...handler
+ }
+ } catch (e) {
+ return {
+ ...getJsonSchemaAnnotations(ast),
+ ...handler
+ }
+ }
+ } else if (!isOverrideAnnotation(handler)) {
+ return {
+ ...go(t, $defs, true, path),
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ }
+ return handler
+ }
+ const surrogate = getSurrogateAnnotation(ast)
+ if (Option.isSome(surrogate)) {
+ return {
+ ...(ast._tag === "Transformation" ? getJsonSchemaAnnotations(ast.to) : {}),
+ ...go(surrogate.value, $defs, handleIdentifier, path),
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ if (handleIdentifier && !AST.isTransformation(ast)) {
+ const identifier = getJSONIdentifier(ast)
+ if (Option.isSome(identifier)) {
+ const id = identifier.value
+ const out = { $ref: get$ref(id) }
+ if (!Record.has($defs, id)) {
+ $defs[id] = out
+ $defs[id] = go(ast, $defs, false, path)
+ }
+ return out
+ }
+ }
+ switch (ast._tag) {
+ case "Declaration":
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ case "Literal": {
+ const literal = ast.literal
+ if (literal === null) {
+ return {
+ enum: [null],
+ ...getJsonSchemaAnnotations(ast)
+ }
+ } else if (Predicate.isString(literal) || Predicate.isNumber(literal) || Predicate.isBoolean(literal)) {
+ return {
+ enum: [literal],
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ }
+ case "UniqueSymbol":
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ case "UndefinedKeyword":
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ case "VoidKeyword":
+ return {
+ ...constVoid,
+ ...getJsonSchemaAnnotations(ast)
+ }
+ case "NeverKeyword":
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ case "UnknownKeyword":
+ return {
+ ...constUnknown,
+ ...getJsonSchemaAnnotations(ast)
+ }
+
+ case "AnyKeyword":
+ return {
+ ...constAny,
+ ...getJsonSchemaAnnotations(ast)
+ }
+ case "ObjectKeyword":
+ return {
+ ...constAnyObject,
+ ...getJsonSchemaAnnotations(ast)
+ }
+ case "StringKeyword": {
+ return ast === AST.stringKeyword ? { type: "string" } : {
+ type: "string",
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "NumberKeyword": {
+ return ast === AST.numberKeyword ? { type: "number" } : {
+ type: "number",
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "BooleanKeyword": {
+ return ast === AST.booleanKeyword ? { type: "boolean" } : {
+ type: "boolean",
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "BigIntKeyword":
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ case "SymbolKeyword":
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ case "TupleType": {
+ const elements = ast.elements.map((e, i) => ({
+ ...go(e.type, $defs, true, path.concat(i)),
+ ...getJsonSchemaAnnotations(e)
+ }))
+ const rest = ast.rest.map((annotatedAST) => ({
+ ...go(annotatedAST.type, $defs, true, path),
+ ...getJsonSchemaAnnotations(annotatedAST)
+ }))
+ const output: Array = { type: "array" }
+ // ---------------------------------------------
+ // handle elements
+ // ---------------------------------------------
+ const len = ast.elements.length
+ if (len > 0) {
+ output.minItems = len - ast.elements.filter((element) => element.isOptional).length
+ output.items = elements
+ }
+ // ---------------------------------------------
+ // handle rest element
+ // ---------------------------------------------
+ const restLength = rest.length
+ if (restLength > 0) {
+ const head = rest[0]
+ const isHomogeneous = restLength === 1 && ast.elements.every((e) => e.type === ast.rest[0].type)
+ if (isHomogeneous) {
+ output.items = head
+ } else {
+ output.additionalItems = head
+ }
+
+ // ---------------------------------------------
+ // handle post rest elements
+ // ---------------------------------------------
+ if (restLength > 1) {
+ throw new Error(getJSONSchemaUnsupportedPostRestElementsErrorMessage(path))
+ }
+ } else {
+ if (len > 0) {
+ output.additionalItems = false
+ } else {
+ output.maxItems = 0
+ }
+ }
+
+ return {
+ ...output,
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "TypeLiteral": {
+ if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) {
+ return {
+ ...constEmpty,
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ let patternProperties: JsonSchema | undefined = undefined
+ let propertyNames: JsonSchema | undefined = undefined
+ for (const is of ast.indexSignatures) {
+ const parameter = is.parameter
+ switch (parameter._tag) {
+ case "StringKeyword": {
+ patternProperties = go(is.type, $defs, true, path)
+ break
+ }
+ case "TemplateLiteral": {
+ patternProperties = go(is.type, $defs, true, path)
+ propertyNames = {
+ type: "string",
+ pattern: AST.getTemplateLiteralRegExp(parameter).source
+ }
+ break
+ }
+ case "Refinement": {
+ patternProperties = go(is.type, $defs, true, path)
+ propertyNames = go(parameter, $defs, true, path)
+ break
+ }
+ case "SymbolKeyword":
+ throw new Error(getJSONSchemaUnsupportedParameterErrorMessage(path, parameter))
+ }
+ }
+ const output: Object = {
+ type: "object",
+ required: [],
+ properties: {},
+ additionalProperties: false
+ }
+ // ---------------------------------------------
+ // handle property signatures
+ // ---------------------------------------------
+ for (let i = 0; i < ast.propertySignatures.length; i++) {
+ const ps = ast.propertySignatures[i]
+ const name = ps.name
+ if (Predicate.isString(name)) {
+ const pruned = pruneUndefinedKeyword(ps)
+ output.properties[name] = {
+ ...go(pruned ? pruned : ps.type, $defs, true, path.concat(ps.name)),
+ ...getJsonSchemaAnnotations(ps)
+ }
+ // ---------------------------------------------
+ // handle optional property signatures
+ // ---------------------------------------------
+ if (!ps.isOptional && pruned === undefined) {
+ output.required.push(name)
+ }
+ } else {
+ throw new Error(getJSONSchemaUnsupportedKeyErrorMessage(name, path))
+ }
+ }
+ // ---------------------------------------------
+ // handle index signatures
+ // ---------------------------------------------
+ if (patternProperties !== undefined) {
+ delete output.additionalProperties
+ output.patternProperties = { "": patternProperties }
+ }
+ if (propertyNames !== undefined) {
+ output.propertyNames = propertyNames
+ }
+
+ return {
+ ...output,
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "Union": {
+ const enums: globalThis.Array = []
+ const anyOf: globalThis.Array = []
+ for (const type of ast.types) {
+ const schema = go(type, $defs, true, path)
+ if ("enum" in schema) {
+ if (Object.keys(schema).length > 1) {
+ anyOf.push(schema)
+ } else {
+ for (const e of schema.enum) {
+ enums.push(e)
+ }
+ }
+ } else {
+ anyOf.push(schema)
+ }
+ }
+ if (anyOf.length === 0) {
+ return { enum: enums, ...getJsonSchemaAnnotations(ast) }
+ } else {
+ if (enums.length >= 1) {
+ anyOf.push({ enum: enums })
+ }
+ return { anyOf, ...getJsonSchemaAnnotations(ast) }
+ }
+ }
+ case "Enums": {
+ return {
+ $comment: "/schemas/enums",
+ anyOf: ast.enums.map((e) => ({ title: e[0], enum: [e[1]] })),
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "Refinement": {
+ throw new Error(getJSONSchemaMissingAnnotationErrorMessage(path, ast))
+ }
+ case "TemplateLiteral": {
+ const regex = AST.getTemplateLiteralRegExp(ast)
+ return {
+ type: "string",
+ description: "a template literal",
+ pattern: regex.source,
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "Suspend": {
+ const identifier = Option.orElse(getJSONIdentifier(ast), () => getJSONIdentifier(ast.f()))
+ if (Option.isNone(identifier)) {
+ throw new Error(getJSONSchemaMissingIdentifierAnnotationErrorMessage(path, ast))
+ }
+ return {
+ ...go(ast.f(), $defs, true, path),
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ case "Transformation": {
+ // Properly handle S.parseJson transformations by focusing on
+ // the 'to' side of the AST. This approach prevents the generation of useless schemas
+ // derived from the 'from' side (type: string), ensuring the output matches the intended
+ // complex schema type.
+ if (isParseJsonTransformation(ast.from)) {
+ return {
+ type: "string",
+ contentMediaType: "application/json",
+ contentSchema: go(ast.to, $defs, true, path),
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ return {
+ ...getJsonSchemaAnnotations(ast.to),
+ ...go(ast.from, $defs, true, path),
+ ...getJsonSchemaAnnotations(ast)
+ }
+ }
+ }
+}
+
+const getJSONSchemaMissingAnnotationErrorMessage = (
+ path: ReadonlyArray,
+ ast: AST.AST
+) =>
+ getMissingAnnotationErrorMessage(
+ `Generating a JSON Schema for this schema requires a "jsonSchema" annotation`,
+ path,
+ ast
+ )
+
+const getJSONSchemaMissingIdentifierAnnotationErrorMessage = (
+ path: ReadonlyArray,
+ ast: AST.AST
+) =>
+ getMissingAnnotationErrorMessage(
+ `Generating a JSON Schema for this schema requires an "identifier" annotation`,
+ path,
+ ast
+ )
+
+const getJSONSchemaUnsupportedParameterErrorMessage = (
+ path: ReadonlyArray,
+ parameter: AST.AST
+): string => getErrorMessage("Unsupported index signature parameter", undefined, path, parameter)
+
+const getJSONSchemaUnsupportedPostRestElementsErrorMessage = (path: ReadonlyArray): string =>
+ getErrorMessage(
+ "Generating a JSON Schema for post-rest elements is not currently supported. You're welcome to contribute by submitting a Pull Request",
+ undefined,
+ path
+ )
+
+const getJSONSchemaUnsupportedKeyErrorMessage = (key: PropertyKey, path: ReadonlyArray): string =>
+ getErrorMessage("Unsupported key", `Cannot encode ${formatPropertyKey(key)} key to JSON Schema`, path)
+
+const getMissingAnnotationErrorMessage = (details?: string, path?: ReadonlyArray, ast?: AST.AST): string =>
+ getErrorMessage("Missing annotation", details, path, ast)
+
+const getErrorMessage = (
+ reason: string,
+ details?: string,
+ path?: ReadonlyArray,
+ ast?: AST.AST
+): string => {
+ let out = reason
+
+ if (path && Arr.isNonEmptyReadonlyArray(path)) {
+ out += `\nat path: ${formatPath(path)}`
+ }
+
+ if (details !== undefined) {
+ out += `\ndetails: ${details}`
+ }
+
+ if (ast) {
+ out += `\nschema (${ast._tag}): ${ast}`
+ }
+
+ return out
+}
+
+const formatPathKey = (key: PropertyKey): string => `[${formatPropertyKey(key)}]`
+
+const formatPath = (path: ParseResult.Path): string =>
+ isNonEmpty(path) ? path.map(formatPathKey).join("") : formatPathKey(path)
+
+const isNonEmpty = (x: ParseResult.SingleOrNonEmpty): x is Arr.NonEmptyReadonlyArray => Array.isArray(x)
+
+const formatPropertyKey = (name: PropertyKey): string => typeof name === "string" ? JSON.stringify(name) : String(name)
+
+const ParseJsonTypeId: unique symbol = Symbol.for("@effect/schema/TypeId/ParseJson")
+const SurrogateAnnotationId = Symbol.for("@effect/schema/annotation/Surrogate")
+const JSONIdentifierAnnotationId = Symbol.for("@effect/schema/annotation/JSONIdentifier")
+
+const getSurrogateAnnotation = AST.getAnnotation(SurrogateAnnotationId)
+const getJSONIdentifierAnnotation = AST.getAnnotation(JSONIdentifierAnnotationId)
+const getJSONIdentifier = (annotated: AST.Annotated) =>
+ Option.orElse(getJSONIdentifierAnnotation(annotated), () => AST.getIdentifierAnnotation(annotated))
diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts
index b85998a25d..51d7599777 100644
--- a/packages/platform/src/index.ts
+++ b/packages/platform/src/index.ts
@@ -194,6 +194,11 @@ export * as Multipart from "./Multipart.js"
*/
export * as OpenApi from "./OpenApi.js"
+/**
+ * @since 1.0.0
+ */
+export * as OpenApiJsonSchema from "./OpenApiJsonSchema.js"
+
/**
* @since 1.0.0
*/
diff --git a/packages/platform/test/OpenApiJsonSchema.test.ts b/packages/platform/test/OpenApiJsonSchema.test.ts
new file mode 100644
index 0000000000..fc984dccfd
--- /dev/null
+++ b/packages/platform/test/OpenApiJsonSchema.test.ts
@@ -0,0 +1,2170 @@
+import * as JsonSchema from "@effect/platform/OpenApiJsonSchema"
+import * as A from "@effect/schema/Arbitrary"
+import * as Schema from "@effect/schema/Schema"
+import AjvNonEsm from "ajv/dist/2019.js"
+import * as fc from "fast-check"
+import { describe, expect, it } from "vitest"
+
+const Ajv = AjvNonEsm.default
+
+type JsonArray = ReadonlyArray
+
+type JsonObject = { readonly [key: string]: Json }
+
+type Json =
+ | null
+ | boolean
+ | number
+ | string
+ | JsonArray
+ | JsonObject
+
+const doProperty = false
+
+const ajvOptions = { strictTuples: false, allowMatchingProperties: true }
+
+const propertyType = (schema: Schema.Schema, options?: {
+ params?: fc.Parameters<[I]>
+}) => {
+ if (!doProperty) {
+ return
+ }
+ const encodedBoundSchema = Schema.encodedBoundSchema(schema)
+ const arb = A.makeLazy(encodedBoundSchema)
+ const is = Schema.is(encodedBoundSchema)
+ const jsonSchema = JsonSchema.make(schema)
+ const validate = new Ajv(ajvOptions).compile(
+ jsonSchema
+ )
+ fc.assert(
+ fc.property(
+ arb(fc),
+ (i) => is(i) && validate(i)
+ ),
+ options?.params
+ )
+}
+
+const expectJSONSchema = (
+ schema: Schema.Schema,
+ expected: object,
+ options: boolean | {
+ params?: fc.Parameters<[I]>
+ } = true
+) => {
+ const jsonSchema = JsonSchema.make(schema)
+ expect(jsonSchema).toEqual(expected)
+ if (options !== false) {
+ propertyType(schema, options === true ? undefined : options)
+ }
+}
+
+const expectError = (schema: Schema.Schema, message: string) => {
+ expect(() => JsonSchema.make(schema)).toThrow(
+ new Error(message)
+ )
+}
+
+const JsonNumber = Schema.Number.pipe(
+ Schema.filter((n) => !Number.isNaN(n) && Number.isFinite(n), {
+ jsonSchema: { type: "number" }
+ })
+)
+
+describe("OpenApiJsonSchema", () => {
+ describe("unsupported schemas", () => {
+ it("a declaration should raise an error", () => {
+ expectError(
+ Schema.ChunkFromSelf(JsonNumber),
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (Declaration): Chunk<{ number | filter }>`
+ )
+ })
+
+ it("a bigint should raise an error", () => {
+ expectError(
+ Schema.BigIntFromSelf,
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (BigIntKeyword): bigint`
+ )
+ })
+
+ it("a symbol should raise an error", () => {
+ expectError(
+ Schema.SymbolFromSelf,
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (SymbolKeyword): symbol`
+ )
+ })
+
+ it("a unique symbol should raise an error", () => {
+ expectError(
+ Schema.UniqueSymbolFromSelf(Symbol.for("@effect/schema/test/a")),
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (UniqueSymbol): Symbol(@effect/schema/test/a)`
+ )
+ })
+
+ it("Undefined should raise an error", () => {
+ expectError(
+ Schema.Undefined,
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (UndefinedKeyword): undefined`
+ )
+ })
+
+ it("Never should raise an error", () => {
+ expectError(
+ Schema.Never,
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (NeverKeyword): never`
+ )
+ })
+
+ it("bigint literals should raise an error", () => {
+ expectError(
+ Schema.Literal(1n),
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (Literal): 1n`
+ )
+ })
+
+ it("Tuple", () => {
+ expectError(
+ Schema.Tuple(Schema.DateFromSelf),
+ `Missing annotation
+at path: [0]
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (Declaration): DateFromSelf`
+ )
+ })
+
+ it("Struct", () => {
+ expectError(
+ Schema.Struct({ a: Schema.DateFromSelf }),
+ `Missing annotation
+at path: ["a"]
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (Declaration): DateFromSelf`
+ )
+ })
+ })
+
+ it("Any", () => {
+ expectJSONSchema(Schema.Any, {
+ "$id": "/schemas/any",
+ "title": "any"
+ })
+ })
+
+ it("Unknown", () => {
+ expectJSONSchema(Schema.Unknown, {
+ "$id": "/schemas/unknown",
+ "title": "unknown"
+ })
+ })
+
+ it("Void", () => {
+ expectJSONSchema(Schema.Void, {
+ "$id": "/schemas/void",
+ "title": "void"
+ })
+ })
+
+ it("Object", () => {
+ const jsonSchema: JsonSchema.Root = {
+ "$id": "/schemas/object",
+ "anyOf": [
+ {
+ "type": "object"
+ },
+ {
+ "type": "array"
+ }
+ ],
+ "description": "an object in the TypeScript meaning, i.e. the `object` type",
+ "title": "object"
+ }
+ expectJSONSchema(Schema.Object, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({})).toEqual(true)
+ expect(validate({ a: 1 })).toEqual(true)
+ expect(validate([])).toEqual(true)
+ expect(validate("a")).toEqual(false)
+ expect(validate(1)).toEqual(false)
+ expect(validate(true)).toEqual(false)
+ })
+
+ it("String", () => {
+ expectJSONSchema(Schema.String, {
+ type: "string"
+ })
+ expectJSONSchema(Schema.String.annotations({}), {
+ type: "string",
+ description: "a string",
+ title: "string"
+ })
+ })
+
+ it("Number", () => {
+ expectJSONSchema(Schema.Number, {
+ type: "number"
+ }, false)
+ expectJSONSchema(Schema.Number.annotations({}), {
+ type: "number",
+ description: "a number",
+ title: "number"
+ }, false)
+ })
+
+ it("Boolean", () => {
+ expectJSONSchema(Schema.Boolean, {
+ type: "boolean"
+ })
+ expectJSONSchema(Schema.Boolean.annotations({}), {
+ type: "boolean",
+ description: "a boolean",
+ title: "boolean"
+ })
+ })
+
+ describe("Literal", () => {
+ it("Null", () => {
+ expectJSONSchema(Schema.Null, {
+ "enum": [null]
+ })
+ })
+
+ it("string literals", () => {
+ expectJSONSchema(Schema.Literal("a"), {
+ "enum": ["a"]
+ })
+ })
+
+ it("number literals", () => {
+ expectJSONSchema(Schema.Literal(1), {
+ "enum": [1]
+ })
+ })
+
+ it("boolean literals", () => {
+ expectJSONSchema(Schema.Literal(true), {
+ "enum": [true]
+ })
+ expectJSONSchema(Schema.Literal(false), {
+ "enum": [false]
+ })
+ })
+ })
+
+ describe("Enums", () => {
+ it("numeric enums", () => {
+ enum Fruits {
+ Apple,
+ Banana
+ }
+ expectJSONSchema(Schema.Enums(Fruits), {
+ "$comment": "/schemas/enums",
+ "anyOf": [
+ {
+ "title": "Apple",
+ "enum": [0]
+ },
+ {
+ "title": "Banana",
+ "enum": [1]
+ }
+ ]
+ })
+ })
+
+ it("string enums", () => {
+ enum Fruits {
+ Apple = "apple",
+ Banana = "banana"
+ }
+ expectJSONSchema(Schema.Enums(Fruits), {
+ "$comment": "/schemas/enums",
+ "anyOf": [
+ {
+ "title": "Apple",
+ "enum": ["apple"]
+ },
+ {
+ "title": "Banana",
+ "enum": ["banana"]
+ }
+ ]
+ })
+ })
+
+ it("mix of string/number enums", () => {
+ enum Fruits {
+ Apple = "apple",
+ Banana = "banana",
+ Cantaloupe = 0
+ }
+ expectJSONSchema(Schema.Enums(Fruits), {
+ "$comment": "/schemas/enums",
+ "anyOf": [
+ {
+ "title": "Apple",
+ "enum": ["apple"]
+ },
+ {
+ "title": "Banana",
+ "enum": ["banana"]
+ },
+ {
+ "title": "Cantaloupe",
+ "enum": [0]
+ }
+ ]
+ })
+ })
+
+ it("const enums", () => {
+ const Fruits = {
+ Apple: "apple",
+ Banana: "banana",
+ Cantaloupe: 3
+ } as const
+ expectJSONSchema(Schema.Enums(Fruits), {
+ "$comment": "/schemas/enums",
+ "anyOf": [
+ {
+ "title": "Apple",
+ "enum": ["apple"]
+ },
+ {
+ "title": "Banana",
+ "enum": ["banana"]
+ },
+ {
+ "title": "Cantaloupe",
+ "enum": [3]
+ }
+ ]
+ })
+ })
+ })
+
+ describe("Union", () => {
+ it("string | number", () => {
+ expectJSONSchema(Schema.Union(Schema.String, JsonNumber), {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ }
+ ]
+ })
+ })
+
+ it(`1 | "a"`, () => {
+ expectJSONSchema(Schema.Literal(1, 2), {
+ "enum": [1, 2]
+ })
+ })
+
+ it(`1 | true | string`, () => {
+ expectJSONSchema(Schema.Union(Schema.Literal(1, true), Schema.String), {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ { "enum": [1, true] }
+ ]
+ })
+ })
+
+ it(`1 | true(with description) | string`, () => {
+ expectJSONSchema(
+ Schema.Union(
+ Schema.Literal(1),
+ Schema.Literal(true).annotations({ description: "description" }),
+ Schema.String
+ ),
+ {
+ "anyOf": [
+ { "enum": [true], "description": "description" },
+ {
+ "type": "string"
+ },
+ { "enum": [1] }
+ ]
+ }
+ )
+ })
+
+ it(`1 | 2 | true(with description) | string`, () => {
+ expectJSONSchema(
+ Schema.Union(
+ Schema.Literal(1, 2),
+ Schema.Literal(true).annotations({ description: "description" }),
+ Schema.String
+ ),
+ {
+ "anyOf": [
+ { "enum": [true], "description": "description" },
+ {
+ "type": "string"
+ },
+ { "enum": [1, 2] }
+ ]
+ }
+ )
+ })
+
+ it("union of literals with descriptions", () => {
+ expectJSONSchema(
+ Schema.Union(
+ Schema.Literal("foo").annotations({ description: "I'm a foo" }),
+ Schema.Literal("bar").annotations({ description: "I'm a bar" })
+ ),
+ {
+ "anyOf": [
+ {
+ "enum": ["foo"],
+ "description": "I'm a foo"
+ },
+ {
+ "enum": ["bar"],
+ "description": "I'm a bar"
+ }
+ ]
+ }
+ )
+ })
+
+ it("union of literals with identifier", () => {
+ expectJSONSchema(
+ Schema.Union(
+ Schema.Literal("foo").annotations({
+ description: "I'm a foo",
+ identifier: "foo"
+ }),
+ Schema.Literal("bar").annotations({
+ description: "I'm a bar",
+ identifier: "bar"
+ })
+ ),
+ {
+ "$defs": {
+ "bar": {
+ "enum": ["bar"],
+ "description": "I'm a bar"
+ },
+ "foo": {
+ "enum": ["foo"],
+ "description": "I'm a foo"
+ }
+ },
+ "anyOf": [
+ {
+ "$ref": "#/$defs/foo"
+ },
+ {
+ "$ref": "#/$defs/bar"
+ }
+ ]
+ }
+ )
+ })
+ })
+
+ describe("Tuple", () => {
+ it("e?", () => {
+ const schema = Schema.Tuple(Schema.optionalElement(JsonNumber))
+ const jsonSchema: JsonSchema.Root = {
+ "type": "array",
+ "minItems": 0,
+ "items": [
+ {
+ "type": "number"
+ }
+ ],
+ "additionalItems": false
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv({ strictTuples: false }).compile(jsonSchema)
+ expect(validate([])).toEqual(true)
+ expect(validate([1])).toEqual(true)
+ expect(validate(["a"])).toEqual(false)
+ expect(validate([1, 2])).toEqual(false)
+ })
+
+ it("e e?", () => {
+ const schema = Schema.Tuple(
+ Schema.element(Schema.String.annotations({ description: "inner-e" })).annotations({ description: "e" }),
+ Schema.optionalElement(JsonNumber.annotations({ description: "inner-e?" })).annotations({ description: "e?" })
+ )
+ const jsonSchema: JsonSchema.Root = {
+ "type": "array",
+ "minItems": 1,
+ "items": [
+ {
+ "type": "string",
+ "title": "string",
+ "description": "e"
+ },
+ {
+ "type": "number",
+ "description": "e?"
+ }
+ ],
+ "additionalItems": false
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv({ strictTuples: false }).compile(jsonSchema)
+ expect(validate(["a"])).toEqual(true)
+ expect(validate(["a", 1])).toEqual(true)
+ expect(validate([])).toEqual(false)
+ expect(validate([1])).toEqual(false)
+ expect(validate([1, 2])).toEqual(false)
+ })
+
+ it("e? r", () => {
+ const schema = Schema.Tuple(
+ [Schema.optionalElement(Schema.String)],
+ Schema.element(JsonNumber.annotations({ description: "inner-r" })).annotations({ description: "r" })
+ )
+ const jsonSchema: JsonSchema.Root = {
+ "type": "array",
+ "minItems": 0,
+ "items": [
+ {
+ "type": "string"
+ }
+ ],
+ "additionalItems": {
+ "type": "number",
+ "description": "r"
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv({ strictTuples: false }).compile(jsonSchema)
+ expect(validate([])).toEqual(true)
+ expect(validate(["a"])).toEqual(true)
+ expect(validate(["a", 1])).toEqual(true)
+ expect(validate([1])).toEqual(false)
+ expect(validate([1, 2])).toEqual(false)
+ expect(validate(["a", "b", 1])).toEqual(false)
+ })
+
+ it("r e should raise an error", () => {
+ expectError(
+ Schema.Tuple([], JsonNumber, Schema.String),
+ "Generating a JSON Schema for post-rest elements is not currently supported. You're welcome to contribute by submitting a Pull Request"
+ )
+ })
+
+ it("empty", () => {
+ const schema = Schema.Tuple()
+ const jsonSchema: JsonSchema.Root = {
+ "type": "array",
+ "maxItems": 0
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate([])).toEqual(true)
+ expect(validate([1])).toEqual(false)
+ })
+
+ it("e", () => {
+ const schema = Schema.Tuple(JsonNumber)
+ const jsonSchema: JsonSchema.Root = {
+ "type": "array",
+ "items": [{
+ "type": "number"
+ }],
+ "minItems": 1,
+ "additionalItems": false
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate([1])).toEqual(true)
+ expect(validate([])).toEqual(false)
+ expect(validate(["a"])).toEqual(false)
+ expect(validate([1, "a"])).toEqual(false)
+ })
+
+ it("e r", () => {
+ const schema = Schema.Tuple([Schema.String], JsonNumber)
+ const jsonSchema: JsonSchema.Root = {
+ "type": "array",
+ "items": [{
+ "type": "string"
+ }],
+ "minItems": 1,
+ "additionalItems": {
+ "type": "number"
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv({ strictTuples: false }).compile({
+ "type": "array",
+ "items": [
+ {
+ "type": "string"
+ }
+ ],
+ "minItems": 1,
+ "additionalItems": {
+ "type": "number"
+ }
+ })
+ expect(validate(["a"])).toEqual(true)
+ expect(validate(["a", 1])).toEqual(true)
+ expect(validate(["a", 1, 2])).toEqual(true)
+ expect(validate(["a", 1, 2, 3])).toEqual(true)
+ expect(validate([])).toEqual(false)
+ expect(validate([1])).toEqual(false)
+ expect(validate(["a", "b"])).toEqual(false)
+ })
+
+ it("r", () => {
+ const schema = Schema.Array(JsonNumber)
+ const jsonSchema: JsonSchema.Root = {
+ "type": "array",
+ "items": {
+ "type": "number"
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate([])).toEqual(true)
+ expect(validate([1])).toEqual(true)
+ expect(validate([1, 2])).toEqual(true)
+ expect(validate([1, 2, 3])).toEqual(true)
+ expect(validate(["a"])).toEqual(false)
+ expect(validate([1, 2, 3, "a"])).toEqual(false)
+ })
+ })
+
+ describe("Struct", () => {
+ it("empty", () => {
+ const schema = Schema.Struct({})
+ const jsonSchema: JsonSchema.Root = {
+ "$id": "/schemas/{}",
+ "anyOf": [{
+ "type": "object"
+ }, {
+ "type": "array"
+ }]
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({})).toEqual(true)
+ expect(validate({ a: 1 })).toEqual(true)
+ expect(validate([])).toEqual(true)
+ expect(validate(null)).toEqual(false)
+ expect(validate(1)).toEqual(false)
+ expect(validate(true)).toEqual(false)
+ })
+
+ it("struct", () => {
+ const schema = Schema.Struct({ a: Schema.String, b: JsonNumber })
+ const jsonSchema: JsonSchema.Root = {
+ "type": "object",
+ "properties": {
+ "a": {
+ "type": "string"
+ },
+ "b": {
+ "type": "number"
+ }
+ },
+ "required": ["a", "b"],
+ "additionalProperties": false
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({ a: "a", b: 1 })).toEqual(true)
+ expect(validate({})).toEqual(false)
+ expect(validate({ a: "a" })).toEqual(false)
+ expect(validate({ b: 1 })).toEqual(false)
+ expect(validate({ a: "a", b: 1, c: true })).toEqual(false)
+ })
+
+ it("exact optional property signature", () => {
+ const schema = Schema.Struct({
+ a: Schema.String,
+ b: Schema.optionalWith(JsonNumber, { exact: true })
+ })
+ const jsonSchema: JsonSchema.Root = {
+ "type": "object",
+ "properties": {
+ "a": {
+ "type": "string"
+ },
+ "b": {
+ "type": "number"
+ }
+ },
+ "required": ["a"],
+ "additionalProperties": false
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({ a: "a", b: 1 })).toEqual(true)
+ expect(validate({ a: "a" })).toEqual(true)
+ expect(validate({})).toEqual(false)
+ expect(validate({ b: 1 })).toEqual(false)
+ expect(validate({ a: "a", b: 1, c: true })).toEqual(false)
+ })
+
+ it("should respect annotations", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.optional(Schema.String).annotations({ description: "an optional string" })
+ }),
+ {
+ type: "object",
+ required: [],
+ properties: {
+ a: {
+ type: "string",
+ "description": "an optional string"
+ }
+ },
+ additionalProperties: false
+ }
+ )
+ })
+
+ it("should raise an error if there is a property named with a symbol", () => {
+ const a = Symbol.for("@effect/schema/test/a")
+ expectError(
+ Schema.Struct({ [a]: Schema.String }),
+ `Unsupported key
+details: Cannot encode Symbol(@effect/schema/test/a) key to JSON Schema`
+ )
+ })
+
+ describe("pruning undefined", () => {
+ it("with an annotation the property should remain required", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.UndefinedOr(Schema.String).annotations({ jsonSchema: { "type": "number" } })
+ }),
+ {
+ "type": "object",
+ "required": ["a"],
+ "properties": {
+ "a": {
+ "type": "number"
+ }
+ },
+ "additionalProperties": false
+ },
+ false
+ )
+ })
+
+ it("should prune `UndefinedKeyword` from an optional property signature", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.optional(Schema.String)
+ }),
+ {
+ "type": "object",
+ "properties": {
+ "a": {
+ "type": "string"
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ )
+ })
+
+ it("should prune `UndefinedKeyword` from a required property signature type and make the property optional by default", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.UndefinedOr(Schema.String)
+ }),
+ {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "a": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ )
+ })
+ })
+ })
+
+ describe("Record", () => {
+ it("Record(symbol, number)", () => {
+ expectError(
+ Schema.Record({ key: Schema.SymbolFromSelf, value: JsonNumber }),
+ `Unsupported index signature parameter
+schema (SymbolKeyword): symbol`
+ )
+ })
+
+ it("record(refinement, number)", () => {
+ expectJSONSchema(
+ Schema.Record({ key: Schema.String.pipe(Schema.minLength(1)), value: JsonNumber }),
+ {
+ type: "object",
+ required: [],
+ properties: {},
+ patternProperties: {
+ "": {
+ type: "number"
+ }
+ },
+ propertyNames: {
+ type: "string",
+ description: "a string at least 1 character(s) long",
+ minLength: 1
+ }
+ }
+ )
+ })
+
+ it("Record(string, number)", () => {
+ expectJSONSchema(Schema.Record({ key: Schema.String, value: JsonNumber }), {
+ "type": "object",
+ "properties": {},
+ "required": [],
+ "patternProperties": {
+ "": {
+ "type": "number"
+ }
+ }
+ })
+ })
+
+ it("Record('a' | 'b', number)", () => {
+ expectJSONSchema(
+ Schema.Record(
+ { key: Schema.Union(Schema.Literal("a"), Schema.Literal("b")), value: JsonNumber }
+ ),
+ {
+ "type": "object",
+ "properties": {
+ "a": {
+ "type": "number"
+ },
+ "b": {
+ "type": "number"
+ }
+ },
+ "required": ["a", "b"],
+ "additionalProperties": false
+ }
+ )
+ })
+
+ it("Record(${string}-${string}, number)", () => {
+ const schema = Schema.Record(
+ { key: Schema.TemplateLiteral(Schema.String, Schema.Literal("-"), Schema.String), value: JsonNumber }
+ )
+ const jsonSchema: JsonSchema.Root = {
+ "type": "object",
+ "required": [],
+ "properties": {},
+ "patternProperties": {
+ "": { type: "number" }
+ },
+ "propertyNames": {
+ "pattern": "^.*-.*$",
+ "type": "string"
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({})).toEqual(true)
+ expect(validate({ "-": 1 })).toEqual(true)
+ expect(validate({ "a-": 1 })).toEqual(true)
+ expect(validate({ "-b": 1 })).toEqual(true)
+ expect(validate({ "a-b": 1 })).toEqual(true)
+ expect(validate({ "": 1 })).toEqual(false)
+ expect(validate({ "-": "a" })).toEqual(false)
+ })
+
+ it("Record(pattern, number)", () => {
+ const schema = Schema.Record(
+ { key: Schema.String.pipe(Schema.pattern(new RegExp("^.*-.*$"))), value: JsonNumber }
+ )
+ const jsonSchema: JsonSchema.Root = {
+ "type": "object",
+ "required": [],
+ "properties": {},
+ "patternProperties": {
+ "": {
+ "type": "number"
+ }
+ },
+ "propertyNames": {
+ "description": "a string matching the pattern ^.*-.*$",
+ "pattern": "^.*-.*$",
+ "type": "string"
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ expect(jsonSchema).toStrictEqual(jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({})).toEqual(true)
+ expect(validate({ "-": 1 })).toEqual(true)
+ expect(validate({ "a-": 1 })).toEqual(true)
+ expect(validate({ "-b": 1 })).toEqual(true)
+ expect(validate({ "a-b": 1 })).toEqual(true)
+ expect(validate({ "": 1 })).toEqual(false)
+ expect(validate({ "-": "a" })).toEqual(false)
+ })
+ })
+
+ it("Struct Record", () => {
+ const schema = Schema.Struct({ a: Schema.String }, Schema.Record({ key: Schema.String, value: Schema.String }))
+ const jsonSchema: JsonSchema.Root = {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ }
+ },
+ "patternProperties": {
+ "": {
+ "type": "string"
+ }
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({ a: "a" })).toEqual(true)
+ expect(validate({ a: "a", b: "b" })).toEqual(true)
+ expect(validate({})).toEqual(false)
+ expect(validate({ b: "b" })).toEqual(false)
+ expect(validate({ a: 1 })).toEqual(false)
+ expect(validate({ a: "a", b: 1 })).toEqual(false)
+ })
+
+ describe("refinements", () => {
+ it("should raise an error when an annotation doesn't exist", () => {
+ expectError(
+ Schema.String.pipe(Schema.filter(() => true)),
+ `Missing annotation
+details: Generating a JSON Schema for this schema requires a "jsonSchema" annotation
+schema (Refinement): { string | filter }`
+ )
+ })
+
+ it("minLength", () => {
+ expectJSONSchema(Schema.String.pipe(Schema.minLength(1)), {
+ "type": "string",
+ "description": "a string at least 1 character(s) long",
+ "minLength": 1
+ })
+ })
+
+ it("maxLength", () => {
+ expectJSONSchema(Schema.String.pipe(Schema.maxLength(1)), {
+ "type": "string",
+ "description": "a string at most 1 character(s) long",
+ "maxLength": 1
+ })
+ })
+
+ it("length: number", () => {
+ expectJSONSchema(Schema.String.pipe(Schema.length(1)), {
+ "type": "string",
+ "description": "a single character",
+ "maxLength": 1,
+ "minLength": 1
+ })
+ })
+
+ it("length: { min, max }", () => {
+ expectJSONSchema(Schema.String.pipe(Schema.length({ min: 2, max: 4 })), {
+ "type": "string",
+ "description": "a string at least 2 character(s) and at most 4 character(s) long",
+ "maxLength": 4,
+ "minLength": 2
+ })
+ })
+
+ it("greaterThan", () => {
+ expectJSONSchema(JsonNumber.pipe(Schema.greaterThan(1)), {
+ "type": "number",
+ "description": "a number greater than 1",
+ "exclusiveMinimum": 1
+ })
+ })
+
+ it("greaterThanOrEqualTo", () => {
+ expectJSONSchema(JsonNumber.pipe(Schema.greaterThanOrEqualTo(1)), {
+ "type": "number",
+ "description": "a number greater than or equal to 1",
+ "minimum": 1
+ })
+ })
+
+ it("lessThan", () => {
+ expectJSONSchema(JsonNumber.pipe(Schema.lessThan(1)), {
+ "type": "number",
+ "description": "a number less than 1",
+ "exclusiveMaximum": 1
+ })
+ })
+
+ it("lessThanOrEqualTo", () => {
+ expectJSONSchema(JsonNumber.pipe(Schema.lessThanOrEqualTo(1)), {
+ "type": "number",
+ "description": "a number less than or equal to 1",
+ "maximum": 1
+ })
+ })
+
+ it("pattern", () => {
+ expectJSONSchema(Schema.String.pipe(Schema.pattern(/^abb+$/)), {
+ "type": "string",
+ "description": "a string matching the pattern ^abb+$",
+ "pattern": "^abb+$"
+ })
+ })
+
+ it("integer", () => {
+ expectJSONSchema(JsonNumber.pipe(Schema.int()), {
+ "type": "integer",
+ "title": "integer",
+ "description": "an integer"
+ })
+ })
+
+ it("Trimmed", () => {
+ const schema = Schema.Trimmed
+ expectJSONSchema(schema, {
+ "description": "a string with no leading or trailing whitespace",
+ "pattern": "^\\S[\\s\\S]*\\S$|^\\S$|^$",
+ "title": "Trimmed",
+ "type": "string"
+ })
+ })
+ })
+
+ it("TemplateLiteral", () => {
+ const schema = Schema.TemplateLiteral(Schema.Literal("a"), Schema.Number)
+ const jsonSchema: JsonSchema.Root = {
+ "type": "string",
+ "pattern": "^a[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$",
+ "description": "a template literal"
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate("a1")).toEqual(true)
+ expect(validate("a12")).toEqual(true)
+ expect(validate("a")).toEqual(false)
+ expect(validate("aa")).toEqual(false)
+ })
+
+ describe("suspend", () => {
+ it("should raise an error if there is no identifier annotation", () => {
+ interface A {
+ readonly a: string
+ readonly as: ReadonlyArray
+ }
+ const schema = Schema.Struct({
+ a: Schema.String,
+ as: Schema.Array(Schema.suspend((): Schema.Schema => schema))
+ })
+ expectError(
+ schema,
+ `Missing annotation
+at path: ["as"]
+details: Generating a JSON Schema for this schema requires an "identifier" annotation
+schema (Suspend): `
+ )
+ })
+
+ it("should support outer suspended schemas", () => {
+ interface A {
+ readonly a: string
+ readonly as: ReadonlyArray
+ }
+ const schema: Schema.Schema = Schema.suspend(() =>
+ // intended outer suspend
+ Schema.Struct({
+ a: Schema.String,
+ as: Schema.Array(schema)
+ })
+ ).annotations({ identifier: "A" })
+ const jsonSchema: JsonSchema.Root = {
+ "$ref": "#/$defs/A",
+ "$defs": {
+ "A": {
+ "type": "object",
+ "required": [
+ "a",
+ "as"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ },
+ "as": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/A"
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({ a: "a1", as: [] })).toEqual(true)
+ expect(validate({ a: "a1", as: [{ a: "a2", as: [] }] })).toEqual(true)
+ expect(validate({ a: "a1", as: [{ a: "a2", as: [] }, { a: "a3", as: [] }] })).toEqual(true)
+ expect(
+ validate({ a: "a1", as: [{ a: "a2", as: [] }, { a: "a3", as: [{ a: "a4", as: [] }] }] })
+ ).toEqual(true)
+ expect(
+ validate({ a: "a1", as: [{ a: "a2", as: [] }, { a: "a3", as: [{ a: "a4", as: [1] }] }] })
+ ).toEqual(false)
+ })
+
+ it("should support inner suspended schemas with inner identifier annotation", () => {
+ interface A {
+ readonly a: string
+ readonly as: ReadonlyArray
+ }
+ const schema = Schema.Struct({
+ a: Schema.String,
+ as: Schema.Array(Schema.suspend((): Schema.Schema => schema).annotations({ identifier: "A" }))
+ })
+ const jsonSchema: JsonSchema.Root = {
+ "type": "object",
+ "required": [
+ "a",
+ "as"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ },
+ "as": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/A"
+ }
+ }
+ },
+ "additionalProperties": false,
+ "$defs": {
+ "A": {
+ "type": "object",
+ "required": [
+ "a",
+ "as"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ },
+ "as": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/A"
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({ a: "a1", as: [] })).toEqual(true)
+ expect(validate({ a: "a1", as: [{ a: "a2", as: [] }] })).toEqual(true)
+ expect(validate({ a: "a1", as: [{ a: "a2", as: [] }, { a: "a3", as: [] }] })).toEqual(true)
+ expect(
+ validate({ a: "a1", as: [{ a: "a2", as: [] }, { a: "a3", as: [{ a: "a4", as: [] }] }] })
+ ).toEqual(true)
+ expect(
+ validate({ a: "a1", as: [{ a: "a2", as: [] }, { a: "a3", as: [{ a: "a4", as: [1] }] }] })
+ ).toEqual(false)
+ })
+
+ it("should support inner suspended schemas with outer identifier annotation", () => {
+ interface Category {
+ readonly name: string
+ readonly categories: ReadonlyArray
+ }
+ const schema = Schema.Struct({
+ name: Schema.String,
+ categories: Schema.Array(Schema.suspend((): Schema.Schema => schema))
+ }).annotations({ identifier: "Category" })
+ const jsonSchema: JsonSchema.Root = {
+ "$ref": "#/$defs/Category",
+ "$defs": {
+ "Category": {
+ "type": "object",
+ "required": [
+ "name",
+ "categories"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "categories": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/Category"
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ expectJSONSchema(schema, jsonSchema)
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({ name: "a1", categories: [] })).toEqual(true)
+ expect(validate({ name: "a1", categories: [{ name: "a2", categories: [] }] })).toEqual(true)
+ expect(validate({ name: "a1", categories: [{ name: "a2", categories: [] }, { name: "a3", categories: [] }] }))
+ .toEqual(true)
+ expect(
+ validate({
+ name: "a1",
+ categories: [{ name: "a2", categories: [] }, { name: "a3", categories: [{ name: "a4", categories: [] }] }]
+ })
+ ).toEqual(true)
+ expect(
+ validate({
+ name: "a1",
+ categories: [{ name: "a2", categories: [] }, { name: "a3", categories: [{ name: "a4", categories: [1] }] }]
+ })
+ ).toEqual(false)
+ })
+
+ it("should support mutually suspended schemas", () => {
+ interface Expression {
+ readonly type: "expression"
+ readonly value: number | Operation
+ }
+
+ interface Operation {
+ readonly type: "operation"
+ readonly operator: "+" | "-"
+ readonly left: Expression
+ readonly right: Expression
+ }
+
+ // intended outer suspend
+ const Expression: Schema.Schema = Schema.suspend(() =>
+ Schema.Struct({
+ type: Schema.Literal("expression"),
+ value: Schema.Union(JsonNumber, Operation)
+ })
+ ).annotations({ identifier: "Expression" })
+
+ // intended outer suspend
+ const Operation: Schema.Schema = Schema.suspend(() =>
+ Schema.Struct({
+ type: Schema.Literal("operation"),
+ operator: Schema.Union(Schema.Literal("+"), Schema.Literal("-")),
+ left: Expression,
+ right: Expression
+ })
+ ).annotations({ identifier: "Operation" })
+
+ const jsonSchema: JsonSchema.Root = {
+ "$ref": "#/$defs/Operation",
+ "$defs": {
+ "Operation": {
+ "type": "object",
+ "required": [
+ "type",
+ "operator",
+ "left",
+ "right"
+ ],
+ "properties": {
+ "type": {
+ "enum": ["operation"]
+ },
+ "operator": {
+ "enum": ["+", "-"]
+ },
+ "left": {
+ "$ref": "#/$defs/Expression"
+ },
+ "right": {
+ "$ref": "#/$defs/Expression"
+ }
+ },
+ "additionalProperties": false
+ },
+ "Expression": {
+ "type": "object",
+ "required": [
+ "type",
+ "value"
+ ],
+ "properties": {
+ "type": {
+ "enum": ["expression"]
+ },
+ "value": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "$ref": "#/$defs/Operation"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ expectJSONSchema(Operation, jsonSchema, { params: { numRuns: 5 } })
+ const validate = new Ajv(ajvOptions).compile(jsonSchema)
+ expect(validate({
+ type: "operation",
+ operator: "+",
+ left: {
+ type: "expression",
+ value: 1
+ },
+ right: {
+ type: "expression",
+ value: {
+ type: "operation",
+ operator: "-",
+ left: {
+ type: "expression",
+ value: 3
+ },
+ right: {
+ type: "expression",
+ value: 2
+ }
+ }
+ }
+ })).toEqual(true)
+ })
+ })
+
+ describe("annotations", () => {
+ it("examples support", () => {
+ expectJSONSchema(Schema.String.annotations({ examples: ["a", "b"] }), {
+ "type": "string",
+ "title": "string",
+ "description": "a string",
+ "examples": ["a", "b"]
+ })
+ })
+
+ it("default support", () => {
+ expectJSONSchema(Schema.String.annotations({ default: "" }), {
+ "type": "string",
+ "title": "string",
+ "description": "a string",
+ "default": ""
+ })
+ })
+
+ it("propertySignature", () => {
+ const schema = Schema.Struct({
+ foo: Schema.propertySignature(Schema.String).annotations({
+ description: "foo description",
+ title: "foo title",
+ examples: ["foo example"]
+ }),
+ bar: Schema.propertySignature(JsonNumber).annotations({
+ description: "bar description",
+ title: "bar title",
+ examples: [1]
+ })
+ })
+ expectJSONSchema(schema, {
+ "type": "object",
+ "required": [
+ "foo",
+ "bar"
+ ],
+ "properties": {
+ "foo": {
+ "type": "string",
+ "description": "foo description",
+ "title": "foo title",
+ "examples": [
+ "foo example"
+ ]
+ },
+ "bar": {
+ "type": "number",
+ "description": "bar description",
+ "title": "bar title",
+ "examples": [
+ 1
+ ]
+ }
+ },
+ "additionalProperties": false
+ })
+ })
+ })
+
+ describe("Class", () => {
+ it("should support make(Class)", () => {
+ class A extends Schema.Class("A")({ a: Schema.String }) {}
+ expectJSONSchema(A, {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "description": "an instance of A",
+ "title": "A"
+ })
+ })
+
+ it("should support make(S.typeSchema(Class))", () => {
+ class A extends Schema.Class("A")({ a: Schema.String }) {}
+ expectJSONSchema(A, {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "description": "an instance of A",
+ "title": "A"
+ })
+ })
+
+ it("should support make(S.typeSchema(Class)) with custom annotation", () => {
+ class A extends Schema.Class("A")({ a: Schema.String }, {
+ jsonSchema: { "type": "custom JSON Schema" }
+ }) {}
+ expectJSONSchema(Schema.typeSchema(A), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("should support make(S.encodedSchema(Class))", () => {
+ class A extends Schema.Class("A")({ a: Schema.String }) {}
+ expectJSONSchema(Schema.encodedSchema(A), {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "title": "A (Encoded side)"
+ })
+ })
+ })
+
+ describe("identifier annotations support", () => {
+ it("on root level schema", () => {
+ expectJSONSchema(Schema.String.annotations({ identifier: "Name" }), {
+ "$ref": "#/$defs/Name",
+ "$defs": {
+ "Name": {
+ "type": "string",
+ "description": "a string",
+ "title": "string"
+ }
+ }
+ })
+ })
+
+ it("on nested schemas", () => {
+ const Name = Schema.String.annotations({
+ identifier: "Name",
+ description: "a name",
+ title: "Name"
+ })
+ const schema = Schema.Struct({ a: Name, b: Schema.Struct({ c: Name }) })
+ expectJSONSchema(schema, {
+ "type": "object",
+ "required": [
+ "a",
+ "b"
+ ],
+ "properties": {
+ "a": {
+ "$ref": "#/$defs/Name"
+ },
+ "b": {
+ "type": "object",
+ "required": [
+ "c"
+ ],
+ "properties": {
+ "c": {
+ "$ref": "#/$defs/Name"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false,
+ "$defs": {
+ "Name": {
+ "type": "string",
+ "description": "a name",
+ "title": "Name"
+ }
+ }
+ })
+ })
+
+ it("should handle identifier annotations when generating a schema through `encodedSchema()`", () => {
+ interface Category {
+ readonly name: string
+ readonly categories: ReadonlyArray
+ }
+
+ const schema: Schema.Schema = Schema.Struct({
+ name: Schema.String,
+ categories: Schema.Array(Schema.suspend(() => schema).annotations({ identifier: "Category" }))
+ })
+
+ const jsonSchema = JsonSchema.make(Schema.encodedSchema(schema))
+ expect(jsonSchema).toEqual({
+ "type": "object",
+ "required": [
+ "name",
+ "categories"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "categories": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/Category"
+ }
+ }
+ },
+ "additionalProperties": false,
+ "$defs": {
+ "Category": {
+ "type": "object",
+ "required": [
+ "name",
+ "categories"
+ ],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "categories": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/Category"
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ })
+ })
+ })
+
+ describe("should handle jsonSchema annotations", () => {
+ it("Void", () => {
+ expectJSONSchema(Schema.Void.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Never", () => {
+ expectJSONSchema(Schema.Never.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Literal", () => {
+ expectJSONSchema(Schema.Literal("a").annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("SymbolFromSelf", () => {
+ expectJSONSchema(Schema.SymbolFromSelf.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("UniqueSymbolFromSelf", () => {
+ expectJSONSchema(
+ Schema.UniqueSymbolFromSelf(Symbol.for("effect/schema/test/a")).annotations({
+ jsonSchema: { "type": "custom JSON Schema" }
+ }),
+ {
+ "type": "custom JSON Schema"
+ },
+ false
+ )
+ })
+
+ it("TemplateLiteral", () => {
+ expectJSONSchema(
+ Schema.TemplateLiteral(Schema.Literal("a"), Schema.String, Schema.Literal("b")).annotations({
+ jsonSchema: { "type": "custom JSON Schema" }
+ }),
+ {
+ "type": "custom JSON Schema"
+ },
+ false
+ )
+ })
+
+ it("Undefined", () => {
+ expectJSONSchema(Schema.Undefined.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Unknown", () => {
+ expectJSONSchema(Schema.Unknown.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Any", () => {
+ expectJSONSchema(Schema.Any.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Object", () => {
+ expectJSONSchema(Schema.Object.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("String", () => {
+ expectJSONSchema(
+ Schema.String.annotations({
+ jsonSchema: { "type": "custom JSON Schema", "description": "description" }
+ }),
+ {
+ "type": "custom JSON Schema",
+ "description": "description"
+ },
+ false
+ )
+ })
+
+ it("Number", () => {
+ expectJSONSchema(Schema.Number.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("BigintFromSelf", () => {
+ expectJSONSchema(Schema.BigIntFromSelf.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Boolean", () => {
+ expectJSONSchema(Schema.Boolean.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Enums", () => {
+ enum Fruits {
+ Apple,
+ Banana
+ }
+ expectJSONSchema(Schema.Enums(Fruits).annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("Tuple", () => {
+ expectJSONSchema(
+ Schema.Tuple(Schema.String, JsonNumber).annotations({ jsonSchema: { "type": "custom JSON Schema" } }),
+ {
+ "type": "custom JSON Schema"
+ },
+ false
+ )
+ })
+
+ it("Struct", () => {
+ expectJSONSchema(
+ Schema.Struct({ a: Schema.String, b: JsonNumber }).annotations({
+ jsonSchema: { "type": "custom JSON Schema" }
+ }),
+ {
+ "type": "custom JSON Schema"
+ },
+ false
+ )
+ })
+
+ it("Union", () => {
+ expectJSONSchema(
+ Schema.Union(Schema.String, JsonNumber).annotations({ jsonSchema: { "type": "custom JSON Schema" } }),
+ {
+ "type": "custom JSON Schema"
+ },
+ false
+ )
+ })
+
+ it("suspend", () => {
+ interface A {
+ readonly a: string
+ readonly as: ReadonlyArray
+ }
+ const schema = Schema.Struct({
+ a: Schema.String,
+ as: Schema.Array(
+ Schema.suspend((): Schema.Schema => schema).annotations({ jsonSchema: { "type": "custom JSON Schema" } })
+ )
+ })
+
+ expectJSONSchema(schema, {
+ "type": "object",
+ "required": [
+ "a",
+ "as"
+ ],
+ "properties": {
+ "a": {
+ "type": "string"
+ },
+ "as": {
+ "type": "array",
+ "items": {
+ "type": "custom JSON Schema"
+ }
+ }
+ },
+ "additionalProperties": false
+ }, false)
+ })
+
+ it("refinement", () => {
+ expectJSONSchema(Schema.Int.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "description": "an integer",
+ "title": "Int",
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("transformation", () => {
+ expectJSONSchema(Schema.NumberFromString.annotations({ jsonSchema: { "type": "custom JSON Schema" } }), {
+ "type": "custom JSON Schema"
+ }, false)
+ })
+
+ it("refinement of a transformation with an override annotation", () => {
+ expectJSONSchema(Schema.Date.annotations({ jsonSchema: { type: "string", format: "date-time" } }), {
+ "format": "date-time",
+ "type": "string"
+ }, false)
+ expectJSONSchema(
+ Schema.Date.annotations({
+ jsonSchema: { anyOf: [{ type: "object" }, { type: "array" }] }
+ }),
+ { anyOf: [{ type: "object" }, { type: "array" }] },
+ false
+ )
+ expectJSONSchema(
+ Schema.Date.annotations({
+ jsonSchema: { anyOf: [{ type: "object" }, { type: "array" }] }
+ }),
+ { anyOf: [{ type: "object" }, { type: "array" }] },
+ false
+ )
+ expectJSONSchema(Schema.Date.annotations({ jsonSchema: { $ref: "x" } }), {
+ $ref: "x"
+ }, false)
+ expectJSONSchema(Schema.Date.annotations({ jsonSchema: { const: 1 } }), {
+ const: 1
+ }, false)
+ expectJSONSchema(Schema.Date.annotations({ jsonSchema: { enum: [1] } }), {
+ enum: [1]
+ }, false)
+ })
+
+ it("refinement of a transformation without an override annotation", () => {
+ expectJSONSchema(Schema.Trim.pipe(Schema.nonEmptyString()), {
+ "type": "string",
+ "title": "Trimmed",
+ "description": "a non empty string"
+ }, false)
+ expectJSONSchema(Schema.Trim.pipe(Schema.nonEmptyString({ jsonSchema: { title: "Description" } })), {
+ "description": "a non empty string",
+ "type": "string",
+ "title": "Trimmed"
+ }, false)
+ expectJSONSchema(
+ Schema.Trim.pipe(Schema.nonEmptyString()).annotations({ jsonSchema: { title: "Description" } }),
+ {
+ "description": "a non empty string",
+ "type": "string",
+ "title": "Trimmed"
+ },
+ false
+ )
+ })
+ })
+
+ describe("transformations", () => {
+ it("should not handle identifiers", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.NumberFromString
+ }),
+ {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "type": "string",
+ description: "a number",
+ title: "number"
+ }
+ },
+ "additionalProperties": false
+ }
+ )
+ })
+
+ it("compose", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.NonEmptyString.pipe(Schema.compose(Schema.NumberFromString))
+ }),
+ {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "type": "string",
+ "description": "a non empty string",
+ "title": "NonEmptyString",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": false
+ }
+ )
+ })
+
+ describe("optional", () => {
+ it("annotations", () => {
+ const schema = Schema.Struct({
+ a: Schema.optionalWith(Schema.NonEmptyString.annotations({ description: "an optional field" }), {
+ default: () => ""
+ })
+ .annotations({ description: "a required field" })
+ })
+ expectJSONSchema(schema, {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "a": {
+ "type": "string",
+ "description": "an optional field",
+ "title": "NonEmptyString",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": false,
+ "title": "Struct (Encoded side)"
+ })
+ expectJSONSchema(Schema.typeSchema(schema), {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "type": "string",
+ "description": "a required field",
+ "title": "NonEmptyString",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": false,
+ "title": "Struct (Type side)"
+ })
+ expectJSONSchema(Schema.encodedSchema(schema), {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "a": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ })
+ })
+
+ it("with default", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.optionalWith(Schema.NonEmptyString, { default: () => "" })
+ }),
+ {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "a": {
+ "type": "string",
+ "description": "a non empty string",
+ "title": "NonEmptyString",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": false,
+ "title": "Struct (Encoded side)"
+ }
+ )
+ })
+
+ it("as Option", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.optionalWith(Schema.NonEmptyString, { as: "Option" })
+ }),
+ {
+ "type": "object",
+ "required": [],
+ "properties": {
+ "a": {
+ "type": "string",
+ "description": "a non empty string",
+ "title": "NonEmptyString",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": false,
+ "title": "Struct (Encoded side)"
+ }
+ )
+ })
+
+ it("fromKey", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.NonEmptyString.pipe(Schema.propertySignature, Schema.fromKey("b"))
+ }),
+ {
+ "type": "object",
+ "required": [
+ "b"
+ ],
+ "properties": {
+ "b": {
+ "type": "string",
+ "description": "a non empty string",
+ "title": "NonEmptyString",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": false,
+ "title": "Struct (Encoded side)"
+ }
+ )
+ })
+
+ it("OptionFromNullOr", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.OptionFromNullOr(Schema.NonEmptyString)
+ }),
+ {
+ "type": "object",
+ "required": [
+ "a"
+ ],
+ "properties": {
+ "a": {
+ "anyOf": [
+ {
+ "type": "string",
+ "description": "a non empty string",
+ "title": "NonEmptyString",
+ "minLength": 1
+ },
+ {
+ "enum": [null]
+ }
+ ],
+ "description": "Option"
+ }
+ },
+ "additionalProperties": false
+ }
+ )
+ })
+ })
+ })
+
+ it(`should correctly generate JSON Schemas by targeting the "to" side of transformations from S.parseJson`, () => {
+ expectJSONSchema(
+ // Define a schema that parses a JSON string into a structured object
+ Schema.parseJson(Schema.Struct({
+ a: Schema.parseJson(Schema.NumberFromString) // Nested parsing from JSON string to number
+ })),
+ {
+ type: "string",
+ contentMediaType: "application/json",
+ contentSchema: {
+ type: "object",
+ required: ["a"],
+ properties: {
+ a: {
+ contentMediaType: "application/json",
+ contentSchema: {
+ type: "string",
+ description: "a number",
+ title: "number"
+ },
+ type: "string"
+ }
+ },
+ additionalProperties: false
+ }
+ },
+ false
+ )
+ })
+
+ it("should correctly generate JSON Schemas for a schema created by extending two refinements using the `extend` API", () => {
+ expectJSONSchema(
+ Schema.Struct({
+ a: Schema.String
+ }).pipe(Schema.filter(() => true, { jsonSchema: { description: "a" } })).pipe(Schema.extend(
+ Schema.Struct({
+ b: JsonNumber
+ }).pipe(Schema.filter(() => true, { jsonSchema: { title: "b" } }))
+ )),
+ {
+ type: "object",
+ required: ["a", "b"],
+ properties: {
+ a: { type: "string" },
+ b: { type: "number" }
+ },
+ additionalProperties: false,
+ description: "a",
+ title: "b"
+ }
+ )
+ })
+
+ it("ReadonlyMapFromRecord", () => {
+ expectJSONSchema(
+ Schema.ReadonlyMapFromRecord({
+ key: Schema.String.pipe(Schema.minLength(2)),
+ value: Schema.NumberFromString
+ }),
+ {
+ description: "ReadonlyMap",
+ type: "object",
+ required: [],
+ properties: {},
+ "patternProperties": {
+ "": {
+ description: "a number",
+ title: "number",
+ type: "string"
+ }
+ },
+ "propertyNames": {
+ "description": "a string at least 2 character(s) long",
+ "minLength": 2,
+ "type": "string"
+ }
+ }
+ )
+ })
+
+ it("MapFromRecord", () => {
+ expectJSONSchema(
+ Schema.MapFromRecord({
+ key: Schema.String.pipe(Schema.minLength(2)),
+ value: Schema.NumberFromString
+ }),
+ {
+ type: "object",
+ description: "Map",
+ required: [],
+ properties: {},
+ "patternProperties": {
+ "": {
+ description: "a number",
+ title: "number",
+ type: "string"
+ }
+ },
+ "propertyNames": {
+ "description": "a string at least 2 character(s) long",
+ "minLength": 2,
+ "type": "string"
+ }
+ }
+ )
+ })
+
+ it("NonEmptyArray", () => {
+ expectJSONSchema(
+ Schema.NonEmptyArray(Schema.String),
+ {
+ type: "array",
+ minItems: 1,
+ items: { type: "string" }
+ }
+ )
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02c192ffd5..f380e34305 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -388,6 +388,9 @@ importers:
'@effect/schema':
specifier: workspace:^
version: link:../schema/dist
+ ajv:
+ specifier: ^8.17.1
+ version: 8.17.1
effect:
specifier: workspace:^
version: link:../effect/dist