@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.
Files changed (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. 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;