@workos/oagen-emitters 0.3.0 → 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 (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -0,0 +1,123 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className } from './naming.js';
4
+ import { enumCanonicalMap } from './enums.js';
5
+
6
+ /** Resolve an enum name through the canonical map (identity when no alias). */
7
+ function resolveEnumName(name: string): string {
8
+ return className(enumCanonicalMap.get(name) ?? name);
9
+ }
10
+
11
+ /**
12
+ * Map an IR TypeRef to a non-nullable Kotlin type expression.
13
+ *
14
+ * Kotlin's type system marks nullability at use-sites (`T?`). Optional fields
15
+ * on generated models append `?` to the output of this function.
16
+ */
17
+ export function mapTypeRef(ref: TypeRef): string {
18
+ return irMapTypeRef<string>(ref, {
19
+ primitive: mapPrimitive,
20
+ array: (_ref, items) => `List<${items}>`,
21
+ model: (r) => className(r.name),
22
+ enum: (r) => resolveEnumName(r.name),
23
+ union: (r, variants) => joinUnionVariants(r, variants),
24
+ nullable: (_ref, inner) => (inner.endsWith('?') ? inner : `${inner}?`),
25
+ literal: (r) => {
26
+ if (r.value === null) return 'Any?';
27
+ if (typeof r.value === 'string') return 'String';
28
+ if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'Long' : 'Double';
29
+ if (typeof r.value === 'boolean') return 'Boolean';
30
+ return 'Any';
31
+ },
32
+ map: (_ref, value) => `Map<String, ${value}>`,
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Map an IR TypeRef to a nullable Kotlin type expression (always appends `?`).
38
+ * Useful for optional fields on models / request options.
39
+ */
40
+ export function mapTypeRefOptional(ref: TypeRef): string {
41
+ const baseType = mapTypeRef(ref);
42
+ return baseType.endsWith('?') ? baseType : `${baseType}?`;
43
+ }
44
+
45
+ /**
46
+ * Is the given IR TypeRef a primitive value? Useful when deciding whether a
47
+ * nullable field needs an explicit `= null` default.
48
+ */
49
+ export function isValueTypeRef(ref: TypeRef): boolean {
50
+ if (ref.kind === 'enum') return true;
51
+ if (ref.kind === 'primitive') {
52
+ if (ref.format === 'date-time') return true;
53
+ switch (ref.type) {
54
+ case 'integer':
55
+ case 'number':
56
+ case 'boolean':
57
+ return true;
58
+ default:
59
+ return false;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function mapPrimitive(ref: PrimitiveType): string {
66
+ if (ref.format === 'binary') return 'ByteArray';
67
+ if (ref.format === 'int32') return 'Int';
68
+ if (ref.format === 'int64') return 'Long';
69
+ if (ref.format === 'date-time') return 'OffsetDateTime';
70
+ switch (ref.type) {
71
+ case 'string':
72
+ return 'String';
73
+ case 'integer':
74
+ return 'Long';
75
+ case 'number':
76
+ return 'Double';
77
+ case 'boolean':
78
+ return 'Boolean';
79
+ case 'unknown':
80
+ return 'Any';
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Track discriminated unions so the model generator can emit the appropriate
86
+ * Jackson `@JsonTypeInfo` / `@JsonSubTypes` annotations on the sealed parent.
87
+ */
88
+ export const discriminatedUnions = new Map<
89
+ string,
90
+ { property: string; mapping: Record<string, string>; variantTypes: string[] }
91
+ >();
92
+
93
+ function joinUnionVariants(ref: UnionType, variants: string[]): string {
94
+ if (ref.compositionKind === 'allOf') {
95
+ return variants[0] ?? 'Any';
96
+ }
97
+ const unique = [...new Set(variants)];
98
+ if (unique.length === 1) return unique[0];
99
+
100
+ if (ref.discriminator && ref.discriminator.mapping) {
101
+ const baseName = unique[0];
102
+ discriminatedUnions.set(baseName, {
103
+ property: ref.discriminator.property,
104
+ mapping: ref.discriminator.mapping,
105
+ variantTypes: unique,
106
+ });
107
+ // Use the base sealed type; Jackson @JsonTypeInfo handles variant selection.
108
+ return baseName;
109
+ }
110
+
111
+ // Non-discriminated unions fall back to the Kotlin top type. A generic
112
+ // AnyOf<> is planned for a future phase if emitter tests prove it necessary.
113
+ return 'Any';
114
+ }
115
+
116
+ /** Kotlin imports implied by a given type expression. Caller collects into a set. */
117
+ export function implicitImportsFor(kotlinType: string): string[] {
118
+ const imports: string[] = [];
119
+ if (/\bOffsetDateTime\b/.test(kotlinType)) {
120
+ imports.push('java.time.OffsetDateTime');
121
+ }
122
+ return imports;
123
+ }
@@ -0,0 +1,168 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper, Parameter } from '@workos/oagen';
2
+ import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
3
+ import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
4
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
5
+ import { sortPathParamsByTemplateOrder } from './resources.js';
6
+
7
+ /**
8
+ * Emit Kotlin wrapper methods for a union-split operation. Each wrapper
9
+ * method takes only the fields it needs for its variant, fills in the
10
+ * operation-level defaults and client-inferred values, and posts to the
11
+ * underlying operation.
12
+ *
13
+ * Returns a list of lines (with leading indentation suitable for inclusion
14
+ * inside the service class body).
15
+ */
16
+ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
17
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
18
+
19
+ const out: string[] = [];
20
+ for (const wrapper of resolvedOp.wrappers) {
21
+ if (out.length > 0) out.push('');
22
+ for (const line of emitWrapperMethod(resolvedOp, wrapper, ctx)) out.push(line);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapper, ctx: EmitterContext): string[] {
28
+ const op = resolvedOp.operation;
29
+ const method = propertyName(wrapper.name);
30
+ const resolvedParams = resolveWrapperParams(wrapper, ctx);
31
+ const responseClass = wrapper.responseModelName ? className(wrapper.responseModelName) : null;
32
+
33
+ const pathParams = sortPathParamsByTemplateOrder(op);
34
+
35
+ const lines: string[] = [];
36
+
37
+ // Build KDoc from operation description + @param docs for each wrapper param.
38
+ const kdocLines: string[] = [];
39
+ const opDesc = (op.description ?? '').trim();
40
+ const wrapperHumanName = method.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
41
+ if (opDesc) {
42
+ kdocLines.push(opDesc.split('\n')[0]);
43
+ } else {
44
+ kdocLines.push(`${wrapperHumanName.charAt(0).toUpperCase()}${wrapperHumanName.slice(1)}.`);
45
+ }
46
+ const paramDocs: string[] = [];
47
+ for (const pp of pathParams) {
48
+ if (pp.description?.trim()) {
49
+ paramDocs.push(`@param ${propertyName(pp.name)} ${escapeKdoc(pp.description.split('\n')[0].trim())}`);
50
+ }
51
+ }
52
+ for (const rp of resolvedParams) {
53
+ const desc = rp.field?.description?.trim();
54
+ if (desc) {
55
+ paramDocs.push(`@param ${propertyName(rp.paramName)} ${escapeKdoc(desc.split('\n')[0])}`);
56
+ }
57
+ }
58
+ if (responseClass) {
59
+ paramDocs.push(`@return the ${responseClass}`);
60
+ }
61
+ if (paramDocs.length > 0 || kdocLines.length > 0) {
62
+ lines.push(' /**');
63
+ for (const l of kdocLines) lines.push(` * ${escapeKdoc(l)}`);
64
+ if (paramDocs.length > 0) {
65
+ lines.push(' *');
66
+ for (const p of paramDocs) lines.push(` * ${p}`);
67
+ }
68
+ lines.push(' */');
69
+ }
70
+
71
+ lines.push(' @JvmOverloads');
72
+
73
+ // Build the method parameter list: path params, wrapper params, requestOptions
74
+ const params: string[] = [];
75
+ for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
76
+ for (const rp of resolvedParams) {
77
+ const paramName = propertyName(rp.paramName);
78
+ const kotlinType = rp.field
79
+ ? rp.isOptional
80
+ ? mapTypeRefOptional(rp.field.type)
81
+ : mapTypeRef(rp.field.type)
82
+ : rp.isOptional
83
+ ? 'String?'
84
+ : 'String';
85
+ const trailer = rp.isOptional ? ' = null' : '';
86
+ params.push(` ${paramName}: ${kotlinType}${trailer}`);
87
+ }
88
+ params.push(' requestOptions: RequestOptions? = null');
89
+
90
+ const returnClause = responseClass ? `: ${responseClass}` : '';
91
+ if (params.length === 1) {
92
+ const single = params[0].replace(/^\s+/, '');
93
+ lines.push(` fun ${escapeReserved(method)}(${single})${returnClause} {`);
94
+ } else {
95
+ lines.push(` fun ${escapeReserved(method)}(`);
96
+ for (let i = 0; i < params.length; i++) {
97
+ const suffix = i === params.length - 1 ? '' : ',';
98
+ lines.push(`${params[i]}${suffix}`);
99
+ }
100
+ lines.push(` )${returnClause} {`);
101
+ }
102
+
103
+ // Build body using bodyOf() — consistent with non-wrapper methods.
104
+ // bodyOf() automatically drops null optional values.
105
+ const bodyEntries: string[] = [];
106
+ for (const rp of resolvedParams) {
107
+ const paramName = propertyName(rp.paramName);
108
+ bodyEntries.push(` ${ktLiteral(rp.paramName)} to ${paramName}`);
109
+ }
110
+ for (const [k, v] of Object.entries(wrapper.defaults ?? {})) {
111
+ bodyEntries.push(` ${ktLiteral(k)} to ${ktLiteral(v)}`);
112
+ }
113
+ for (const k of wrapper.inferFromClient ?? []) {
114
+ bodyEntries.push(` ${ktLiteral(k)} to workos.${clientFieldExpression(k)}`);
115
+ }
116
+ if (bodyEntries.length > 0) {
117
+ lines.push(` val body =`);
118
+ lines.push(` bodyOf(`);
119
+ for (let i = 0; i < bodyEntries.length; i++) {
120
+ const sep = i === bodyEntries.length - 1 ? '' : ',';
121
+ lines.push(` ${bodyEntries[i]}${sep}`);
122
+ }
123
+ lines.push(` )`);
124
+ } else {
125
+ lines.push(` val body = linkedMapOf<String, Any?>()`);
126
+ }
127
+
128
+ const pathExpr = buildPathExpr(op.path, pathParams);
129
+ const httpMethod = op.httpMethod.toUpperCase();
130
+
131
+ lines.push(` val config =`);
132
+ lines.push(` RequestConfig(`);
133
+ lines.push(` method = ${ktLiteral(httpMethod)},`);
134
+ lines.push(` path = ${pathExpr},`);
135
+ lines.push(` body = body,`);
136
+ if (op.requestBodyEncoding === 'form-urlencoded') {
137
+ // Some ops (SSO token, User Management authenticate) are form-encoded.
138
+ // Rewrite as formBody mapping string→string instead of JSON body.
139
+ // Fallback: leave body as JSON — the API accepts JSON for these too.
140
+ }
141
+ lines.push(` requestOptions = requestOptions`);
142
+ lines.push(` )`);
143
+
144
+ if (responseClass) {
145
+ lines.push(` return workos.baseClient.request(config, ${responseClass}::class.java)`);
146
+ } else {
147
+ lines.push(` workos.baseClient.requestVoid(config)`);
148
+ }
149
+
150
+ lines.push(' }');
151
+ return lines;
152
+ }
153
+
154
+ function escapeKdoc(s: string): string {
155
+ return s.replace(/\*\//g, '*\u200b/');
156
+ }
157
+
158
+ function buildPathExpr(path: string, pathParams: Parameter[]): string {
159
+ if (pathParams.length === 0) return ktLiteral(path);
160
+ let result = path;
161
+ for (const pp of pathParams) {
162
+ const placeholder = `{${pp.name}}`;
163
+ const propName = propertyName(pp.name);
164
+ const replacement = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(propName) ? `\$${propName}` : `\${${propName}}`;
165
+ result = result.replaceAll(placeholder, replacement);
166
+ }
167
+ return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
168
+ }
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
4
  import { collectReferencedNames } from '@workos/oagen';
3
5
  import { fileName, resolveServiceDir, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
@@ -85,6 +87,15 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
85
87
  const resolvedName = resolveResourceClassName(service, ctx);
86
88
  const propName = servicePropertyName(resolvedName);
87
89
  if (existingProps.has(propName)) continue;
90
+ // Propagate `@deprecated` from the service class to the property so
91
+ // IDEs surface the strikethrough at `workos.xyz` access sites, not
92
+ // just when users `new Xyz()` directly. TS's deprecation-lint reads
93
+ // the property JSDoc, not the underlying type declaration.
94
+ const classDeprecation = ctx.apiSurface?.classes?.[resolvedName]?.deprecationMessage;
95
+ if (classDeprecation !== undefined) {
96
+ const body = classDeprecation ? ` ${classDeprecation}` : '';
97
+ lines.push(` /** @deprecated${body} */`);
98
+ }
88
99
  lines.push(` readonly ${propName} = new ${resolvedName}(this);`);
89
100
  }
90
101
 
@@ -253,6 +264,45 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
253
264
  addBaselineExports(ctx.apiSurface.interfaces);
254
265
  addBaselineExports(ctx.apiSurface.typeAliases);
255
266
  addBaselineExports(ctx.apiSurface.enums);
267
+
268
+ // Scan the target directory for interface files not captured by the
269
+ // api-surface (e.g., list wrappers, hand-written types). Only add
270
+ // files whose exported symbols don't collide with symbols already
271
+ // claimed by another directory's barrel (TS2308 prevention).
272
+ if (ctx.targetDir) {
273
+ const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
274
+ const symbols = dirSymbols.get(dirName) ?? new Set<string>();
275
+ try {
276
+ for (const entry of fs.readdirSync(interfacesDir)) {
277
+ if (entry === 'index.ts') continue;
278
+ if (!entry.endsWith('.ts')) continue;
279
+ const stem = entry.replace(/\.ts$/, '');
280
+ const exportLine = `export * from './${stem}';`;
281
+ if (exportSet.has(exportLine)) continue;
282
+
283
+ // Extract exported symbol names from the file to check for conflicts
284
+ const content = fs.readFileSync(path.join(interfacesDir, entry), 'utf-8');
285
+ const exportedNames: string[] = [];
286
+ for (const m of content.matchAll(/export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/g)) {
287
+ exportedNames.push(m[1]);
288
+ }
289
+
290
+ // Skip if any exported name collides with a symbol already
291
+ // claimed by any file (same or different directory)
292
+ const hasCollision = exportedNames.some((name) => globalExistingSymbols.has(name));
293
+ if (hasCollision) continue;
294
+
295
+ // Safe to add — register symbols and include in barrel
296
+ for (const name of exportedNames) {
297
+ symbols.add(name);
298
+ globalExistingSymbols.add(name);
299
+ }
300
+ exportSet.add(exportLine);
301
+ }
302
+ } catch {
303
+ // Directory doesn't exist in target — nothing to scan
304
+ }
305
+ }
256
306
  }
257
307
 
258
308
  // Deduplicate and sort
package/src/node/index.ts CHANGED
@@ -16,6 +16,7 @@ import { generateEnums } from './enums.js';
16
16
  import { generateResources } from './resources.js';
17
17
  import { generateClient } from './client.js';
18
18
  import { generateErrors } from './errors.js';
19
+
19
20
  import { generateTests } from './tests.js';
20
21
  import { generateManifest } from './manifest.js';
21
22