Skip to content

Commit fe5bfba

Browse files
authored
Merge pull request #3532 from hey-api/feat/plugin-pydantic-resolvers
feat: add resolvers to pydantic
2 parents 750c5d7 + 8ad7991 commit fe5bfba

27 files changed

Lines changed: 303 additions & 102 deletions

File tree

dev/python/presets.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ export const presets = {
99
containerName: 'OpenCode',
1010
strategy: 'single',
1111
},
12+
paramsStructure: 'flat',
1213
}),
1314
],
1415
validated: () => [
1516
/** SDK + Pydantic validation */
16-
sdk(),
17+
sdk({
18+
paramsStructure: 'flat',
19+
}),
1720
pydantic(),
1821
],
1922
} as const satisfies Record<string, () => ReadonlyArray<PluginConfig>>;

docs/openapi-ts/plugins/concepts/resolvers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: Understand the concepts behind plugins.
77

88
Sometimes the default plugin behavior isn't what you need or expect. Resolvers let you patch plugins in a safe and performant way, without forking or reimplementing core logic.
99

10-
Currently available for [Valibot](/openapi-ts/plugins/valibot) and [Zod](/openapi-ts/plugins/zod).
10+
Currently available for [TypeScript](/openapi-ts/plugins/typescript), [Valibot](/openapi-ts/plugins/valibot), and [Zod](/openapi-ts/plugins/zod).
1111

1212
## Examples
1313

docs/openapi-ts/plugins/typescript.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ export default {
166166

167167
:::
168168

169+
## Resolvers
170+
171+
You can further customize this plugin's behavior using [resolvers](/openapi-ts/plugins/concepts/resolvers).
172+
169173
## API
170174

171175
You can view the complete list of options in the [UserConfig](https://github.com/hey-api/openapi-ts/blob/main/packages/openapi-ts/src/plugins/@hey-api/typescript/types.ts) interface.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
11
export { defaultConfig, defineConfig } from './config';
2+
export type {
3+
EnumResolverContext,
4+
NumberResolverContext,
5+
ObjectResolverContext,
6+
Resolvers,
7+
StringResolverContext,
8+
} from './resolvers';
29
export type { PydanticPlugin } from './types';
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Plugin, SchemaVisitorContext, SchemaWithType, Walker } from '@hey-api/shared';
2+
3+
import type { DollarPyDsl } from '../../py-dsl';
4+
import type { PydanticField, PydanticFinal, PydanticResult, PydanticType } from './shared/types';
5+
import type { PydanticPlugin } from './types';
6+
7+
export type Resolvers = Plugin.Resolvers<{
8+
/**
9+
* Resolver for enum schemas.
10+
*
11+
* Allows customization of how enum types are rendered.
12+
*
13+
* Returning `undefined` will execute the default resolver logic.
14+
*/
15+
enum?: (ctx: EnumResolverContext) => PydanticType | undefined;
16+
/**
17+
* Resolver for number schemas.
18+
*
19+
* Allows customization of how number types are rendered.
20+
*
21+
* Returning `undefined` will execute the default resolver logic.
22+
*/
23+
number?: (ctx: NumberResolverContext) => PydanticType | undefined;
24+
/**
25+
* Resolver for object schemas.
26+
*
27+
* Allows customization of how object types are rendered.
28+
*
29+
* Returning `undefined` will execute the default resolver logic.
30+
*/
31+
object?: (
32+
ctx: ObjectResolverContext,
33+
) => (PydanticType & { fields?: Array<PydanticField> }) | undefined;
34+
/**
35+
* Resolver for string schemas.
36+
*
37+
* Allows customization of how string types are rendered.
38+
*
39+
* Returning `undefined` will execute the default resolver logic.
40+
*/
41+
string?: (ctx: StringResolverContext) => PydanticType | undefined;
42+
}>;
43+
44+
interface BaseContext extends DollarPyDsl {
45+
/** The plugin instance. */
46+
plugin: PydanticPlugin['Instance'];
47+
}
48+
49+
export interface EnumResolverContext extends BaseContext {
50+
/**
51+
* Nodes used to build different parts of the result.
52+
*/
53+
nodes: {
54+
base: (ctx: EnumResolverContext) => PydanticType;
55+
items: (ctx: EnumResolverContext) => {
56+
enumMembers: Required<PydanticFinal>['enumMembers'];
57+
isNullable: boolean;
58+
};
59+
};
60+
schema: SchemaWithType<'enum'>;
61+
}
62+
63+
export interface NumberResolverContext extends BaseContext {
64+
/**
65+
* Nodes used to build different parts of the result.
66+
*/
67+
nodes: {
68+
base: (ctx: NumberResolverContext) => PydanticType;
69+
const: (ctx: NumberResolverContext) => PydanticType | undefined;
70+
};
71+
schema: SchemaWithType<'integer' | 'number'>;
72+
}
73+
74+
export interface ObjectResolverContext extends BaseContext {
75+
_childResults: Array<PydanticResult>;
76+
applyModifiers: (result: PydanticResult, opts: { optional?: boolean }) => PydanticFinal;
77+
/**
78+
* Nodes used to build different parts of the result.
79+
*/
80+
nodes: {
81+
additionalProperties: (ctx: ObjectResolverContext) => PydanticType | null | undefined;
82+
base: (ctx: ObjectResolverContext) => PydanticType & { fields?: Array<PydanticField> };
83+
fields: (ctx: ObjectResolverContext) => Array<PydanticField>;
84+
};
85+
schema: SchemaWithType<'object'>;
86+
walk: Walker<PydanticResult, PydanticPlugin['Instance']>;
87+
walkerCtx: SchemaVisitorContext<PydanticPlugin['Instance']>;
88+
}
89+
90+
export interface StringResolverContext extends BaseContext {
91+
/**
92+
* Nodes used to build different parts of the result.
93+
*/
94+
nodes: {
95+
base: (ctx: StringResolverContext) => PydanticType;
96+
const: (ctx: StringResolverContext) => PydanticType | undefined;
97+
};
98+
schema: SchemaWithType<'string'>;
99+
}

packages/openapi-python/src/plugins/pydantic/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import type {
77
Plugin,
88
} from '@hey-api/shared';
99

10+
import type { Resolvers } from './resolvers';
11+
1012
export type UserConfig = Plugin.Name<'pydantic'> &
1113
Plugin.Hooks &
1214
Plugin.UserComments &
13-
Plugin.UserExports & {
15+
Plugin.UserExports &
16+
Resolvers & {
1417
/**
1518
* Casing convention for generated names.
1619
*
@@ -185,7 +188,8 @@ export type UserConfig = Plugin.Name<'pydantic'> &
185188
export type Config = Plugin.Name<'pydantic'> &
186189
Plugin.Hooks &
187190
Plugin.Comments &
188-
Plugin.Exports & {
191+
Plugin.Exports &
192+
Resolvers & {
189193
/** Casing convention for generated names. */
190194
case: Casing;
191195
/** Configuration for reusable schema definitions. */

packages/openapi-python/src/plugins/pydantic/v2/toAst/enum.ts

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { Symbol } from '@hey-api/codegen-core';
21
import type { SchemaWithType } from '@hey-api/shared';
32
import { toCase } from '@hey-api/shared';
43

54
import { $ } from '../../../../py-dsl';
5+
import type { EnumResolverContext } from '../../resolvers';
66
import type { PydanticFinal, PydanticType } from '../../shared/types';
77
import type { PydanticPlugin } from '../../types';
88

@@ -21,13 +21,8 @@ function toEnumMemberName(value: string | number): string {
2121
return toCase(value, 'SCREAMING_SNAKE_CASE');
2222
}
2323

24-
function extractEnumMembers(
25-
schema: SchemaWithType<'enum'>,
26-
plugin: PydanticPlugin['Instance'],
27-
): {
28-
enumMembers: Required<PydanticFinal>['enumMembers'];
29-
isNullable: boolean;
30-
} {
24+
function itemsNode(ctx: EnumResolverContext) {
25+
const { plugin, schema } = ctx;
3126
const enumMembers: Required<PydanticFinal>['enumMembers'] = [];
3227
let isNullable = false;
3328

@@ -51,51 +46,67 @@ function extractEnumMembers(
5146
return { enumMembers, isNullable };
5247
}
5348

54-
function toLiteralType(
55-
enumMembers: Required<PydanticFinal>['enumMembers'],
56-
plugin: PydanticPlugin['Instance'],
57-
): string | Symbol | ReturnType<typeof $.subscript> {
49+
function baseNode(ctx: EnumResolverContext): PydanticType {
50+
const { plugin } = ctx;
51+
const { enumMembers } = ctx.nodes.items(ctx);
52+
5853
if (enumMembers.length === 0) {
59-
return plugin.external('typing.Any');
54+
return {
55+
type: plugin.external('typing.Any'),
56+
};
57+
}
58+
59+
const mode = plugin.config.enums ?? 'enum';
60+
61+
if (mode === 'literal') {
62+
if (enumMembers.length === 0) {
63+
return {
64+
type: plugin.external('typing.Any'),
65+
};
66+
}
67+
68+
const literal = plugin.external('typing.Literal');
69+
const values = enumMembers.map((m) =>
70+
// TODO: replace
71+
typeof m.value === 'string' ? `"<<<<${m.value}"` : `<<<${m.value}`,
72+
);
73+
74+
return {
75+
type: $(literal).slice(...values),
76+
};
6077
}
6178

62-
const literal = plugin.external('typing.Literal');
63-
const values = enumMembers.map((m) =>
64-
// TODO: replace
65-
typeof m.value === 'string' ? `"<<<<${m.value}"` : `<<<${m.value}`,
66-
);
79+
return {};
80+
}
6781

68-
return $(literal).slice(...values);
82+
function enumResolver(ctx: EnumResolverContext): PydanticType {
83+
return ctx.nodes.base(ctx);
6984
}
7085

7186
export function enumToType({
72-
mode = 'enum',
7387
plugin,
7488
schema,
7589
}: {
76-
mode?: 'enum' | 'literal';
7790
plugin: PydanticPlugin['Instance'];
7891
schema: SchemaWithType<'enum'>;
7992
}): EnumToTypeResult {
80-
const { enumMembers, isNullable } = extractEnumMembers(schema, plugin);
93+
const ctx: EnumResolverContext = {
94+
$,
95+
nodes: {
96+
base: baseNode,
97+
items: itemsNode,
98+
},
99+
plugin,
100+
schema,
101+
};
81102

82-
if (enumMembers.length === 0) {
83-
return {
84-
enumMembers,
85-
isNullable,
86-
type: plugin.external('typing.Any'),
87-
};
88-
}
103+
const resolver = plugin.config['~resolvers']?.enum;
104+
const resolved = resolver?.(ctx) ?? enumResolver(ctx);
89105

90-
if (mode === 'literal') {
91-
return {
92-
enumMembers,
93-
isNullable,
94-
type: toLiteralType(enumMembers, plugin),
95-
};
96-
}
106+
const { enumMembers, isNullable } = ctx.nodes.items(ctx);
97107

98108
return {
109+
...resolved,
99110
enumMembers,
100111
isNullable,
101112
};

packages/openapi-python/src/plugins/pydantic/v2/toAst/number.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
import type { SchemaWithType } from '@hey-api/shared';
22

33
import { $ } from '../../../../py-dsl';
4+
import type { NumberResolverContext } from '../../resolvers';
45
import type { PydanticType } from '../../shared/types';
56
import type { PydanticPlugin } from '../../types';
67
import type { FieldConstraints } from '../constants';
78

8-
export function numberToType({
9-
plugin,
10-
schema,
11-
}: {
12-
plugin: PydanticPlugin['Instance'];
13-
schema: SchemaWithType<'integer' | 'number'>;
14-
}): PydanticType {
15-
const constraints: FieldConstraints = {};
9+
function constNode(ctx: NumberResolverContext): PydanticType | undefined {
10+
const { plugin, schema } = ctx;
1611

1712
if (typeof schema.const === 'number') {
1813
const literal = plugin.external('typing.Literal');
@@ -21,6 +16,14 @@ export function numberToType({
2116
};
2217
}
2318

19+
return undefined;
20+
}
21+
22+
function baseNode(ctx: NumberResolverContext): PydanticType {
23+
const { schema } = ctx;
24+
25+
const constraints: FieldConstraints = {};
26+
2427
if (schema.minimum !== undefined) {
2528
constraints.ge = schema.minimum;
2629
}
@@ -46,3 +49,31 @@ export function numberToType({
4649
type: schema.type === 'integer' ? 'int' : 'float',
4750
};
4851
}
52+
53+
function numberResolver(ctx: NumberResolverContext): PydanticType {
54+
const constResult = ctx.nodes.const(ctx);
55+
if (constResult) return constResult;
56+
57+
return ctx.nodes.base(ctx);
58+
}
59+
60+
export function numberToType({
61+
plugin,
62+
schema,
63+
}: {
64+
plugin: PydanticPlugin['Instance'];
65+
schema: SchemaWithType<'integer' | 'number'>;
66+
}): PydanticType {
67+
const ctx: NumberResolverContext = {
68+
$,
69+
nodes: {
70+
base: baseNode,
71+
const: constNode,
72+
},
73+
plugin,
74+
schema,
75+
};
76+
77+
const resolver = plugin.config['~resolvers']?.number;
78+
return resolver?.(ctx) ?? numberResolver(ctx);
79+
}

0 commit comments

Comments
 (0)