@workos/oagen-emitters 0.2.1 → 0.4.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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,472 @@
1
+ import type { Model, Field, TypeRef, Enum } 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
+ // ---------------------------------------------------------------------------
107
+ // Synthetic model / enum collection
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Accumulator for synthetic models and enums generated from inline
112
+ * definitions encountered during oneOf flattening.
113
+ */
114
+ interface SyntheticCollector {
115
+ models: Model[];
116
+ enums: Array<{ name: string; values: Array<{ value: string; description?: string }> }>;
117
+ /** Track names already used to avoid duplicates. */
118
+ usedNames: Set<string>;
119
+ }
120
+
121
+ function createCollector(): SyntheticCollector {
122
+ return { models: [], enums: [], usedNames: new Set() };
123
+ }
124
+
125
+ /**
126
+ * Singularize a snake_case name for use as an array-item model name.
127
+ * `redirect_uris` -> `redirect_uri`, `scopes` -> `scope`.
128
+ */
129
+ function singularizeSnake(name: string): string {
130
+ if (name.endsWith('ies') && name.length > 3) {
131
+ return `${name.slice(0, -3)}y`;
132
+ }
133
+ if (name.endsWith('s') && !name.endsWith('ss')) {
134
+ return name.slice(0, -1);
135
+ }
136
+ return name;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // rawSchemaToTypeRef -- with synthetic model/enum generation
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Convert a raw OpenAPI type+format to an IR TypeRef.
145
+ *
146
+ * When `parentModelName` and `fieldName` are provided, inline objects and
147
+ * enums generate synthetic models/enums instead of degrading to `unknown`
148
+ * or `string`.
149
+ */
150
+ function rawSchemaToTypeRef(
151
+ schema: Record<string, any>,
152
+ parentModelName?: string,
153
+ fName?: string,
154
+ collector?: SyntheticCollector,
155
+ ): TypeRef {
156
+ if (schema.const !== undefined) {
157
+ return { kind: 'literal', value: schema.const };
158
+ }
159
+ if (schema.enum && collector && parentModelName && fName) {
160
+ // Generate a synthetic enum
161
+ const syntheticName = `${parentModelName}_${fName}`;
162
+ if (!collector.usedNames.has(syntheticName)) {
163
+ collector.usedNames.add(syntheticName);
164
+ collector.enums.push({
165
+ name: syntheticName,
166
+ values: (schema.enum as string[]).map((v: string) => ({
167
+ value: v,
168
+ description: undefined,
169
+ })),
170
+ });
171
+ }
172
+ return {
173
+ kind: 'enum',
174
+ name: syntheticName,
175
+ values: schema.enum as string[],
176
+ } as TypeRef;
177
+ }
178
+ if (schema.enum) {
179
+ // Simple string enum -- represent as primitive string (no collector)
180
+ return { kind: 'primitive', type: 'string' } as TypeRef;
181
+ }
182
+ if (schema.$ref) {
183
+ const refName = schema.$ref.split('/').pop()!;
184
+ return { kind: 'model', name: refName } as TypeRef;
185
+ }
186
+
187
+ // Handle nullable type arrays like [string, null]
188
+ let baseType = schema.type;
189
+ let isNullable = false;
190
+ if (Array.isArray(baseType)) {
191
+ const nonNull = baseType.filter((t: string) => t !== 'null');
192
+ isNullable = baseType.includes('null');
193
+ baseType = nonNull[0] ?? 'string';
194
+ }
195
+
196
+ let ref: TypeRef;
197
+ if (baseType === 'object' && schema.properties && collector && parentModelName && fName) {
198
+ // Inline object -- generate a synthetic model
199
+ const syntheticName = `${parentModelName}_${fName}`;
200
+ if (!collector.usedNames.has(syntheticName)) {
201
+ collector.usedNames.add(syntheticName);
202
+ const fields: Field[] = [];
203
+ const requiredSet = new Set<string>(schema.required ?? []);
204
+ for (const [propName, propSchema] of Object.entries(schema.properties) as [string, Record<string, any>][]) {
205
+ fields.push({
206
+ name: propName,
207
+ type: rawSchemaToTypeRef(propSchema, syntheticName, propName, collector),
208
+ required: requiredSet.has(propName),
209
+ description: propSchema.description,
210
+ deprecated: propSchema.deprecated,
211
+ });
212
+ }
213
+ collector.models.push({
214
+ name: syntheticName,
215
+ fields,
216
+ description: schema.description,
217
+ } as Model);
218
+ }
219
+ ref = { kind: 'model', name: syntheticName } as TypeRef;
220
+ } else if (baseType === 'object' && schema.properties) {
221
+ // Inline object -- treat as unknown (no collector)
222
+ ref = { kind: 'primitive', type: 'unknown' } as TypeRef;
223
+ } else if (baseType === 'array' && schema.items) {
224
+ // For array items that are inline objects, use the singular field name
225
+ const itemFieldName = fName ? singularizeSnake(fName) : undefined;
226
+ ref = {
227
+ kind: 'array',
228
+ items: rawSchemaToTypeRef(schema.items, parentModelName, itemFieldName, collector),
229
+ } as TypeRef;
230
+ } else if (baseType === 'boolean') {
231
+ ref = { kind: 'primitive', type: 'boolean' } as TypeRef;
232
+ } else if (baseType === 'integer' || baseType === 'number') {
233
+ ref = { kind: 'primitive', type: baseType } as TypeRef;
234
+ } else {
235
+ ref = { kind: 'primitive', type: 'string' } as TypeRef;
236
+ }
237
+
238
+ if (isNullable) {
239
+ return { kind: 'nullable', inner: ref } as TypeRef;
240
+ }
241
+ return ref;
242
+ }
243
+
244
+ /**
245
+ * Extract fields from a raw OpenAPI object schema.
246
+ * All fields are returned as optional (not required) since they come from
247
+ * oneOf variants where only one variant is active at a time.
248
+ */
249
+ function extractFieldsFromRawSchema(
250
+ schema: Record<string, any>,
251
+ parentModelName?: string,
252
+ collector?: SyntheticCollector,
253
+ ): Field[] {
254
+ const fields: Field[] = [];
255
+ const props = schema.properties ?? {};
256
+ for (const [name, propSchema] of Object.entries(props) as [string, Record<string, any>][]) {
257
+ fields.push({
258
+ name,
259
+ type: rawSchemaToTypeRef(propSchema, parentModelName, name, collector),
260
+ required: false, // All oneOf variant fields are optional
261
+ description: propSchema.description,
262
+ deprecated: propSchema.deprecated,
263
+ });
264
+ }
265
+ return fields;
266
+ }
267
+
268
+ /**
269
+ * Recursively collect all fields from a oneOf schema, flattening nested
270
+ * allOf+oneOf compositions. All fields are marked optional.
271
+ */
272
+ function collectOneOfFields(
273
+ schema: Record<string, any>,
274
+ parentModelName?: string,
275
+ collector?: SyntheticCollector,
276
+ ): Field[] {
277
+ const allFields: Field[] = [];
278
+ const seenFieldNames = new Set<string>();
279
+
280
+ function walkSchema(s: Record<string, any>): void {
281
+ // Direct properties
282
+ if (s.properties) {
283
+ for (const f of extractFieldsFromRawSchema(s, parentModelName, collector)) {
284
+ if (!seenFieldNames.has(f.name)) {
285
+ seenFieldNames.add(f.name);
286
+ allFields.push(f);
287
+ }
288
+ }
289
+ }
290
+ // allOf composition
291
+ if (s.allOf) {
292
+ for (const sub of s.allOf) {
293
+ walkSchema(sub);
294
+ }
295
+ }
296
+ // oneOf composition (flatten all variants)
297
+ if (s.oneOf) {
298
+ for (const variant of s.oneOf) {
299
+ walkSchema(variant);
300
+ }
301
+ }
302
+ // anyOf composition
303
+ if (s.anyOf) {
304
+ for (const variant of s.anyOf) {
305
+ walkSchema(variant);
306
+ }
307
+ }
308
+ }
309
+
310
+ walkSchema(schema);
311
+ return allFields;
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Array-item type upgrade
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /**
319
+ * Check if a TypeRef is `unknown` (the degraded type for inline objects).
320
+ */
321
+ function isUnknownType(ref: TypeRef): boolean {
322
+ return ref.kind === 'primitive' && (ref as any).type === 'unknown';
323
+ }
324
+
325
+ /**
326
+ * If a field is an `array<unknown>` (or `nullable<array<unknown>>`) and the
327
+ * raw spec defines inline object/enum items, replace the item type with a
328
+ * synthetic model/enum. Returns the original field unchanged when no upgrade
329
+ * is needed.
330
+ */
331
+ function upgradeArrayItemType(
332
+ field: Field,
333
+ rawSchema: Record<string, any>,
334
+ parentModelName: string,
335
+ collector: SyntheticCollector,
336
+ ): Field {
337
+ // Unwrap nullable to find the array
338
+ let arrayRef: TypeRef | null = null;
339
+ let isNullableWrapper = false;
340
+ if (field.type.kind === 'array') {
341
+ arrayRef = field.type;
342
+ } else if (field.type.kind === 'nullable' && field.type.inner.kind === 'array') {
343
+ arrayRef = field.type.inner;
344
+ isNullableWrapper = true;
345
+ }
346
+ if (!arrayRef || arrayRef.kind !== 'array') return field;
347
+ if (!isUnknownType(arrayRef.items)) return field;
348
+
349
+ // Look up the raw spec for this field
350
+ const rawProp = rawSchema.properties?.[field.name];
351
+ if (!rawProp) return field;
352
+
353
+ // Handle the case where the raw property is inside a nullable type array
354
+ let rawArraySchema = rawProp;
355
+ if (Array.isArray(rawProp.type)) {
356
+ const nonNull = rawProp.type.filter((t: string) => t !== 'null');
357
+ if (nonNull[0] === 'array') {
358
+ rawArraySchema = rawProp;
359
+ }
360
+ }
361
+ if (rawArraySchema.type !== 'array' && !(Array.isArray(rawArraySchema.type) && rawArraySchema.type.includes('array')))
362
+ return field;
363
+ if (!rawArraySchema.items) return field;
364
+
365
+ // Generate a proper TypeRef from the raw items schema
366
+ const itemFieldName = singularizeSnake(field.name);
367
+ const newItemRef = rawSchemaToTypeRef(rawArraySchema.items, parentModelName, itemFieldName, collector);
368
+ if (isUnknownType(newItemRef)) return field;
369
+
370
+ const newArrayRef: TypeRef = { kind: 'array', items: newItemRef } as TypeRef;
371
+ const newType: TypeRef = isNullableWrapper ? ({ kind: 'nullable', inner: newArrayRef } as TypeRef) : newArrayRef;
372
+
373
+ return { ...field, type: newType };
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Module-level store for synthetic enums produced during enrichment.
378
+ // Consumed by `getSyntheticEnums()` after `enrichModelsFromSpec` runs.
379
+ // ---------------------------------------------------------------------------
380
+ let _lastSyntheticEnums: Enum[] = [];
381
+
382
+ /**
383
+ * Return the synthetic enums generated during the last call to
384
+ * `enrichModelsFromSpec`. Call this after enrichment to merge them into the
385
+ * enum generation phase.
386
+ */
387
+ export function getSyntheticEnums(): Enum[] {
388
+ return _lastSyntheticEnums;
389
+ }
390
+
391
+ /**
392
+ * Enrich IR models by flattening oneOf/allOf+oneOf variant fields from the raw spec.
393
+ *
394
+ * For models with 0 fields whose raw spec schema is a pure oneOf:
395
+ * - Collect all variant fields and add them as optional fields.
396
+ *
397
+ * For models whose raw spec schema has allOf containing a oneOf:
398
+ * - Collect the missing variant fields and add them as optional.
399
+ *
400
+ * Inline objects and enums in oneOf branches are promoted to synthetic
401
+ * models/enums instead of degrading to `object` / `string`.
402
+ *
403
+ * Returns a new array of enriched models (original models are not mutated).
404
+ * Synthetic enums are stored internally; retrieve them via `getSyntheticEnums()`.
405
+ */
406
+ export function enrichModelsFromSpec(models: Model[]): Model[] {
407
+ const spec = loadRawSpec();
408
+ if (!spec) {
409
+ _lastSyntheticEnums = [];
410
+ return models;
411
+ }
412
+
413
+ const collector = createCollector();
414
+ // Avoid name collisions with existing models
415
+ for (const m of models) collector.usedNames.add(m.name);
416
+
417
+ const enriched = models.map((model) => {
418
+ const rawSchema = lookupRawSchema(model.name);
419
+ if (!rawSchema) return model;
420
+
421
+ const hasOneOf = rawSchema.oneOf || rawSchema.allOf?.some((s: any) => s.oneOf);
422
+ if (!hasOneOf) return model;
423
+
424
+ // Skip schemas with discriminator -- those are intentional unions
425
+ const hasDiscriminator =
426
+ rawSchema.discriminator ||
427
+ rawSchema.oneOf?.some((v: any) => v.discriminator) ||
428
+ rawSchema.allOf?.some((s: any) => s.discriminator || s.oneOf?.some((v: any) => v.discriminator));
429
+ if (hasDiscriminator) return model;
430
+
431
+ // Collect all variant fields from the raw schema, generating synthetic
432
+ // models/enums for inline definitions along the way.
433
+ const variantFields = collectOneOfFields(rawSchema, model.name, collector);
434
+ if (variantFields.length === 0) return model;
435
+
436
+ // Merge variant fields into the existing model, skipping duplicates
437
+ const existingNames = new Set(model.fields.map((f) => f.name));
438
+ const newFields = variantFields.filter((f) => !existingNames.has(f.name));
439
+
440
+ if (newFields.length === 0) return model;
441
+
442
+ return {
443
+ ...model,
444
+ fields: [...model.fields, ...newFields],
445
+ };
446
+ });
447
+
448
+ // Second pass: fix array fields whose items degraded to `unknown` in the
449
+ // IR but are actually inline objects or enums in the raw spec.
450
+ const enriched2 = enriched.map((model) => {
451
+ const rawSchema = lookupRawSchema(model.name);
452
+ if (!rawSchema?.properties) return model;
453
+
454
+ let modified = false;
455
+ const newFields = model.fields.map((field) => {
456
+ const upgraded = upgradeArrayItemType(field, rawSchema, model.name, collector);
457
+ if (upgraded !== field) modified = true;
458
+ return upgraded;
459
+ });
460
+
461
+ return modified ? { ...model, fields: newFields } : model;
462
+ });
463
+
464
+ // Convert synthetic enum collector entries to proper Enum objects
465
+ _lastSyntheticEnums = collector.enums.map((e) => ({
466
+ name: e.name,
467
+ values: e.values.map((v) => ({ value: v.value, description: v.description })),
468
+ })) as Enum[];
469
+
470
+ // Append synthetic models to the output
471
+ return [...enriched2, ...collector.models];
472
+ }
@@ -0,0 +1,154 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Shared acronym fixes
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /**
6
+ * Base set of acronym corrections applied after PascalCase conversion.
7
+ * These are domain-specific terms that `toPascalCase` produces as e.g.
8
+ * "Sso" but should be rendered as "SSO" in every SDK language.
9
+ *
10
+ * Language emitters can extend this with additional entries (e.g. Go adds
11
+ * API, URL, HTTP, JSON, etc. per Go naming conventions).
12
+ */
13
+ export const BASE_ACRONYM_FIXES: readonly [RegExp, string][] = [
14
+ [/Workos/g, 'WorkOS'],
15
+ [/Sso/g, 'SSO'],
16
+ [/Mfa/g, 'MFA'],
17
+ [/Jwt/g, 'JWT'],
18
+ [/Cors/g, 'CORS'],
19
+ [/Saml/g, 'SAML'],
20
+ [/Scim/g, 'SCIM'],
21
+ [/Rbac/g, 'RBAC'],
22
+ [/Oauth/g, 'OAuth'],
23
+ [/Oidc/g, 'OIDC'],
24
+ ];
25
+
26
+ /**
27
+ * Apply acronym corrections to a PascalCase string.
28
+ * Uses the base set by default; pass extra entries for language-specific
29
+ * conventions (e.g. Go's `[/Api(?=[A-Z]|$)/g, 'API']`).
30
+ */
31
+ export function applyAcronymFixes(s: string, extra?: readonly [RegExp, string][]): string {
32
+ let result = s;
33
+ for (const [pattern, replacement] of BASE_ACRONYM_FIXES) {
34
+ result = result.replace(pattern, replacement);
35
+ }
36
+ if (extra) {
37
+ for (const [pattern, replacement] of extra) {
38
+ result = result.replace(pattern, replacement);
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // URN stripping
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Strip URN OAuth grant-type prefixes from discriminator-derived schema names. */
49
+ export function stripUrnPrefix(name: string): string {
50
+ // Handles both OAuth and Oauth casing from different PascalCase implementations
51
+ return name.replace(/^Urn(?:IetfParams|Workos)O[Aa]uthGrantType/, '');
52
+ }
53
+
54
+ /**
55
+ * Build the GoDoc prefix for a field comment.
56
+ * If the description already starts with a verb (e.g., "Distinguishes...", "Indicates..."),
57
+ * returns just the lowered description. Otherwise prepends "is ".
58
+ *
59
+ * fieldDocComment("ID", "the unique identifier") → "ID is the unique identifier"
60
+ * fieldDocComment("Object", "Distinguishes the X") → "Object distinguishes the X"
61
+ */
62
+ export function fieldDocComment(fieldName: string, description: string): string {
63
+ const lowered = lowerFirstForDoc(description);
64
+ if (startsWithVerb(lowered)) {
65
+ return `${fieldName} ${lowered}`;
66
+ }
67
+ return `${fieldName} is ${lowered}`;
68
+ }
69
+
70
+ /** Known English 3rd-person-singular verbs that appear as OpenAPI description starters. */
71
+ const VERB_STARTERS = new Set([
72
+ 'distinguishes',
73
+ 'indicates',
74
+ 'represents',
75
+ 'contains',
76
+ 'specifies',
77
+ 'determines',
78
+ 'controls',
79
+ 'defines',
80
+ 'identifies',
81
+ 'describes',
82
+ 'returns',
83
+ 'creates',
84
+ 'deletes',
85
+ 'updates',
86
+ 'lists',
87
+ 'provides',
88
+ 'enables',
89
+ 'disables',
90
+ 'allows',
91
+ 'prevents',
92
+ 'triggers',
93
+ 'marks',
94
+ 'tracks',
95
+ 'stores',
96
+ 'holds',
97
+ 'maps',
98
+ 'links',
99
+ 'connects',
100
+ 'wraps',
101
+ 'denotes',
102
+ 'shows',
103
+ 'tells',
104
+ 'gives',
105
+ 'takes',
106
+ 'sets',
107
+ 'gets',
108
+ 'configures',
109
+ 'manages',
110
+ 'validates',
111
+ 'authenticates',
112
+ 'authorizes',
113
+ 'verifies',
114
+ 'limits',
115
+ 'restricts',
116
+ 'overrides',
117
+ 'applies',
118
+ ]);
119
+
120
+ function startsWithVerb(desc: string): boolean {
121
+ const firstWord = desc
122
+ .split(/\s/)[0]
123
+ .toLowerCase()
124
+ .replace(/[^a-z]/g, '');
125
+ return VERB_STARTERS.has(firstWord);
126
+ }
127
+
128
+ /**
129
+ * Select the correct indefinite article ("a" or "an") for a word.
130
+ */
131
+ export function articleFor(word: string): string {
132
+ return /^[aeiou]/i.test(word) ? 'an' : 'a';
133
+ }
134
+
135
+ /**
136
+ * Lowercase the first character of a string for doc comments/descriptions.
137
+ * Handles acronyms: "SSO-specific" becomes "sso-specific" not "sSO-specific".
138
+ * "JSONSchema" becomes "jsonSchema", "IP Address" becomes "ip Address".
139
+ */
140
+ export function lowerFirstForDoc(s: string): string {
141
+ if (!s) return s;
142
+ const acronymMatch = s.match(/^[A-Z]{2,}/);
143
+ if (acronymMatch) {
144
+ const acronym = acronymMatch[0];
145
+ const nextChar = s[acronym.length];
146
+ // If followed by a lowercase letter (e.g. "SSOAuth"), keep the last
147
+ // uppercase char as the start of the next camelCase word.
148
+ if (nextChar && /[a-z]/.test(nextChar)) {
149
+ return acronym.slice(0, -1).toLowerCase() + acronym.slice(-1) + s.slice(acronym.length);
150
+ }
151
+ return acronym.toLowerCase() + s.slice(acronym.length);
152
+ }
153
+ return s.charAt(0).toLowerCase() + s.slice(1);
154
+ }
@@ -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;