Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/actions/environment/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ on:
branches: [main]

jobs:

setup_environment:
name: Install packages
runs-on: ubuntu-latest
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions examples/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/to-json-schema/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { config } from "@repo/eslint-config/base";

/** @type {import("eslint").Linter.Config} */
export default config;
55 changes: 55 additions & 0 deletions packages/to-json-schema/package.json
Original file line number Diff line number Diff line change
@@ -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 <ruben.van.rooij@gmail.com>",
"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"
}
}
201 changes: 201 additions & 0 deletions packages/to-json-schema/src/convert-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading