diff --git a/packages/to-valibot/LICENSE.md b/packages/to-valibot/LICENSE.md new file mode 100644 index 000000000..e086b10f1 --- /dev/null +++ b/packages/to-valibot/LICENSE.md @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Rokas Muningis + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/to-valibot/README.md b/packages/to-valibot/README.md new file mode 100644 index 000000000..80aee001c --- /dev/null +++ b/packages/to-valibot/README.md @@ -0,0 +1,152 @@ +# (OpenAPI Declaration, JSON Schema) to Valibot + +Utility to convert JSON Schemas and OpenAPI Declarations to [Valibot](https://valibot.dev) schemas. + +## Example usage + +### 1. Converting list of OpenAPI Declarations in .json format to Valibot +```ts +import { valibotGenerator } from "@valibot/to-valibot"; + +const generate = valibotGenerator({ + outDir: "./src/types", +}); + +const schemas = await Promise.all([ + fetch("http://api.example.org/v2/api-docs?group=my-awesome-api").then(r => r.json()), + fetch("http://api.example.org/v2/api-docs?group=other-api").then(r => r.json()), + fetch("http://api.example.org/v2/api-docs?group=legacy-api").then(r => r.json()), +]); + +await generate({ + format: 'openapi-json', + schemas, +}); +``` + +### 2. Converting OpenAPI Declarations in .yaml format to Valibot +```ts +import { readFile } from "node:fs/promises";; +import { valibotGenerator } from "@valibot/to-valibot"; + +const generate = valibotGenerator({ + outDir: "./src/types", +}); + +const schema = readFile("./declarations/my-awesome-api.yaml"); + +await generate({ + format: 'openapi-yaml', + schema, +}); +``` + +### 3. Converting JSON Schema to Valibot +```ts +import { readFile } from "node:fs/promises";; +import { valibotGenerator } from "@valibot/to-valibot"; +import schema from "~/schemas/my-api.json"; + +const generate = valibotGenerator({ + outDir: "./src/types", +}); + +await generate({ + format: 'json', + schema, +}); +``` + +## API + +### valibotGenerator + +`valibotGenerator` accepts options object with these parameters + +| Name | Type | Required | Description | +| ------- | ------- | --------- | ----------------------------------------------------------------- | +| outDir | string | yes | Declare in which directory generated schema(s) should be written | + +### generate + +`generate` function returned by `valibotGenerator` accepts different set of options, depending on format. + +| Name | Type | Required | Description | +| -------- | -------------- | --------- | --------------------------------------------- | +| format | 'openapi-yaml' | yes | Format specification for the generated output | +| schema | string | no* | Single schema to be processed | +| schemas | string[] | no* | Multiple schemas to be processed | +\* Either `schema` OR `schemas` must be provided, but not both. + +| Name | Type | Required | Description | +| -------- | ------------------------ | --------- | --------------------------------------------- | +| format | 'openapi-json' \| 'json' | yes | Format specification for the generated output | +| schema | string \| object | no* | Single schema to be processed | +| schemas | (string \| object)[] | no* | Multiple schemas to be processed | +\* Either `schema` OR `schemas` must be provided, but not both. + + +## Supported features + +Same set of features are supported both in OpenAPI Declarations and JSON Schemas + +| Feature | Status | Note | +| ------------------------------- | ------ | ------------------------------------------------------------------- | +| required | ✅ | | +| description | ✅ | | +| const | ⚠️ | Only works with primitive values | +|---------------------------------|--------|---------------------------------------------------------------------| +| string | ✅ | | +| enum | ✅ | | +| minLength | ✅ | | +| maxLength | ✅ | | +| pattern | ✅ | | +| format="email" | ✅ | | +| format="uuid" | ✅ | | +| format="date-time" | ✅ | | +| format="date" | ✅ | | +| format="time" | ✅ | | +| format="duration" | ⚠️ | https://github.com/fabian-hiller/valibot/pull/1102 | +| format="idn-email" | ❌ | | +| format="hostname" | ❌ | | +| format="idn-hostname" | ❌ | | +| format="ipv4" | ✅ | | +| format="ipv6" | ✅ | | +| format="json-pointer" | ❌ | | +| format="relative-json-pointer" | ❌ | | +| format="uri" | ❌ | | +| format="uri-reference" | ❌ | | +| format="uri-template" | ❌ | | +| format="iri" | ❌ | | +| format="iri-reference" | ❌ | | +|---------------------------------|--------|---------------------------------------------------------------------| +| number | ✅ | | +| integer | ✅ | | +| exclusiveMaximum | ✅ | | +| exclusiveMinium | ✅ | | +| maximum | ✅ | | +| minium | ✅ | | +| multipleOf | ✅ | | +|---------------------------------|--------|---------------------------------------------------------------------| +| array | ⚠️ | Only single array item kind is supported for now | +| minItems | ✅ | | +| maxItems | ✅ | | +| uniqueItems | ✅ | | +| prefixItems | ❌ | | +| contains | ❌ | | +| minContains | ❌ | | +| maxContains | ❌ | | +|---------------------------------|--------|---------------------------------------------------------------------| +| object | ✅ | | +| patternProperties | ❌ | | +| additionalProperties | ✅ | | +| minProperties | ⚠️ | https://github.com/fabian-hiller/valibot/pull/1100 | +| maxProperties | ⚠️ | https://github.com/fabian-hiller/valibot/pull/1100 | +|---------------------------------|--------|---------------------------------------------------------------------| +| boolean | ✅ | | +| null | ✅ | | +|---------------------------------|--------|---------------------------------------------------------------------| +| anyOf | ❌ | | +| allOf | ❌ | | +| oneOf | ❌ | | +| not | ❌ | | \ No newline at end of file diff --git a/packages/to-valibot/eslint.config.js b/packages/to-valibot/eslint.config.js new file mode 100644 index 000000000..01cf2d67e --- /dev/null +++ b/packages/to-valibot/eslint.config.js @@ -0,0 +1,72 @@ +import eslint from '@eslint/js'; +import importPlugin from 'eslint-plugin-import'; +import jsdoc from 'eslint-plugin-jsdoc'; +import pluginSecurity from 'eslint-plugin-security'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + tseslint.configs.strict, + tseslint.configs.stylistic, + jsdoc.configs['flat/recommended'], + pluginSecurity.configs.recommended, + { + files: ['lib/**/*.ts'], + extends: [importPlugin.flatConfigs.recommended], + plugins: { jsdoc }, + rules: { + // Enable rules ----------------------------------------------------------- + + // TypeScript + '@typescript-eslint/consistent-type-definitions': 'off', // Enforce declaring types using `interface` keyword for better TS performance. + '@typescript-eslint/consistent-type-imports': 'warn', + + // Import + 'import/extensions': ['error', 'always'], // Require file extensions + + // JSDoc + 'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }], + 'jsdoc/sort-tags': [ + 'error', + { + linesBetween: 1, + tagSequence: [ + { tags: ['deprecated'] }, + { tags: ['param'] }, + { tags: ['returns'] }, + ], + }, + ], + // NOTE: For overloads functions, we only require a JSDoc at the top + // SEE: https://github.com/gajus/eslint-plugin-jsdoc/issues/666 + 'jsdoc/require-jsdoc': [ + 'error', + { + contexts: [ + 'ExportNamedDeclaration[declaration.type="TSDeclareFunction"]:not(ExportNamedDeclaration[declaration.type="TSDeclareFunction"] + ExportNamedDeclaration[declaration.type="TSDeclareFunction"])', + 'ExportNamedDeclaration[declaration.type="FunctionDeclaration"]:not(ExportNamedDeclaration[declaration.type="TSDeclareFunction"] + ExportNamedDeclaration[declaration.type="FunctionDeclaration"])', + ], + require: { + FunctionDeclaration: false, + }, + }, + ], + + // Disable rules ---------------------------------------------------------- + + // TypeScript + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + + // Imports + 'no-duplicate-imports': 'off', + + // JSDoc + 'jsdoc/require-param-type': 'off', + 'jsdoc/require-returns-type': 'off', + + // Security + 'security/detect-object-injection': 'off', // Too many false positives + }, + } +); diff --git a/packages/to-valibot/jsr.json b/packages/to-valibot/jsr.json new file mode 100644 index 000000000..2f1ef24a2 --- /dev/null +++ b/packages/to-valibot/jsr.json @@ -0,0 +1,9 @@ +{ + "name": "@valibot/to-valibot", + "version": "0.0.0", + "exports": "./src/index.ts", + "publish": { + "include": ["lib/**/*.ts", "README.md"], + "exclude": ["lib/**/*.spec.ts", "spec/**/*"] + } +} diff --git a/packages/to-valibot/lib/index.ts b/packages/to-valibot/lib/index.ts new file mode 100644 index 000000000..185a91753 --- /dev/null +++ b/packages/to-valibot/lib/index.ts @@ -0,0 +1,55 @@ +import { writeFile } from 'node:fs/promises'; +import { ValibotGenerator } from './parser-and-generator.ts'; +import { slugify } from './utils/basic.ts'; + +interface GeneratorOptions { + outDir: string; +} + +type GenerateOptions = + | { format: 'openapi-json' | 'json'; schema: object } + | { format: 'openapi-json' | 'json'; schemas: object[] } + | { format: 'openapi-json' | 'json' | 'openapi-yaml'; schema: string } + | { format: 'openapi-json' | 'json' | 'openapi-yaml'; schemas: string[] }; + +interface ValibotGeneratorReturn { + generate: (opt: GenerateOptions) => Promise; +} +const valibotGenerator = ( + options: GeneratorOptions +): ValibotGeneratorReturn => { + const generate = async (opt: GenerateOptions): Promise => { + if ('schemas' in opt) { + for (const schema of opt.schemas) { + const schemaCode = + typeof schema === 'string' + ? new ValibotGenerator(schema, opt.format) + : new ValibotGenerator( + schema, + opt.format as 'openapi-json' | 'json' + ); + + const code = schemaCode.generate(); + const name = slugify(schemaCode.title); + console.log(code, name, `${options.outDir}/${name}.ts`); + await writeFile(`${options.outDir}/${name}.ts`, code); + } + } else { + const schemaCode = + typeof opt.schema === 'string' + ? new ValibotGenerator(opt.schema, opt.format) + : new ValibotGenerator( + opt.schema, + opt.format as 'openapi-json' | 'json' + ); + + const code = schemaCode.generate(); + const name = slugify(schemaCode.title); + await writeFile(`${options.outDir}/${name}.ts`, code); + } + }; + + return { generate }; +}; + +export { valibotGenerator }; diff --git a/packages/to-valibot/lib/parser-and-generator.ts b/packages/to-valibot/lib/parser-and-generator.ts new file mode 100644 index 000000000..597897cab --- /dev/null +++ b/packages/to-valibot/lib/parser-and-generator.ts @@ -0,0 +1,819 @@ +import { + any, + array, + check, + type InferOutput, + object, + optional, + parse, + pipe, + record, + string, +} from 'valibot'; +import { parse as parseYaml } from 'yaml'; +import { + actionDescription, + actionEmail, + actionInteger, + actionIPv4, + actionIPv6, + actionIsoDate, + actionIsoDateTime, + actionIsoTime, + actionMaxLength, + actionMaxValue, + actionMinLength, + actionMinValue, + actionMultipleOf, + type ActionNode, + actionRegex, + actionUniqueItems, + actionUUID, + type AnyNode, + methodPipe, + schemaNodeAllOf, + schemaNodeAnyOf, + schemaNodeArray, + schemaNodeBoolean, + schemaNodeConst, + schemaNodeLiteral, + schemaNodeNot, + schemaNodeNull, + schemaNodeNumber, + schemaNodeObject, + schemaNodeOneOf, + schemaNodeOptional, + schemaNodeReference, + schemaNodeString, + schemaNodeUnion, +} from './schema-nodes.ts'; +import type { + JSONSchema, + JSONSchemaArray, + JSONSchemaBoolean, + JSONSchemaNull, + JSONSchemaNumber, + JSONSchemaObject, + JSONSchemaString, +} from './types.ts'; +import { appendSchema, capitalize, normalizeTitle } from './utils/basic.ts'; +import { findAndHandleCircularReferences } from './utils/circular-refs.ts'; +import { topologicalSort } from './utils/topological-sort.ts'; + +const OpenAPISchema = object({ + info: object({ + title: string(), + }), + components: object({ + schemas: record(string(), any()), + }), +}); + +const JSONSchemaSchema = object({ + $schema: string(), + title: string(), + type: string(), + description: optional(string()), + definitions: optional( + record( + string(), + pipe( + any(), + check(() => true) + ) + ) + ), + properties: pipe( + any(), + check>(() => true) + ), + required: optional(array(string())), +}); +type JSONSchemaSchema = InferOutput; + +const customRules = { + uniqueItems: { + code: `const uniqueItems = ( + message?: Message +): CheckItemsAction => + checkItems((item, i, arr) => arr.indexOf(item) === i, message);`, + imports: ['CheckItemsAction', 'checkItems'], + }, +} as const; +type CustomRules = keyof typeof customRules; + +type AllowedImports = + | 'CheckItemsAction' + | 'GenericSchema' + | 'InferOutput' + | 'array' + | 'boolean' + | 'check' + | 'checkItems' + | 'email' + | 'integer' + | 'lazy' + | 'literal' + | 'maxLength' + | 'maxValue' + | 'minLength' + | 'minValue' + | 'multipleOf' + | 'null' + | 'number' + | 'object' + | 'optional' + | 'pipe' + | 'regex' + | 'string' + | 'strictObject' + | 'union' + | 'uuid' + | 'isoDateTime' + | 'isoDate' + | 'isoTime' + | 'ipv4' + | 'ipv6' + | 'objectWithRest'; + +class ValibotGenerator { + private root: + | { format: 'json'; value: JSONSchemaSchema; title: string } + | { + format: 'openapi-json' | 'openapi-yaml'; + value: Record; + title: string; + }; + + get title(): string { + return this.root.title; + } + + private refs = new Map(); + private schemas: Record = {}; + private dependsOn: Record = {}; + private usedImports = new Set(); + private customRules = new Set(); + + private __currentSchema: string | null = null; + + constructor( + content: string, + format: 'openapi-json' | 'openapi-yaml' | 'json' + ); + constructor(content: object, format: 'openapi-json' | 'json'); + constructor( + content: string | object, + format: 'openapi-json' | 'openapi-yaml' | 'json' + ) { + switch (format) { + case 'openapi-json': { + const parsed = parse( + OpenAPISchema, + typeof content === 'string' ? JSON.parse(content) : content + ); + this.root = { + value: parsed.components.schemas, + format, + title: parsed.info.title, + }; + return this; + } + case 'openapi-yaml': { + const parsed = parse(OpenAPISchema, parseYaml(content as string)); + this.root = { + value: parsed.components.schemas, + format, + title: parsed.info.title, + }; + return this; + } + case 'json': { + const parsed = parse( + JSONSchemaSchema, + typeof content === 'string' ? JSON.parse(content) : content + ); + this.root = { + value: parsed, + format, + title: parsed.title, + }; + return this; + } + } + } + + public generate(): string { + switch (this.root.format) { + case 'openapi-json': + case 'openapi-yaml': { + this.parseOpenAPI(this.root.value); + break; + } + case 'json': { + this.parseJSONSchema(this.root.value); + break; + } + } + + this.usedImports.add('InferOutput'); + + const { circularReferences, selfReferencing } = + findAndHandleCircularReferences(this.dependsOn); + + const visit = (node: AnyNode, schemaName: string) => { + if (node.name === 'object') { + this.usedImports.add(node.type); + } + else if (node.name === '$ref') { + /** skip */ + } else if (node.name in customRules) { + this.customRules.add('uniqueItems'); + for (const imp of customRules[node.name as CustomRules].imports) { + this.usedImports.add(imp); + } + } else { + // above if statement with `node.name in customRules` does not help + // inferring that those strings should be omitted + this.usedImports.add(node.name as Exclude); + } + + switch (node.name) { + case '$ref': + if ( + (selfReferencing.includes(node.ref) && schemaName === node.ref) || + circularReferences[schemaName]?.includes(node.ref) + ) { + this.usedImports.add('GenericSchema'); + this.usedImports.add('lazy'); + node.lazy = true; + } + break; + case 'object': + for (const child of Object.values(node.value)) { + visit(child, schemaName); + } + break; + case 'union': + for (const child of node.value) { + visit(child, schemaName); + } + break; + case 'array': + case 'optional': + if (node.value) visit(node.value, schemaName); + break; + case 'pipe': + node.value.forEach((v) => visit(v, schemaName)); + break; + } + }; + + for (const [schemaName, schema] of Object.entries(this.schemas)) { + visit(schema, schemaName); + } + + const output: string[] = []; + + const imports = [...this.usedImports.values()].sort((a, b) => { + const aStartsWithUpper = /^[A-Z]/.test(a); + const bStartsWithUpper = /^[A-Z]/.test(b); + + if (aStartsWithUpper && !bStartsWithUpper) return -1; + else if (!aStartsWithUpper && bStartsWithUpper) return 1; + else return a.localeCompare(b); + }); + output.push(`import { `, imports.join(', '), ' } from "valibot";\n'); + + const cr = Array.from(this.customRules.values()); + if (cr.length > 0) { + output.push('\n\n'); + output.push(cr.map((rule) => customRules[rule].code).join('\n\n'), '\n'); + } + + const schemas = topologicalSort(this.schemas, this.dependsOn); + for (const [schemaName, schemaNode] of schemas) { + output.push('\n\n'); + const schemaCode = this.generateSchemaCode(schemaNode); + if ( + selfReferencing.includes(schemaName) || + schemaName in circularReferences + ) { + const typeName = schemaName.replace(/Schema/, ''); + const typeDeclaration = this.generateSchemaTypeDeclaration(schemaNode); + const typeAnnotation = + selfReferencing.includes(schemaName) || + schemaName in circularReferences + ? `: GenericSchema<${typeName}>` + : ''; + output.push(`export type ${typeName} = ${typeDeclaration}`, '\n\n'); + output.push( + `export const ${schemaName}${typeAnnotation} = ${schemaCode};`, + '\n' + ); + } else { + output.push(`export const ${schemaName} = ${schemaCode};`, '\n\n'); + output.push( + `export type ${schemaName.replace(/Schema/, '')} = InferOutput;\n` + ); + } + } + + return output.join(''); + } + + private parseJSONSchema(values: JSONSchemaSchema) { + if (values.definitions) { + for (const [key, value] of Object.entries(values.definitions)) { + const name = capitalize(appendSchema(key)); + this.__currentSchema = name; + this.dependsOn[this.__currentSchema] = []; + this.refs.set(`#/definitions/${key}`, name); + this.schemas[name] = this.parseSchema(value, true); + } + } + + const name = appendSchema(normalizeTitle(values.title)); + this.__currentSchema = name; + this.dependsOn[this.__currentSchema] = []; + this.refs.set(`#/definitions/${name}`, name); + + this.schemas[name] = this.parseObjectType({ + type: 'object', + properties: values.properties, + required: values.required ?? [], + description: values.description, + }); + } + + private parseOpenAPI(values: Record) { + for (const key in values) { + const name = capitalize(key); + this.refs.set(`#/components/schemas/${key}`, appendSchema(name)); + } + + for (const [key, schema] of Object.entries(values)) { + const name = appendSchema(capitalize(key)); + this.__currentSchema = name; + this.dependsOn[this.__currentSchema] = []; + this.schemas[this.__currentSchema] = this.parseSchema(schema, true); + } + } + + private parseSchema( + schema: Schema, + required: boolean + ): AnyNode { + if ('$ref' in schema) { + const schemaName = schema.$ref + .replace('#/components/schemas/', '') + .replace('#/definitions/', ''); + this.dependsOn[this.__currentSchema!]!.push( + appendSchema(capitalize(schemaName)) + ); + return required + ? schemaNodeReference({ ref: capitalize(appendSchema(schemaName)) }) + : schemaNodeOptional({ + value: schemaNodeReference({ + ref: capitalize(appendSchema(schemaName)), + }), + }); + } else if ('const' in schema) { + return schemaNodeConst({ value: schema.const }) + } else if ('type' in schema) { + switch (schema.type) { + case 'string': + if ('enum' in schema) { + return this.parseEnumType(schema, required); + } else { + return this.parseStringType(schema, required); + } + case 'number': + case 'integer': + if ('enum' in schema) { + return this.parseEnumType(schema, required); + } else { + return this.parseNumberType(schema, required); + } + case 'boolean': + return this.parseBooleanType(schema, required); + case 'array': + return this.parseArrayType(schema, required); + case 'object': + return this.parseObjectType(schema, required); + case 'null': + return this.parseNullType(schema, required); + default: + throw new Error( + `Unsupported type: ${(schema as { type: string }).type}` + ); + } + } else { + if (schema.allOf !== undefined) { + return schemaNodeAllOf({ value: schema.allOf.map(item => this.parseSchema(item, true)) }) + } else if (schema.oneOf !== undefined) { + return schemaNodeOneOf({ value: schema.oneOf.map(item => this.parseSchema(item, true)) }) + } else if (schema.anyOf !== undefined) { + return schemaNodeAnyOf({ value: schema.anyOf.map(item => this.parseSchema(item, true)) }) + } else if (schema.not !== undefined) { + return schemaNodeNot({ value: this.parseSchema(schema.not, true) }); + } + console.error(schema); + throw new Error( + '`allOf`, `anyOf`, `oneOf` and `not` are not yet implemented' + ); + } + } + + private parseEnumType( + schema: JSONSchemaString | JSONSchemaNumber, + required: boolean + ): AnyNode { + const actions: ActionNode[] = []; + const content = schema.enum!.map((v) => { + const value = schema.type === 'string' ? `'${v}'` : v; + return schemaNodeLiteral({ value }); + }); + + if (schema.description !== undefined) { + actions.push(actionDescription(schema.description)); + } + + let value: AnyNode = schemaNodeUnion({ value: content }); + if (actions.length) value = methodPipe(value, actions); + if (!required) value = schemaNodeOptional({ value }); + return value; + } + + private parseStringType( + schema: JSONSchemaString, + required: boolean + ): AnyNode { + let value: AnyNode = schemaNodeString(); + + const actions: ActionNode[] = []; + if (schema.minLength !== undefined) { + actions.push(actionMinLength(schema.minLength)); + } + if (schema.maxLength !== undefined) { + actions.push(actionMaxLength(schema.maxLength)); + } + + switch (schema.format) { + case "email": + actions.push(actionEmail()); + break; + case "uuid": + actions.push(actionUUID()); + break; + case "date-time": + actions.push(actionIsoDateTime()); + break; + case "date": { + actions.push(actionIsoDate()); + break; + } + case "time": + actions.push(actionIsoTime()); + break; + case "duration": + console.error('format="duration" not yet implemented!') + case "idn-email": + console.error('format="idn-email" not yet implemented!') + case "hostname": + console.error('format="hostname" not yet implemented!') + case "idn-hostname": + console.error('format="idn-hostname" not yet implemented!') + case "ipv4": + actions.push(actionIPv4()); + break; + case "ipv6": + actions.push(actionIPv6()); + break; + case "json-pointer": + console.error('format="json-pointer" not yet implemented!') + case "relative-json-pointer": + console.error('format="relative-json-pointer" not yet implemented!') + case "uri": + console.error('format="uri" not yet implemented!') + case "uri-reference": + console.error('format="uri-reference" not yet implemented!') + case "uri-template": + console.error('format="uri-template" not yet implemented!') + case "iri": + console.error('format="iri" not yet implemented!') + case "iri-reference": + console.error('format="iri-reference" not yet implemented!') + } + + + if (schema.pattern) { + actions.push(actionRegex(schema.pattern)); + } + + if (schema.description !== undefined) { + actions.push(actionDescription(schema.description)); + } + + if (actions.length) value = methodPipe(value, actions); + if (!required) value = schemaNodeOptional({ value: value }); + + return value; + } + + private parseNumberType( + schema: JSONSchemaNumber, + required: boolean + ): AnyNode { + let value: AnyNode = schemaNodeNumber(); + + const actions: ActionNode[] = []; + if (schema.type === "integer") actions.push(actionInteger()); + + if (schema.minimum !== undefined) + actions.push(actionMinValue(schema.minimum)); + else if (schema.exclusiveMinimum !== undefined) + actions.push(actionMinValue(schema.exclusiveMinimum + 1)); + if (schema.maximum !== undefined) + actions.push(actionMaxValue(schema.maximum)); + else if (schema.exclusiveMaximum !== undefined) + actions.push(actionMaxValue(schema.exclusiveMaximum - 1)); + + if (schema.multipleOf !== undefined) + actions.push(actionMultipleOf(schema.multipleOf)); + + if (schema.description !== undefined) { + actions.push(actionDescription(schema.description)); + } + + if (actions.length) value = methodPipe(value, actions); + if (!required) value = schemaNodeOptional({ value }); + + return value; + } + + private parseArrayType(schema: JSONSchemaArray, required: boolean): AnyNode { + if (!schema.items) { + return schemaNodeArray({}); + } + const kind = Array.isArray(schema.items) + ? schemaNodeUnion({ + value: schema.items.map((item) => this.parseSchema(item, true)), + }) + : this.parseSchema(schema.items, true); + let value: AnyNode = schemaNodeArray({ value: kind }); + const actions: ActionNode[] = []; + + if (schema.minItems !== undefined) { + actions.push(actionMinLength(schema.minItems)); + } + if (schema.maxItems !== undefined) { + actions.push(actionMaxLength(schema.maxItems)); + } + if (schema.uniqueItems) { + actions.push(actionUniqueItems()); + } + + if (schema.description !== undefined) { + actions.push(actionDescription(schema.description)); + } + + if (actions.length) value = methodPipe(value, actions); + if (!required) value = schemaNodeOptional({ value }); + + return value; + } + + private parseBooleanType( + schema: JSONSchemaBoolean, + required: boolean + ): AnyNode { + let value: AnyNode = schemaNodeBoolean(); + const actions: ActionNode[] = []; + + if (schema.description !== undefined) { + actions.push(actionDescription(schema.description)); + } + + if (actions.length) value = methodPipe(value, actions); + if (!required) value = schemaNodeOptional({ value }); + return value; + } + + private parseNullType(schema: JSONSchemaNull, required?: boolean): AnyNode { + let value: AnyNode = schemaNodeNull(); + const actions: ActionNode[] = []; + + if (schema.description !== undefined) { + actions.push(actionDescription(schema.description)); + } + + if (actions.length) value = methodPipe(value, actions); + if (!required) value = schemaNodeOptional({ value }); + return value; + } + + private parseObjectType(schema: JSONSchemaObject, required = true): AnyNode { + const content = Object.fromEntries( + Object.entries(schema.properties ?? {}) + .map(([key, value]) => { + const required = schema.required?.includes(key) ?? false; + return [key, this.parseSchema(value, required)]; + }) + .filter(Boolean) + ); + + const type = schema.additionalProperties === false + ? "strictObject" + : typeof schema.additionalProperties === "object" + ? "objectWithRest" + : "object"; + let value: AnyNode = schemaNodeObject({ + value: content, + type, + withRest: type === "objectWithRest" ? this.parseSchema(schema.additionalProperties, true) : undefined + }); + const actions: ActionNode[] = []; + + if (schema.description !== undefined) { + actions.push(actionDescription(schema.description)); + } + + if (actions.length) value = methodPipe(value, actions); + if (!required) value = schemaNodeOptional({ value }); + + return value; + } + + private generateNodeType(node: AnyNode, depth = 1): string { + switch (node.name) { + case 'email': + case 'uuid': + case 'uniqueItems': + case 'isoDateTime': + case 'multipleOf': + case 'maxLength': + case 'maxValue': + case 'minLength': + case 'minValue': + case 'regex': + case 'description': + case 'isoDate': + case 'isoTime': + case 'ipv4': + case 'ipv6': { + return ''; + } + case 'pipe': { + return this.generateNodeType(node.value[0]); + } + case 'boolean': + case 'string': + case 'number': + case 'null': { + return node.name; + } + case '$ref': { + return node.ref.replace(/Schema/, ''); + } + case 'array': { + if (!node.value) return `any[]`; + return `${this.generateNodeType(node.value, depth)}[]`; + } + case 'integer': { + return 'number'; + } + case 'literal': { + return typeof node.value === 'string' + ? `'${node.value}'` + : `${node.value}`; + } + case 'object': { + const items = Object.entries(node.value); + if (items.length === 0) return `object`; + + const inner: string = items + .map(([key, item]) => { + return item.name === 'optional' + ? `${' '.repeat(depth)}${key}?: ${this.generateNodeType(item.value, depth + 1)};\n` + : `${' '.repeat(depth)}${key}: ${this.generateNodeType(item, depth + 1)};\n`; + }) + .join(''); + return `{\n${inner}${' '.repeat(depth - 1)}}`; + } + case 'union': { + const inner = node.value + .map((item) => this.generateNodeType(item, depth)) + .join(' | '); + return `(${inner})`; + } + case 'optional': { + throw new Error('Top-level optional is unsupported'); + } + } + } + + private generateNodeCode(node: AnyNode, depth = 1): string { + switch (node.name) { + case '$ref': + if (node.lazy) return `lazy(() => ${node.ref})`; + return node.ref; + case 'array': + if (!node.value) return 'array()'; + return `array(${this.generateNodeCode(node.value, depth)})`; + + case 'integer': + return 'integer()'; + case 'number': + return `number()`; + case 'literal': + return `literal(${node.value})`; + case 'maxLength': + return `maxLength(${node.value})`; + case 'minLength': + return `minLength(${node.value})`; + case 'maxValue': + return `maxValue(${node.value})`; + case 'minValue': + return `minValue(${node.value})`; + case 'multipleOf': + return `multipleOf(${node.value})`; + case 'description': + return `description("${node.value}")`; + case 'null': + return 'null()'; + case 'object': { + const kind = node.type; + const withRest = node.type === "objectWithRest" ? node.withRest : undefined; + if (withRest) { + const items = Object.entries(node.value); + if (items.length === 0) return `objectWithRest({}, ${this.generateNodeCode(withRest, depth)})`; + + const inner: string = items + .map( + ([key, item]) => + `${' '.repeat(depth)}${key}: ${this.generateNodeCode(item, depth + 1)},\n` + ) + .join(''); + return `objectWithRest({\n${inner}${' '.repeat(depth - 1)}},\n${' '.repeat(depth - 1)}${this.generateNodeCode(withRest, depth)})`; + } + + const items = Object.entries(node.value); + if (items.length === 0) return `${kind}({})`; + + const inner: string = items + .map( + ([key, item]) => + `${' '.repeat(depth)}${key}: ${this.generateNodeCode(item, depth + 1)},\n` + ) + .join(''); + return `${kind}({\n${inner}${' '.repeat(depth - 1)}})`; + } + case 'optional': + return `optional(${this.generateNodeCode(node.value, depth)})`; + case 'pipe': { + const inner: string = node.value + .map((item) => this.generateNodeCode(item, depth)) + .join(', '); + return `pipe(${inner})`; + } + case 'regex': { + return `regex(/${node.value}/)`; + } + case 'string': { + return `string()`; + } + case 'union': { + const inner: string = + node.value + ?.map( + (item) => + `${' '.repeat(depth)}${this.generateNodeCode(item, depth + 1)},\n` + ) + .join('') ?? ''; + return `union([\n${inner}${' '.repeat(depth - 1)}])`; + } + case 'uniqueItems': + case 'uuid': + case 'boolean': + case 'email': + case 'isoDateTime': + case 'isoDate': + case 'isoTime': + case 'ipv4': + case 'ipv6': { + return `${node.name}()`; + } + } + } + + private generateSchemaTypeDeclaration(schema: AnyNode): string { + return this.generateNodeType(schema); + } + + private generateSchemaCode(schema: AnyNode): string { + return this.generateNodeCode(schema); + } +} + +export { ValibotGenerator }; diff --git a/packages/to-valibot/lib/schema-nodes.ts b/packages/to-valibot/lib/schema-nodes.ts new file mode 100644 index 000000000..d78e7c92b --- /dev/null +++ b/packages/to-valibot/lib/schema-nodes.ts @@ -0,0 +1,352 @@ +type ExtractAdditionalProps = { + [K in keyof T as K extends keyof { name: string } ? never : K]: T[K]; +}; +type ExtractRequiredKeys = { + [K in keyof T]-?: object extends Pick ? never : K; +}[keyof T]; + +type HasProps = keyof T extends never ? false : true; + +type MethodNodeBase = { + name: Name; + value: Value; +}; +type MethodNodePipe = MethodNodeBase<'pipe', AnyNode[]>; +const methodPipe = (item: AnyNode, value: AnyNode[]): MethodNodePipe => ({ + name: 'pipe', + value: [item, ...value], +}); + +type MethodNode = MethodNodePipe; + +type Action = Node extends { value: infer V } + ? (value: V, message?: string) => Node + : (message?: string) => Node; + +type ActionNodeBase< + Name extends string, + Value = undefined, +> = Value extends undefined + ? { + name: Name; + message?: string | undefined; + custom?: true; + } + : { + value: Value; + name: Name; + message?: string | undefined; + custom?: true; + }; + +type ActionNodeInteger = ActionNodeBase<'integer'>; +const actionInteger: Action = (message) => ({ + name: 'integer', + message +}); + +type ActionNodeMinLength = ActionNodeBase<'minLength', number>; +const actionMinLength: Action = (value, message) => ({ + name: 'minLength', + value, + message, +}); + +type ActionNodeMaxLength = ActionNodeBase<'maxLength', number>; +const actionMaxLength: Action = (value, message) => ({ + name: 'maxLength', + value, + message, +}); + +type ActionNodeMinValue = ActionNodeBase<'minValue', number>; +const actionMinValue: Action = (value, message) => ({ + name: 'minValue', + value, + message, +}); + +type ActionNodeMaxValue = ActionNodeBase<'maxValue', number>; +const actionMaxValue: Action = (value, message) => ({ + name: 'maxValue', + value, + message, +}); + +type ActionNodeMultipleOf = ActionNodeBase<'multipleOf', number>; +const actionMultipleOf: Action = (value, message) => ({ + name: 'multipleOf', + value, + message, +}); + +type ActionNodeRegex = ActionNodeBase<'regex', string>; +const actionRegex: Action = (value, message) => ({ + name: 'regex', + value, + message, +}); + +type ActionNodeUniqueItems = ActionNodeBase<'uniqueItems'>; +const actionUniqueItems: Action = (message) => ({ + name: 'uniqueItems', + custom: true, + message, +}); + +type ActionNodeEmail = ActionNodeBase<'email'>; +const actionEmail: Action = (message) => ({ + name: 'email', + message, +}); + +type ActionNodeUUID = ActionNodeBase<'uuid'>; +const actionUUID: Action = (message) => ({ + name: 'uuid', + message, +}); + +type ActionNodeIsoDateTime = ActionNodeBase<'isoDateTime'>; +const actionIsoDateTime: Action = (message) => ({ + name: 'isoDateTime', + message, +}); + +type ActionNodeIsoDate = ActionNodeBase<'isoDate'>; +const actionIsoDate: Action = (message) => ({ + name: 'isoDate', + message, +}); + +type ActionNodeIsoTime = ActionNodeBase<'isoTime'>; +const actionIsoTime: Action = (message) => ({ + name: 'isoTime', + message, +}); + +type ActionNodeDescription = ActionNodeBase<'description', string>; +const actionDescription: Action = (value) => ({ + name: 'description', + value, +}); + +type ActionNodeIPv4 = ActionNodeBase<'ipv4'>; +const actionIPv4: Action = (message) => ({ + name: 'ipv4', + message, +}); + +type ActionNodeIPv6 = ActionNodeBase<'ipv6'>; +const actionIPv6: Action = (message) => ({ + name: 'ipv6', + message, +}); + +type ActionNode = + | ActionNodeInteger + | ActionNodeMinLength + | ActionNodeMaxLength + | ActionNodeMinValue + | ActionNodeMaxValue + | ActionNodeMultipleOf + | ActionNodeEmail + | ActionNodeUUID + | ActionNodeIsoDateTime + | ActionNodeIsoDate + | ActionNodeIsoTime + | ActionNodeRegex + | ActionNodeUniqueItems + | ActionNodeDescription + | ActionNodeIPv4 + | ActionNodeIPv6; + +type SchemaNodeBase = { + name: Name; + message?: string; +}; + +type NodeFactory = + HasProps> extends true + ? ExtractRequiredKeys> extends never + ? (props?: Omit) => Node + : (props: Omit) => Node + : () => Node; + +type SchemaNodeString = SchemaNodeBase<'string'>; +const schemaNodeString: NodeFactory = (props) => ({ + name: 'string', + ...props, +}); + +type SchemaNodeNumber = SchemaNodeBase<'number'>; +const schemaNodeNumber: NodeFactory = (props) => ({ + name: 'number', + ...props, +}); + +type SchemaNodeInteger = SchemaNodeBase<'integer'>; +const schemaNodeInteger: NodeFactory = (props) => ({ + name: 'integer', + ...props, +}); + +type SchemaNodeBoolean = SchemaNodeBase<'boolean'>; +const schemaNodeBoolean: NodeFactory = (props) => ({ + name: 'boolean', + ...props, +}); + +type SchemaNodeObject = SchemaNodeBase<'object'> & ({ + value: Record; + type: "object" | "strictObject" | "objectWithRest"; + withRest?: AnyNode; +}); + +const schemaNodeObject: NodeFactory = (props) => ({ + name: 'object', + ...props, +}); + +type SchemaNodeArray = SchemaNodeBase<'array'> & { + value?: AnyNode; +}; +const schemaNodeArray: NodeFactory = (props) => ({ + name: 'array', + ...props, +}); + +type SchemaNodeUnion = SchemaNodeBase<'union'> & { + value: AnyNode[]; +}; +const schemaNodeUnion: NodeFactory = (props) => ({ + name: 'union', + ...props, +}); + +type SchemaNodeAllOf = SchemaNodeBase<'allOf'> & { + value: AnyNode[]; +} +const schemaNodeAllOf: NodeFactory = (props) => ({ + name: 'allOf', + ...props, +}); + +type SchemaNodeAnyOf = SchemaNodeBase<'anyOf'> & { + value: AnyNode[]; +} +const schemaNodeAnyOf: NodeFactory = (props) => ({ + name: 'anyOf', + ...props, +}); + +type SchemaNodeOneOf = SchemaNodeBase<'oneOf'> & { + value: AnyNode[]; +} +const schemaNodeOneOf: NodeFactory = (props) => ({ + name: 'oneOf', + ...props, +}); + +type SchemaNodeNot = SchemaNodeBase<'not'> & { + value: AnyNode; +} +const schemaNodeNot: NodeFactory = (props) => ({ + name: 'not', + ...props, +}); + +type SchemaNodeNull = SchemaNodeBase<'null'>; +const schemaNodeNull: NodeFactory = (props) => ({ + name: 'null', + ...props, +}); + +type SchemaNodeLiteral = SchemaNodeBase<'literal'> & { + value?: string | number; +}; +const schemaNodeLiteral: NodeFactory = (props) => ({ + name: 'literal', + ...props, +}); + +type SchemaNodeOptional = SchemaNodeBase<'optional'> & { + value: AnyNode; +}; +const schemaNodeOptional: NodeFactory = (props) => ({ + name: 'optional', + ...props, +}); + +type SchemaNodeReference = SchemaNodeBase<'$ref'> & { + ref: string; + lazy?: boolean; +}; +const schemaNodeReference: NodeFactory = (props) => ({ + name: '$ref', + ...props, +}); + +type SchemaNodeConst = SchemaNodeBase<'const'> & { + value: any +}; +const schemaNodeConst: NodeFactory = (props) => ({ + name: 'const', + ...props, +}); + +type SchemaNode = + | SchemaNodeString + | SchemaNodeNumber + | SchemaNodeInteger + | SchemaNodeBoolean + | SchemaNodeObject + | SchemaNodeArray + | SchemaNodeUnion + | SchemaNodeNull + | SchemaNodeOptional + | SchemaNodeLiteral + | SchemaNodeReference + | SchemaNodeAllOf + | SchemaNodeAnyOf + | SchemaNodeOneOf + | SchemaNodeNot + | SchemaNodeConst; + +type AnyNode = SchemaNode | MethodNode | ActionNode; + +export type { ActionNode, AnyNode, SchemaNode }; +export { + methodPipe, + actionInteger, + actionMinLength, + actionMaxLength, + actionEmail, + actionIsoDateTime, + actionIsoDate, + actionIsoTime, + actionUUID, + actionRegex, + actionUniqueItems, + actionMaxValue, + actionMinValue, + actionMultipleOf, + actionDescription, + actionIPv4, + actionIPv6, + schemaNodeString, + schemaNodeNumber, + schemaNodeBoolean, + schemaNodeObject, + schemaNodeArray, + schemaNodeUnion, + schemaNodeInteger, + schemaNodeOptional, + schemaNodeNull, + schemaNodeLiteral, + schemaNodeReference, + schemaNodeAllOf, + schemaNodeAnyOf, + schemaNodeNot, + schemaNodeOneOf, + schemaNodeConst +}; diff --git a/packages/to-valibot/lib/types.ts b/packages/to-valibot/lib/types.ts new file mode 100644 index 000000000..61956a59e --- /dev/null +++ b/packages/to-valibot/lib/types.ts @@ -0,0 +1,97 @@ +interface JSONSchemaBase { + title?: string; + description?: string | undefined; + default?: T; + examples?: T[]; + $comment?: string; + $id?: string; + $schema?: string; + definitions?: Record; + enum?: T[]; + const?: T; + readOnly?: boolean; + writeOnly?: boolean; +} + +interface JSONSchemaString extends JSONSchemaBase { + type: 'string'; + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + contentMediaType?: string; + contentEncoding?: string; + enum?: string[]; +} + +interface JSONSchemaNumber extends JSONSchemaBase { + type: 'number' | 'integer'; + multipleOf?: number; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; +} + +interface JSONSchemaBoolean extends JSONSchemaBase { + type: 'boolean'; +} + +interface JSONSchemaNull extends JSONSchemaBase { + type: 'null'; +} + +interface JSONSchemaArray extends JSONSchemaBase { + type: 'array'; + items?: JSONSchema | JSONSchema[]; + additionalItems?: boolean | JSONSchema; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + contains?: JSONSchema; +} + +interface JSONSchemaObject extends JSONSchemaBase { + type: 'object'; + properties?: Record; + patternProperties?: Record; + additionalProperties?: boolean | JSONSchema; + required?: string[]; + propertyNames?: JSONSchema; + minProperties?: number; + maxProperties?: number; + dependencies?: Record; +} + +interface JSONSchemaCombined extends JSONSchemaBase { + allOf?: JSONSchema[]; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + not?: JSONSchema; +} + +interface JSONSchemaRef { + $ref: string; +} + +type JSONSchema = + | JSONSchemaString + | JSONSchemaNumber + | JSONSchemaBoolean + | JSONSchemaNull + | JSONSchemaArray + | JSONSchemaObject + | JSONSchemaCombined + | JSONSchemaRef; + +export type { + JSONSchema, + JSONSchemaString, + JSONSchemaNumber, + JSONSchemaBoolean, + JSONSchemaNull, + JSONSchemaArray, + JSONSchemaObject, + JSONSchemaCombined, + JSONSchemaRef, +}; diff --git a/packages/to-valibot/lib/utils/basic.ts b/packages/to-valibot/lib/utils/basic.ts new file mode 100644 index 000000000..16c1b3563 --- /dev/null +++ b/packages/to-valibot/lib/utils/basic.ts @@ -0,0 +1,8 @@ +const capitalize = (s: string): string => + s.charAt(0).toUpperCase() + s.slice(1); +const appendSchema = (s: string): string => + s.endsWith('Schema') ? s : `${s}Schema`; +const normalizeTitle = (s: string): string => s.replace(/ /g, ''); +const slugify = (s: string): string => s.toLowerCase().replace(/ /g, '-'); + +export { capitalize, appendSchema, normalizeTitle, slugify }; diff --git a/packages/to-valibot/lib/utils/circular-refs.spec.ts b/packages/to-valibot/lib/utils/circular-refs.spec.ts new file mode 100644 index 000000000..c124f95bb --- /dev/null +++ b/packages/to-valibot/lib/utils/circular-refs.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { findAndHandleCircularReferences } from './circular-refs.ts'; + +describe('#findAndHandleCircularReferences()', () => { + it('should find self-referencing schemas', () => { + const output = findAndHandleCircularReferences({ + TreeNodeSchema: ['TreeNodeSchema'], + LinkedListNodeSchema: ['LinkedListNodeSchema'], + SelfRefSchema: ['TreeNodeSchema', 'LinkedListNodeSchema'], + }); + expect(output).toEqual({ + selfReferencing: ['TreeNodeSchema', 'LinkedListNodeSchema'], + circularReferences: {}, + }); + }); + + it('should find circular referencing schemas', () => { + const output = findAndHandleCircularReferences({ + PersonSchema: ['PersonSchema'], + CompanySchema: ['EmployeeSchema'], + EmployeeSchema: ['CompanySchema', 'EmployeeSchema'], + CircularRefsSchema: ['PersonSchema', 'CompanySchema'], + }); + expect(output).toEqual({ + selfReferencing: ['PersonSchema', 'EmployeeSchema'], + circularReferences: { + EmployeeSchema: ['CompanySchema'], + CompanySchema: ['EmployeeSchema'], + }, + }); + }); +}); diff --git a/packages/to-valibot/lib/utils/circular-refs.ts b/packages/to-valibot/lib/utils/circular-refs.ts new file mode 100644 index 000000000..503d3dd77 --- /dev/null +++ b/packages/to-valibot/lib/utils/circular-refs.ts @@ -0,0 +1,30 @@ +const findAndHandleCircularReferences = ( + dependenciesMap: Record +): { + selfReferencing: string[]; + circularReferences: Record; +} => { + const selfReferencing: string[] = []; + const circularReferencesSet: Record> = {}; + + for (const [schemaName, dependencies] of Object.entries(dependenciesMap)) { + if (dependencies.includes(schemaName)) selfReferencing.push(schemaName); + for (const dependency of dependencies) { + if ( + dependenciesMap[dependency].includes(schemaName) && + dependency !== schemaName + ) { + circularReferencesSet[schemaName] ??= new Set(); + circularReferencesSet[schemaName].add(dependency); + } + } + } + + const circularReferences = Object.fromEntries( + Object.entries(circularReferencesSet).map(([k, v]) => [k, [...v]]) + ); + + return { selfReferencing, circularReferences }; +}; + +export { findAndHandleCircularReferences }; diff --git a/packages/to-valibot/lib/utils/topological-sort.spec.ts b/packages/to-valibot/lib/utils/topological-sort.spec.ts new file mode 100644 index 000000000..41b9342b9 --- /dev/null +++ b/packages/to-valibot/lib/utils/topological-sort.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { topologicalSort } from './topological-sort.ts'; + +describe('#topologicalSort()', () => { + it('should sort provided objects', () => { + const res = topologicalSort( + { + lorem: null, + baz: null, + bar: null, + foo: null, + }, + { + lorem: ['foo'], + bar: ['lorem'], + baz: ['bar', 'foo'], + } + ); + + expect(res).toEqual([ + ['foo', null], + ['lorem', null], + ['bar', null], + ['baz', null], + ]); + }); +}); diff --git a/packages/to-valibot/lib/utils/topological-sort.ts b/packages/to-valibot/lib/utils/topological-sort.ts new file mode 100644 index 000000000..1be026593 --- /dev/null +++ b/packages/to-valibot/lib/utils/topological-sort.ts @@ -0,0 +1,25 @@ +const topologicalSort = ( + objects: Record, + dependsOn: Record +): [string, T][] => { + const visited = new Set(); + const entries: [string, T][] = []; + + const visit = (name: string) => { + if (visited.has(name)) return; + visited.add(name); + + const dependencies = dependsOn[name] ?? []; + for (const dependency of dependencies) { + visit(dependency); + } + + entries.push([name, objects[name]]); + }; + + Object.keys(objects).forEach((name) => visit(name)); + + return entries; +}; + +export { topologicalSort }; diff --git a/packages/to-valibot/package.json b/packages/to-valibot/package.json new file mode 100644 index 000000000..eac973ce7 --- /dev/null +++ b/packages/to-valibot/package.json @@ -0,0 +1,55 @@ +{ + "name": "@valibot/to-valibot", + "version": "0.0.0", + "license": "MIT", + "author": "Rokas Muningis", + "repository": { + "type": "git", + "url": "https://github.com/muningis/to-valibot" + }, + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "test": "vitest --typecheck", + "coverage": "vitest run --coverage --isolate", + "lint": "eslint \"lib/**/*.ts*\" && tsc --noEmit && deno check ./lib/index.ts", + "lint.fix": "eslint \"lib/**/*.ts*\" --fix", + "format": "prettier --write ./lib", + "format.check": "prettier --check ./lib", + "build": "tsup" + }, + "peerDependencies": { + "typescript": "5.8.2", + "valibot": "^1.0.0", + "yaml": "2.7.0" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@trivago/prettier-plugin-sort-imports": "5.2.2", + "@types/node": "^22.13.5", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.3", + "eslint-plugin-security": "^3.0.1", + "eslint": "^9.21.0", + "globals": "^16.0.0", + "prettier": "3.5.3", + "tsup": "8.4.0", + "typescript-eslint": "^8.25.0", + "vitest": "^3.0.9", + "yaml": "2.7.0" + } +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/circular-refs-schema.json b/packages/to-valibot/spec/fixtures/circular-refs-schema.json new file mode 100644 index 000000000..37b258974 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/circular-refs-schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "person": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "spouse": { + "$ref": "#/definitions/person" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/person" + } + } + }, + "required": [ + "name" + ] + }, + "company": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "employees": { + "type": "array", + "items": { + "$ref": "#/definitions/employee" + } + } + }, + "required": [ + "name" + ] + }, + "employee": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "company": { + "$ref": "#/definitions/company" + }, + "manager": { + "$ref": "#/definitions/employee" + }, + "subordinates": { + "type": "array", + "items": { + "$ref": "#/definitions/employee" + } + } + }, + "required": [ + "name" + ] + } + }, + "type": "object", + "properties": { + "family": { + "$ref": "#/definitions/person" + }, + "organization": { + "$ref": "#/definitions/company" + } + }, + "required": [ + "family", + "organization" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/circular-refs-schema.json b/packages/to-valibot/spec/fixtures/input/circular-refs-schema.json new file mode 100644 index 000000000..0f1595f0b --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/circular-refs-schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Circular Refs Schema", + "definitions": { + "person": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "spouse": { + "$ref": "#/definitions/person" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/person" + } + } + }, + "required": [ + "name" + ] + }, + "company": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "employees": { + "type": "array", + "items": { + "$ref": "#/definitions/employee" + } + } + }, + "required": [ + "name" + ] + }, + "employee": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "company": { + "$ref": "#/definitions/company" + }, + "manager": { + "$ref": "#/definitions/employee" + }, + "subordinates": { + "type": "array", + "items": { + "$ref": "#/definitions/employee" + } + } + }, + "required": [ + "name" + ] + } + }, + "type": "object", + "properties": { + "family": { + "$ref": "#/definitions/person" + }, + "organization": { + "$ref": "#/definitions/company" + } + }, + "required": [ + "family", + "organization" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/complex-refs-schema.json b/packages/to-valibot/spec/fixtures/input/complex-refs-schema.json new file mode 100644 index 000000000..908b5a2ba --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/complex-refs-schema.json @@ -0,0 +1,256 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Complex Refs Schema", + "definitions": { + "status": { + "type": "string", + "enum": [ + "active", + "inactive", + "pending", + "suspended" + ] + }, + "priority": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "category": { + "type": "string", + "enum": [ + "bug", + "feature", + "enhancement", + "documentation" + ] + }, + "severity": { + "type": "string", + "enum": [ + "trivial", + "minor", + "major", + "critical" + ] + }, + "userRole": { + "type": "string", + "enum": [ + "admin", + "manager", + "developer", + "viewer" + ] + }, + "permission": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "read", + "write", + "delete", + "execute" + ] + }, + "resource": { + "type": "string" + } + }, + "required": [ + "action", + "resource" + ] + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/userRole" + }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/permission" + } + } + }, + "required": [ + "id", + "username", + "role" + ] + }, + "comment": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "content": { + "type": "string" + }, + "author": { + "$ref": "#/definitions/user" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "content", + "author", + "createdAt" + ] + }, + "issue": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/status" + }, + "priority": { + "$ref": "#/definitions/priority" + }, + "category": { + "$ref": "#/definitions/category" + }, + "severity": { + "$ref": "#/definitions/severity" + }, + "assignee": { + "$ref": "#/definitions/user" + }, + "reporter": { + "$ref": "#/definitions/user" + }, + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/comment" + } + }, + "relatedIssues": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "title", + "description", + "status", + "priority", + "category", + "severity", + "reporter", + "createdAt" + ] + }, + "project": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/status" + }, + "owner": { + "$ref": "#/definitions/user" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/user" + } + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/definitions/issue" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "owner", + "createdAt" + ] + } + }, + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/definitions/project" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user" + } + } + }, + "required": [ + "projects" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/complicated-schema.json b/packages/to-valibot/spec/fixtures/input/complicated-schema.json new file mode 100644 index 000000000..edee74c19 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/complicated-schema.json @@ -0,0 +1,223 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "API with Shared Schema Components", + "version": "1.0.0", + "description": "This API schema demonstrates two main components sharing a third schema with various data types and constraints" + }, + "paths": { + "/example": { + "get": { + "summary": "Example endpoint", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MainComponent1" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "MainComponent1": { + "type": "object", + "required": [ + "id", + "name", + "sharedData" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "minLength": 3, + "maxLength": 50, + "pattern": "^[A-Za-z0-9\\s]+$" + }, + "requiredCount": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "isActive": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 10, + "uniqueItems": true + }, + "decimalValue": { + "type": "number", + "format": "double", + "minimum": 0.1, + "maximum": 99.9, + "multipleOf": 0.1 + }, + "sharedData": { + "$ref": "#/components/schemas/SharedComponent" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "MainComponent2": { + "type": "object", + "required": [ + "code", + "sharedData" + ], + "properties": { + "code": { + "type": "string", + "minLength": 5, + "maxLength": 10 + }, + "priority": { + "type": "integer", + "enum": [ + 1, + 2, + 3, + 5, + 8 + ] + }, + "floatRange": { + "type": "number", + "format": "float", + "exclusiveMinimum": 0, + "exclusiveMaximum": 10 + }, + "options": { + "type": "array", + "items": { + "type": "object", + "required": [ + "optionId", + "optionValue" + ], + "properties": { + "optionId": { + "type": "string" + }, + "optionValue": { + "type": "boolean" + } + } + }, + "maxItems": 5 + }, + "enabledFeatures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "feature1", + "feature2", + "feature3" + ] + } + }, + "statusHistory": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "date-time" + } + }, + "sharedData": { + "$ref": "#/components/schemas/SharedComponent" + }, + "lastUpdated": { + "type": "string", + "format": "date" + } + } + }, + "SharedComponent": { + "type": "object", + "required": [ + "sharedId", + "category" + ], + "properties": { + "sharedId": { + "type": "string", + "format": "uuid" + }, + "category": { + "type": "string", + "enum": [ + "Type A", + "Type B", + "Type C" + ] + }, + "validityPeriod": { + "type": "integer", + "minimum": 30, + "maximum": 365 + }, + "isPublic": { + "type": "boolean", + "default": false + }, + "metadata": { + "type": "object", + "properties": { + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "owner": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "version" + ] + }, + "scores": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 10 + }, + "minItems": 3, + "maxItems": 10 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/complicated-schema.yaml b/packages/to-valibot/spec/fixtures/input/complicated-schema.yaml new file mode 100644 index 000000000..e9a809c58 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/complicated-schema.yaml @@ -0,0 +1,156 @@ +openapi: 3.1.0 +info: + title: API with Shared Schema Components + version: 1.0.0 + description: This API schema demonstrates two main components sharing a third schema with various data types and constraints + +paths: + /example: + get: + summary: Example endpoint + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/MainComponent1' + +components: + schemas: + # First main component schema + MainComponent1: + type: object + required: + - id + - name + - sharedData + properties: + id: + type: string + format: uuid + name: + type: string + minLength: 3 + maxLength: 50 + pattern: '^[A-Za-z0-9\s]+$' + requiredCount: + type: integer + minimum: 1 + maximum: 100 + default: 10 + isActive: + type: boolean + tags: + type: array + items: + type: string + minItems: 1 + maxItems: 10 + uniqueItems: true + decimalValue: + type: number + format: double + minimum: 0.1 + maximum: 99.9 + multipleOf: 0.1 + sharedData: + $ref: '#/components/schemas/SharedComponent' + createdAt: + type: string + format: date-time + + # Second main component schema + MainComponent2: + type: object + required: + - code + - sharedData + properties: + code: + type: string + minLength: 5 + maxLength: 10 + priority: + type: integer + enum: [1, 2, 3, 5, 8] + floatRange: + type: number + format: float + exclusiveMinimum: 0 + exclusiveMaximum: 10 + options: + type: array + items: + type: object + required: + - optionId + - optionValue + properties: + optionId: + type: string + optionValue: + type: boolean + maxItems: 5 + enabledFeatures: + type: array + items: + type: string + enum: ["feature1", "feature2", "feature3"] + statusHistory: + type: object + additionalProperties: + type: string + format: date-time + sharedData: + $ref: '#/components/schemas/SharedComponent' + lastUpdated: + type: string + format: date + + # Shared component schema used by both main components + SharedComponent: + type: object + required: + - sharedId + - category + properties: + sharedId: + type: string + format: uuid + category: + type: string + enum: ["Type A", "Type B", "Type C"] + validityPeriod: + type: integer + minimum: 30 + maximum: 365 + isPublic: + type: boolean + default: false + metadata: + type: object + properties: + version: + type: string + pattern: '^\d+\.\d+\.\d+$' + owner: + type: string + description: + type: string + maxLength: 500 + required: + - version + scores: + type: array + items: + type: number + minimum: 0 + maximum: 10 + minItems: 3 + maxItems: 10 + tags: + type: array + items: + type: string + uniqueItems: true \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/comprehensive-schema.json b/packages/to-valibot/spec/fixtures/input/comprehensive-schema.json new file mode 100644 index 000000000..471fa9aa3 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/comprehensive-schema.json @@ -0,0 +1,179 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Comprehensive Schema", + "type": "object", + "properties": { + "simpleAdditionalProps": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": { + "type": "string" + } + }, + "complexAdditionalProps": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "user", + "admin", + "guest" + ] + }, + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "limitedProperties": { + "type": "object", + "minProperties": 2, + "maxProperties": 5, + "additionalProperties": { + "type": "string" + } + }, + "constantValue": { + "const": "fixed-value-123" + }, + "formatStrings": { + "type": "object", + "properties": { + "dateStr": { + "type": "string", + "format": "date" + }, + "timeStr": { + "type": "string", + "format": "time" + }, + "durationStr": { + "type": "string", + "format": "duration" + }, + "idnEmail": { + "type": "string", + "format": "idn-email" + }, + "hostname": { + "type": "string", + "format": "hostname" + }, + "idnHostname": { + "type": "string", + "format": "idn-hostname" + }, + "ipv4": { + "type": "string", + "format": "ipv4" + }, + "ipv6": { + "type": "string", + "format": "ipv6" + }, + "jsonPointer": { + "type": "string", + "format": "json-pointer" + }, + "relativeJsonPointer": { + "type": "string", + "format": "relative-json-pointer" + }, + "uri": { + "type": "string", + "format": "uri" + }, + "uriReference": { + "type": "string", + "format": "uri-reference" + }, + "uriTemplate": { + "type": "string", + "format": "uri-template" + }, + "iri": { + "type": "string", + "format": "iri" + }, + "iriReference": { + "type": "string", + "format": "iri-reference" + } + } + }, + "prefixItemsArray": { + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ], + "items": { + "type": "string" + } + }, + "containsArray": { + "type": "array", + "contains": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "required": true + } + } + }, + "containsLimitsArray": { + "type": "array", + "contains": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "minContains": 2, + "maxContains": 5 + } + }, + "required": [ + "simpleAdditionalProps", + "complexAdditionalProps", + "limitedProperties", + "constantValue", + "formatStrings", + "prefixItemsArray", + "containsArray", + "containsLimitsArray" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/logical-operators-schema.json b/packages/to-valibot/spec/fixtures/input/logical-operators-schema.json new file mode 100644 index 000000000..bd203e0c8 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/logical-operators-schema.json @@ -0,0 +1,180 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Logical Operators Schema", + "properties": { + "anyOfExample": { + "anyOf": [ + { + "type": "string", + "minLength": 5 + }, + { + "type": "number", + "minimum": 10 + }, + { + "type": "object", + "properties": { + "code": { + "type": "string" + } + }, + "required": [ + "code" + ] + } + ] + }, + "allOfExample": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + { + "type": "object", + "properties": { + "age": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "age" + ] + } + ] + }, + "oneOfExample": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "circle" + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "type", + "radius" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "rectangle" + }, + "width": { + "type": "number", + "minimum": 0 + }, + "height": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "type", + "width", + "height" + ], + "additionalProperties": false + } + ] + }, + "notExample": { + "not": { + "type": "object", + "properties": { + "forbidden": { + "type": "string" + }, + "status": { + "const": "inactive" + } + }, + "required": [ + "forbidden", + "status" + ] + } + }, + "combinedExample": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "metadata": { + "allOf": [ + { + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "created" + ] + }, + { + "not": { + "type": "object", + "properties": { + "deleted": { + "const": true + } + } + } + } + ] + } + }, + "required": [ + "value", + "metadata" + ] + } + }, + "required": [ + "anyOfExample", + "allOfExample", + "oneOfExample", + "notExample", + "combinedExample" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/medium-refs-schema.json b/packages/to-valibot/spec/fixtures/input/medium-refs-schema.json new file mode 100644 index 000000000..99dc43469 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/medium-refs-schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Medium Refs Schema", + "definitions": { + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + } + }, + "required": [ + "street", + "city", + "country" + ] + }, + "contact": { + "type": "object", + "properties": { + "phone": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "phone", + "email" + ] + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "billingAddress": { + "$ref": "#/definitions/address" + }, + "shippingAddress": { + "$ref": "#/definitions/address" + }, + "primaryContact": { + "$ref": "#/definitions/contact" + }, + "secondaryContacts": { + "type": "array", + "items": { + "$ref": "#/definitions/contact" + }, + "maxItems": 3 + } + }, + "required": [ + "id", + "name", + "billingAddress", + "primaryContact" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/no-refs-schema.json b/packages/to-valibot/spec/fixtures/input/no-refs-schema.json new file mode 100644 index 000000000..801a1c4a1 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/no-refs-schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "No Refs Schema", + "type": "object", + "description": "Schema without any references defining a person", + "properties": { + "name": { + "type": "string", + "minLength": 2, + "maxLength": 50, + "description": "Name of a person" + }, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150, + "description": "Age of a person" + }, + "email": { + "type": "string", + "format": "email", + "description": "Email address of a person" + }, + "isActive": { + "type": "boolean", + "description": "Indicates if user is currently active" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "maxItems": 5, + "description": "Tags by which user can be found" + } + }, + "required": [ + "name", + "age", + "email" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/self-ref-schema.json b/packages/to-valibot/spec/fixtures/input/self-ref-schema.json new file mode 100644 index 000000000..3d9fc438e --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/self-ref-schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Self Ref Schema", + "definitions": { + "treeNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/treeNode" + } + } + }, + "required": [ + "id", + "name" + ] + }, + "linkedListNode": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "next": { + "$ref": "#/definitions/linkedListNode" + } + }, + "required": [ + "value" + ] + } + }, + "type": "object", + "properties": { + "tree": { + "$ref": "#/definitions/treeNode" + }, + "linkedList": { + "$ref": "#/definitions/linkedListNode" + } + }, + "required": [ + "tree", + "linkedList" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/small-schema.json b/packages/to-valibot/spec/fixtures/input/small-schema.json new file mode 100644 index 000000000..961b21caa --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/small-schema.json @@ -0,0 +1,147 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Example API", + "version": "1.0.0", + "description": "An example OpenAPI schema for testing purposes" + }, + "paths": { + "/users": { + "post": { + "summary": "Create a new user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "required": [ + "id", + "name", + "email" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the user" + }, + "name": { + "type": "string", + "minLength": 2, + "maxLength": 100, + "description": "Full name of the user" + }, + "email": { + "type": "string", + "format": "email", + "description": "User's email address" + }, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150, + "description": "User's age in years" + }, + "isActive": { + "type": "boolean", + "default": true, + "description": "Whether the user account is active" + }, + "preferences": { + "type": "object", + "properties": { + "theme": { + "type": "string", + "enum": [ + "light", + "dark", + "system" + ], + "default": "system" + }, + "notifications": { + "type": "boolean", + "default": true + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 10, + "description": "User's associated tags" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "When the user was created" + }, + "metadata": { + "type": "object", + "additionalProperties": true, + "description": "Additional user metadata" + } + } + }, + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "Error code" + }, + "message": { + "type": "string", + "description": "Error message" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Additional error details" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/input/small-schema.yaml b/packages/to-valibot/spec/fixtures/input/small-schema.yaml new file mode 100644 index 000000000..0859ae45d --- /dev/null +++ b/packages/to-valibot/spec/fixtures/input/small-schema.yaml @@ -0,0 +1,103 @@ +openapi: 3.0.0 +info: + title: Example API + version: 1.0.0 + description: An example OpenAPI schema for testing purposes + +paths: + /users: + post: + summary: Create a new user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: User created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + schemas: + User: + type: object + required: + - id + - name + - email + properties: + id: + type: string + format: uuid + description: Unique identifier for the user + name: + type: string + minLength: 2 + maxLength: 100 + description: Full name of the user + email: + type: string + format: email + description: User's email address + age: + type: integer + minimum: 0 + maximum: 150 + description: User's age in years + isActive: + type: boolean + default: true + description: Whether the user account is active + preferences: + type: object + properties: + theme: + type: string + enum: [light, dark, system] + default: system + notifications: + type: boolean + default: true + tags: + type: array + items: + type: string + maxItems: 10 + description: User's associated tags + createdAt: + type: string + format: date-time + description: When the user was created + metadata: + type: object + additionalProperties: true + description: Additional user metadata + + Error: + type: object + additionalProperties: false + required: + - code + - message + properties: + code: + type: string + description: Error code + message: + type: string + description: Error message + details: + type: object + additionalProperties: true + description: Additional error details \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/output/circular-refs-schema.ts b/packages/to-valibot/spec/fixtures/output/circular-refs-schema.ts new file mode 100644 index 000000000..70e0e2797 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/circular-refs-schema.ts @@ -0,0 +1,48 @@ +import { GenericSchema, InferOutput, array, lazy, object, optional, string } from "valibot"; + + +export type Person = { + name: string; + spouse?: Person; + children?: Person[]; +} + +export const PersonSchema: GenericSchema = object({ + name: string(), + spouse: optional(lazy(() => PersonSchema)), + children: optional(array(lazy(() => PersonSchema))), +}); + + +export type Employee = { + name: string; + company?: Company; + manager?: Employee; + subordinates?: Employee[]; +} + +export const EmployeeSchema: GenericSchema = object({ + name: string(), + company: optional(lazy(() => CompanySchema)), + manager: optional(lazy(() => EmployeeSchema)), + subordinates: optional(array(lazy(() => EmployeeSchema))), +}); + + +export type Company = { + name: string; + employees?: Employee[]; +} + +export const CompanySchema: GenericSchema = object({ + name: string(), + employees: optional(array(lazy(() => EmployeeSchema))), +}); + + +export const CircularRefsSchema = object({ + family: PersonSchema, + organization: CompanySchema, +}); + +export type CircularRefs = InferOutput; diff --git a/packages/to-valibot/spec/fixtures/output/complex-refs-schema.ts b/packages/to-valibot/spec/fixtures/output/complex-refs-schema.ts new file mode 100644 index 000000000..47c921242 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/complex-refs-schema.ts @@ -0,0 +1,132 @@ +import { CheckItemsAction, InferOutput, array, checkItems, isoDateTime, literal, object, optional, pipe, string, union, uuid } from "valibot"; + + +const uniqueItems = ( + message?: Message +): CheckItemsAction => + checkItems((item, i, arr) => arr.indexOf(item) === i, message); + + +export const StatusSchema = union([ + literal('active'), + literal('inactive'), + literal('pending'), + literal('suspended'), +]); + +export type Status = InferOutput; + + +export const PrioritySchema = union([ + literal('low'), + literal('medium'), + literal('high'), + literal('critical'), +]); + +export type Priority = InferOutput; + + +export const CategorySchema = union([ + literal('bug'), + literal('feature'), + literal('enhancement'), + literal('documentation'), +]); + +export type Category = InferOutput; + + +export const SeveritySchema = union([ + literal('trivial'), + literal('minor'), + literal('major'), + literal('critical'), +]); + +export type Severity = InferOutput; + + +export const UserRoleSchema = union([ + literal('admin'), + literal('manager'), + literal('developer'), + literal('viewer'), +]); + +export type UserRole = InferOutput; + + +export const PermissionSchema = object({ + action: union([ + literal('read'), + literal('write'), + literal('delete'), + literal('execute'), + ]), + resource: string(), +}); + +export type Permission = InferOutput; + + +export const UserSchema = object({ + id: pipe(string(), uuid()), + username: string(), + role: UserRoleSchema, + permissions: optional(array(PermissionSchema)), +}); + +export type User = InferOutput; + + +export const CommentSchema = object({ + id: pipe(string(), uuid()), + content: string(), + author: UserSchema, + createdAt: pipe(string(), isoDateTime()), +}); + +export type Comment = InferOutput; + + +export const IssueSchema = object({ + id: pipe(string(), uuid()), + title: string(), + description: string(), + status: StatusSchema, + priority: PrioritySchema, + category: CategorySchema, + severity: SeveritySchema, + assignee: optional(UserSchema), + reporter: UserSchema, + comments: optional(array(CommentSchema)), + relatedIssues: optional(array(pipe(string(), uuid()))), + tags: optional(pipe(array(string()), uniqueItems())), + createdAt: pipe(string(), isoDateTime()), + updatedAt: optional(pipe(string(), isoDateTime())), +}); + +export type Issue = InferOutput; + + +export const ProjectSchema = object({ + id: pipe(string(), uuid()), + name: string(), + description: optional(string()), + status: optional(StatusSchema), + owner: UserSchema, + members: optional(array(UserSchema)), + issues: optional(array(IssueSchema)), + createdAt: pipe(string(), isoDateTime()), +}); + +export type Project = InferOutput; + + +export const ComplexRefsSchema = object({ + projects: array(ProjectSchema), + users: optional(array(UserSchema)), +}); + +export type ComplexRefs = InferOutput; diff --git a/packages/to-valibot/spec/fixtures/output/complicated-schema.ts b/packages/to-valibot/spec/fixtures/output/complicated-schema.ts new file mode 100644 index 000000000..070d1cb32 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/complicated-schema.ts @@ -0,0 +1,69 @@ +import { CheckItemsAction, InferOutput, array, boolean, checkItems, integer, isoDate, isoDateTime, literal, maxLength, maxValue, minLength, minValue, multipleOf, number, object, objectWithRest, optional, pipe, regex, string, union, uuid } from "valibot"; + + +const uniqueItems = ( + message?: Message +): CheckItemsAction => + checkItems((item, i, arr) => arr.indexOf(item) === i, message); + + +export const SharedComponentSchema = object({ + sharedId: pipe(string(), uuid()), + category: union([ + literal('Type A'), + literal('Type B'), + literal('Type C'), + ]), + validityPeriod: optional(pipe(number(), integer(), minValue(30), maxValue(365))), + isPublic: optional(boolean()), + metadata: optional(object({ + version: pipe(string(), regex(/^\d+\.\d+\.\d+$/)), + owner: optional(string()), + description: optional(pipe(string(), maxLength(500))), + })), + scores: optional(pipe(array(pipe(number(), minValue(0), maxValue(10))), minLength(3), maxLength(10))), + tags: optional(pipe(array(string()), uniqueItems())), +}); + +export type SharedComponent = InferOutput; + + +export const MainComponent1Schema = object({ + id: pipe(string(), uuid()), + name: pipe(string(), minLength(3), maxLength(50), regex(/^[A-Za-z0-9\s]+$/)), + requiredCount: optional(pipe(number(), integer(), minValue(1), maxValue(100))), + isActive: optional(boolean()), + tags: optional(pipe(array(string()), minLength(1), maxLength(10), uniqueItems())), + decimalValue: optional(pipe(number(), minValue(0.1), maxValue(99.9), multipleOf(0.1))), + sharedData: SharedComponentSchema, + createdAt: optional(pipe(string(), isoDateTime())), +}); + +export type MainComponent1 = InferOutput; + + +export const MainComponent2Schema = object({ + code: pipe(string(), minLength(5), maxLength(10)), + priority: optional(union([ + literal(1), + literal(2), + literal(3), + literal(5), + literal(8), + ])), + floatRange: optional(pipe(number(), minValue(1), maxValue(9))), + options: optional(pipe(array(object({ + optionId: string(), + optionValue: boolean(), + })), maxLength(5))), + enabledFeatures: optional(array(union([ + literal('feature1'), + literal('feature2'), + literal('feature3'), + ]))), + statusHistory: optional(objectWithRest({}, pipe(string(), isoDateTime()))), + sharedData: SharedComponentSchema, + lastUpdated: optional(pipe(string(), isoDate())), +}); + +export type MainComponent2 = InferOutput; diff --git a/packages/to-valibot/spec/fixtures/output/comprehensive-schema.ts b/packages/to-valibot/spec/fixtures/output/comprehensive-schema.ts new file mode 100644 index 000000000..eebb1af95 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/comprehensive-schema.ts @@ -0,0 +1,26 @@ +import { isoDateTime, minSize, pipe } from "valibot"; +import { array, InferOutput, literal, union } from "valibot"; +import { object, objectWithRest, string } from "valibot" + +export const ComprehensiveSchema = object({ + simpleAdditionalProps: objectWithRest({}, string()), + complexAdditionalProps: objectWithRest({ + id: string(), + }, object({ + type: union([ + literal("user"), + literal("admin"), + literal("guest"), + ]), + permissions: array(string()), + metadata: object({ + created: pipe(string(), isoDateTime()) + }) + })), + limitedProperties: pipe(objectWithRest({}, string()), minProperties(2), maxProperties(5)), + constantValue: literal("fixed-value-123"), + formatStrings: object({ + + }), +}); +export type Comprehensive = InferOutput; \ No newline at end of file diff --git a/packages/to-valibot/spec/fixtures/output/logical-operators-schema.ts b/packages/to-valibot/spec/fixtures/output/logical-operators-schema.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/to-valibot/spec/fixtures/output/medium-refs-schema.ts b/packages/to-valibot/spec/fixtures/output/medium-refs-schema.ts new file mode 100644 index 000000000..f19266f9f --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/medium-refs-schema.ts @@ -0,0 +1,31 @@ +import { InferOutput, array, email, maxLength, object, optional, pipe, string, uuid } from "valibot"; + + +export const AddressSchema = object({ + street: string(), + city: string(), + country: string(), + postalCode: optional(string()), +}); + +export type Address = InferOutput; + + +export const ContactSchema = object({ + phone: string(), + email: pipe(string(), email()), +}); + +export type Contact = InferOutput; + + +export const MediumRefsSchema = object({ + id: pipe(string(), uuid()), + name: string(), + billingAddress: AddressSchema, + shippingAddress: optional(AddressSchema), + primaryContact: ContactSchema, + secondaryContacts: optional(pipe(array(ContactSchema), maxLength(3))), +}); + +export type MediumRefs = InferOutput; diff --git a/packages/to-valibot/spec/fixtures/output/no-refs-schema.ts b/packages/to-valibot/spec/fixtures/output/no-refs-schema.ts new file mode 100644 index 000000000..a331bd16e --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/no-refs-schema.ts @@ -0,0 +1,18 @@ +import { CheckItemsAction, InferOutput, array, boolean, checkItems, description, email, integer, maxLength, maxValue, minLength, minValue, number, object, optional, pipe, string } from "valibot"; + + +const uniqueItems = ( + message?: Message +): CheckItemsAction => + checkItems((item, i, arr) => arr.indexOf(item) === i, message); + + +export const NoRefsSchema = pipe(object({ + name: pipe(string(), minLength(2), maxLength(50), description("Name of a person")), + age: pipe(number(), integer(), minValue(0), maxValue(150), description("Age of a person")), + email: pipe(string(), email(), description("Email address of a person")), + isActive: optional(pipe(boolean(), description("Indicates if user is currently active"))), + tags: optional(pipe(array(string()), maxLength(5), uniqueItems(), description("Tags by which user can be found"))), +}), description("Schema without any references defining a person")); + +export type NoRefs = InferOutput; diff --git a/packages/to-valibot/spec/fixtures/output/self-ref-schema.ts b/packages/to-valibot/spec/fixtures/output/self-ref-schema.ts new file mode 100644 index 000000000..b44200a89 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/self-ref-schema.ts @@ -0,0 +1,33 @@ +import { GenericSchema, InferOutput, array, lazy, object, optional, pipe, string, uuid } from "valibot"; + + +export type TreeNode = { + id: string; + name: string; + children?: TreeNode[]; +} + +export const TreeNodeSchema: GenericSchema = object({ + id: pipe(string(), uuid()), + name: string(), + children: optional(array(lazy(() => TreeNodeSchema))), +}); + + +export type LinkedListNode = { + value: string; + next?: LinkedListNode; +} + +export const LinkedListNodeSchema: GenericSchema = object({ + value: string(), + next: optional(lazy(() => LinkedListNodeSchema)), +}); + + +export const SelfRefSchema = object({ + tree: TreeNodeSchema, + linkedList: LinkedListNodeSchema, +}); + +export type SelfRef = InferOutput; diff --git a/packages/to-valibot/spec/fixtures/output/small-schema.ts b/packages/to-valibot/spec/fixtures/output/small-schema.ts new file mode 100644 index 000000000..b1cc0eef6 --- /dev/null +++ b/packages/to-valibot/spec/fixtures/output/small-schema.ts @@ -0,0 +1,32 @@ +import { InferOutput, array, boolean, description, email, integer, isoDateTime, literal, maxLength, maxValue, minLength, minValue, number, object, optional, pipe, strictObject, string, union, uuid } from "valibot"; + + +export const UserSchema = object({ + id: pipe(string(), uuid(), description("Unique identifier for the user")), + name: pipe(string(), minLength(2), maxLength(100), description("Full name of the user")), + email: pipe(string(), email(), description("User's email address")), + age: optional(pipe(number(), integer(), minValue(0), maxValue(150), description("User's age in years"))), + isActive: optional(pipe(boolean(), description("Whether the user account is active"))), + preferences: optional(object({ + theme: optional(union([ + literal('light'), + literal('dark'), + literal('system'), + ])), + notifications: optional(boolean()), + })), + tags: optional(pipe(array(string()), maxLength(10), description("User's associated tags"))), + createdAt: optional(pipe(string(), isoDateTime(), description("When the user was created"))), + metadata: optional(pipe(object({}), description("Additional user metadata"))), +}); + +export type User = InferOutput; + + +export const ErrorSchema = strictObject({ + code: pipe(string(), description("Error code")), + message: pipe(string(), description("Error message")), + details: optional(pipe(object({}), description("Additional error details"))), +}); + +export type Error = InferOutput; diff --git a/packages/to-valibot/spec/fixtures/self-ref-schema.json b/packages/to-valibot/spec/fixtures/self-ref-schema.json new file mode 100644 index 000000000..4853ff1ea --- /dev/null +++ b/packages/to-valibot/spec/fixtures/self-ref-schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "treeNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/treeNode" + } + } + }, + "required": [ + "id", + "name" + ] + }, + "linkedListNode": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "next": { + "$ref": "#/definitions/linkedListNode" + } + }, + "required": [ + "value" + ] + } + }, + "type": "object", + "properties": { + "tree": { + "$ref": "#/definitions/treeNode" + }, + "linkedList": { + "$ref": "#/definitions/linkedListNode" + } + }, + "required": [ + "tree", + "linkedList" + ] +} \ No newline at end of file diff --git a/packages/to-valibot/spec/json-schema.spec.ts b/packages/to-valibot/spec/json-schema.spec.ts new file mode 100644 index 000000000..b71b47702 --- /dev/null +++ b/packages/to-valibot/spec/json-schema.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { ValibotGenerator } from '../lib/parser-and-generator'; +import { getFileContents } from './utils/get-file-contents'; + +describe('should generate valibot schemas from JSON Schemas', () => { + it('should parse JSON Schema without references', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/no-refs-schema.json' + ); + const noRefsSchemaOutput = await getFileContents( + 'spec/fixtures/output/no-refs-schema.ts' + ); + + const parser = new ValibotGenerator(schema, 'json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(noRefsSchemaOutput.split('\n')); + }); + + it('should parse JSON Schema with references', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/medium-refs-schema.json' + ); + const mediumRefsSchemaOutput = await getFileContents( + 'spec/fixtures/output/medium-refs-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(mediumRefsSchemaOutput.split('\n')); + }); + + it('should parse JSON Schema with nested references', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/complex-refs-schema.json' + ); + const complexRefsSchemaOutput = await getFileContents( + 'spec/fixtures/output/complex-refs-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(complexRefsSchemaOutput.split('\n')); + }); + + it('should parse JSON Schema with comprehensive properties', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/comprehensive-schema.json' + ); + const complexRefsSchemaOutput = await getFileContents( + 'spec/fixtures/output/comprehensive-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(complexRefsSchemaOutput.split('\n')); + }); + + it('should parse JSON Schema with logical operators (anyOf, allof, oneOf, not)', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/logical-operators-schema.json' + ); + const complexRefsSchemaOutput = await getFileContents( + 'spec/fixtures/output/logical-operators-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(complexRefsSchemaOutput.split('\n')); + }); +}); diff --git a/packages/to-valibot/spec/lazy-schemas.spec.ts b/packages/to-valibot/spec/lazy-schemas.spec.ts new file mode 100644 index 000000000..1cf17a2f5 --- /dev/null +++ b/packages/to-valibot/spec/lazy-schemas.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { ValibotGenerator } from '../lib/parser-and-generator'; +import { getFileContents } from './utils/get-file-contents'; + +describe('should generate valibot schemas from self referencing and circular schemas', () => { + it('should parse JSON Schema with circular references', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/circular-refs-schema.json' + ); + const noRefsSchemaOutput = await getFileContents( + 'spec/fixtures/output/circular-refs-schema.ts' + ); + + const parser = new ValibotGenerator(schema, 'json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(noRefsSchemaOutput.split('\n')); + }); + + it('should parse self referencing JSON Schema', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/self-ref-schema.json' + ); + const mediumRefsSchemaOutput = await getFileContents( + 'spec/fixtures/output/self-ref-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(mediumRefsSchemaOutput.split('\n')); + }); +}); diff --git a/packages/to-valibot/spec/openapi-json.spec.ts b/packages/to-valibot/spec/openapi-json.spec.ts new file mode 100644 index 000000000..2f540a20a --- /dev/null +++ b/packages/to-valibot/spec/openapi-json.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { ValibotGenerator } from '../lib/parser-and-generator'; +import { getFileContents } from './utils/get-file-contents'; + +describe('should generate valibot schemas from OpenAPI json declaration file', () => { + it('should parse small declaration file', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/small-schema.json' + ); + const smallSchema = await getFileContents( + 'spec/fixtures/output/small-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'openapi-json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(smallSchema.split('\n')); + }); + + it('should parse complicated declaration file', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/complicated-schema.json' + ); + const complexSchema = await getFileContents( + 'spec/fixtures/output/complicated-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'openapi-json'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(complexSchema.split('\n')); + }); +}); diff --git a/packages/to-valibot/spec/openapi-yaml.spec.ts b/packages/to-valibot/spec/openapi-yaml.spec.ts new file mode 100644 index 000000000..4ba207475 --- /dev/null +++ b/packages/to-valibot/spec/openapi-yaml.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { ValibotGenerator } from '../lib/parser-and-generator'; +import { getFileContents } from './utils/get-file-contents'; + +describe('should generate valibot schemas from OpenAPI yaml declaration file', () => { + it('should parse small declaration file', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/small-schema.yaml' + ); + const smallSchema = await getFileContents( + 'spec/fixtures/output/small-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'openapi-yaml'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(smallSchema.split('\n')); + }); + + it('should parse complicated declaration file', async () => { + const schema = await getFileContents( + 'spec/fixtures/input/complicated-schema.yaml' + ); + const complexSchema = await getFileContents( + 'spec/fixtures/output/complicated-schema.ts' + ); + const parser = new ValibotGenerator(schema, 'openapi-yaml'); + const parsed = parser.generate(); + expect(parsed.split('\n')).toEqual(complexSchema.split('\n')); + }); +}); diff --git a/packages/to-valibot/spec/utils/get-file-contents.ts b/packages/to-valibot/spec/utils/get-file-contents.ts new file mode 100644 index 000000000..0d082bb5e --- /dev/null +++ b/packages/to-valibot/spec/utils/get-file-contents.ts @@ -0,0 +1,8 @@ +import { readFile } from "node:fs/promises"; + +const getFileContents = async (path: string) => { + const file = await readFile(path); + return file.toString(); +} + +export { getFileContents } \ No newline at end of file diff --git a/packages/to-valibot/tsconfig.json b/packages/to-valibot/tsconfig.json new file mode 100644 index 000000000..98b8c7b3c --- /dev/null +++ b/packages/to-valibot/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": true, + "exactOptionalPropertyTypes": true, + "isolatedDeclarations": true, + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "node", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2020", + "paths": { + "valibot": ["../../library/src/index.ts"], + "valibot/*": ["../../library/src/*"] + } + }, + "include": ["lib", "spec"] +} diff --git a/packages/to-valibot/tsup.config.ts b/packages/to-valibot/tsup.config.ts new file mode 100644 index 000000000..329fd842d --- /dev/null +++ b/packages/to-valibot/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['./lib/index.ts'], + clean: true, + format: ['esm', 'cjs'], + minify: false, + dts: true, + outDir: './dist', +}); \ No newline at end of file diff --git a/packages/to-valibot/vitest.config.ts b/packages/to-valibot/vitest.config.ts new file mode 100644 index 000000000..644b6433f --- /dev/null +++ b/packages/to-valibot/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: {} +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad856c0a2..4d66ce3a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,55 @@ importers: specifier: 3.0.7 version: 3.0.7(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(jsdom@26.0.0)(yaml@2.7.0) + packages/to-valibot: + dependencies: + typescript: + specifier: 5.8.2 + version: 5.8.2 + valibot: + specifier: ^1.0.0 + version: 1.0.0(typescript@5.8.2) + devDependencies: + '@eslint/js': + specifier: ^9.21.0 + version: 9.21.0 + '@trivago/prettier-plugin-sort-imports': + specifier: 5.2.2 + version: 5.2.2(prettier@3.5.3) + '@types/node': + specifier: ^22.13.5 + version: 22.13.5 + eslint: + specifier: ^9.21.0 + version: 9.21.0(jiti@2.4.2) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.31.0(@typescript-eslint/parser@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3))(eslint@9.21.0(jiti@2.4.2)) + eslint-plugin-jsdoc: + specifier: ^50.6.3 + version: 50.6.3(eslint@9.21.0(jiti@2.4.2)) + eslint-plugin-security: + specifier: ^3.0.1 + version: 3.0.1 + globals: + specifier: ^16.0.0 + version: 16.0.0 + prettier: + specifier: 3.5.3 + version: 3.5.3 + tsup: + specifier: 8.4.0 + version: 8.4.0(jiti@2.4.2)(postcss@8.5.3)(typescript@5.8.2)(yaml@2.7.0) + typescript-eslint: + specifier: ^8.25.0 + version: 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + vitest: + specifier: ^3.0.9 + version: 3.0.9(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(jsdom@26.0.0)(yaml@2.7.0) + yaml: + specifier: 2.7.0 + version: 2.7.0 + website: dependencies: '@builder.io/qwik': @@ -2319,6 +2368,9 @@ packages: '@vitest/expect@3.0.7': resolution: {integrity: sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==} + '@vitest/expect@3.0.9': + resolution: {integrity: sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==} + '@vitest/mocker@3.0.7': resolution: {integrity: sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==} peerDependencies: @@ -2330,21 +2382,47 @@ packages: vite: optional: true + '@vitest/mocker@3.0.9': + resolution: {integrity: sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.0.7': resolution: {integrity: sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==} + '@vitest/pretty-format@3.0.9': + resolution: {integrity: sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==} + '@vitest/runner@3.0.7': resolution: {integrity: sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==} + '@vitest/runner@3.0.9': + resolution: {integrity: sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==} + '@vitest/snapshot@3.0.7': resolution: {integrity: sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==} + '@vitest/snapshot@3.0.9': + resolution: {integrity: sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==} + '@vitest/spy@3.0.7': resolution: {integrity: sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==} + '@vitest/spy@3.0.9': + resolution: {integrity: sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==} + '@vitest/utils@3.0.7': resolution: {integrity: sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==} + '@vitest/utils@3.0.9': + resolution: {integrity: sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==} + '@xhmikosr/archive-type@6.0.1': resolution: {integrity: sha512-PB3NeJL8xARZt52yDBupK0dNPn8uIVQDe15qNehUpoeeLWCZyAOam4vGXnoZGz2N9D1VXtjievJuCsXam2TmbQ==} engines: {node: ^14.14.0 || >=16.0.0} @@ -6643,6 +6721,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -7788,6 +7871,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.8.2: + resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -8052,6 +8140,11 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true + vite-node@3.0.9: + resolution: {integrity: sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-static-copy@2.2.0: resolution: {integrity: sha512-ytMrKdR9iWEYHbUxs6x53m+MRl4SJsOSoMu1U1+Pfg0DjPeMlsRVx3RR5jvoonineDquIue83Oq69JvNsFSU5w==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8165,6 +8258,34 @@ packages: jsdom: optional: true + vitest@3.0.9: + resolution: {integrity: sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.0.9 + '@vitest/ui': 3.0.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -10195,6 +10316,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.5.3)': + dependencies: + '@babel/generator': 7.26.9 + '@babel/parser': 7.26.9 + '@babel/traverse': 7.26.9 + '@babel/types': 7.26.9 + javascript-natural-sort: 0.7.1 + lodash: 4.17.21 + prettier: 3.5.3 + transitivePeerDependencies: + - supports-color + '@trysound/sax@0.2.0': {} '@ts-morph/common@0.11.1': @@ -10347,6 +10480,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.25.0(@typescript-eslint/parser@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + '@typescript-eslint/scope-manager': 8.25.0 + '@typescript-eslint/type-utils': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + '@typescript-eslint/utils': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + '@typescript-eslint/visitor-keys': 8.25.0 + eslint: 9.21.0(jiti@2.4.2) + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 2.0.1(typescript@5.8.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.25.0(eslint@8.57.1)(typescript@5.7.3)': dependencies: '@typescript-eslint/scope-manager': 8.25.0 @@ -10371,6 +10521,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.25.0 + '@typescript-eslint/types': 8.25.0 + '@typescript-eslint/typescript-estree': 8.25.0(typescript@5.8.2) + '@typescript-eslint/visitor-keys': 8.25.0 + debug: 4.4.0(supports-color@9.4.0) + eslint: 9.21.0(jiti@2.4.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.25.0': dependencies: '@typescript-eslint/types': 8.25.0 @@ -10398,6 +10560,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)': + dependencies: + '@typescript-eslint/typescript-estree': 8.25.0(typescript@5.8.2) + '@typescript-eslint/utils': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + debug: 4.4.0(supports-color@9.4.0) + eslint: 9.21.0(jiti@2.4.2) + ts-api-utils: 2.0.1(typescript@5.8.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@5.62.0': {} '@typescript-eslint/types@8.25.0': {} @@ -10430,6 +10603,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.25.0(typescript@5.8.2)': + dependencies: + '@typescript-eslint/types': 8.25.0 + '@typescript-eslint/visitor-keys': 8.25.0 + debug: 4.4.0(supports-color@9.4.0) + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 2.0.1(typescript@5.8.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.25.0(eslint@8.57.1)(typescript@5.7.3)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) @@ -10452,6 +10639,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@9.21.0(jiti@2.4.2)) + '@typescript-eslint/scope-manager': 8.25.0 + '@typescript-eslint/types': 8.25.0 + '@typescript-eslint/typescript-estree': 8.25.0(typescript@5.8.2) + eslint: 9.21.0(jiti@2.4.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@5.62.0': dependencies: '@typescript-eslint/types': 5.62.0 @@ -10670,6 +10868,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 + '@vitest/expect@3.0.9': + dependencies: + '@vitest/spy': 3.0.9 + '@vitest/utils': 3.0.9 + chai: 5.2.0 + tinyrainbow: 2.0.0 + '@vitest/mocker@3.0.7(vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0))': dependencies: '@vitest/spy': 3.0.7 @@ -10678,31 +10883,64 @@ snapshots: optionalDependencies: vite: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0) + '@vitest/mocker@3.0.9(vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0))': + dependencies: + '@vitest/spy': 3.0.9 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0) + '@vitest/pretty-format@3.0.7': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@3.0.9': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/runner@3.0.7': dependencies: '@vitest/utils': 3.0.7 pathe: 2.0.3 + '@vitest/runner@3.0.9': + dependencies: + '@vitest/utils': 3.0.9 + pathe: 2.0.3 + '@vitest/snapshot@3.0.7': dependencies: '@vitest/pretty-format': 3.0.7 magic-string: 0.30.17 pathe: 2.0.3 + '@vitest/snapshot@3.0.9': + dependencies: + '@vitest/pretty-format': 3.0.9 + magic-string: 0.30.17 + pathe: 2.0.3 + '@vitest/spy@3.0.7': dependencies: tinyspy: 3.0.2 + '@vitest/spy@3.0.9': + dependencies: + tinyspy: 3.0.2 + '@vitest/utils@3.0.7': dependencies: '@vitest/pretty-format': 3.0.7 loupe: 3.1.3 tinyrainbow: 2.0.0 + '@vitest/utils@3.0.9': + dependencies: + '@vitest/pretty-format': 3.0.9 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + '@xhmikosr/archive-type@6.0.1': dependencies: file-type: 18.7.0 @@ -15597,6 +15835,8 @@ snapshots: prettier@3.5.2: {} + prettier@3.5.3: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -16780,6 +17020,10 @@ snapshots: dependencies: typescript: 5.7.3 + ts-api-utils@2.0.1(typescript@5.8.2): + dependencies: + typescript: 5.8.2 + ts-interface-checker@0.1.13: {} ts-invariant@0.10.3: @@ -16875,6 +17119,33 @@ snapshots: - tsx - yaml + tsup@8.4.0(jiti@2.4.2)(postcss@8.5.3)(typescript@5.8.2)(yaml@2.7.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.0) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.0 + debug: 4.4.0(supports-color@9.4.0) + esbuild: 0.25.0 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.3)(yaml@2.7.0) + resolve-from: 5.0.0 + rollup: 4.34.8 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.12 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.3 + typescript: 5.8.2 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsutils@3.21.0(typescript@5.7.3): dependencies: tslib: 1.14.1 @@ -16952,10 +17223,22 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-eslint@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.25.0(@typescript-eslint/parser@8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + '@typescript-eslint/parser': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + '@typescript-eslint/utils': 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2) + eslint: 9.21.0(jiti@2.4.2) + typescript: 5.8.2 + transitivePeerDependencies: + - supports-color + typescript@4.9.5: {} typescript@5.7.3: {} + typescript@5.8.2: {} + ufo@1.5.4: {} uid-promise@1.0.0: {} @@ -17147,6 +17430,10 @@ snapshots: optionalDependencies: typescript: 5.7.3 + valibot@1.0.0(typescript@5.8.2): + optionalDependencies: + typescript: 5.8.2 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -17237,6 +17524,27 @@ snapshots: - tsx - yaml + vite-node@3.0.9(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0): + dependencies: + cac: 6.7.14 + debug: 4.4.0(supports-color@9.4.0) + es-module-lexer: 1.6.0 + pathe: 2.0.3 + vite: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-static-copy@2.2.0(vite@5.4.14(@types/node@22.13.5)): dependencies: chokidar: 3.6.0 @@ -17328,6 +17636,47 @@ snapshots: - tsx - yaml + vitest@3.0.9(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.13.5)(jiti@2.4.2)(jsdom@26.0.0)(yaml@2.7.0): + dependencies: + '@vitest/expect': 3.0.9 + '@vitest/mocker': 3.0.9(vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0)) + '@vitest/pretty-format': 3.0.9 + '@vitest/runner': 3.0.9 + '@vitest/snapshot': 3.0.9 + '@vitest/spy': 3.0.9 + '@vitest/utils': 3.0.9 + chai: 5.2.0 + debug: 4.4.0(supports-color@9.4.0) + expect-type: 1.1.0 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.8.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 6.2.0(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0) + vite-node: 3.0.9(@types/node@22.13.5)(jiti@2.4.2)(yaml@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 5.0.0 + '@types/debug': 4.1.12 + '@types/node': 22.13.5 + jsdom: 26.0.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/website/src/routes/guides/(advanced)/json-schema/index.mdx b/website/src/routes/guides/(advanced)/json-schema/index.mdx index f89223920..7ec84e4f9 100644 --- a/website/src/routes/guides/(advanced)/json-schema/index.mdx +++ b/website/src/routes/guides/(advanced)/json-schema/index.mdx @@ -27,6 +27,27 @@ const JsonEmailSchema = toJsonSchema(ValibotEmailSchema); // -> { type: 'string', format: 'email' } ``` +## OpenAPI Declarations and JSON Schema to Valibot + +If your API provides OpenAPI Declarations or JSON Schemas, those can be easily converted to Valibot schemas, so you don't have to do it yourself and keep it in sync. This can be done using the official `valibotGenerator` function. This function is provided via a separate package called [`@valibot/to-valibot`](https://github.com/fabian-hiller/valibot/tree/main/packages/to-valibot). + +> See the [README](https://github.com/fabian-hiller/valibot/blob/main/packages/to-valibot/README.md) of the `@valibot/to-valibot` package for more details. + +```ts +import { valibotGenerator } from "@valibot/to-valibot"; + +const generate = valibotGenerator({ + outDir: "./src/types", +}); + +const schema = fetch("http://api.example.org/v2/api-docs?group=my-awesome-api").then(r => r.json()); + +await generate({ + format: 'openapi-json', + schema, +}); +``` + ## Cons of JSON Schema Valibot schemas intentionally do not output JSON Schema natively. This is because JSON Schema is limited to JSON-compliant data structures. In addition, more advanced features like transformations are not supported. Since we want to leverage the full power of TypeScript, we output a custom format instead. diff --git a/website/src/routes/guides/(get-started)/ecosystem/index.mdx b/website/src/routes/guides/(get-started)/ecosystem/index.mdx index ad1965d3c..9d3643dde 100644 --- a/website/src/routes/guides/(get-started)/ecosystem/index.mdx +++ b/website/src/routes/guides/(get-started)/ecosystem/index.mdx @@ -77,6 +77,7 @@ This page is for you if you are looking for frameworks or libraries that support ## X to Valibot +- [@valibot/to-valibot](https://github.com/fabian-hiller/valibot/tree/main/packages/to-valibot): The official converter from OpenAPI Declarations and JSON schema to Valibot - [graphql-codegen-typescript-validation-schema](https://github.com/Code-Hex/graphql-codegen-typescript-validation-schema): GraphQL Code Generator plugin to generate form validation schema from your GraphQL schema. - [TypeBox-Codegen](https://sinclairzx81.github.io/typebox-workbench/): Code generation for schema libraries - [TypeMap](https://github.com/sinclairzx81/typemap/): Uniform Syntax, Mapping and Compiler Library for TypeBox, Valibot and Zod