这是indexloc提供的服务,不要输入任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions dev/openapi-ts.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ export default defineConfig(() => {
// 'circular.yaml',
// 'dutchie.json',
// 'invalid',
// 'full.yaml',
'full.yaml',
// 'openai.yaml',
// 'opencode.yaml',
// 'sdk-instance.yaml',
// 'string-with-format.yaml',
'transformers.json',
// 'transformers.json',
// 'type-format.yaml',
// 'validators.yaml',
// 'validators-circular-ref.json',
Expand Down Expand Up @@ -396,6 +396,14 @@ export default defineConfig(() => {
},
},
'~resolvers': {
object: {
// base({ $, additional, pipes, shape }) {
// if (additional === undefined) {
// return pipes.push($('v').attr('looseObject').call(shape));
// }
// return;
// },
},
string: {
formats: {
// date: ({ $, pipes }) => pipes.push($('v').attr('isoDateTime').call()),
Expand All @@ -407,7 +415,7 @@ export default defineConfig(() => {
{
// case: 'snake_case',
// comments: false,
compatibilityVersion: 3,
compatibilityVersion: 4,
dates: {
// local: true,
// offset: true,
Expand Down Expand Up @@ -456,6 +464,15 @@ export default defineConfig(() => {
},
},
'~resolvers': {
object: {
base({ $, additional, shape }) {
if (!additional) {
// return $('z').attr('object').call(shape).attr('passthrough').call()
return $('z').attr('looseObject').call(shape);
}
return;
},
},
string: {
formats: {
// date: ({ $ }) => $('z').attr('date').call(),
Expand Down
38 changes: 36 additions & 2 deletions packages/openapi-ts/src/plugins/valibot/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type ts from 'typescript';

import type { IR } from '~/ir/types';
import type { DefinePlugin, Plugin } from '~/plugins';
import type { CallTsDsl, DollarTsDsl } from '~/ts-dsl';
import type { CallTsDsl, DollarTsDsl, ObjectTsDsl } from '~/ts-dsl';
import type { StringCase, StringName } from '~/types/case';

import type { IApi } from './api';
Expand Down Expand Up @@ -317,7 +319,7 @@ export type Config = Plugin.Name<'valibot'> &
};
};

export type FormatResolverArgs = DollarTsDsl & {
type SharedResolverArgs = DollarTsDsl & {
/**
* The current builder state being processed by this resolver.
*
Expand All @@ -330,10 +332,42 @@ export type FormatResolverArgs = DollarTsDsl & {
*/
pipes: Array<CallTsDsl>;
plugin: ValibotPlugin['Instance'];
};

export type FormatResolverArgs = SharedResolverArgs & {
schema: IR.SchemaObject;
};

export type ObjectBaseResolverArgs = SharedResolverArgs & {
/** Null = never */
additional?: ts.Expression | null;
schema: IR.SchemaObject;
shape: ObjectTsDsl;
};

type Resolvers = Plugin.Resolvers<{
/**
* Resolvers for object schemas.
*
* Allows customization of how object types are rendered.
*
* Example path: `~resolvers.object.base`
*
* Returning `undefined` from a resolver will apply the default
* generation behavior for the object schema.
*/
object?: {
/**
* Controls how object schemas are constructed.
*
* Called with the fully assembled shape (properties) and any additional
* property schema, allowing the resolver to choose the correct Valibot
* base constructor and modify the schema chain if needed.
*
* Returning `undefined` will execute the default resolver logic.
*/
base?: (args: ObjectBaseResolverArgs) => CallTsDsl | undefined;
};
/**
* Resolvers for string schemas.
*
Expand Down
202 changes: 79 additions & 123 deletions packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,60 @@
import ts from 'typescript';
import type ts from 'typescript';

import type { SchemaWithType } from '~/plugins';
import { toRef } from '~/plugins/shared/utils/refs';
import { tsc } from '~/tsc';
import { numberRegExp } from '~/utils/regexp';
import { $, type CallTsDsl } from '~/ts-dsl';

import { pipesToAst } from '../../shared/pipesToAst';
import type { Ast, IrSchemaToAstOptions } from '../../shared/types';
import type { ObjectBaseResolverArgs } from '../../types';
import { identifiers } from '../constants';
import { irSchemaToAst } from '../plugin';

function defaultObjectBaseResolver({
additional,
pipes,
plugin,
shape,
}: ObjectBaseResolverArgs): number {
const v = plugin.referenceSymbol({
category: 'external',
resource: 'valibot.v',
});

// Handle `additionalProperties: { type: 'never' }` → v.strictObject()
if (additional === null) {
return pipes.push(
$(v.placeholder).attr(identifiers.schemas.strictObject).call(shape),
);
}

// Handle additionalProperties as schema → v.record() or v.objectWithRest()
if (additional) {
if (shape.isEmpty) {
return pipes.push(
$(v.placeholder)
.attr(identifiers.schemas.record)
.call(
$(v.placeholder).attr(identifiers.schemas.string).call(),
additional,
),
);
}

// If there are named properties, use v.objectWithRest() to validate both
return pipes.push(
$(v.placeholder)
.attr(identifiers.schemas.objectWithRest)
.call(shape, additional),
);
}

// Default case → v.object()
return pipes.push(
$(v.placeholder).attr(identifiers.schemas.object).call(shape),
);
}

export const objectToAst = ({
plugin,
schema,
Expand All @@ -18,10 +63,11 @@ export const objectToAst = ({
schema: SchemaWithType<'object'>;
}): Omit<Ast, 'typeName'> => {
const result: Partial<Omit<Ast, 'typeName'>> = {};
const pipes: Array<CallTsDsl> = [];

// TODO: parser - handle constants
const properties: Array<ts.PropertyAssignment> = [];

const shape = $.object().pretty();
const required = schema.required ?? [];

for (const name in schema.properties) {
Expand All @@ -37,130 +83,40 @@ export const objectToAst = ({
path: toRef([...state.path.value, 'properties', name]),
},
});
if (propertyAst.hasLazyExpression) {
result.hasLazyExpression = true;
}
if (propertyAst.hasLazyExpression) result.hasLazyExpression = true;

numberRegExp.lastIndex = 0;
let propertyName;
if (numberRegExp.test(name)) {
// For numeric literals, we'll handle negative numbers by using a string literal
// instead of trying to use a PrefixUnaryExpression
propertyName = name.startsWith('-')
? ts.factory.createStringLiteral(name)
: ts.factory.createNumericLiteral(name);
} else {
propertyName = name;
}
// TODO: parser - abstract safe property name logic
if (
((name.match(/^[0-9]/) && name.match(/\D+/g)) || name.match(/\W/g)) &&
!name.startsWith("'") &&
!name.endsWith("'")
) {
propertyName = `'${name}'`;
}
properties.push(
tsc.propertyAssignment({
initializer: pipesToAst({ pipes: propertyAst.pipes, plugin }),
name: propertyName,
}),
);
}

const v = plugin.referenceSymbol({
category: 'external',
resource: 'valibot.v',
});

// Handle additionalProperties: false (which becomes type: 'never' in IR)
// Use v.strictObject() to forbid additional properties
if (
schema.additionalProperties &&
typeof schema.additionalProperties === 'object' &&
schema.additionalProperties.type === 'never'
) {
result.pipes = [
tsc.callExpression({
functionName: tsc.propertyAccessExpression({
expression: v.placeholder,
name: identifiers.schemas.strictObject,
}),
parameters: [
ts.factory.createObjectLiteralExpression(properties, true),
],
}),
];
return result as Omit<Ast, 'typeName'>;
shape.prop(name, pipesToAst({ pipes: propertyAst.pipes, plugin }));
}

// Handle additionalProperties with a schema (not just true/false)
// This supports objects with dynamic keys (e.g., Record<string, T>)
if (
schema.additionalProperties &&
typeof schema.additionalProperties === 'object' &&
schema.additionalProperties.type !== undefined
) {
const additionalAst = irSchemaToAst({
plugin,
schema: schema.additionalProperties,
state: {
...state,
path: toRef([...state.path.value, 'additionalProperties']),
},
});
if (additionalAst.hasLazyExpression) {
result.hasLazyExpression = true;
}

// If there are no named properties, use v.record() directly
if (!Object.keys(properties).length) {
result.pipes = [
tsc.callExpression({
functionName: tsc.propertyAccessExpression({
expression: v.placeholder,
name: identifiers.schemas.record,
}),
parameters: [
tsc.callExpression({
functionName: tsc.propertyAccessExpression({
expression: v.placeholder,
name: identifiers.schemas.string,
}),
parameters: [],
}),
pipesToAst({ pipes: additionalAst.pipes, plugin }),
],
}),
];
return result as Omit<Ast, 'typeName'>;
let additional: ts.Expression | null | undefined;
if (schema.additionalProperties && schema.additionalProperties.type) {
if (schema.additionalProperties.type === 'never') {
additional = null;
} else {
const additionalAst = irSchemaToAst({
plugin,
schema: schema.additionalProperties,
state: {
...state,
path: toRef([...state.path.value, 'additionalProperties']),
},
});
if (additionalAst.hasLazyExpression) result.hasLazyExpression = true;
additional = pipesToAst({ pipes: additionalAst.pipes, plugin });
}

// If there are named properties, use v.objectWithRest() to validate both
// The rest parameter is the schema for each additional property value
result.pipes = [
tsc.callExpression({
functionName: tsc.propertyAccessExpression({
expression: v.placeholder,
name: identifiers.schemas.objectWithRest,
}),
parameters: [
ts.factory.createObjectLiteralExpression(properties, true),
pipesToAst({ pipes: additionalAst.pipes, plugin }),
],
}),
];
return result as Omit<Ast, 'typeName'>;
}

result.pipes = [
tsc.callExpression({
functionName: tsc.propertyAccessExpression({
expression: v.placeholder,
name: identifiers.schemas.object,
}),
parameters: [ts.factory.createObjectLiteralExpression(properties, true)],
}),
];
const args: ObjectBaseResolverArgs = {
$,
additional,
pipes,
plugin,
schema,
shape,
};
const resolver = plugin.config['~resolvers']?.object?.base;
if (!resolver?.(args)) defaultObjectBaseResolver(args);

result.pipes = [pipesToAst({ pipes, plugin })];
return result as Omit<Ast, 'typeName'>;
};
Loading
Loading