Skip to content

Commit

Permalink
Special case S.parseJson to generate JSON Schemas by targeting the … (
Browse files Browse the repository at this point in the history
  • Loading branch information
gcanti authored Jun 26, 2024
1 parent 9b2fc3b commit d71c192
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 24 deletions.
59 changes: 59 additions & 0 deletions .changeset/rude-meals-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
"@effect/schema": patch
---

Special case `S.parseJson` to generate JSON Schemas by targeting the "to" side of transformations, closes #3086

Resolved an issue where `JSONSchema.make` improperly generated JSON Schemas for schemas defined with `S.parseJson(<real schema>)`. Previously, invoking `JSONSchema.make` on these transformed schemas produced a JSON Schema corresponding to a string type rather than the underlying real schema.

Before

```ts
import { JSONSchema, Schema } from "@effect/schema"

// Define a schema that parses a JSON string into a structured object
const schema = Schema.parseJson(
Schema.Struct({
a: Schema.parseJson(Schema.NumberFromString) // Nested parsing from JSON string to number
})
)

console.log(JSONSchema.make(schema))
/*
{
'$schema': 'http://json-schema.org/draft-07/schema#',
'$ref': '#/$defs/JsonString',
'$defs': {
JsonString: {
type: 'string',
description: 'a JSON string',
title: 'JsonString'
}
}
}
*/
```

Now

```ts
import { JSONSchema, Schema } from "@effect/schema"

// Define a schema that parses a JSON string into a structured object
const schema = Schema.parseJson(
Schema.Struct({
a: Schema.parseJson(Schema.NumberFromString) // Nested parsing from JSON string to number
})
)

console.log(JSONSchema.make(schema))
/*
{
'$schema': 'http://json-schema.org/draft-07/schema#',
type: 'object',
required: [ 'a' ],
properties: { a: { type: 'string', description: 'a string', title: 'string' } },
additionalProperties: false
}
*/
```
28 changes: 28 additions & 0 deletions packages/schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1781,6 +1781,34 @@ the default would be:
*/
```

### Understanding `Schema.parseJson` in JSON Schema Generation

When utilizing `Schema.parseJson` within the `@effect/schema` library, JSON Schema generation follows a specialized approach. Instead of merely generating a JSON Schema for a string—which would be the default output representing the "from" side of the transformation defined by `Schema.parseJson`—it specifically generates the JSON Schema for the actual schema provided as an argument.

**Example of Generating JSON Schema with `Schema.parseJson`**

```ts
import { JSONSchema, Schema } from "@effect/schema"

// Define a schema that parses a JSON string into a structured object
const schema = Schema.parseJson(
Schema.Struct({
a: Schema.parseJson(Schema.NumberFromString) // Nested parsing from JSON string to number
})
)

console.log(JSONSchema.make(schema))
/*
{
'$schema': 'http://json-schema.org/draft-07/schema#',
type: 'object',
required: [ 'a' ],
properties: { a: { type: 'string', description: 'a string', title: 'string' } },
additionalProperties: false
}
*/
```

## Generating Equivalences

The `make` function, which is part of the `@effect/schema/Equivalence` module, allows you to generate an [Equivalence](https://effect-ts.github.io/effect/schema/Equivalence.ts.html) based on a schema definition:
Expand Down
14 changes: 12 additions & 2 deletions packages/schema/src/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Predicate from "effect/Predicate"
import * as Record from "effect/Record"
import * as AST from "./AST.js"
import * as errors_ from "./internal/errors.js"
import * as filters_ from "./internal/filters.js"
import type * as Schema from "./Schema.js"

/**
Expand Down Expand Up @@ -297,6 +298,9 @@ const hasTransformation = (ast: AST.Refinement): boolean => {
return false
}

const isParseJsonTransformation = (ast: AST.AST): boolean =>
ast.annotations[AST.TypeAnnotationId] === filters_.ParseJsonTypeId

const go = (
ast: AST.AST,
$defs: Record<string, JsonSchema7>,
Expand Down Expand Up @@ -549,7 +553,13 @@ const go = (
}
return go(ast.f(), $defs, true, path)
}
case "Transformation":
return go(ast.from, $defs, true, path)
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.
const next = isParseJsonTransformation(ast.from) ? ast.to : ast.from
return go(next, $defs, true, path)
}
}
}
44 changes: 22 additions & 22 deletions packages/schema/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4120,6 +4120,24 @@ const JsonString = String$.annotations({
[AST.DescriptionAnnotationId]: "a JSON string"
})

const getParseJsonTransformation = (options?: ParseJsonOptions) =>
transformOrFail(
JsonString,
Unknown,
{
decode: (s, _, ast) =>
ParseResult.try({
try: () => JSON.parse(s, options?.reviver),
catch: (e: any) => new ParseResult.Type(ast, s, e.message)
}),
encode: (u, _, ast) =>
ParseResult.try({
try: () => JSON.stringify(u, options?.replacer, options?.space),
catch: (e: any) => new ParseResult.Type(ast, u, e.message)
})
}
).annotations({ typeId: filters_.ParseJsonTypeId })

/**
* The `ParseJson` combinator provides a method to convert JSON strings into the `unknown` type using the underlying
* functionality of `JSON.parse`. It also utilizes `JSON.stringify` for encoding.
Expand All @@ -4140,28 +4158,10 @@ const JsonString = String$.annotations({
export const parseJson: {
<A, I, R>(schema: Schema<A, I, R>, options?: ParseJsonOptions): SchemaClass<A, string, R>
(options?: ParseJsonOptions): SchemaClass<unknown, string>
} = <A, I, R>(schema?: Schema<A, I, R> | ParseJsonOptions, o?: ParseJsonOptions) => {
if (isSchema(schema)) {
return compose(parseJson(o), schema as any) as any
}
const options: ParseJsonOptions | undefined = schema as any
return transformOrFail(
JsonString,
Unknown,
{
decode: (s, _, ast) =>
ParseResult.try({
try: () => JSON.parse(s, options?.reviver),
catch: (e: any) => new ParseResult.Type(ast, s, e.message)
}),
encode: (u, _, ast) =>
ParseResult.try({
try: () => JSON.stringify(u, options?.replacer, options?.space),
catch: (e: any) => new ParseResult.Type(ast, u, e.message)
})
}
)
}
} = <A, I, R>(schema?: Schema<A, I, R> | ParseJsonOptions, o?: ParseJsonOptions) =>
isSchema(schema)
? compose(parseJson(o), schema) as any
: getParseJsonTransformation(schema as ParseJsonOptions | undefined)

/**
* @category string constructors
Expand Down
3 changes: 3 additions & 0 deletions packages/schema/src/internal/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,6 @@ export const MaxItemsTypeId: Schema.MaxItemsTypeId = Symbol.for(
export const ItemsCountTypeId: Schema.ItemsCountTypeId = Symbol.for(
"@effect/schema/TypeId/ItemsCount"
) as Schema.ItemsCountTypeId

/** @internal */
export const ParseJsonTypeId: unique symbol = Symbol.for("@effect/schema/TypeId/ParseJson")
16 changes: 16 additions & 0 deletions packages/schema/test/JSONSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2142,6 +2142,22 @@ schema (Suspend): <suspended schema>`
})
})
})

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
})),
{
"$schema": "http://json-schema.org/draft-07/schema#",
type: "object",
required: ["a"],
properties: { a: { type: "string", description: "a string", title: "string" } },
additionalProperties: false
}
)
})
})

export const decode = <A>(schema: JSONSchema.JsonSchema7Root): Schema.Schema<A> =>
Expand Down

0 comments on commit d71c192

Please sign in to comment.