@workos/oagen-emitters 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11893 -3226
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +78 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +2 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +374 -364
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +32 -12
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +18 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +99 -69
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
3
|
+
import { className } from './naming.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map an IR TypeRef to a Python type hint string.
|
|
7
|
+
* Uses standard library types: str, int, float, bool, List, Dict, Optional, Union.
|
|
8
|
+
*/
|
|
9
|
+
export function mapTypeRef(ref: TypeRef): string {
|
|
10
|
+
return irMapTypeRef<string>(ref, {
|
|
11
|
+
primitive: mapPrimitive,
|
|
12
|
+
array: (ref, items) => {
|
|
13
|
+
void ref;
|
|
14
|
+
return `List[${items}]`;
|
|
15
|
+
},
|
|
16
|
+
model: (r) => `"${className(r.name)}"`,
|
|
17
|
+
enum: (r) => `"${className(r.name)}"`,
|
|
18
|
+
union: (r, variants) => joinUnionVariants(r, variants),
|
|
19
|
+
nullable: (ref, inner) => {
|
|
20
|
+
void ref;
|
|
21
|
+
return `Optional[${inner}]`;
|
|
22
|
+
},
|
|
23
|
+
literal: (r) =>
|
|
24
|
+
typeof r.value === 'string' ? `Literal["${r.value}"]` : r.value === null ? 'None' : `Literal[${String(r.value)}]`,
|
|
25
|
+
map: (ref, value) => {
|
|
26
|
+
void ref;
|
|
27
|
+
return `Dict[str, ${value}]`;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Map an IR TypeRef to a plain Python type string (no quotes around model/enum refs).
|
|
34
|
+
* Used for import collection and direct type references.
|
|
35
|
+
*/
|
|
36
|
+
export function mapTypeRefUnquoted(ref: TypeRef, knownEnums?: Set<string>, allowRawEnumStrings = false): string {
|
|
37
|
+
return irMapTypeRef<string>(ref, {
|
|
38
|
+
primitive: mapPrimitive,
|
|
39
|
+
array: (ref, items) => {
|
|
40
|
+
void ref;
|
|
41
|
+
return `List[${items}]`;
|
|
42
|
+
},
|
|
43
|
+
model: (r) => className(r.name),
|
|
44
|
+
enum: (r) => {
|
|
45
|
+
if (knownEnums && !knownEnums.has(r.name)) return 'str';
|
|
46
|
+
const enumType = className(r.name);
|
|
47
|
+
return allowRawEnumStrings ? `Union[${enumType}, str]` : enumType;
|
|
48
|
+
},
|
|
49
|
+
union: (r, variants) => joinUnionVariants(r, variants),
|
|
50
|
+
nullable: (ref, inner) => {
|
|
51
|
+
void ref;
|
|
52
|
+
return `Optional[${inner}]`;
|
|
53
|
+
},
|
|
54
|
+
literal: (r) =>
|
|
55
|
+
typeof r.value === 'string' ? `Literal["${r.value}"]` : r.value === null ? 'None' : `Literal[${String(r.value)}]`,
|
|
56
|
+
map: (ref, value) => {
|
|
57
|
+
void ref;
|
|
58
|
+
return `Dict[str, ${value}]`;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mapPrimitive(ref: PrimitiveType): string {
|
|
64
|
+
if (ref.format) {
|
|
65
|
+
switch (ref.format) {
|
|
66
|
+
case 'binary':
|
|
67
|
+
return 'bytes';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
switch (ref.type) {
|
|
71
|
+
case 'string':
|
|
72
|
+
return 'str';
|
|
73
|
+
case 'integer':
|
|
74
|
+
return 'int';
|
|
75
|
+
case 'number':
|
|
76
|
+
return 'float';
|
|
77
|
+
case 'boolean':
|
|
78
|
+
return 'bool';
|
|
79
|
+
case 'unknown':
|
|
80
|
+
return 'Any';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function joinUnionVariants(ref: UnionType, variants: string[]): string {
|
|
85
|
+
if (ref.compositionKind === 'allOf') {
|
|
86
|
+
// Python doesn't have intersection types; use the first variant
|
|
87
|
+
return variants[0] ?? 'Any';
|
|
88
|
+
}
|
|
89
|
+
// Deduplicate identical variants (e.g., Union[Foo, Foo] -> Foo)
|
|
90
|
+
const unique = [...new Set(variants)];
|
|
91
|
+
if (unique.length === 1) return unique[0];
|
|
92
|
+
return `Union[${unique.join(', ')}]`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
+
import { toSnakeCase } from '@workos/oagen';
|
|
3
|
+
import { className, fieldName } from './naming.js';
|
|
4
|
+
import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate Python wrapper method lines for split operations.
|
|
8
|
+
*
|
|
9
|
+
* Each wrapper is a typed convenience method that:
|
|
10
|
+
* - Accepts only the exposed params (not the full union body)
|
|
11
|
+
* - Injects constant defaults (e.g., grant_type)
|
|
12
|
+
* - Reads inferred fields from client config (e.g., client_id)
|
|
13
|
+
* - Delegates to the HTTP client with the constructed body
|
|
14
|
+
*
|
|
15
|
+
* Generates both sync and async versions.
|
|
16
|
+
*/
|
|
17
|
+
export function generateSyncWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
|
|
18
|
+
return generateWrapperMethodsInner(resolvedOp, ctx, false);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generateAsyncWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
|
|
22
|
+
return generateWrapperMethodsInner(resolvedOp, ctx, true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function generateWrapperMethodsInner(resolvedOp: ResolvedOperation, ctx: EmitterContext, isAsync: boolean): string[] {
|
|
26
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
27
|
+
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
|
|
30
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
31
|
+
lines.push('');
|
|
32
|
+
emitWrapperMethod(lines, resolvedOp, wrapper, ctx, isAsync);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function emitWrapperMethod(
|
|
39
|
+
lines: string[],
|
|
40
|
+
resolvedOp: ResolvedOperation,
|
|
41
|
+
wrapper: ResolvedWrapper,
|
|
42
|
+
ctx: EmitterContext,
|
|
43
|
+
isAsync: boolean,
|
|
44
|
+
): void {
|
|
45
|
+
const op = resolvedOp.operation;
|
|
46
|
+
const method = wrapper.name; // already snake_case
|
|
47
|
+
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
48
|
+
|
|
49
|
+
// Build signature
|
|
50
|
+
const defKeyword = isAsync ? 'async def' : 'def';
|
|
51
|
+
lines.push(` ${defKeyword} ${method}(`);
|
|
52
|
+
lines.push(' self,');
|
|
53
|
+
|
|
54
|
+
// Path params as positional args
|
|
55
|
+
for (const param of op.pathParams) {
|
|
56
|
+
const paramName = fieldName(param.name);
|
|
57
|
+
const paramType = resolveSimpleType(param.type);
|
|
58
|
+
lines.push(` ${paramName}: ${paramType},`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
lines.push(' *,');
|
|
62
|
+
|
|
63
|
+
// Exposed params as keyword args
|
|
64
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
65
|
+
const pyName = fieldName(paramName);
|
|
66
|
+
const pyType = field ? resolveSimpleType(field.type) : 'str';
|
|
67
|
+
|
|
68
|
+
if (isOptional) {
|
|
69
|
+
lines.push(` ${pyName}: Optional[${pyType}] = None,`);
|
|
70
|
+
} else {
|
|
71
|
+
lines.push(` ${pyName}: ${pyType},`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push(' request_options: Optional[RequestOptions] = None,');
|
|
76
|
+
|
|
77
|
+
// Return type
|
|
78
|
+
const responseType = wrapper.responseModelName ? className(wrapper.responseModelName) : 'None';
|
|
79
|
+
|
|
80
|
+
lines.push(` ) -> ${responseType}:`);
|
|
81
|
+
|
|
82
|
+
// Docstring
|
|
83
|
+
lines.push(` """${formatWrapperDescription(wrapper.name)}."""`);
|
|
84
|
+
|
|
85
|
+
// Build body dict
|
|
86
|
+
lines.push(' body: Dict[str, Any] = {');
|
|
87
|
+
|
|
88
|
+
// Constant defaults
|
|
89
|
+
for (const [key, value] of Object.entries(wrapper.defaults)) {
|
|
90
|
+
lines.push(` "${key}": ${pythonLiteral(value)},`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Exposed params (required ones go directly)
|
|
94
|
+
for (const { paramName, isOptional } of wrapperParams) {
|
|
95
|
+
if (isOptional) continue;
|
|
96
|
+
const pyName = fieldName(paramName);
|
|
97
|
+
lines.push(` "${paramName}": ${pyName},`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lines.push(' }');
|
|
101
|
+
|
|
102
|
+
// Inferred fields from client config
|
|
103
|
+
for (const field of wrapper.inferFromClient) {
|
|
104
|
+
const expr = clientFieldExpression(field);
|
|
105
|
+
lines.push(` if ${expr} is not None:`);
|
|
106
|
+
lines.push(` body["${field}"] = ${expr}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Optional exposed params
|
|
110
|
+
for (const { paramName, isOptional } of wrapperParams) {
|
|
111
|
+
if (!isOptional) continue;
|
|
112
|
+
const pyName = fieldName(paramName);
|
|
113
|
+
lines.push(` if ${pyName} is not None:`);
|
|
114
|
+
lines.push(` body["${paramName}"] = ${pyName}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Build path expression
|
|
118
|
+
let pathExpr: string;
|
|
119
|
+
if (op.pathParams.length > 0) {
|
|
120
|
+
let path = op.path.replace(/^\//, '');
|
|
121
|
+
for (const p of op.pathParams) {
|
|
122
|
+
path = path.replace(`{${p.name}}`, `{${fieldName(p.name)}}`);
|
|
123
|
+
}
|
|
124
|
+
pathExpr = `f"${path}"`;
|
|
125
|
+
} else {
|
|
126
|
+
pathExpr = `"${op.path.replace(/^\//, '')}"`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Make the request
|
|
130
|
+
const awaitPrefix = isAsync ? 'await ' : '';
|
|
131
|
+
lines.push('');
|
|
132
|
+
|
|
133
|
+
if (wrapper.responseModelName) {
|
|
134
|
+
lines.push(` return ${awaitPrefix}self._client.request(`);
|
|
135
|
+
lines.push(` method="${op.httpMethod.toUpperCase()}",`);
|
|
136
|
+
lines.push(` path=${pathExpr},`);
|
|
137
|
+
lines.push(' body=body,');
|
|
138
|
+
lines.push(` model=${className(wrapper.responseModelName)},`);
|
|
139
|
+
lines.push(' request_options=request_options,');
|
|
140
|
+
lines.push(' )');
|
|
141
|
+
} else {
|
|
142
|
+
lines.push(` ${awaitPrefix}self._client.request(`);
|
|
143
|
+
lines.push(` method="${op.httpMethod.toUpperCase()}",`);
|
|
144
|
+
lines.push(` path=${pathExpr},`);
|
|
145
|
+
lines.push(' body=body,');
|
|
146
|
+
lines.push(' request_options=request_options,');
|
|
147
|
+
lines.push(' )');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Convert a value to a Python literal. */
|
|
152
|
+
export function pythonLiteral(value: string | number | boolean): string {
|
|
153
|
+
if (typeof value === 'string') return `"${value.replace(/"/g, '\\"')}"`;
|
|
154
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
155
|
+
return String(value);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Get the Python expression for reading a client config field. */
|
|
159
|
+
export function clientFieldExpression(field: string): string {
|
|
160
|
+
switch (field) {
|
|
161
|
+
case 'client_id':
|
|
162
|
+
return 'self._client.client_id';
|
|
163
|
+
case 'client_secret':
|
|
164
|
+
return 'self._client._api_key';
|
|
165
|
+
default:
|
|
166
|
+
return `self._client.${toSnakeCase(field)}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Resolve a TypeRef to a simple Python type string. */
|
|
171
|
+
function resolveSimpleType(ref: any): string {
|
|
172
|
+
if (ref.kind === 'primitive') {
|
|
173
|
+
switch (ref.type) {
|
|
174
|
+
case 'string':
|
|
175
|
+
return 'str';
|
|
176
|
+
case 'integer':
|
|
177
|
+
return 'int';
|
|
178
|
+
case 'number':
|
|
179
|
+
return 'float';
|
|
180
|
+
case 'boolean':
|
|
181
|
+
return 'bool';
|
|
182
|
+
default:
|
|
183
|
+
return 'Any';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (ref.kind === 'nullable') return resolveSimpleType(ref.inner);
|
|
187
|
+
if (ref.kind === 'array') return `List[${resolveSimpleType(ref.items)}]`;
|
|
188
|
+
if (ref.kind === 'model') return className(ref.name);
|
|
189
|
+
if (ref.kind === 'enum') return className(ref.name);
|
|
190
|
+
return 'Any';
|
|
191
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type { Model, Field, TypeRef } from '@workos/oagen';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
// @ts-ignore -- js-yaml has no type declarations in this project
|
|
5
|
+
import { load as yamlLoad } from 'js-yaml';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detect whether a model is a list wrapper -- the standard paginated
|
|
9
|
+
* list envelope with `data` (array), `list_metadata`, and optionally `object: 'list'`.
|
|
10
|
+
*
|
|
11
|
+
* These models are redundant because each language SDK already has its own
|
|
12
|
+
* pagination wrapper, and the runtime handles deserialization.
|
|
13
|
+
*/
|
|
14
|
+
export function isListWrapperModel(model: Model): boolean {
|
|
15
|
+
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
16
|
+
|
|
17
|
+
// Must have a `data` field that is an array type
|
|
18
|
+
const dataField = fieldsByName.get('data');
|
|
19
|
+
if (!dataField) return false;
|
|
20
|
+
if (dataField.type.kind !== 'array') return false;
|
|
21
|
+
|
|
22
|
+
// Must have a `list_metadata` field (IR may use snake_case or camelCase)
|
|
23
|
+
const listMetadataField = fieldsByName.get('list_metadata') ?? fieldsByName.get('listMetadata');
|
|
24
|
+
if (!listMetadataField) return false;
|
|
25
|
+
|
|
26
|
+
// Optionally has an `object` field with literal value 'list'
|
|
27
|
+
const objectField = fieldsByName.get('object');
|
|
28
|
+
if (objectField) {
|
|
29
|
+
if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect whether a model is a list metadata model (e.g., ListMetadata).
|
|
39
|
+
* These models typically have exactly `before` and `after` nullable string fields.
|
|
40
|
+
*/
|
|
41
|
+
export function isListMetadataModel(model: Model): boolean {
|
|
42
|
+
if (model.fields.length !== 2) return false;
|
|
43
|
+
|
|
44
|
+
const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
|
|
45
|
+
const before = fieldsByName.get('before');
|
|
46
|
+
const after = fieldsByName.get('after');
|
|
47
|
+
|
|
48
|
+
if (!before || !after) return false;
|
|
49
|
+
|
|
50
|
+
return isNullableString(before) && isNullableString(after);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Check if a field type is nullable string (nullable<string> or just string). */
|
|
54
|
+
function isNullableString(field: Field): boolean {
|
|
55
|
+
if (field.type.kind === 'primitive' && field.type.type === 'string') return true;
|
|
56
|
+
if (field.type.kind === 'nullable' && field.type.inner.kind === 'primitive' && field.type.inner.type === 'string')
|
|
57
|
+
return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// oneOf / allOf+oneOf flattening
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Discover the OpenAPI spec path from CLI args or environment.
|
|
67
|
+
* Returns null if not found.
|
|
68
|
+
*/
|
|
69
|
+
function discoverSpecPath(): string | null {
|
|
70
|
+
// Check --spec CLI arg
|
|
71
|
+
const args = process.argv;
|
|
72
|
+
for (let i = 0; i < args.length; i++) {
|
|
73
|
+
if (args[i] === '--spec' && args[i + 1]) return resolve(args[i + 1]);
|
|
74
|
+
if (args[i]?.startsWith('--spec=')) return resolve(args[i].slice('--spec='.length));
|
|
75
|
+
}
|
|
76
|
+
// Check OPENAPI_SPEC_PATH env
|
|
77
|
+
if (process.env.OPENAPI_SPEC_PATH) return resolve(process.env.OPENAPI_SPEC_PATH);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Cached raw spec to avoid re-reading on multiple calls. */
|
|
82
|
+
let _rawSpecCache: Record<string, any> | null = null;
|
|
83
|
+
let _rawSpecLoaded = false;
|
|
84
|
+
|
|
85
|
+
function loadRawSpec(): Record<string, any> | null {
|
|
86
|
+
if (_rawSpecLoaded) return _rawSpecCache;
|
|
87
|
+
_rawSpecLoaded = true;
|
|
88
|
+
const specPath = discoverSpecPath();
|
|
89
|
+
if (!specPath || !existsSync(specPath)) return null;
|
|
90
|
+
try {
|
|
91
|
+
const content = readFileSync(specPath, 'utf-8');
|
|
92
|
+
_rawSpecCache = yamlLoad(content) as Record<string, any>;
|
|
93
|
+
return _rawSpecCache;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Look up a schema by name in the raw spec's components/schemas. */
|
|
100
|
+
function lookupRawSchema(name: string): Record<string, any> | null {
|
|
101
|
+
const spec = loadRawSpec();
|
|
102
|
+
if (!spec) return null;
|
|
103
|
+
return spec?.components?.schemas?.[name] ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Convert a raw OpenAPI type+format to an IR TypeRef. */
|
|
107
|
+
function rawSchemaToTypeRef(schema: Record<string, any>): TypeRef {
|
|
108
|
+
if (schema.const !== undefined) {
|
|
109
|
+
return { kind: 'literal', value: schema.const };
|
|
110
|
+
}
|
|
111
|
+
if (schema.enum) {
|
|
112
|
+
// Simple string enum -- represent as primitive string
|
|
113
|
+
return { kind: 'primitive', type: 'string' } as TypeRef;
|
|
114
|
+
}
|
|
115
|
+
if (schema.$ref) {
|
|
116
|
+
const refName = schema.$ref.split('/').pop()!;
|
|
117
|
+
return { kind: 'model', name: refName } as TypeRef;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Handle nullable type arrays like [string, null]
|
|
121
|
+
let baseType = schema.type;
|
|
122
|
+
let isNullable = false;
|
|
123
|
+
if (Array.isArray(baseType)) {
|
|
124
|
+
const nonNull = baseType.filter((t: string) => t !== 'null');
|
|
125
|
+
isNullable = baseType.includes('null');
|
|
126
|
+
baseType = nonNull[0] ?? 'string';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let ref: TypeRef;
|
|
130
|
+
if (baseType === 'object' && schema.properties) {
|
|
131
|
+
// Inline object -- treat as unknown
|
|
132
|
+
ref = { kind: 'primitive', type: 'unknown' } as TypeRef;
|
|
133
|
+
} else if (baseType === 'array' && schema.items) {
|
|
134
|
+
ref = { kind: 'array', items: rawSchemaToTypeRef(schema.items) } as TypeRef;
|
|
135
|
+
} else if (baseType === 'boolean') {
|
|
136
|
+
ref = { kind: 'primitive', type: 'boolean' } as TypeRef;
|
|
137
|
+
} else if (baseType === 'integer' || baseType === 'number') {
|
|
138
|
+
ref = { kind: 'primitive', type: baseType } as TypeRef;
|
|
139
|
+
} else {
|
|
140
|
+
ref = { kind: 'primitive', type: 'string' } as TypeRef;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isNullable) {
|
|
144
|
+
return { kind: 'nullable', inner: ref } as TypeRef;
|
|
145
|
+
}
|
|
146
|
+
return ref;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extract fields from a raw OpenAPI object schema.
|
|
151
|
+
* All fields are returned as optional (not required) since they come from
|
|
152
|
+
* oneOf variants where only one variant is active at a time.
|
|
153
|
+
*/
|
|
154
|
+
function extractFieldsFromRawSchema(schema: Record<string, any>): Field[] {
|
|
155
|
+
const fields: Field[] = [];
|
|
156
|
+
const props = schema.properties ?? {};
|
|
157
|
+
for (const [name, propSchema] of Object.entries(props) as [string, Record<string, any>][]) {
|
|
158
|
+
fields.push({
|
|
159
|
+
name,
|
|
160
|
+
type: rawSchemaToTypeRef(propSchema),
|
|
161
|
+
required: false, // All oneOf variant fields are optional
|
|
162
|
+
description: propSchema.description,
|
|
163
|
+
deprecated: propSchema.deprecated,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return fields;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Recursively collect all fields from a oneOf schema, flattening nested
|
|
171
|
+
* allOf+oneOf compositions. All fields are marked optional.
|
|
172
|
+
*/
|
|
173
|
+
function collectOneOfFields(schema: Record<string, any>): Field[] {
|
|
174
|
+
const allFields: Field[] = [];
|
|
175
|
+
const seenFieldNames = new Set<string>();
|
|
176
|
+
|
|
177
|
+
function walkSchema(s: Record<string, any>): void {
|
|
178
|
+
// Direct properties
|
|
179
|
+
if (s.properties) {
|
|
180
|
+
for (const f of extractFieldsFromRawSchema(s)) {
|
|
181
|
+
if (!seenFieldNames.has(f.name)) {
|
|
182
|
+
seenFieldNames.add(f.name);
|
|
183
|
+
allFields.push(f);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// allOf composition
|
|
188
|
+
if (s.allOf) {
|
|
189
|
+
for (const sub of s.allOf) {
|
|
190
|
+
walkSchema(sub);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// oneOf composition (flatten all variants)
|
|
194
|
+
if (s.oneOf) {
|
|
195
|
+
for (const variant of s.oneOf) {
|
|
196
|
+
walkSchema(variant);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// anyOf composition
|
|
200
|
+
if (s.anyOf) {
|
|
201
|
+
for (const variant of s.anyOf) {
|
|
202
|
+
walkSchema(variant);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
walkSchema(schema);
|
|
208
|
+
return allFields;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Enrich IR models by flattening oneOf/allOf+oneOf variant fields from the raw spec.
|
|
213
|
+
*
|
|
214
|
+
* For models with 0 fields whose raw spec schema is a pure oneOf:
|
|
215
|
+
* - Collect all variant fields and add them as optional fields.
|
|
216
|
+
*
|
|
217
|
+
* For models whose raw spec schema has allOf containing a oneOf:
|
|
218
|
+
* - Collect the missing variant fields and add them as optional.
|
|
219
|
+
*
|
|
220
|
+
* Returns a new array of enriched models (original models are not mutated).
|
|
221
|
+
*/
|
|
222
|
+
export function enrichModelsFromSpec(models: Model[]): Model[] {
|
|
223
|
+
const spec = loadRawSpec();
|
|
224
|
+
if (!spec) return models;
|
|
225
|
+
|
|
226
|
+
return models.map((model) => {
|
|
227
|
+
const rawSchema = lookupRawSchema(model.name);
|
|
228
|
+
if (!rawSchema) return model;
|
|
229
|
+
|
|
230
|
+
const hasOneOf = rawSchema.oneOf || rawSchema.allOf?.some((s: any) => s.oneOf);
|
|
231
|
+
if (!hasOneOf) return model;
|
|
232
|
+
|
|
233
|
+
// Skip schemas with discriminator -- those are intentional unions
|
|
234
|
+
const hasDiscriminator =
|
|
235
|
+
rawSchema.discriminator ||
|
|
236
|
+
rawSchema.oneOf?.some((v: any) => v.discriminator) ||
|
|
237
|
+
rawSchema.allOf?.some((s: any) => s.discriminator || s.oneOf?.some((v: any) => v.discriminator));
|
|
238
|
+
if (hasDiscriminator) return model;
|
|
239
|
+
|
|
240
|
+
// Collect all variant fields from the raw schema
|
|
241
|
+
const variantFields = collectOneOfFields(rawSchema);
|
|
242
|
+
if (variantFields.length === 0) return model;
|
|
243
|
+
|
|
244
|
+
// Merge variant fields into the existing model, skipping duplicates
|
|
245
|
+
const existingNames = new Set(model.fields.map((f) => f.name));
|
|
246
|
+
const newFields = variantFields.filter((f) => !existingNames.has(f.name));
|
|
247
|
+
|
|
248
|
+
if (newFields.length === 0) return model;
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
...model,
|
|
252
|
+
fields: [...model.fields, ...newFields],
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Strip URN OAuth grant-type prefixes from discriminator-derived schema names. */
|
|
2
|
+
export function stripUrnPrefix(name: string): string {
|
|
3
|
+
// Handles both OAuth and Oauth casing from different PascalCase implementations
|
|
4
|
+
return name.replace(/^Urn(?:IetfParams|Workos)O[Aa]uthGrantType/, '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build the GoDoc prefix for a field comment.
|
|
9
|
+
* If the description already starts with a verb (e.g., "Distinguishes...", "Indicates..."),
|
|
10
|
+
* returns just the lowered description. Otherwise prepends "is ".
|
|
11
|
+
*
|
|
12
|
+
* fieldDocComment("ID", "the unique identifier") → "ID is the unique identifier"
|
|
13
|
+
* fieldDocComment("Object", "Distinguishes the X") → "Object distinguishes the X"
|
|
14
|
+
*/
|
|
15
|
+
export function fieldDocComment(fieldName: string, description: string): string {
|
|
16
|
+
const lowered = lowerFirstForDoc(description);
|
|
17
|
+
if (startsWithVerb(lowered)) {
|
|
18
|
+
return `${fieldName} ${lowered}`;
|
|
19
|
+
}
|
|
20
|
+
return `${fieldName} is ${lowered}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Known English 3rd-person-singular verbs that appear as OpenAPI description starters. */
|
|
24
|
+
const VERB_STARTERS = new Set([
|
|
25
|
+
'distinguishes',
|
|
26
|
+
'indicates',
|
|
27
|
+
'represents',
|
|
28
|
+
'contains',
|
|
29
|
+
'specifies',
|
|
30
|
+
'determines',
|
|
31
|
+
'controls',
|
|
32
|
+
'defines',
|
|
33
|
+
'identifies',
|
|
34
|
+
'describes',
|
|
35
|
+
'returns',
|
|
36
|
+
'creates',
|
|
37
|
+
'deletes',
|
|
38
|
+
'updates',
|
|
39
|
+
'lists',
|
|
40
|
+
'provides',
|
|
41
|
+
'enables',
|
|
42
|
+
'disables',
|
|
43
|
+
'allows',
|
|
44
|
+
'prevents',
|
|
45
|
+
'triggers',
|
|
46
|
+
'marks',
|
|
47
|
+
'tracks',
|
|
48
|
+
'stores',
|
|
49
|
+
'holds',
|
|
50
|
+
'maps',
|
|
51
|
+
'links',
|
|
52
|
+
'connects',
|
|
53
|
+
'wraps',
|
|
54
|
+
'denotes',
|
|
55
|
+
'shows',
|
|
56
|
+
'tells',
|
|
57
|
+
'gives',
|
|
58
|
+
'takes',
|
|
59
|
+
'sets',
|
|
60
|
+
'gets',
|
|
61
|
+
'configures',
|
|
62
|
+
'manages',
|
|
63
|
+
'validates',
|
|
64
|
+
'authenticates',
|
|
65
|
+
'authorizes',
|
|
66
|
+
'verifies',
|
|
67
|
+
'limits',
|
|
68
|
+
'restricts',
|
|
69
|
+
'overrides',
|
|
70
|
+
'applies',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function startsWithVerb(desc: string): boolean {
|
|
74
|
+
const firstWord = desc
|
|
75
|
+
.split(/\s/)[0]
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z]/g, '');
|
|
78
|
+
return VERB_STARTERS.has(firstWord);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Select the correct indefinite article ("a" or "an") for a word.
|
|
83
|
+
*/
|
|
84
|
+
export function articleFor(word: string): string {
|
|
85
|
+
return /^[aeiou]/i.test(word) ? 'an' : 'a';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Lowercase the first character of a string for doc comments/descriptions.
|
|
90
|
+
* Handles acronyms: "SSO-specific" becomes "sso-specific" not "sSO-specific".
|
|
91
|
+
* "JSONSchema" becomes "jsonSchema", "IP Address" becomes "ip Address".
|
|
92
|
+
*/
|
|
93
|
+
export function lowerFirstForDoc(s: string): string {
|
|
94
|
+
if (!s) return s;
|
|
95
|
+
const acronymMatch = s.match(/^[A-Z]{2,}/);
|
|
96
|
+
if (acronymMatch) {
|
|
97
|
+
const acronym = acronymMatch[0];
|
|
98
|
+
const nextChar = s[acronym.length];
|
|
99
|
+
// If followed by a lowercase letter (e.g. "SSOAuth"), keep the last
|
|
100
|
+
// uppercase char as the start of the next camelCase word.
|
|
101
|
+
if (nextChar && /[a-z]/.test(nextChar)) {
|
|
102
|
+
return acronym.slice(0, -1).toLowerCase() + acronym.slice(-1) + s.slice(acronym.length);
|
|
103
|
+
}
|
|
104
|
+
return acronym.toLowerCase() + s.slice(acronym.length);
|
|
105
|
+
}
|
|
106
|
+
return s.charAt(0).toLowerCase() + s.slice(1);
|
|
107
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-spec services: hand-maintained modules that are wired into the
|
|
3
|
+
* generated client class alongside the spec-driven service accessors.
|
|
4
|
+
*
|
|
5
|
+
* Each entry describes one hand-maintained module. Emitters translate these
|
|
6
|
+
* to language-idiomatic class names, property names, and import paths.
|
|
7
|
+
*
|
|
8
|
+
* Adding a new non-spec service here is the *only* change needed in the
|
|
9
|
+
* emitter repo — each language emitter reads this list and generates the
|
|
10
|
+
* appropriate client accessor.
|
|
11
|
+
*/
|
|
12
|
+
export interface NonSpecService {
|
|
13
|
+
/** Logical identifier (snake_case). Used as the canonical key. */
|
|
14
|
+
id: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Human-readable description. Not emitted anywhere — exists so that
|
|
18
|
+
* someone reading this file understands what the service does.
|
|
19
|
+
*/
|
|
20
|
+
description: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The canonical list of non-spec services that every SDK must expose.
|
|
25
|
+
*
|
|
26
|
+
* Order here determines emission order in the generated client.
|
|
27
|
+
*/
|
|
28
|
+
export const NON_SPEC_SERVICES: readonly NonSpecService[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'passwordless',
|
|
31
|
+
description: 'Passwordless (magic-link) session endpoints, not yet in the OpenAPI spec.',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'vault',
|
|
35
|
+
description: 'Vault KV storage, key operations, and client-side AES-GCM encrypt/decrypt.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'webhook_verification',
|
|
39
|
+
description: 'Webhook signature verification and event deserialization (H01/H02).',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'actions',
|
|
43
|
+
description: 'AuthKit Actions request verification and response signing (H03).',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'session_manager',
|
|
47
|
+
description: 'Sealed session cookies, JWT validation, JWKS helpers (H04-H07, H13).',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'pkce',
|
|
51
|
+
description:
|
|
52
|
+
'PKCE utilities, AuthKit/SSO PKCE URL builders, code exchange, public client factory (H08-H11, H15, H16, H19).',
|
|
53
|
+
},
|
|
54
|
+
] as const;
|