diff --git a/.github/actions/environment/action.yml b/.github/actions/environment/action.yml index 0baa98c..b60e557 100644 --- a/.github/actions/environment/action.yml +++ b/.github/actions/environment/action.yml @@ -26,7 +26,8 @@ runs: with: path: | ${{ github.workspace }}/packages/dynz/dist - ${{ github.workspace }}/packages/react-hook-form-resolver/dist + ${{ github.workspace }}/packages/react-hook-form/dist + ${{ github.workspace }}/packages/to-json-schema/dist key: library-build-cache-${{ hashFiles('packages/**/*.ts', '!packages/**/*.test.ts') }} - name: Build packages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f775b59..5f64bf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,6 @@ on: branches: [main] jobs: - setup_environment: name: Install packages runs-on: ubuntu-latest @@ -15,7 +14,7 @@ jobs: uses: actions/checkout@v4 - name: Setup environment uses: ./.github/actions/environment - + library_preview: name: Publish library via pkg.pr.new runs-on: ubuntu-latest @@ -26,7 +25,7 @@ jobs: - name: Setup environment uses: ./.github/actions/environment - name: Publish dynz library - run: pnpx pkg-pr-new publish --comment=update --pnpm './packages/dynz' './packages/react-hook-form' + run: pnpx pkg-pr-new publish --comment=update --pnpm './packages/dynz' './packages/react-hook-form' './packages/to-json-schema' format_check: name: Run format in library @@ -39,7 +38,7 @@ jobs: uses: ./.github/actions/environment - name: Format check run: pnpm format:check - + lint: name: Run lint in library needs: setup_environment diff --git a/examples/example/package.json b/examples/example/package.json index 187328f..f0a8f4a 100644 --- a/examples/example/package.json +++ b/examples/example/package.json @@ -11,6 +11,7 @@ "lint": "eslint . --max-warnings 0" }, "dependencies": { + "@dynz/to-json-schema": "workspace:*", "dynz": "workspace:*", "nodemon": "^3.1.10", "ts-node": "^10.9.2", diff --git a/packages/to-json-schema/eslint.config.mjs b/packages/to-json-schema/eslint.config.mjs new file mode 100644 index 0000000..9b56f93 --- /dev/null +++ b/packages/to-json-schema/eslint.config.mjs @@ -0,0 +1,4 @@ +import { config } from "@repo/eslint-config/base"; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/to-json-schema/package.json b/packages/to-json-schema/package.json new file mode 100644 index 0000000..f57cca5 --- /dev/null +++ b/packages/to-json-schema/package.json @@ -0,0 +1,55 @@ +{ + "name": "@dynz/to-json-schema", + "version": "0.0.1", + "private": false, + "license": "BUSL-1.1", + "type": "module", + "sideEffects": false, + "keywords": [ + "typescript", + "schema", + "validation", + "json-schema", + "form", + "type-safe" + ], + "repository": { + "type": "git", + "url": "https://github.com/rubenvanrooij/dynz" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "author": "Ruben van Rooij ", + "description": "Convert dynz schemas to standard JSON Schema.", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "check-types": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest run --watch" + }, + "peerDependencies": { + "dynz": "^1.0.0" + }, + "devDependencies": { + "tsdown": "^0.16.5" + } +} diff --git a/packages/to-json-schema/src/convert-rules.test.ts b/packages/to-json-schema/src/convert-rules.test.ts new file mode 100644 index 0000000..f421930 --- /dev/null +++ b/packages/to-json-schema/src/convert-rules.test.ts @@ -0,0 +1,201 @@ +import { ref, v } from "dynz"; +import { describe, expect, it, vi } from "vitest"; +import { applyRule } from "./convert-rules"; +import type { JsonSchema } from "./types"; + +describe("applyRule", () => { + it("maps min/max to minimum/maximum", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min", min: v(5) }, "number", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max", max: v(10) }, "number", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ minimum: 5, maximum: 10 }); + }); + + it("maps min_length/max_length to minLength/maxLength", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min_length", min: v(1) }, "string", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max_length", max: v(50) }, "string", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ minLength: 1, maxLength: 50 }); + }); + + it("maps min_length/max_length on an array schema to minItems/maxItems", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min_length", min: v(1) }, "array", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max_length", max: v(3) }, "array", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ minItems: 1, maxItems: 3 }); + }); + + it("maps min_entries/max_entries (object key count) to minProperties/maxProperties", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "min_entries", min: v(1) }, "object", { errorMode: "ignore", mode: "input" }); + applyRule(jsonSchema, { type: "max_entries", max: v(3) }, "object", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ minProperties: 1, maxProperties: 3 }); + }); + + it("maps max_precision to multipleOf", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "max_precision", maxPrecision: v(2) }, "number", { + errorMode: "ignore", + mode: "input", + }); + + expect(jsonSchema.multipleOf).toBeCloseTo(0.01); + }); + + it("maps regex to pattern", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$" }, "string", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ pattern: "^[a-z]+$" }); + }); + + it("warns when regex flags are set but still emits the pattern", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "regex", regex: "^[a-z]+$", flags: "i" }, "string", { + errorMode: "warn", + mode: "input", + }); + + expect(jsonSchema.pattern).toBe("^[a-z]+$"); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("maps email to format", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "email" }, "string", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ format: "email" }); + }); + + it("maps is_numeric to a numeric pattern", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "is_numeric" }, "string", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema.pattern).toBe("^[+-]?\\d+(\\.\\d+)?$"); + }); + + it("maps equals to const", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "equals", equals: v("admin") }, "string", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ const: "admin" }); + }); + + it("maps includes on a string schema to pattern", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "includes", includes: v("foo") }, "string", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ pattern: "foo" }); + }); + + it("maps includes on an array schema to contains", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "includes", includes: v("admin") }, "array", { errorMode: "ignore", mode: "input" }); + + expect(jsonSchema).toEqual({ contains: { const: "admin" } }); + }); + + it("maps not_includes to a negated pattern/contains", () => { + const stringSchema: JsonSchema = {}; + applyRule(stringSchema, { type: "not_includes", notIncludes: v("foo") }, "string", { + errorMode: "ignore", + mode: "input", + }); + expect(stringSchema).toEqual({ not: { pattern: "foo" } }); + + const arraySchema: JsonSchema = {}; + applyRule(arraySchema, { type: "not_includes", notIncludes: v("admin") }, "array", { + errorMode: "ignore", + mode: "input", + }); + expect(arraySchema).toEqual({ not: { contains: { const: "admin" } } }); + }); + + it("maps one_of to enum when every value is static", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "one_of", values: [v("a"), v("b")] }, "string", { + errorMode: "ignore", + mode: "input", + }); + + expect(jsonSchema).toEqual({ enum: ["a", "b"] }); + }); + + it("skips one_of entirely when any value is not static", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "one_of", values: [v("a"), ref("other")] }, "string", { + errorMode: "ignore", + mode: "input", + }); + + expect(jsonSchema).toEqual({}); + }); + + it("maps not_one_of to not/enum", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "not_one_of", values: [v(1), v(2)] }, "number", { + errorMode: "ignore", + mode: "input", + }); + + expect(jsonSchema).toEqual({ not: { enum: [1, 2] } }); + }); + + it("maps a single static mime_type to contentMediaType", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "mime_type", mimeType: v("image/png") }, "file", { + errorMode: "ignore", + mode: "input", + }); + + expect(jsonSchema).toEqual({ contentMediaType: "image/png" }); + }); + + it("skips mime_type with multiple values", () => { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, { type: "mime_type", mimeType: v(["image/png", "image/jpeg"]) }, "file", { + errorMode: "ignore", + mode: "input", + }); + + expect(jsonSchema).toEqual({}); + }); + + it("skips rules with no JSON Schema equivalent", () => { + for (const rule of [ + { type: "min_date" as const, min: v(new Date()) }, + { type: "max_date" as const, max: v(new Date()) }, + { type: "before" as const, before: v(new Date()) }, + { type: "after" as const, after: v(new Date()) }, + { type: "min_size" as const, min: v(1) }, + { type: "max_size" as const, max: v(1) }, + { type: "custom" as const, name: "foo", params: {} }, + { type: "conditional" as const, cases: [] }, + ]) { + const jsonSchema: JsonSchema = {}; + applyRule(jsonSchema, rule, "string", { errorMode: "ignore", mode: "input" }); + expect(jsonSchema).toEqual({}); + } + }); + + it("throws when errorMode is 'throw' and a rule value is not static", () => { + expect(() => + applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "throw", mode: "input" }) + ).toThrow(); + }); + + it("does not throw or warn when errorMode is 'ignore'", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + expect(() => + applyRule({}, { type: "min", min: ref("other") }, "number", { errorMode: "ignore", mode: "input" }) + ).not.toThrow(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/to-json-schema/src/convert-rules.ts b/packages/to-json-schema/src/convert-rules.ts new file mode 100644 index 0000000..a71f22f --- /dev/null +++ b/packages/to-json-schema/src/convert-rules.ts @@ -0,0 +1,255 @@ +import type { Rule, SchemaType } from "dynz"; +import { reportIssue } from "./report-issue"; +import { resolveStatic } from "./resolve-static"; +import type { ConversionContext, JsonSchema } from "./types"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Applies a dynz rule to a JSON Schema in place. Rules whose value can't be + * statically resolved (references, predicates, transformers) or that have + * no JSON Schema equivalent are skipped and reported via `reportIssue`. + */ +export function applyRule( + jsonSchema: JsonSchema, + rule: Rule, + schemaType: SchemaType, + context: ConversionContext +): void { + switch (rule.type) { + case "min": { + const min = resolveStatic(rule.min); + if (min.ok) { + jsonSchema.minimum = min.value as number; + } else { + reportIssue(context, `"min" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max": { + const max = resolveStatic(rule.max); + if (max.ok) { + jsonSchema.maximum = max.value as number; + } else { + reportIssue(context, `"max" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "min_length": { + // dynz reuses "min_length" for both string length and array length (via .min() on + // either schema kind), so the JSON Schema keyword depends on which kind this is. + const min = resolveStatic(rule.min); + if (min.ok) { + if (schemaType === "array") { + jsonSchema.minItems = min.value as number; + } else { + jsonSchema.minLength = min.value as number; + } + } else { + reportIssue(context, `"min_length" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max_length": { + const max = resolveStatic(rule.max); + if (max.ok) { + if (schemaType === "array") { + jsonSchema.maxItems = max.value as number; + } else { + jsonSchema.maxLength = max.value as number; + } + } else { + reportIssue(context, `"max_length" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "min_entries": { + // "min_entries"/"max_entries" are only produced by object schemas (.minEntries()), for + // the number of keys — i.e. JSON Schema's "minProperties"/"maxProperties". + const min = resolveStatic(rule.min); + if (min.ok) { + jsonSchema.minProperties = min.value as number; + } else { + reportIssue(context, `"min_entries" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max_entries": { + const max = resolveStatic(rule.max); + if (max.ok) { + jsonSchema.maxProperties = max.value as number; + } else { + reportIssue(context, `"max_entries" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "max_precision": { + const maxPrecision = resolveStatic(rule.maxPrecision); + if (maxPrecision.ok) { + jsonSchema.multipleOf = 1 / 10 ** (maxPrecision.value as number); + } else { + reportIssue(context, `"max_precision" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "regex": { + jsonSchema.pattern = rule.regex; + if (rule.flags) { + reportIssue( + context, + `"regex" rule flags ("${rule.flags}") cannot be represented in JSON Schema's "pattern" keyword and were ignored.` + ); + } + break; + } + + case "email": { + jsonSchema.format = "email"; + break; + } + + case "is_numeric": { + // Best-effort approximation: JSON Schema has no dedicated "numeric string" keyword. + jsonSchema.pattern = "^[+-]?\\d+(\\.\\d+)?$"; + break; + } + + case "equals": { + const equals = resolveStatic(rule.equals); + if (equals.ok) { + jsonSchema.const = equals.value; + } else { + reportIssue(context, `"equals" rule with a non-static value cannot be converted to JSON Schema.`); + } + break; + } + + case "includes": { + const includes = resolveStatic(rule.includes); + if (!includes.ok) { + reportIssue(context, `"includes" rule with a non-static value cannot be converted to JSON Schema.`); + break; + } + + if (schemaType === "array") { + jsonSchema.contains = { const: includes.value }; + } else { + jsonSchema.pattern = escapeRegExp(String(includes.value)); + } + break; + } + + case "not_includes": { + const notIncludes = resolveStatic(rule.notIncludes); + if (!notIncludes.ok) { + reportIssue(context, `"not_includes" rule with a non-static value cannot be converted to JSON Schema.`); + break; + } + + jsonSchema.not = + schemaType === "array" + ? { contains: { const: notIncludes.value } } + : { pattern: escapeRegExp(String(notIncludes.value)) }; + break; + } + + case "one_of": { + const values: unknown[] = []; + for (const value of rule.values) { + const resolved = resolveStatic(value); + if (!resolved.ok) { + reportIssue(context, `"one_of" rule with a non-static value cannot be converted to JSON Schema.`); + return; + } + values.push(resolved.value); + } + jsonSchema.enum = values; + break; + } + + case "not_one_of": { + const values: unknown[] = []; + for (const value of rule.values) { + const resolved = resolveStatic(value); + if (!resolved.ok) { + reportIssue(context, `"not_one_of" rule with a non-static value cannot be converted to JSON Schema.`); + return; + } + values.push(resolved.value); + } + jsonSchema.not = { enum: values }; + break; + } + + case "mime_type": { + const mimeType = resolveStatic(rule.mimeType); + if (!mimeType.ok) { + reportIssue(context, `"mime_type" rule with a non-static value cannot be converted to JSON Schema.`); + break; + } + + if (typeof mimeType.value === "string") { + jsonSchema.contentMediaType = mimeType.value; + } else if (Array.isArray(mimeType.value) && mimeType.value.length === 1) { + jsonSchema.contentMediaType = mimeType.value[0]; + } else { + reportIssue(context, `"mime_type" rule with multiple mime types has no JSON Schema equivalent.`); + } + break; + } + + case "min_date": + case "max_date": + case "before": + case "after": { + reportIssue( + context, + `"${rule.type}" rule has no JSON Schema equivalent (date comparisons aren't representable).` + ); + break; + } + + case "min_size": + case "max_size": { + reportIssue( + context, + `"${rule.type}" rule has no JSON Schema equivalent (byte-size constraints aren't representable).` + ); + break; + } + + case "custom": { + reportIssue(context, `"custom" rule "${rule.name}" has no JSON Schema equivalent.`); + break; + } + + case "conditional": { + reportIssue(context, `"conditional" rule has no JSON Schema equivalent.`); + break; + } + + default: { + reportIssue(context, `Unknown rule cannot be converted to JSON Schema.`); + } + } +} + +export function applyRules( + jsonSchema: JsonSchema, + rules: Rule[] | undefined, + schemaType: SchemaType, + context: ConversionContext +): void { + for (const rule of rules ?? []) { + applyRule(jsonSchema, rule, schemaType, context); + } +} diff --git a/packages/to-json-schema/src/convert-schema.test.ts b/packages/to-json-schema/src/convert-schema.test.ts new file mode 100644 index 0000000..c7410b7 --- /dev/null +++ b/packages/to-json-schema/src/convert-schema.test.ts @@ -0,0 +1,199 @@ +import { array, boolean, date, eq, file, literal, number, object, ref, string, v } from "dynz"; +import type { Schema } from "dynz"; +import { describe, expect, it, vi } from "vitest"; +import { convertSchema } from "./convert-schema"; +import type { ConversionContext } from "./types"; + +const ctx: ConversionContext = { errorMode: "ignore", mode: "input" }; + +describe("convertSchema", () => { + it("converts primitive schemas", () => { + expect(convertSchema(string(), ctx)).toEqual({ type: "string" }); + expect(convertSchema(number(), ctx)).toEqual({ type: "number" }); + expect(convertSchema(boolean(), ctx)).toEqual({ type: "boolean" }); + expect(convertSchema(date(), ctx)).toEqual({ type: "string", format: "date-time" }); + }); + + it("converts a literal schema, including null", () => { + expect(convertSchema(literal("admin"), ctx)).toEqual({ const: "admin" }); + expect(convertSchema(literal(null), ctx)).toEqual({ const: null }); + }); + + it("converts an enum schema", () => { + const schema: Schema = { type: "enum", enum: { A: "a", B: "b" } }; + + expect(convertSchema(schema, ctx)).toEqual({ type: "string", enum: ["a", "b"] }); + }); + + it("converts an options schema, honoring statically enabled/disabled entries", () => { + const schema: Schema = { + type: "options", + options: ["a", { enabled: true, value: "b" }, { enabled: false, value: "c" }], + }; + + expect(convertSchema(schema, ctx)).toEqual({ type: "string", enum: ["a", "b"] }); + }); + + it("keeps a predicate-enabled option and warns", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const schema: Schema = { + type: "options", + options: [{ enabled: eq(ref("type"), v("x")), value: "d" }], + }; + + expect(convertSchema(schema, { errorMode: "warn", mode: "input" })).toEqual({ type: "string", enum: ["d"] }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("converts a file schema with a static mime_type rule", () => { + const schema = file().mimeType(v("image/png")); + + expect(convertSchema(schema, ctx)).toEqual({ type: "string", contentMediaType: "image/png" }); + }); + + it("converts an expression schema to an unconstrained schema", () => { + const schema: Schema = { type: "expression", value: v(1) }; + + expect(convertSchema(schema, ctx)).toEqual({}); + }); + + it("converts an array schema, applying items and entry-count rules", () => { + const schema = array(string()).min(1).max(3); + + expect(convertSchema(schema, ctx)).toEqual({ + type: "array", + items: { type: "string" }, + minItems: 1, + maxItems: 3, + }); + }); + + it("converts an object schema, marking only statically-mandatory fields as required", () => { + const schema = object({ + name: string(), + nickname: string().optional(), + role: string().setRequired(eq(ref("type"), v("admin"))), + hidden: string().setIncluded(false), + }); + + expect(convertSchema(schema, ctx)).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + nickname: { type: "string" }, + role: { type: "string" }, + hidden: { type: "string" }, + }, + required: ["name"], + }); + }); + + it("omits expression fields from an object in 'input' mode (default) but includes them in 'output' mode", () => { + const schema = object({ + name: string(), + computed: { type: "expression", value: v(1) }, + }); + + expect(convertSchema(schema, { errorMode: "ignore", mode: "input" })).toEqual({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }); + + expect(convertSchema(schema, { errorMode: "ignore", mode: "output" })).toEqual({ + type: "object", + properties: { name: { type: "string" }, computed: {} }, + required: ["name", "computed"], + }); + }); + + it("omits expression fields from a discriminated union member in 'input' mode but includes them in 'output' mode", () => { + const schema: Schema = { + type: "discriminated_union", + key: "kind", + schemas: [{ kind: "a", value: string(), computed: { type: "expression", value: v(1) } }], + }; + + expect(convertSchema(schema, { errorMode: "ignore", mode: "input" })).toEqual({ + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, value: { type: "string" } }, + required: ["kind", "value"], + }, + ], + }); + + expect(convertSchema(schema, { errorMode: "ignore", mode: "output" })).toEqual({ + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, value: { type: "string" }, computed: {} }, + required: ["kind", "value", "computed"], + }, + ], + }); + }); + + it("converts a discriminated union to oneOf, dropping non-schema sibling entries", () => { + // Built as a plain schema literal: the discriminatedUnion() fluent builder's CheckMember + // constraint statically forbids non-schema sibling fields, but the underlying + // DiscriminatedUnionSchema type still allows them (e.g. for dynamically constructed schemas). + const schema: Schema = { + type: "discriminated_union", + key: "kind", + schemas: [ + { kind: "a", value: string() }, + { kind: "b", value: number(), meta: "ignored" }, + ], + }; + + expect(convertSchema(schema, ctx)).toEqual({ + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, value: { type: "string" } }, + required: ["kind", "value"], + }, + { + type: "object", + properties: { kind: { const: "b" }, value: { type: "number" } }, + required: ["kind", "value"], + }, + ], + }); + }); + + it("wraps private fields in the plain/masked shape", () => { + const schema = string().setPrivate(true); + + expect(convertSchema(schema, ctx)).toEqual({ + oneOf: [ + { + type: "object", + properties: { state: { const: "plain" }, value: { type: "string" } }, + required: ["state"], + }, + { + type: "object", + properties: { state: { const: "masked" }, value: { type: "string" } }, + required: ["state", "value"], + }, + ], + }); + }); + + it("carries over a static default value", () => { + expect(convertSchema(string().setDefault("hi"), ctx)).toEqual({ type: "string", default: "hi" }); + }); + + it("serializes a Date default to an ISO string", () => { + const defaultDate = new Date("2024-01-01T00:00:00.000Z"); + expect(convertSchema(date().setDefault(defaultDate), ctx)).toEqual({ + type: "string", + format: "date-time", + default: defaultDate.toISOString(), + }); + }); +}); diff --git a/packages/to-json-schema/src/convert-schema.ts b/packages/to-json-schema/src/convert-schema.ts new file mode 100644 index 0000000..f28f976 --- /dev/null +++ b/packages/to-json-schema/src/convert-schema.ts @@ -0,0 +1,224 @@ +import { type OptionsValue, type OptionValue, type Schema, SchemaType } from "dynz"; +import { applyRules } from "./convert-rules"; +import { reportIssue } from "./report-issue"; +import type { ConversionContext, JsonSchema } from "./types"; + +function isMandatory(schema: Schema): boolean { + const isIncluded = schema.included === undefined || schema.included === true; + const isRequired = schema.required === undefined || schema.required === true; + return isIncluded && isRequired; +} + +function jsonTypeOf(value: unknown): string { + return typeof value; +} + +function applyEnumValues(jsonSchema: JsonSchema, values: unknown[]): void { + jsonSchema.enum = values; + const types = Array.from(new Set(values.map(jsonTypeOf))); + if (types.length === 1) { + jsonSchema.type = types[0]; + } else if (types.length > 1) { + jsonSchema.type = types; + } +} + +function collectOptionValues(options: OptionsValue, context: ConversionContext): unknown[] { + const values: unknown[] = []; + + const visit = (option: OptionValue): void => { + if (typeof option !== "object") { + values.push(option); + return; + } + + if (option.enabled === false) { + return; + } + + if (typeof option.enabled === "object") { + reportIssue( + context, + `An "options" entry has a predicate-based "enabled" flag; whether it's enabled can't be determined statically, so it was included in the JSON Schema "enum" anyway.` + ); + } + + visit(option.value); + }; + + for (const option of options) { + visit(option); + } + + return values; +} + +function isSchema(value: Schema | string | number | boolean): value is Schema { + return typeof value === "object" && value !== null && "type" in value; +} + +function shouldOmitField(schema: Schema, context: ConversionContext): boolean { + return context.mode === "input" && schema.type === SchemaType.EXPRESSION; +} + +function applyPrivacyWrapper(schema: Schema, innerSchema: JsonSchema): JsonSchema { + if (schema.private !== true) { + return innerSchema; + } + + return { + oneOf: [ + { + type: "object", + properties: { state: { const: "plain" }, value: innerSchema }, + required: ["state"], + }, + { + type: "object", + properties: { state: { const: "masked" }, value: { type: "string" } }, + required: ["state", "value"], + }, + ], + }; +} + +function applyDefault(schema: Schema, jsonSchema: JsonSchema): void { + if (schema.default === undefined) { + return; + } + + jsonSchema.default = schema.default instanceof Date ? schema.default.toISOString() : schema.default; +} + +/** + * Converts a dynz schema to a JSON Schema (2020-12) document. Rule values + * and schema kinds without a JSON Schema equivalent are handled per + * `context.errorMode`. + */ +export function convertSchema(schema: Schema, context: ConversionContext): JsonSchema { + const jsonSchema = convertSchemaKind(schema, context); + applyDefault(schema, jsonSchema); + return applyPrivacyWrapper(schema, jsonSchema); +} + +function convertSchemaKind(schema: Schema, context: ConversionContext): JsonSchema { + switch (schema.type) { + case SchemaType.STRING: { + const jsonSchema: JsonSchema = { type: "string" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.NUMBER: { + const jsonSchema: JsonSchema = { type: "number" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.BOOLEAN: { + const jsonSchema: JsonSchema = { type: "boolean" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.DATE: { + // JSON Schema has no native date type; represented as an ISO 8601 string. + const jsonSchema: JsonSchema = { type: "string", format: "date-time" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.LITERAL: { + return { const: schema.value }; + } + + case SchemaType.ENUM: { + const jsonSchema: JsonSchema = {}; + applyEnumValues(jsonSchema, Object.values(schema.enum)); + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.OPTIONS: { + const jsonSchema: JsonSchema = {}; + applyEnumValues(jsonSchema, collectOptionValues(schema.options, context)); + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.FILE: { + // JSON Schema has no native file type; a "mime_type" rule (if static) adds contentMediaType. + const jsonSchema: JsonSchema = { type: "string" }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.EXPRESSION: { + // Expression values are computed at runtime; their type can't be known ahead of time. + // As an object/discriminated-union field, this is only reached in "output" mode — + // in "input" mode such fields are omitted entirely by shouldOmitField(). + return {}; + } + + case SchemaType.ARRAY: { + const jsonSchema: JsonSchema = { + type: "array", + items: convertSchema(schema.schema, context), + }; + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.OBJECT: { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, fieldSchema] of Object.entries(schema.fields)) { + if (shouldOmitField(fieldSchema, context)) { + continue; + } + + properties[key] = convertSchema(fieldSchema, context); + if (isMandatory(fieldSchema)) { + required.push(key); + } + } + + const jsonSchema: JsonSchema = { type: "object", properties }; + if (required.length > 0) { + jsonSchema.required = required; + } + applyRules(jsonSchema, schema.rules, schema.type, context); + return jsonSchema; + } + + case SchemaType.DISCRIMINATED_UNION: { + const members = schema.schemas.map((member) => { + const properties: Record = { + [schema.key]: { const: member[schema.key] }, + }; + const required: string[] = [schema.key]; + + for (const [key, value] of Object.entries(member)) { + if (key === schema.key || !isSchema(value) || shouldOmitField(value, context)) { + continue; + } + + properties[key] = convertSchema(value, context); + if (isMandatory(value)) { + required.push(key); + } + } + + return { type: "object", properties, required } satisfies JsonSchema; + }); + + return { oneOf: members }; + } + + default: { + reportIssue(context, `Unknown schema type cannot be converted to JSON Schema.`); + return {}; + } + } +} diff --git a/packages/to-json-schema/src/index.ts b/packages/to-json-schema/src/index.ts new file mode 100644 index 0000000..f4b1b94 --- /dev/null +++ b/packages/to-json-schema/src/index.ts @@ -0,0 +1,2 @@ +export { toStandardJsonSchema } from "./to-standard-json-schema"; +export type { ConversionConfig, ErrorMode, JsonSchema } from "./types"; diff --git a/packages/to-json-schema/src/report-issue.ts b/packages/to-json-schema/src/report-issue.ts new file mode 100644 index 0000000..8733976 --- /dev/null +++ b/packages/to-json-schema/src/report-issue.ts @@ -0,0 +1,15 @@ +import type { ConversionContext } from "./types"; + +/** + * Reports an unsupported or unresolvable conversion according to the + * configured error mode: throws, warns via `console.warn`, or is a no-op. + */ +export function reportIssue(context: ConversionContext, message: string): void { + if (context.errorMode === "throw") { + throw new Error(message); + } + + if (context.errorMode === "warn") { + console.warn(`[@dynz/to-json-schema] ${message}`); + } +} diff --git a/packages/to-json-schema/src/resolve-static.ts b/packages/to-json-schema/src/resolve-static.ts new file mode 100644 index 0000000..1cf76ac --- /dev/null +++ b/packages/to-json-schema/src/resolve-static.ts @@ -0,0 +1,19 @@ +import type { ParamaterValue, ValueType } from "dynz"; + +export type StaticResolution = { ok: true; value: T } | { ok: false }; + +/** + * Attempts to extract a literal value from a rule parameter. Only values + * wrapped with `v()`/`val()`/`st()` (i.e. `{ type: "st" }`) are statically + * known — references, predicates and transformers depend on runtime data + * and cannot be represented in a JSON Schema document. + */ +export function resolveStatic( + value: ParamaterValue | undefined +): StaticResolution { + if (value !== undefined && typeof value === "object" && "type" in value && value.type === "st") { + return { ok: true, value: value.value }; + } + + return { ok: false }; +} diff --git a/packages/to-json-schema/src/to-standard-json-schema.test.ts b/packages/to-json-schema/src/to-standard-json-schema.test.ts new file mode 100644 index 0000000..92661c1 --- /dev/null +++ b/packages/to-json-schema/src/to-standard-json-schema.test.ts @@ -0,0 +1,76 @@ +import { array, eq, number, object, ref, string, v } from "dynz"; +import { describe, expect, it, vi } from "vitest"; +import { toStandardJsonSchema } from "./to-standard-json-schema"; + +describe("toStandardJsonSchema", () => { + it("converts a realistic object schema end-to-end", () => { + const schema = object({ + email: string().email(), + age: number().min(0).max(150).optional(), + role: string().oneOf([v("admin"), v("member")]), + promoCode: string().setRequired(eq(ref("role"), v("admin"))), + tags: array(string()).min(0), + }); + + expect(toStandardJsonSchema(schema)).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + email: { type: "string", format: "email" }, + age: { type: "number", minimum: 0, maximum: 150 }, + role: { type: "string", enum: ["admin", "member"] }, + promoCode: { type: "string" }, + tags: { type: "array", items: { type: "string" }, minItems: 0 }, + }, + // "age" is optional, "promoCode" is only conditionally required (a Predicate), + // so neither is statically mandatory. + required: ["email", "role", "tags"], + }); + }); + + it("defaults to errorMode 'warn' for unresolvable rule values", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const schema = string().min(ref("minLength")); + + expect(toStandardJsonSchema(schema)).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "string", + }); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("throws when errorMode is 'throw' and a rule value is unresolvable", () => { + const schema = string().min(ref("minLength")); + + expect(() => toStandardJsonSchema(schema, { errorMode: "throw" })).toThrow(); + }); + + it("defaults to mode 'input', omitting expression fields", () => { + const schema = object({ + name: string(), + fullName: { type: "expression", value: v(1) }, + }); + + expect(toStandardJsonSchema(schema)).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }); + }); + + it("includes expression fields when mode is 'output'", () => { + const schema = object({ + name: string(), + fullName: { type: "expression", value: v(1) }, + }); + + expect(toStandardJsonSchema(schema, { mode: "output" })).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { name: { type: "string" }, fullName: {} }, + required: ["name", "fullName"], + }); + }); +}); diff --git a/packages/to-json-schema/src/to-standard-json-schema.ts b/packages/to-json-schema/src/to-standard-json-schema.ts new file mode 100644 index 0000000..e0381ec --- /dev/null +++ b/packages/to-json-schema/src/to-standard-json-schema.ts @@ -0,0 +1,20 @@ +import type { Schema } from "dynz"; +import { convertSchema } from "./convert-schema"; +import type { ConversionConfig, JsonSchema } from "./types"; + +/** + * Converts a dynz schema to a standard JSON Schema (2020-12) document. + * + * Rule values that depend on runtime data (references, predicates, + * transformers) and schema kinds without a JSON Schema equivalent are + * handled according to `config.errorMode` (default `"warn"`). Whether + * `expression` fields are included is controlled by `config.mode` + * (default `"input"`, which omits them). + */ +export function toStandardJsonSchema(schema: T, config: ConversionConfig = {}): JsonSchema { + const jsonSchema = convertSchema(schema, { + errorMode: config.errorMode ?? "warn", + mode: config.mode ?? "input", + }); + return { $schema: "https://json-schema.org/draft/2020-12/schema", ...jsonSchema }; +} diff --git a/packages/to-json-schema/src/types.ts b/packages/to-json-schema/src/types.ts new file mode 100644 index 0000000..37a3647 --- /dev/null +++ b/packages/to-json-schema/src/types.ts @@ -0,0 +1,62 @@ +/** + * A JSON Schema (2020-12) document. Loosely typed since the shape varies + * significantly by keyword combination. + */ +export interface JsonSchema { + $schema?: string; + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: JsonSchema; + enum?: unknown[]; + const?: unknown; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + minItems?: number; + maxItems?: number; + minProperties?: number; + maxProperties?: number; + pattern?: string; + format?: string; + multipleOf?: number; + contentMediaType?: string; + contains?: JsonSchema; + not?: JsonSchema; + oneOf?: JsonSchema[]; + default?: unknown; + [key: string]: unknown; +} + +/** + * The policy for handling schema kinds/rule values that cannot be + * represented in JSON Schema. + */ +export type ErrorMode = "throw" | "warn" | "ignore"; + +/** + * Whether the conversion targets the data a consumer sends in (`"input"`) + * or the data dynz produces after validation (`"output"`). `expression` + * schemas are computed/derived values that are never part of the input, so + * in `"input"` mode they're silently removed from the generated schema; in + * `"output"` mode they're included (using their best-effort mapping). + */ +export type ConversionMode = "input" | "output"; + +export interface ConversionConfig { + /** + * Policy for handling unsupported/unresolvable rule values and schema + * kinds. Defaults to `"warn"`. + */ + errorMode?: ErrorMode; + /** + * Whether to convert for input or output data. Defaults to `"input"`. + */ + mode?: ConversionMode; +} + +export interface ConversionContext { + readonly errorMode: ErrorMode; + readonly mode: ConversionMode; +} diff --git a/packages/to-json-schema/tsconfig.json b/packages/to-json-schema/tsconfig.json new file mode 100644 index 0000000..f5e69af --- /dev/null +++ b/packages/to-json-schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/to-json-schema/tsdown.config.ts b/packages/to-json-schema/tsdown.config.ts new file mode 100644 index 0000000..9402bc5 --- /dev/null +++ b/packages/to-json-schema/tsdown.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig([ + { + entry: ["./src/index.ts"], + clean: true, + format: ["esm", "cjs"], + minify: false, + dts: true, + outDir: "./dist", + }, + { + entry: ["./src/index.ts"], + clean: true, + format: ["esm", "cjs"], + minify: true, + dts: false, + outDir: "./dist", + outExtensions: ({ format }) => ({ + js: format === "cjs" ? ".min.cjs" : ".min.js", + }), + }, +]); diff --git a/packages/to-json-schema/vitest.config.ts b/packages/to-json-schema/vitest.config.ts new file mode 100644 index 0000000..2a9db48 --- /dev/null +++ b/packages/to-json-schema/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import rootConfig from "../../vitest.config.ts"; + +export default mergeConfig( + rootConfig, + defineProject({ + test: { + typecheck: { + tsconfig: "./tsconfig.json", + }, + }, + }) +) as object; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3252a29..bac3259 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: examples/example: dependencies: + '@dynz/to-json-schema': + specifier: workspace:* + version: link:../../packages/to-json-schema dynz: specifier: workspace:* version: link:../../packages/dynz @@ -220,6 +223,16 @@ importers: specifier: ^0.16.5 version: 0.16.5(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.2) + packages/to-json-schema: + dependencies: + dynz: + specifier: ^1.0.0 + version: link:../dynz + devDependencies: + tsdown: + specifier: ^0.16.5 + version: 0.16.5(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.2) + packages: '@alloc/quick-lru@5.2.0':