Skip to content

Commit fc374af

Browse files
committed
add FromJsonSchema module
1 parent 030a41a commit fc374af

File tree

6 files changed

+373
-4
lines changed

6 files changed

+373
-4
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* @since 4.0.0
3+
*/
4+
import * as Arr from "../collections/Array.ts"
5+
import * as Predicate from "../data/Predicate.ts"
6+
import { format } from "../interfaces/Inspectable.ts"
7+
import type * as Annotations from "./Annotations.ts"
8+
import type * as Schema from "./Schema.ts"
9+
10+
/**
11+
* @since 4.0.0
12+
*/
13+
export function make(schema: unknown, options?: {
14+
readonly target: Annotations.JsonSchema.Target
15+
readonly definitions?: Schema.JsonSchema.Definitions | undefined
16+
}): string {
17+
const definitions = options?.definitions ?? {}
18+
return go(schema, { definitions })
19+
}
20+
21+
const Never = "Schema.Never"
22+
const Unknown = "Schema.Unknown"
23+
24+
function Union(members: ReadonlyArray<string>, mode: "anyOf" | "oneOf"): string {
25+
return `Schema.Union([${members.join(", ")}]${mode === "oneOf" ? ", { mode: \"oneOf\" }" : ""})`
26+
}
27+
28+
interface GoOptions {
29+
readonly definitions: Schema.JsonSchema.Definitions
30+
}
31+
32+
function go(schema: unknown, options: GoOptions): string {
33+
if (Predicate.isBoolean(schema)) {
34+
return schema ? Unknown : Never
35+
}
36+
if (Predicate.isObject(schema)) {
37+
return checksAndAnnotations(schema, options)
38+
}
39+
return Unknown
40+
}
41+
42+
function getAnnotations(schema: Record<string, unknown>): ReadonlyArray<string> {
43+
const annotations: Array<string> = []
44+
if (Predicate.isString(schema.title)) {
45+
annotations.push(`title: "${schema.title}"`)
46+
}
47+
if (Predicate.isString(schema.description)) {
48+
annotations.push(`description: "${schema.description}"`)
49+
}
50+
if (Predicate.isString(schema.default)) {
51+
annotations.push(`default: ${format(schema.default)}`)
52+
}
53+
if (Arr.isArray(schema.examples)) {
54+
annotations.push(`examples: [${schema.examples.map((example) => format(example)).join(", ")}]`)
55+
}
56+
return annotations
57+
}
58+
59+
function getChecks(schema: Record<string, unknown>): Array<string> {
60+
const checks: Array<string> = []
61+
if (Predicate.isNumber(schema.minLength)) {
62+
checks.push(`Schema.isMinLength(${schema.minLength})`)
63+
}
64+
if (Predicate.isNumber(schema.maxLength)) {
65+
checks.push(`Schema.isMaxLength(${schema.maxLength})`)
66+
}
67+
if (Arr.isArray(schema.allOf)) {
68+
for (const member of schema.allOf) {
69+
if (Predicate.isObject(member)) {
70+
const c = getChecks(member)
71+
if (c.length > 0) {
72+
const a = getAnnotations(member)
73+
if (a.length > 0) {
74+
c[c.length - 1] = c[c.length - 1].substring(0, c[c.length - 1].length - 1) + `, { ${a.join(", ")} })`
75+
}
76+
c.forEach((check) => checks.push(check))
77+
}
78+
}
79+
}
80+
}
81+
return checks
82+
}
83+
84+
function checksAndAnnotations(schema: Record<string, unknown>, options: GoOptions): string {
85+
let out = base(schema, options)
86+
const c = getChecks(schema)
87+
const a = getAnnotations(schema)
88+
if (a.length > 0) {
89+
out += `.annotate({ ${a.join(", ")} })`
90+
}
91+
if (c.length > 0) {
92+
out += `.check(${c.join(", ")})`
93+
}
94+
return out
95+
}
96+
97+
function baseByType(type: unknown): string {
98+
if (Predicate.isString(type)) {
99+
switch (type) {
100+
case "null":
101+
return "Schema.Null"
102+
case "string":
103+
return "Schema.String"
104+
case "number":
105+
return "Schema.Number"
106+
case "integer":
107+
return "Schema.Int"
108+
case "boolean":
109+
return "Schema.Boolean"
110+
case "object":
111+
return "Schema.Struct({})"
112+
case "array":
113+
return "Schema.Tuple([])"
114+
}
115+
}
116+
return Unknown
117+
}
118+
119+
function base(schema: Record<string, unknown>, options: GoOptions): string {
120+
if ("type" in schema) {
121+
if (Arr.isArray(schema.type)) {
122+
return Union(schema.type.map(baseByType), "anyOf")
123+
}
124+
return baseByType(schema.type)
125+
}
126+
if (Arr.isArray(schema.anyOf)) {
127+
if (schema.anyOf.length === 0) return Never
128+
return Union(schema.anyOf.map((schema) => go(schema, options)), "anyOf")
129+
}
130+
if (Arr.isArray(schema.oneOf)) {
131+
if (schema.oneOf.length === 0) return Never
132+
return Union(schema.oneOf.map((schema) => go(schema, options)), "oneOf")
133+
}
134+
if (Predicate.isObject(schema.not) && Object.keys(schema.not).length === 0) {
135+
return Never
136+
}
137+
if (Predicate.isString(schema.$ref)) {
138+
const identifier = getIdentifierFromRef(schema.$ref)
139+
if (identifier === undefined) {
140+
throw new Error(`Invalid $ref: ${schema.$ref}`)
141+
}
142+
return go(options.definitions[identifier], options) + `.annotate({ identifier: "${identifier}" })`
143+
}
144+
return Unknown
145+
}
146+
147+
function getIdentifierFromRef(ref: string): string | undefined {
148+
return ref.split("/").pop()
149+
}

packages/effect/src/schema/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export * as Annotations from "./Annotations.ts"
1212
*/
1313
export * as AST from "./AST.ts"
1414

15+
/**
16+
* @since 4.0.0
17+
*/
18+
export * as FromJsonSchema from "./FromJsonSchema.ts"
19+
1520
/**
1621
* @since 4.0.0
1722
*/
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import type { Annotations } from "effect/schema"
2+
import { FromJsonSchema, Schema } from "effect/schema"
3+
import { describe, it } from "vitest"
4+
import { deepStrictEqual } from "../utils/assert.ts"
5+
6+
function assertRoundtrip(schema: Schema.Top) {
7+
const document = Schema.makeJsonSchemaDraft2020_12(schema)
8+
const code = FromJsonSchema.make(document.schema)
9+
const fn = new Function("Schema", `return ${code}`)
10+
const generated = fn(Schema)
11+
const codedocument = Schema.makeJsonSchemaDraft2020_12(generated)
12+
deepStrictEqual(codedocument, document)
13+
deepStrictEqual(FromJsonSchema.make(codedocument.schema), code)
14+
}
15+
16+
function assertOutput(
17+
input: {
18+
readonly schema: Record<string, unknown> | boolean
19+
readonly definitions?: Schema.JsonSchema.Definitions | undefined
20+
readonly target?: Annotations.JsonSchema.Target | undefined
21+
},
22+
expected: string
23+
) {
24+
const code = FromJsonSchema.make(input.schema, {
25+
target: input.target ?? "2020-12",
26+
definitions: input.definitions ?? {}
27+
})
28+
deepStrictEqual(code, expected)
29+
}
30+
31+
describe("FromJsonSchema", () => {
32+
it("should handle `true` as `Schema.Unknown`", () => {
33+
assertOutput({ schema: true }, "Schema.Unknown")
34+
})
35+
36+
it("should handle `false` as `Schema.Never`", () => {
37+
assertOutput({ schema: false }, "Schema.Never")
38+
})
39+
40+
describe("type as array", () => {
41+
it("string | number", () => {
42+
assertOutput({
43+
schema: {
44+
"type": ["string", "number"]
45+
}
46+
}, "Schema.Union([Schema.String, Schema.Number])")
47+
})
48+
49+
it("string | number & annotations", () => {
50+
assertOutput({
51+
schema: {
52+
"type": ["string", "number"],
53+
"description": "description"
54+
}
55+
}, `Schema.Union([Schema.String, Schema.Number]).annotate({ description: "description" })`)
56+
})
57+
})
58+
59+
it("should handle annotations", () => {
60+
assertOutput(
61+
{
62+
schema: {
63+
"type": "string",
64+
"title": "title",
65+
"description": "description",
66+
"default": "a",
67+
"examples": ["a", "b"]
68+
}
69+
},
70+
`Schema.String.annotate({ title: "title", description: "description", default: "a", examples: ["a", "b"] })`
71+
)
72+
})
73+
74+
it("should handle checks", () => {
75+
assertOutput(
76+
{
77+
schema: {
78+
"type": "string",
79+
"minLength": 1
80+
}
81+
},
82+
`Schema.String.check(Schema.isMinLength(1))`
83+
)
84+
assertOutput(
85+
{
86+
schema: {
87+
"type": "string",
88+
"description": "description",
89+
"minLength": 1
90+
}
91+
},
92+
`Schema.String.annotate({ description: "description" }).check(Schema.isMinLength(1))`
93+
)
94+
})
95+
96+
it("should handle refs", () => {
97+
assertOutput(
98+
{
99+
schema: {
100+
"$ref": "#/definitions/ID"
101+
},
102+
definitions: {
103+
"ID": {
104+
"type": "string"
105+
}
106+
}
107+
},
108+
`Schema.String.annotate({ identifier: "ID" })`
109+
)
110+
})
111+
112+
describe("roundtrips", () => {
113+
it("Never", () => {
114+
assertRoundtrip(Schema.Never)
115+
})
116+
117+
it("Unknown", () => {
118+
assertRoundtrip(Schema.Unknown)
119+
})
120+
121+
it("Null", () => {
122+
assertRoundtrip(Schema.Null)
123+
})
124+
125+
describe("String", () => {
126+
it("basic", () => {
127+
assertRoundtrip(Schema.String)
128+
})
129+
130+
it("with annotations", () => {
131+
assertRoundtrip(Schema.String.annotate({ title: "title", description: "description" }))
132+
})
133+
134+
it("with check", () => {
135+
assertRoundtrip(Schema.String.check(Schema.isMinLength(1)))
136+
})
137+
138+
it("with check and annotations", () => {
139+
assertRoundtrip(Schema.String.annotate({ description: "description" }).check(Schema.isMinLength(1)))
140+
assertRoundtrip(Schema.String.check(Schema.isMinLength(1)).annotate({ description: "description" }))
141+
assertRoundtrip(Schema.String.check(Schema.isMinLength(1, { description: "description" })))
142+
})
143+
144+
it("with checks", () => {
145+
assertRoundtrip(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10)))
146+
})
147+
148+
it("with checks and annotations", () => {
149+
assertRoundtrip(
150+
Schema.String.annotate({ description: "description" }).check(Schema.isMinLength(1), Schema.isMaxLength(10))
151+
)
152+
assertRoundtrip(
153+
Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10)).annotate({ description: "description" })
154+
)
155+
assertRoundtrip(
156+
Schema.String.check(Schema.isMinLength(1, { description: "description" }), Schema.isMaxLength(10))
157+
)
158+
assertRoundtrip(
159+
Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(10, { description: "description" }))
160+
)
161+
assertRoundtrip(
162+
Schema.String.annotate({ description: "description1" }).check(
163+
Schema.isMinLength(1),
164+
Schema.isMaxLength(10, { description: "description2" })
165+
)
166+
)
167+
assertRoundtrip(
168+
Schema.String.check(
169+
Schema.isMinLength(1, { description: "description1" }),
170+
Schema.isMaxLength(10, { description: "description2" })
171+
)
172+
)
173+
})
174+
})
175+
176+
it("Number", () => {
177+
assertRoundtrip(Schema.Number)
178+
})
179+
180+
it("Boolean", () => {
181+
assertRoundtrip(Schema.Boolean)
182+
})
183+
184+
it("Int", () => {
185+
assertRoundtrip(Schema.Int)
186+
})
187+
188+
it.skip("Struct", () => {
189+
assertRoundtrip(Schema.Struct({}))
190+
})
191+
192+
describe("Tuple", () => {
193+
it("empty", () => {
194+
assertRoundtrip(Schema.Tuple([]))
195+
})
196+
})
197+
198+
describe("Union", () => {
199+
it("empty", () => {
200+
assertRoundtrip(Schema.Union([]))
201+
})
202+
203+
it("String | Number", () => {
204+
assertRoundtrip(Schema.Union([Schema.String, Schema.Number]))
205+
})
206+
})
207+
})
208+
})
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
describe("OpenApiGenerator", () => {
4+
it("should generate a client", () => {
5+
expect(true).toBe(true)
6+
})
7+
})

packages/tools/openapi-generator/tsconfig.test.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "http://json.schemastore.org/tsconfig",
33
"extends": "../../../tsconfig.base.jsonc",
4-
"include": ["test", "dtslint", "benchmark"],
4+
"include": ["test/**/*.ts"],
55
"references": [{ "path": "tsconfig.src.json" }],
66
"compilerOptions": {
77
"tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo",
@@ -10,8 +10,8 @@
1010
"erasableSyntaxOnly": false,
1111
"baseUrl": ".",
1212
"paths": {
13-
"effect": ["src/index.ts"],
14-
"effect/*": ["src/*/index.ts", "src/*.ts"]
13+
"@effect/openapi-generator": ["src/index.ts"],
14+
"@effect/openapi-generator/*": ["src/*/index.ts", "src/*.ts"]
1515
},
1616
"types": ["node"],
1717
"plugins": [

0 commit comments

Comments
 (0)