@bram-dc/fastify-type-provider-zod 5.0.2 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -46
- package/dist/cjs/core.cjs +125 -0
- package/dist/cjs/core.cjs.map +1 -0
- package/dist/cjs/core.d.cts +68 -0
- package/dist/cjs/errors.cjs +57 -0
- package/dist/cjs/errors.cjs.map +1 -0
- package/dist/cjs/errors.d.cts +30 -0
- package/dist/cjs/index.cjs +16 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/index.d.cts +2 -0
- package/dist/cjs/registry.cjs +43 -0
- package/dist/cjs/registry.cjs.map +1 -0
- package/dist/cjs/registry.d.cts +9 -0
- package/dist/cjs/utils.cjs +21 -0
- package/dist/cjs/utils.cjs.map +1 -0
- package/dist/cjs/utils.d.cts +12 -0
- package/dist/cjs/zod-to-json.cjs +93 -0
- package/dist/cjs/zod-to-json.cjs.map +1 -0
- package/dist/cjs/zod-to-json.d.cts +8 -0
- package/dist/esm/core.d.ts +68 -0
- package/dist/esm/core.js +125 -0
- package/dist/esm/core.js.map +1 -0
- package/dist/esm/errors.d.ts +30 -0
- package/dist/esm/errors.js +57 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/registry.d.ts +9 -0
- package/dist/esm/registry.js +43 -0
- package/dist/esm/registry.js.map +1 -0
- package/dist/esm/utils.d.ts +12 -0
- package/dist/esm/utils.js +21 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/esm/zod-to-json.d.ts +8 -0
- package/dist/esm/zod-to-json.js +93 -0
- package/dist/esm/zod-to-json.js.map +1 -0
- package/package.json +75 -58
- package/src/core.ts +228 -0
- package/src/errors.ts +99 -0
- package/src/index.ts +21 -0
- package/src/registry.ts +64 -0
- package/src/utils.ts +33 -0
- package/src/zod-to-json.ts +160 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -16
- package/dist/src/core.d.ts +0 -59
- package/dist/src/core.js +0 -121
- package/dist/src/errors.d.ts +0 -35
- package/dist/src/errors.js +0 -43
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { $ZodRegistry, toJSONSchema, $ZodType } from "zod/v4/core";
|
|
2
|
+
import { getReferenceUri } from "./utils.js";
|
|
3
|
+
const SCHEMA_REGISTRY_ID_PLACEHOLDER = "__SCHEMA__ID__PLACEHOLDER__";
|
|
4
|
+
const SCHEMA_URI_PLACEHOLDER = "__SCHEMA__PLACEHOLDER__";
|
|
5
|
+
function isZodDate(entity) {
|
|
6
|
+
return entity instanceof $ZodType && entity._zod.def.type === "date";
|
|
7
|
+
}
|
|
8
|
+
function isZodUnion(entity) {
|
|
9
|
+
return entity instanceof $ZodType && entity._zod.def.type === "union";
|
|
10
|
+
}
|
|
11
|
+
function isZodUndefined(entity) {
|
|
12
|
+
return entity instanceof $ZodType && entity._zod.def.type === "undefined";
|
|
13
|
+
}
|
|
14
|
+
const getOverride = (ctx, io) => {
|
|
15
|
+
if (isZodUnion(ctx.zodSchema)) {
|
|
16
|
+
ctx.jsonSchema.anyOf = ctx.jsonSchema.anyOf?.filter((schema) => Object.keys(schema).length > 0);
|
|
17
|
+
}
|
|
18
|
+
if (isZodDate(ctx.zodSchema)) {
|
|
19
|
+
if (io === "output") {
|
|
20
|
+
ctx.jsonSchema.type = "string";
|
|
21
|
+
ctx.jsonSchema.format = "date-time";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (isZodUndefined(ctx.zodSchema)) {
|
|
25
|
+
if (io === "output") {
|
|
26
|
+
ctx.jsonSchema.type = "null";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const deleteInvalidProperties = (schema) => {
|
|
31
|
+
const object = { ...schema };
|
|
32
|
+
delete object.id;
|
|
33
|
+
delete object.$schema;
|
|
34
|
+
delete object.$id;
|
|
35
|
+
return object;
|
|
36
|
+
};
|
|
37
|
+
const zodSchemaToJson = (zodSchema, registry, io, config) => {
|
|
38
|
+
const schemaRegistryEntry = registry.get(zodSchema);
|
|
39
|
+
if (schemaRegistryEntry?.id) {
|
|
40
|
+
return { $ref: getReferenceUri(schemaRegistryEntry.id) };
|
|
41
|
+
}
|
|
42
|
+
const tempRegistry = new $ZodRegistry();
|
|
43
|
+
tempRegistry.add(zodSchema, { id: SCHEMA_REGISTRY_ID_PLACEHOLDER });
|
|
44
|
+
const {
|
|
45
|
+
schemas: { [SCHEMA_REGISTRY_ID_PLACEHOLDER]: result }
|
|
46
|
+
} = toJSONSchema(tempRegistry, {
|
|
47
|
+
...config,
|
|
48
|
+
io,
|
|
49
|
+
target: config.target,
|
|
50
|
+
metadata: registry,
|
|
51
|
+
unrepresentable: config.unrepresentable ?? "any",
|
|
52
|
+
cycles: "ref",
|
|
53
|
+
reused: "inline",
|
|
54
|
+
/**
|
|
55
|
+
* The uri option only allows customizing the base path of the `$ref`, and it automatically appends a path to it.
|
|
56
|
+
* As a workaround, we set a placeholder that looks something like this.
|
|
57
|
+
* @see jsonSchemaReplaceRef
|
|
58
|
+
* @see https://github.com/colinhacks/zod/issues/4750
|
|
59
|
+
*/
|
|
60
|
+
uri: () => SCHEMA_URI_PLACEHOLDER,
|
|
61
|
+
override: config.override ?? ((ctx) => getOverride(ctx, io))
|
|
62
|
+
});
|
|
63
|
+
const jsonSchema = deleteInvalidProperties(result);
|
|
64
|
+
return JSON.parse(JSON.stringify(jsonSchema), (__key, value) => {
|
|
65
|
+
if (typeof value === "string" && value.startsWith(SCHEMA_URI_PLACEHOLDER)) {
|
|
66
|
+
return getReferenceUri(value.slice(SCHEMA_URI_PLACEHOLDER.length));
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const zodRegistryToJson = (registry, io, config) => {
|
|
72
|
+
const result = toJSONSchema(registry, {
|
|
73
|
+
...config,
|
|
74
|
+
io,
|
|
75
|
+
target: config.target,
|
|
76
|
+
metadata: registry,
|
|
77
|
+
unrepresentable: config.unrepresentable ?? "any",
|
|
78
|
+
cycles: "ref",
|
|
79
|
+
reused: "inline",
|
|
80
|
+
uri: (id) => getReferenceUri(id),
|
|
81
|
+
override: config.override ?? ((ctx) => getOverride(ctx, io))
|
|
82
|
+
}).schemas;
|
|
83
|
+
const jsonSchemas = {};
|
|
84
|
+
for (const id in result) {
|
|
85
|
+
jsonSchemas[id] = deleteInvalidProperties(result[id]);
|
|
86
|
+
}
|
|
87
|
+
return jsonSchemas;
|
|
88
|
+
};
|
|
89
|
+
export {
|
|
90
|
+
zodRegistryToJson,
|
|
91
|
+
zodSchemaToJson
|
|
92
|
+
};
|
|
93
|
+
//# sourceMappingURL=zod-to-json.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zod-to-json.js","sources":["../../src/zod-to-json.ts"],"sourcesContent":["import type {\n $ZodDate,\n $ZodUndefined,\n $ZodUnion,\n JSONSchema,\n RegistryToJSONSchemaParams,\n} from 'zod/v4/core'\nimport { $ZodRegistry, $ZodType, toJSONSchema } from 'zod/v4/core'\nimport type { SchemaRegistryMeta } from './registry'\nimport { getReferenceUri } from './utils'\n\nconst SCHEMA_REGISTRY_ID_PLACEHOLDER = '__SCHEMA__ID__PLACEHOLDER__'\nconst SCHEMA_URI_PLACEHOLDER = '__SCHEMA__PLACEHOLDER__'\n\nfunction isZodDate(entity: unknown): entity is $ZodDate {\n return entity instanceof $ZodType && entity._zod.def.type === 'date'\n}\n\nfunction isZodUnion(entity: unknown): entity is $ZodUnion {\n return entity instanceof $ZodType && entity._zod.def.type === 'union'\n}\n\nfunction isZodUndefined(entity: unknown): entity is $ZodUndefined {\n return entity instanceof $ZodType && entity._zod.def.type === 'undefined'\n}\n\nconst getOverride = (\n ctx: {\n zodSchema: $ZodType\n jsonSchema: JSONSchema.BaseSchema\n },\n io: 'input' | 'output',\n) => {\n if (isZodUnion(ctx.zodSchema)) {\n // Filter unrepresentable types in unions\n // TODO: Should be fixed upstream and not merged in this plugin.\n // Remove when passed: https://github.com/colinhacks/zod/pull/5013\n ctx.jsonSchema.anyOf = ctx.jsonSchema.anyOf?.filter((schema) => Object.keys(schema).length > 0)\n }\n\n if (isZodDate(ctx.zodSchema)) {\n // Allow dates to be represented as strings in output schemas\n if (io === 'output') {\n ctx.jsonSchema.type = 'string'\n ctx.jsonSchema.format = 'date-time'\n }\n }\n\n if (isZodUndefined(ctx.zodSchema)) {\n // Allow undefined to be represented as null in output schemas\n if (io === 'output') {\n ctx.jsonSchema.type = 'null'\n }\n }\n}\n\nexport type ZodToJsonConfig = {} & Omit<\n RegistryToJSONSchemaParams,\n 'io' | 'metadata' | 'cycles' | 'reused' | 'uri'\n>\n\nconst deleteInvalidProperties: (\n schema: JSONSchema.BaseSchema,\n) => Omit<JSONSchema.BaseSchema, 'id' | '$schema'> = (schema) => {\n const object = { ...schema }\n\n delete object.id\n delete object.$schema\n\n // ToDo added in newer zod\n delete object.$id\n\n return object\n}\n\nexport const zodSchemaToJson: (\n zodSchema: $ZodType,\n registry: $ZodRegistry<SchemaRegistryMeta>,\n io: 'input' | 'output',\n config: ZodToJsonConfig,\n) => ReturnType<typeof deleteInvalidProperties> = (zodSchema, registry, io, config) => {\n /**\n * Checks whether the provided schema is registered in the given registry.\n * If it is present and has an `id`, it can be referenced as component.\n *\n * @see https://github.com/turkerdev/fastify-type-provider-zod/issues/173\n */\n const schemaRegistryEntry = registry.get(zodSchema)\n if (schemaRegistryEntry?.id) {\n return { $ref: getReferenceUri(schemaRegistryEntry.id) }\n }\n\n /**\n * Unfortunately, at the time of writing, there is no way to generate a schema with `$ref`\n * using `toJSONSchema` and a zod schema.\n *\n * As a workaround, we create a zod registry containing only the specific schema we want to convert.\n *\n * @see https://github.com/colinhacks/zod/issues/4281\n */\n const tempRegistry = new $ZodRegistry<SchemaRegistryMeta>()\n tempRegistry.add(zodSchema, { id: SCHEMA_REGISTRY_ID_PLACEHOLDER })\n\n const {\n schemas: { [SCHEMA_REGISTRY_ID_PLACEHOLDER]: result },\n } = toJSONSchema(tempRegistry, {\n ...config,\n io,\n target: config.target,\n metadata: registry,\n unrepresentable: config.unrepresentable ?? 'any',\n cycles: 'ref',\n reused: 'inline',\n /**\n * The uri option only allows customizing the base path of the `$ref`, and it automatically appends a path to it.\n * As a workaround, we set a placeholder that looks something like this.\n * @see jsonSchemaReplaceRef\n * @see https://github.com/colinhacks/zod/issues/4750\n */\n uri: () => SCHEMA_URI_PLACEHOLDER,\n override: config.override ?? ((ctx) => getOverride(ctx, io)),\n })\n\n const jsonSchema = deleteInvalidProperties(result)\n\n /**\n * Replace the previous generated placeholders with the final `$ref` value\n */\n return JSON.parse(JSON.stringify(jsonSchema), (__key, value) => {\n if (typeof value === 'string' && value.startsWith(SCHEMA_URI_PLACEHOLDER)) {\n return getReferenceUri(value.slice(SCHEMA_URI_PLACEHOLDER.length))\n }\n return value\n }) as typeof result\n}\n\nexport const zodRegistryToJson: (\n registry: $ZodRegistry<SchemaRegistryMeta>,\n io: 'input' | 'output',\n config: ZodToJsonConfig,\n) => Record<string, JSONSchema.BaseSchema> = (registry, io, config) => {\n const result = toJSONSchema(registry, {\n ...config,\n io,\n target: config.target,\n metadata: registry,\n unrepresentable: config.unrepresentable ?? 'any',\n cycles: 'ref',\n reused: 'inline',\n uri: (id) => getReferenceUri(id),\n override: config.override ?? ((ctx) => getOverride(ctx, io)),\n }).schemas\n\n const jsonSchemas: Record<string, JSONSchema.BaseSchema> = {}\n for (const id in result) {\n jsonSchemas[id] = deleteInvalidProperties(result[id])\n }\n\n return jsonSchemas\n}\n"],"names":[],"mappings":";;AAWA,MAAM,iCAAiC;AACvC,MAAM,yBAAyB;AAE/B,SAAS,UAAU,QAAqC;AACtD,SAAO,kBAAkB,YAAY,OAAO,KAAK,IAAI,SAAS;AAChE;AAEA,SAAS,WAAW,QAAsC;AACxD,SAAO,kBAAkB,YAAY,OAAO,KAAK,IAAI,SAAS;AAChE;AAEA,SAAS,eAAe,QAA0C;AAChE,SAAO,kBAAkB,YAAY,OAAO,KAAK,IAAI,SAAS;AAChE;AAEA,MAAM,cAAc,CAClB,KAIA,OACG;AACH,MAAI,WAAW,IAAI,SAAS,GAAG;AAI7B,QAAI,WAAW,QAAQ,IAAI,WAAW,OAAO,OAAO,CAAC,WAAW,OAAO,KAAK,MAAM,EAAE,SAAS,CAAC;AAAA,EAChG;AAEA,MAAI,UAAU,IAAI,SAAS,GAAG;AAE5B,QAAI,OAAO,UAAU;AACnB,UAAI,WAAW,OAAO;AACtB,UAAI,WAAW,SAAS;AAAA,IAC1B;AAAA,EACF;AAEA,MAAI,eAAe,IAAI,SAAS,GAAG;AAEjC,QAAI,OAAO,UAAU;AACnB,UAAI,WAAW,OAAO;AAAA,IACxB;AAAA,EACF;AACF;AAOA,MAAM,0BAE+C,CAAC,WAAW;AAC/D,QAAM,SAAS,EAAE,GAAG,OAAA;AAEpB,SAAO,OAAO;AACd,SAAO,OAAO;AAGd,SAAO,OAAO;AAEd,SAAO;AACT;AAEO,MAAM,kBAKqC,CAAC,WAAW,UAAU,IAAI,WAAW;AAOrF,QAAM,sBAAsB,SAAS,IAAI,SAAS;AAClD,MAAI,qBAAqB,IAAI;AAC3B,WAAO,EAAE,MAAM,gBAAgB,oBAAoB,EAAE,EAAA;AAAA,EACvD;AAUA,QAAM,eAAe,IAAI,aAAA;AACzB,eAAa,IAAI,WAAW,EAAE,IAAI,gCAAgC;AAElE,QAAM;AAAA,IACJ,SAAS,EAAE,CAAC,8BAA8B,GAAG,OAAA;AAAA,EAAO,IAClD,aAAa,cAAc;AAAA,IAC7B,GAAG;AAAA,IACH;AAAA,IACA,QAAQ,OAAO;AAAA,IACf,UAAU;AAAA,IACV,iBAAiB,OAAO,mBAAmB;AAAA,IAC3C,QAAQ;AAAA,IACR,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOR,KAAK,MAAM;AAAA,IACX,UAAU,OAAO,aAAa,CAAC,QAAQ,YAAY,KAAK,EAAE;AAAA,EAAA,CAC3D;AAED,QAAM,aAAa,wBAAwB,MAAM;AAKjD,SAAO,KAAK,MAAM,KAAK,UAAU,UAAU,GAAG,CAAC,OAAO,UAAU;AAC9D,QAAI,OAAO,UAAU,YAAY,MAAM,WAAW,sBAAsB,GAAG;AACzE,aAAO,gBAAgB,MAAM,MAAM,uBAAuB,MAAM,CAAC;AAAA,IACnE;AACA,WAAO;AAAA,EACT,CAAC;AACH;AAEO,MAAM,oBAIgC,CAAC,UAAU,IAAI,WAAW;AACrE,QAAM,SAAS,aAAa,UAAU;AAAA,IACpC,GAAG;AAAA,IACH;AAAA,IACA,QAAQ,OAAO;AAAA,IACf,UAAU;AAAA,IACV,iBAAiB,OAAO,mBAAmB;AAAA,IAC3C,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,KAAK,CAAC,OAAO,gBAAgB,EAAE;AAAA,IAC/B,UAAU,OAAO,aAAa,CAAC,QAAQ,YAAY,KAAK,EAAE;AAAA,EAAA,CAC3D,EAAE;AAEH,QAAM,cAAqD,CAAA;AAC3D,aAAW,MAAM,QAAQ;AACvB,gBAAY,EAAE,IAAI,wBAAwB,OAAO,EAAE,CAAC;AAAA,EACtD;AAEA,SAAO;AACT;"}
|
package/package.json
CHANGED
|
@@ -1,61 +1,78 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"build": "tsc",
|
|
14
|
-
"test": "npm run build && npm run typescript && vitest",
|
|
15
|
-
"test:coverage": "vitest --coverage",
|
|
16
|
-
"lint": "biome check . && tsc --project tsconfig.lint.json --noEmit",
|
|
17
|
-
"lint:fix": "biome check --write .",
|
|
18
|
-
"typescript": "tsd",
|
|
19
|
-
"prepublishOnly": "npm run build"
|
|
2
|
+
"name": "@bram-dc/fastify-type-provider-zod",
|
|
3
|
+
"version": "7.0.0",
|
|
4
|
+
"description": "Zod Type Provider for Fastify@5",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/cjs/index.cjs",
|
|
7
|
+
"module": "./dist/esm/index.js",
|
|
8
|
+
"types": "./dist/cjs/index.d.cts",
|
|
9
|
+
"exports": {
|
|
10
|
+
"require": {
|
|
11
|
+
"types": "./dist/cjs/index.d.cts",
|
|
12
|
+
"default": "./dist/cjs/index.cjs"
|
|
20
13
|
},
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
},
|
|
25
|
-
"repository": {
|
|
26
|
-
"url": "https://github.com/turkerdev/fastify-type-provider-zod"
|
|
27
|
-
},
|
|
28
|
-
"keywords": [
|
|
29
|
-
"fastify",
|
|
30
|
-
"zod",
|
|
31
|
-
"type",
|
|
32
|
-
"provider"
|
|
33
|
-
],
|
|
34
|
-
"author": "turkerd",
|
|
35
|
-
"license": "MIT",
|
|
36
|
-
"bugs": {
|
|
37
|
-
"url": "https://github.com/turkerdev/fastify-type-provider-zod/issues"
|
|
38
|
-
},
|
|
39
|
-
"homepage": "https://github.com/turkerdev/fastify-type-provider-zod",
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"@fastify/error": "^4.0.0"
|
|
42
|
-
},
|
|
43
|
-
"devDependencies": {
|
|
44
|
-
"@biomejs/biome": "^1.9.3",
|
|
45
|
-
"@fastify/swagger": "^9.1.0",
|
|
46
|
-
"@fastify/swagger-ui": "^5.0.1",
|
|
47
|
-
"@kibertoad/biome-config": "^1.2.1",
|
|
48
|
-
"@types/node": "^20.16.10",
|
|
49
|
-
"@vitest/coverage-v8": "^2.1.2",
|
|
50
|
-
"fastify": "^5.0.0",
|
|
51
|
-
"fastify-plugin": "^5.0.1",
|
|
52
|
-
"oas-validator": "^5.0.8",
|
|
53
|
-
"tsd": "^0.31.2",
|
|
54
|
-
"typescript": "^5.6.2",
|
|
55
|
-
"vitest": "^2.1.2",
|
|
56
|
-
"zod": "^4.0.0-beta.20250412T085909"
|
|
57
|
-
},
|
|
58
|
-
"tsd": {
|
|
59
|
-
"directory": "types"
|
|
14
|
+
"default": {
|
|
15
|
+
"types": "./dist/esm/index.d.ts",
|
|
16
|
+
"default": "./dist/esm/index.js"
|
|
60
17
|
}
|
|
61
|
-
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "vite build",
|
|
25
|
+
"prepare": "npm run build",
|
|
26
|
+
"test": "vitest",
|
|
27
|
+
"test:coverage": "vitest --coverage",
|
|
28
|
+
"test:ci": "npm run build && npm run typescript && npm run test:coverage",
|
|
29
|
+
"lint": "biome check . && tsc --noEmit",
|
|
30
|
+
"lint:fix": "biome check --write .",
|
|
31
|
+
"typescript": "tsd",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"url": "https://github.com/turkerdev/fastify-type-provider-zod"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"fastify",
|
|
39
|
+
"zod",
|
|
40
|
+
"type",
|
|
41
|
+
"provider"
|
|
42
|
+
],
|
|
43
|
+
"author": "turkerd",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/turkerdev/fastify-type-provider-zod/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/turkerdev/fastify-type-provider-zod",
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@fastify/error": "^4.2.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@fastify/swagger": ">=9.5.1",
|
|
54
|
+
"fastify": "^5.5.0",
|
|
55
|
+
"openapi-types": "^12.1.3",
|
|
56
|
+
"zod": ">=4.1.5"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@biomejs/biome": "^2.3.0",
|
|
60
|
+
"@fastify/swagger": "^9.5.2",
|
|
61
|
+
"@fastify/swagger-ui": "^5.2.3",
|
|
62
|
+
"@kibertoad/biome-config": "^2.0.0",
|
|
63
|
+
"@readme/openapi-parser": "^5.0.2",
|
|
64
|
+
"@types/node": "^24.9.1",
|
|
65
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
66
|
+
"fastify": "^5.6.1",
|
|
67
|
+
"fastify-plugin": "^5.0.1",
|
|
68
|
+
"oas-validator": "^5.0.8",
|
|
69
|
+
"tsd": "^0.33.0",
|
|
70
|
+
"typescript": "^5.9.3",
|
|
71
|
+
"unplugin-isolated-decl": "^0.15.2",
|
|
72
|
+
"vitest": "^3.2.4",
|
|
73
|
+
"zod": "^4.2.0"
|
|
74
|
+
},
|
|
75
|
+
"tsd": {
|
|
76
|
+
"directory": "types"
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { SwaggerTransform, SwaggerTransformObject } from '@fastify/swagger'
|
|
2
|
+
import type {
|
|
3
|
+
FastifyPluginAsync,
|
|
4
|
+
FastifyPluginCallback,
|
|
5
|
+
FastifyPluginOptions,
|
|
6
|
+
FastifySchema,
|
|
7
|
+
FastifySchemaCompiler,
|
|
8
|
+
FastifyTypeProvider,
|
|
9
|
+
RawServerBase,
|
|
10
|
+
RawServerDefault,
|
|
11
|
+
} from 'fastify'
|
|
12
|
+
import type { FastifySerializerCompiler } from 'fastify/types/schema'
|
|
13
|
+
import type { $ZodRegistry, output } from 'zod/v4/core'
|
|
14
|
+
import { $ZodType, globalRegistry, safeDecode, safeEncode } from 'zod/v4/core'
|
|
15
|
+
import { createValidationError, InvalidSchemaError, ResponseSerializationError } from './errors'
|
|
16
|
+
import { generateIORegistries, type SchemaRegistryMeta } from './registry'
|
|
17
|
+
import { assertIsOpenAPIObject, getJSONSchemaTarget } from './utils'
|
|
18
|
+
import { type ZodToJsonConfig, zodRegistryToJson, zodSchemaToJson } from './zod-to-json'
|
|
19
|
+
|
|
20
|
+
type FreeformRecord = Record<string, any>
|
|
21
|
+
|
|
22
|
+
const defaultSkipList = [
|
|
23
|
+
'/documentation/',
|
|
24
|
+
'/documentation/initOAuth',
|
|
25
|
+
'/documentation/json',
|
|
26
|
+
'/documentation/uiConfig',
|
|
27
|
+
'/documentation/yaml',
|
|
28
|
+
'/documentation/*',
|
|
29
|
+
'/documentation/static/*',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export interface ZodTypeProvider extends FastifyTypeProvider {
|
|
33
|
+
validator: this['schema'] extends $ZodType ? output<this['schema']> : unknown
|
|
34
|
+
serializer: this['schema'] extends $ZodType ? output<this['schema']> : unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Schema extends FastifySchema {
|
|
38
|
+
hide?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type CreateJsonSchemaTransformOptions = {
|
|
42
|
+
skipList?: readonly string[]
|
|
43
|
+
schemaRegistry?: $ZodRegistry<SchemaRegistryMeta>
|
|
44
|
+
zodToJsonConfig?: ZodToJsonConfig
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const createJsonSchemaTransform = ({
|
|
48
|
+
skipList = defaultSkipList,
|
|
49
|
+
schemaRegistry = globalRegistry,
|
|
50
|
+
zodToJsonConfig = {},
|
|
51
|
+
}: CreateJsonSchemaTransformOptions): SwaggerTransform<Schema> => {
|
|
52
|
+
return (document) => {
|
|
53
|
+
assertIsOpenAPIObject(document)
|
|
54
|
+
|
|
55
|
+
const { schema, url } = document
|
|
56
|
+
|
|
57
|
+
if (!schema) {
|
|
58
|
+
return {
|
|
59
|
+
schema,
|
|
60
|
+
url,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const target = getJSONSchemaTarget(document.openapiObject.openapi)
|
|
65
|
+
const config = {
|
|
66
|
+
target,
|
|
67
|
+
...zodToJsonConfig,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { inputRegistry, outputRegistry } = generateIORegistries(schemaRegistry)
|
|
71
|
+
|
|
72
|
+
const { response, headers, querystring, body, params, hide, ...rest } = schema
|
|
73
|
+
|
|
74
|
+
const transformed: FreeformRecord = {}
|
|
75
|
+
|
|
76
|
+
if (skipList.includes(url) || hide) {
|
|
77
|
+
transformed.hide = true
|
|
78
|
+
return { schema: transformed, url }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const zodSchemas: FreeformRecord = { headers, querystring, body, params }
|
|
82
|
+
|
|
83
|
+
for (const prop in zodSchemas) {
|
|
84
|
+
const zodSchema = zodSchemas[prop]
|
|
85
|
+
if (zodSchema) {
|
|
86
|
+
transformed[prop] = zodSchemaToJson(zodSchema, inputRegistry, 'input', config)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (response) {
|
|
91
|
+
transformed.response = {}
|
|
92
|
+
|
|
93
|
+
for (const prop in response as any) {
|
|
94
|
+
const zodSchema = resolveSchema((response as any)[prop])
|
|
95
|
+
|
|
96
|
+
transformed.response[prop] = zodSchemaToJson(zodSchema, outputRegistry, 'output', config)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const prop in rest) {
|
|
101
|
+
const meta = rest[prop as keyof typeof rest]
|
|
102
|
+
if (meta) {
|
|
103
|
+
transformed[prop] = meta
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { schema: transformed, url }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const jsonSchemaTransform: SwaggerTransform<Schema> = createJsonSchemaTransform({})
|
|
112
|
+
|
|
113
|
+
type CreateJsonSchemaTransformObjectOptions = {
|
|
114
|
+
schemaRegistry?: $ZodRegistry<SchemaRegistryMeta>
|
|
115
|
+
zodToJsonConfig?: ZodToJsonConfig
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const createJsonSchemaTransformObject =
|
|
119
|
+
({
|
|
120
|
+
schemaRegistry = globalRegistry,
|
|
121
|
+
zodToJsonConfig = {},
|
|
122
|
+
}: CreateJsonSchemaTransformObjectOptions): SwaggerTransformObject =>
|
|
123
|
+
(document) => {
|
|
124
|
+
assertIsOpenAPIObject(document)
|
|
125
|
+
|
|
126
|
+
const target = getJSONSchemaTarget(document.openapiObject.openapi)
|
|
127
|
+
const config = {
|
|
128
|
+
target,
|
|
129
|
+
...zodToJsonConfig,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { inputRegistry, outputRegistry } = generateIORegistries(schemaRegistry)
|
|
133
|
+
const inputSchemas = zodRegistryToJson(inputRegistry, 'input', config)
|
|
134
|
+
const outputSchemas = zodRegistryToJson(outputRegistry, 'output', config)
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
...document.openapiObject,
|
|
138
|
+
components: {
|
|
139
|
+
...document.openapiObject.components,
|
|
140
|
+
schemas: {
|
|
141
|
+
...document.openapiObject.components?.schemas,
|
|
142
|
+
...inputSchemas,
|
|
143
|
+
...outputSchemas,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
} as ReturnType<SwaggerTransformObject>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const jsonSchemaTransformObject: SwaggerTransformObject = createJsonSchemaTransformObject({})
|
|
150
|
+
|
|
151
|
+
export const validatorCompiler: FastifySchemaCompiler<$ZodType> =
|
|
152
|
+
({ schema }) =>
|
|
153
|
+
(data) => {
|
|
154
|
+
const result = safeDecode(schema, data)
|
|
155
|
+
if (result.error) {
|
|
156
|
+
return { error: createValidationError(result.error) as unknown as Error }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { value: result.data }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveSchema(maybeSchema: $ZodType | { properties: $ZodType }): $ZodType {
|
|
163
|
+
if (maybeSchema instanceof $ZodType) {
|
|
164
|
+
return maybeSchema as $ZodType
|
|
165
|
+
}
|
|
166
|
+
if ('properties' in maybeSchema && maybeSchema.properties instanceof $ZodType) {
|
|
167
|
+
return maybeSchema.properties as $ZodType
|
|
168
|
+
}
|
|
169
|
+
throw new InvalidSchemaError(JSON.stringify(maybeSchema))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type ReplacerFunction = (this: any, key: string, value: any) => any
|
|
173
|
+
|
|
174
|
+
export type ZodSerializerCompilerOptions = {
|
|
175
|
+
replacer?: ReplacerFunction
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const createSerializerCompiler =
|
|
179
|
+
(
|
|
180
|
+
options?: ZodSerializerCompilerOptions,
|
|
181
|
+
): FastifySerializerCompiler<$ZodType | { properties: $ZodType }> =>
|
|
182
|
+
({ schema: maybeSchema, method, url }) =>
|
|
183
|
+
(data) => {
|
|
184
|
+
const schema = resolveSchema(maybeSchema)
|
|
185
|
+
|
|
186
|
+
const result = safeEncode(schema, data)
|
|
187
|
+
if (result.error) {
|
|
188
|
+
throw new ResponseSerializationError(method, url, { cause: result.error })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return JSON.stringify(result.data, options?.replacer)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const serializerCompiler: ReturnType<typeof createSerializerCompiler> =
|
|
195
|
+
createSerializerCompiler({})
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* FastifyPluginCallbackZod with Zod automatic type inference
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* ```typescript
|
|
202
|
+
* import { FastifyPluginCallbackZod } from "fastify-type-provider-zod"
|
|
203
|
+
*
|
|
204
|
+
* const plugin: FastifyPluginCallbackZod = (fastify, options, done) => {
|
|
205
|
+
* done()
|
|
206
|
+
* }
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
export type FastifyPluginCallbackZod<
|
|
210
|
+
Options extends FastifyPluginOptions = Record<never, never>,
|
|
211
|
+
Server extends RawServerBase = RawServerDefault,
|
|
212
|
+
> = FastifyPluginCallback<Options, Server, ZodTypeProvider>
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* FastifyPluginAsyncZod with Zod automatic type inference
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```typescript
|
|
219
|
+
* import { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
|
220
|
+
*
|
|
221
|
+
* const plugin: FastifyPluginAsyncZod = async (fastify, options) => {
|
|
222
|
+
* }
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
export type FastifyPluginAsyncZod<
|
|
226
|
+
Options extends FastifyPluginOptions = Record<never, never>,
|
|
227
|
+
Server extends RawServerBase = RawServerDefault,
|
|
228
|
+
> = FastifyPluginAsync<Options, Server, ZodTypeProvider>
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import createError, { type FastifyErrorConstructor } from '@fastify/error'
|
|
2
|
+
import type { FastifyError } from 'fastify'
|
|
3
|
+
import type { FastifySchemaValidationError } from 'fastify/types/schema'
|
|
4
|
+
import type { $ZodError } from 'zod/v4/core'
|
|
5
|
+
|
|
6
|
+
export const InvalidSchemaError: FastifyErrorConstructor<
|
|
7
|
+
{
|
|
8
|
+
code: string
|
|
9
|
+
},
|
|
10
|
+
[string]
|
|
11
|
+
> = createError<[string]>('FST_ERR_INVALID_SCHEMA', 'Invalid schema passed: %s', 500)
|
|
12
|
+
|
|
13
|
+
const ZodFastifySchemaValidationErrorSymbol: symbol = Symbol.for('ZodFastifySchemaValidationError')
|
|
14
|
+
|
|
15
|
+
export type ZodFastifySchemaValidationError = FastifySchemaValidationError & {
|
|
16
|
+
[ZodFastifySchemaValidationErrorSymbol]: true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ResponseSerializationBase: FastifyErrorConstructor<
|
|
20
|
+
{
|
|
21
|
+
code: string
|
|
22
|
+
},
|
|
23
|
+
[
|
|
24
|
+
{
|
|
25
|
+
cause: $ZodError
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
> = createError<[{ cause: $ZodError }]>(
|
|
29
|
+
'FST_ERR_RESPONSE_SERIALIZATION',
|
|
30
|
+
"Response doesn't match the schema",
|
|
31
|
+
500,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
export class ResponseSerializationError extends ResponseSerializationBase {
|
|
35
|
+
cause!: $ZodError
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
public method: string,
|
|
39
|
+
public url: string,
|
|
40
|
+
options: { cause: $ZodError },
|
|
41
|
+
) {
|
|
42
|
+
super({ cause: options.cause })
|
|
43
|
+
|
|
44
|
+
this.cause = options.cause
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isResponseSerializationError(value: unknown): value is ResponseSerializationError {
|
|
49
|
+
return 'method' in (value as ResponseSerializationError)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isZodFastifySchemaValidationError(
|
|
53
|
+
error: unknown,
|
|
54
|
+
): error is ZodFastifySchemaValidationError {
|
|
55
|
+
return (
|
|
56
|
+
typeof error === 'object' &&
|
|
57
|
+
error !== null &&
|
|
58
|
+
(error as ZodFastifySchemaValidationError)[ZodFastifySchemaValidationErrorSymbol] === true
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hasZodFastifySchemaValidationErrors(
|
|
63
|
+
error: unknown,
|
|
64
|
+
): error is Omit<FastifyError, 'validation'> & { validation: ZodFastifySchemaValidationError[] } {
|
|
65
|
+
return (
|
|
66
|
+
typeof error === 'object' &&
|
|
67
|
+
error !== null &&
|
|
68
|
+
'validation' in error &&
|
|
69
|
+
Array.isArray(error.validation) &&
|
|
70
|
+
error.validation.length > 0 &&
|
|
71
|
+
isZodFastifySchemaValidationError(error.validation[0])
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function omit<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Omit<T, K> {
|
|
76
|
+
const result = {} as Omit<T, K>
|
|
77
|
+
for (const key of Object.keys(obj) as Array<keyof T>) {
|
|
78
|
+
if (!keys.includes(key as K)) {
|
|
79
|
+
// @ts-expect-error
|
|
80
|
+
result[key] = obj[key]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createValidationError(error: $ZodError): ZodFastifySchemaValidationError[] {
|
|
87
|
+
return error.issues.map((issue) => {
|
|
88
|
+
return {
|
|
89
|
+
[ZodFastifySchemaValidationErrorSymbol]: true,
|
|
90
|
+
keyword: issue.code,
|
|
91
|
+
instancePath: `/${issue.path.join('/')}`,
|
|
92
|
+
schemaPath: `#/${issue.path.join('/')}/${issue.code}`,
|
|
93
|
+
message: issue.message,
|
|
94
|
+
params: {
|
|
95
|
+
...omit(issue, ['path', 'code', 'message']),
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createJsonSchemaTransform,
|
|
3
|
+
createJsonSchemaTransformObject,
|
|
4
|
+
createSerializerCompiler,
|
|
5
|
+
type FastifyPluginAsyncZod,
|
|
6
|
+
type FastifyPluginCallbackZod,
|
|
7
|
+
jsonSchemaTransform,
|
|
8
|
+
jsonSchemaTransformObject,
|
|
9
|
+
serializerCompiler,
|
|
10
|
+
validatorCompiler,
|
|
11
|
+
type ZodSerializerCompilerOptions,
|
|
12
|
+
type ZodTypeProvider,
|
|
13
|
+
} from './core'
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
hasZodFastifySchemaValidationErrors,
|
|
17
|
+
InvalidSchemaError,
|
|
18
|
+
isResponseSerializationError,
|
|
19
|
+
ResponseSerializationError,
|
|
20
|
+
type ZodFastifySchemaValidationError,
|
|
21
|
+
} from './errors'
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { $ZodRegistry, type $ZodType } from 'zod/v4/core'
|
|
2
|
+
|
|
3
|
+
export type SchemaRegistryMeta = {
|
|
4
|
+
id?: string | undefined
|
|
5
|
+
[key: string]: unknown
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const getSchemaId = (id: string, io: 'input' | 'output'): string => {
|
|
9
|
+
return io === 'input' ? `${id}Input` : id
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A WeakMap that falls back to another WeakMap when a key is not found, this is to ensure nested metadata is properly resolved
|
|
13
|
+
class WeakMapWithFallback extends WeakMap<$ZodType, SchemaRegistryMeta> {
|
|
14
|
+
constructor(private fallback: WeakMap<$ZodType, SchemaRegistryMeta>) {
|
|
15
|
+
super()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(key: $ZodType): SchemaRegistryMeta | undefined {
|
|
19
|
+
return super.get(key) ?? this.fallback.get(key)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
has(key: $ZodType): boolean {
|
|
23
|
+
return super.has(key) || this.fallback.has(key)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const copyRegistry = (
|
|
28
|
+
inputRegistry: $ZodRegistry<SchemaRegistryMeta>,
|
|
29
|
+
idReplaceFn: (id: string) => string,
|
|
30
|
+
): $ZodRegistry<SchemaRegistryMeta> => {
|
|
31
|
+
const outputRegistry = new $ZodRegistry<SchemaRegistryMeta>()
|
|
32
|
+
|
|
33
|
+
outputRegistry._map = new WeakMapWithFallback(inputRegistry._map)
|
|
34
|
+
|
|
35
|
+
inputRegistry._idmap.forEach((schema, id) => {
|
|
36
|
+
outputRegistry.add(schema, {
|
|
37
|
+
...inputRegistry._map.get(schema),
|
|
38
|
+
id: idReplaceFn(id),
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return outputRegistry
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const generateIORegistries = (
|
|
46
|
+
baseRegistry: $ZodRegistry<SchemaRegistryMeta>,
|
|
47
|
+
): {
|
|
48
|
+
inputRegistry: $ZodRegistry<SchemaRegistryMeta>
|
|
49
|
+
outputRegistry: $ZodRegistry<SchemaRegistryMeta>
|
|
50
|
+
} => {
|
|
51
|
+
const inputRegistry = copyRegistry(baseRegistry, (id) => getSchemaId(id, 'input'))
|
|
52
|
+
const outputRegistry = copyRegistry(baseRegistry, (id) => getSchemaId(id, 'output'))
|
|
53
|
+
|
|
54
|
+
// Detect colliding schemas
|
|
55
|
+
inputRegistry._idmap.forEach((_, id) => {
|
|
56
|
+
if (outputRegistry._idmap.has(id)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Collision detected for schema "${id}". There is already an input schema with the same name.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return { inputRegistry, outputRegistry }
|
|
64
|
+
}
|