From 1370ad97c75a74dfbe426e73c93e627b01f75147 Mon Sep 17 00:00:00 2001 From: gcanti Date: Sun, 10 Dec 2023 10:54:46 +0100 Subject: [PATCH] ensure that JSON Schema annotations can be exclusively applied to refinements --- .changeset/cyan-lemons-fry.md | 5 +++++ README.md | 39 ++++++++++++++++++++++++++++++++- docs/modules/Schema.ts.md | 9 ++++++++ src/JSONSchema.ts | 8 +------ src/Schema.ts | 17 ++++++++++++-- test/JSONSchema.test.ts | 26 +++++----------------- test/Schema/annotations.test.ts | 12 +++++----- 7 files changed, 78 insertions(+), 38 deletions(-) create mode 100644 .changeset/cyan-lemons-fry.md diff --git a/.changeset/cyan-lemons-fry.md b/.changeset/cyan-lemons-fry.md new file mode 100644 index 000000000..6bce6655e --- /dev/null +++ b/.changeset/cyan-lemons-fry.md @@ -0,0 +1,5 @@ +--- +"@effect/schema": patch +--- + +ensure that JSON Schema annotations can be exclusively applied to refinements diff --git a/README.md b/README.md index a4b21e1a2..73aabb5cb 100644 --- a/README.md +++ b/README.md @@ -665,7 +665,7 @@ Output: ## Generating JSON Schemas -The `to` function, which is part of the `@effect/schema/JSONSchema` module, allows you to generate a JSON Schema based on a schema definition: +The `to` / `from` functions, which are part of the `@effect/schema/JSONSchema` module, allow you to generate a JSON Schema based on a schema definition: ```ts import * as JSONSchema from "@effect/schema/JSONSchema"; @@ -818,6 +818,43 @@ In the example above, we define a schema for a "Category" that can contain a "na This ensures that the JSON Schema properly handles the recursive structure and creates distinct definitions for each annotated schema, improving readability and maintainability. +### JSON Schema Annotations + +When defining a **refinement** (e.g., through the `filter` function), you can attach a JSON Schema annotation to your schema containing a JSON Schema "fragment" related to this particular refinement. This fragment will be used to generate the corresponding JSON Schema. Note that if the schema consists of more than one refinement, the corresponding annotations will be merged. + +```ts +import * as JSONSchema from "@effect/schema/JSONSchema"; +import * as Schema from "@effect/schema/Schema"; + +// Simulate one or more refinements +const Positive = Schema.number.pipe( + Schema.filter((n) => n > 0, { + jsonSchema: { minimum: 0 }, + }) +); + +const schema = Positive.pipe( + Schema.filter((n) => n <= 10, { + jsonSchema: { maximum: 10 }, + }) +); + +console.log(JSONSchema.to(schema)); +/* +Output: +{ + '$schema': 'http://json-schema.org/draft-07/schema#', + type: 'number', + description: 'a number', + title: 'number', + minimum: 0, + maximum: 10 +} +*/ +``` + +As seen in the example, the JSON Schema annotations are merged with the base JSON Schema from `Schema.number`. This approach helps handle multiple refinements while maintaining clarity in your code. + ## Generating Equivalences The `to` function, which is part of the `@effect/schema/Equivalence` module, allows you to generate an [Equivalence](https://effect-ts.github.io/effect/modules/Equivalence.ts.html) based on a schema definition: diff --git a/docs/modules/Schema.ts.md b/docs/modules/Schema.ts.md index 425a1396a..090b4fe0e 100644 --- a/docs/modules/Schema.ts.md +++ b/docs/modules/Schema.ts.md @@ -1199,6 +1199,10 @@ Added in v1.0.0 ## jsonSchema +Attaches a JSON Schema annotation to a schema that represents a refinement. + +If the schema is composed of more than one refinement, the corresponding annotations will be merged. + **Signature** ```ts @@ -4029,6 +4033,11 @@ Added in v1.0.0 ```ts export interface FilterAnnotations extends DocAnnotations { readonly typeId?: AST.TypeAnnotation | { id: AST.TypeAnnotation; params: unknown } + /** + * Attaches a JSON Schema annotation to this refinement. + * + * If the schema is composed of more than one refinement, the corresponding annotations will be merged. + */ readonly jsonSchema?: AST.JSONSchemaAnnotation readonly arbitrary?: (...args: ReadonlyArray>) => Arbitrary readonly pretty?: (...args: ReadonlyArray>) => Pretty diff --git a/src/JSONSchema.ts b/src/JSONSchema.ts index 6d61db7db..df3cba24a 100644 --- a/src/JSONSchema.ts +++ b/src/JSONSchema.ts @@ -292,13 +292,7 @@ export const DEFINITION_PREFIX = "#/$defs/" const go = (ast: AST.AST, $defs: Record): JsonSchema7 => { switch (ast._tag) { case "Declaration": { - const annotation = AST.getJSONSchemaAnnotation(ast) - if (Option.isSome(annotation)) { - return annotation.value as any - } - throw new Error( - "cannot build a JSON Schema for declarations without a JSON Schema annotation" - ) + throw new Error("cannot convert a declaration to JSON Schema") } case "Literal": { const literal = ast.literal diff --git a/src/Schema.ts b/src/Schema.ts index 3678f47e2..b270d761d 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -1496,6 +1496,11 @@ export interface DocAnnotations extends AST.Annotations { */ export interface FilterAnnotations extends DocAnnotations { readonly typeId?: AST.TypeAnnotation | { id: AST.TypeAnnotation; params: unknown } + /** + * Attaches a JSON Schema annotation to this refinement. + * + * If the schema is composed of more than one refinement, the corresponding annotations will be merged. + */ readonly jsonSchema?: AST.JSONSchemaAnnotation readonly arbitrary?: (...args: ReadonlyArray>) => Arbitrary readonly pretty?: (...args: ReadonlyArray>) => Pretty @@ -1569,12 +1574,20 @@ export const documentation = make(AST.setAnnotation(self.ast, AST.DocumentationAnnotationId, documentation)) /** + * Attaches a JSON Schema annotation to a schema that represents a refinement. + * + * If the schema is composed of more than one refinement, the corresponding annotations will be merged. + * * @category annotations * @since 1.0.0 */ export const jsonSchema = - (jsonSchema: AST.JSONSchemaAnnotation) => (self: Schema): Schema => - make(AST.setAnnotation(self.ast, AST.JSONSchemaAnnotationId, jsonSchema)) + (jsonSchema: AST.JSONSchemaAnnotation) => (self: Schema): Schema => { + if (AST.isRefinement(self.ast)) { + return make(AST.setAnnotation(self.ast, AST.JSONSchemaAnnotationId, jsonSchema)) + } + throw new Error("JSON Schema annotations can be applied exclusively to refinements") + } /** * @category annotations diff --git a/test/JSONSchema.test.ts b/test/JSONSchema.test.ts index 3673a0913..bdc479852 100644 --- a/test/JSONSchema.test.ts +++ b/test/JSONSchema.test.ts @@ -1,7 +1,6 @@ import * as A from "@effect/schema/Arbitrary" import * as AST from "@effect/schema/AST" import * as JSONSchema from "@effect/schema/JSONSchema" -import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import AjvNonEsm from "ajv" import * as Option from "effect/Option" @@ -74,26 +73,11 @@ describe("JSONSchema", () => { propertyFrom(Schema.struct({ a: Schema.string, b: Schema.NumberFromString })) }) - describe("declaration", () => { - it("should raise an error when an annotation doesn't exist", () => { - const schema = Schema.chunk(JsonNumber) - expect(() => JSONSchema.to(schema)).toThrow( - new Error("cannot build a JSON Schema for declarations without a JSON Schema annotation") - ) - }) - - it("should return the provided JSON Schema when an annotation exists", () => { - const schema = Schema.declare( - [], - Schema.struct({}), - () => (input) => ParseResult.succeed(input), - { - [AST.JSONSchemaAnnotationId]: { type: "string" }, - [A.ArbitraryHookId]: (): A.Arbitrary => (fc) => fc.string() - } - ) - propertyTo(schema) - }) + it("declaration should raise an error", () => { + const schema = Schema.chunk(JsonNumber) + expect(() => JSONSchema.to(schema)).toThrow( + new Error("cannot convert a declaration to JSON Schema") + ) }) it("bigint should raise an error", () => { diff --git a/test/Schema/annotations.test.ts b/test/Schema/annotations.test.ts index 71357d998..caf324875 100644 --- a/test/Schema/annotations.test.ts +++ b/test/Schema/annotations.test.ts @@ -65,14 +65,12 @@ describe("Schema/annotations", () => { expect(S.isSchema(schema)).toEqual(true) }) - it("jsonSchema", () => { - const schema = S.string.pipe(S.jsonSchema({ type: "string" })) - expect(schema.ast.annotations).toEqual({ - [AST.JSONSchemaAnnotationId]: { type: "string" }, - [AST.TitleAnnotationId]: "string", - [AST.DescriptionAnnotationId]: "a string" + describe("jsonSchema", () => { + it("should raise an error on non refinements", () => { + expect(() => S.string.pipe(S.jsonSchema({ type: "number" }))).toThrow( + new Error("JSON Schema annotations can be applied exclusively to refinements") + ) }) - expect(S.isSchema(schema)).toEqual(true) }) it("message as annotation options", async () => {