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