@workos/oagen-emitters 0.4.0 → 0.5.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +35 -224
- package/dist/index.d.mts +9 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -15234
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +5 -5
- package/oagen.config.ts +5 -373
- package/package.json +10 -34
- package/src/dotnet/index.ts +6 -4
- package/src/dotnet/models.ts +58 -82
- package/src/dotnet/naming.ts +44 -6
- package/src/dotnet/resources.ts +350 -29
- package/src/dotnet/tests.ts +44 -24
- package/src/dotnet/type-map.ts +44 -17
- package/src/dotnet/wrappers.ts +21 -10
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +10 -5
- package/src/go/models.ts +6 -1
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +14 -0
- package/src/kotlin/client.ts +7 -2
- package/src/kotlin/enums.ts +30 -3
- package/src/kotlin/models.ts +97 -6
- package/src/kotlin/naming.ts +7 -1
- package/src/kotlin/resources.ts +370 -39
- package/src/kotlin/tests.ts +120 -6
- package/src/node/client.ts +38 -11
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +156 -52
- package/src/node/tests.ts +76 -27
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/models.ts +0 -33
- package/src/php/resources.ts +199 -18
- package/src/php/tests.ts +26 -2
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +6 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +13 -3
- package/src/python/enums.ts +28 -3
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +138 -1
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +131 -7
- package/src/shared/naming-utils.ts +36 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/test/dotnet/client.test.ts +2 -2
- package/test/dotnet/models.test.ts +7 -9
- package/test/dotnet/resources.test.ts +135 -3
- package/test/dotnet/tests.test.ts +5 -5
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +1 -1
- package/test/kotlin/resources.test.ts +210 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +134 -26
- package/test/node/utils.test.ts +140 -0
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +66 -1
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/go/tests.ts
CHANGED
|
@@ -152,6 +152,7 @@ function generateServiceTest(
|
|
|
152
152
|
|
|
153
153
|
// Deduplicate test functions by method name
|
|
154
154
|
const emittedTestMethods = new Set<string>();
|
|
155
|
+
const resolvedLookupMain = buildResolvedLookup(ctx);
|
|
155
156
|
for (const op of service.operations) {
|
|
156
157
|
const plan = planOperation(op);
|
|
157
158
|
const method = resolveGoMethodName(op, resolvedName, ctx);
|
|
@@ -162,7 +163,38 @@ function generateServiceTest(
|
|
|
162
163
|
if (emittedTestMethods.has(method)) continue;
|
|
163
164
|
emittedTestMethods.add(method);
|
|
164
165
|
|
|
166
|
+
// Skip operations with wrapper splits — the parent method isn't emitted
|
|
167
|
+
// (see src/go/resources.ts), only the typed wrappers are tested below.
|
|
168
|
+
const resolvedMain = lookupResolved(op, resolvedLookupMain);
|
|
169
|
+
if ((resolvedMain?.wrappers?.length ?? 0) > 0) continue;
|
|
170
|
+
|
|
165
171
|
const testName = `Test${accessorName}_${method}`;
|
|
172
|
+
const isUrlBuilder = resolvedMain?.urlBuilder ?? false;
|
|
173
|
+
|
|
174
|
+
if (isUrlBuilder) {
|
|
175
|
+
// URL-builder methods return a string synchronously without making
|
|
176
|
+
// an HTTP request. Assert the URL contains the expected path.
|
|
177
|
+
const expectedPath = buildExpectedPath(op);
|
|
178
|
+
// Call args: no ctx, path params (if any), then params struct.
|
|
179
|
+
const callArgs: string[] = [];
|
|
180
|
+
for (const p of sortPathParamsByTemplateOrder(op)) {
|
|
181
|
+
callArgs.push(`"test_${p.name}"`);
|
|
182
|
+
}
|
|
183
|
+
const hidden = buildHiddenParams(resolvedMain);
|
|
184
|
+
const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
|
|
185
|
+
if (hasVisibleQueryParams) {
|
|
186
|
+
const pName = paramsStructName(resolvedName, method);
|
|
187
|
+
callArgs.push(`&${ctx.namespace}.${pName}{}`);
|
|
188
|
+
}
|
|
189
|
+
lines.push(`func ${testName}(t *testing.T) {`);
|
|
190
|
+
lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithClientID("client_test"))`);
|
|
191
|
+
lines.push(`\turl := client.${accessorName}().${method}(${callArgs.join(', ')})`);
|
|
192
|
+
lines.push('\trequire.NotEmpty(t, url)');
|
|
193
|
+
lines.push(`\trequire.Contains(t, url, "${expectedPath}")`);
|
|
194
|
+
lines.push('}');
|
|
195
|
+
lines.push('');
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
166
198
|
|
|
167
199
|
if (isPaginated && op.pagination) {
|
|
168
200
|
// Pagination test
|
|
@@ -371,7 +403,7 @@ function generateServiceTest(
|
|
|
371
403
|
if (emittedTestMethods.has(wrapperMethod)) continue;
|
|
372
404
|
emittedTestMethods.add(wrapperMethod);
|
|
373
405
|
|
|
374
|
-
const wrapperParamsStruct =
|
|
406
|
+
const wrapperParamsStruct = paramsStructName(resolvedName, wrapperMethod);
|
|
375
407
|
const responseType = wrapper.responseModelName;
|
|
376
408
|
const testName = `Test${accessorName}_${wrapperMethod}`;
|
|
377
409
|
const fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
|
|
@@ -398,8 +430,12 @@ function generateServiceTest(
|
|
|
398
430
|
}
|
|
399
431
|
}
|
|
400
432
|
|
|
401
|
-
// Error test (one per file: 401)
|
|
402
|
-
|
|
433
|
+
// Error test (one per file: 401). Skip wrapper-split ops (parent method
|
|
434
|
+
// isn't emitted) and URL-builder ops (no HTTP call to error-test against).
|
|
435
|
+
const sampleOp = service.operations.find((o) => {
|
|
436
|
+
const r = lookupResolved(o, resolvedLookupMain);
|
|
437
|
+
return (r?.wrappers?.length ?? 0) === 0 && !(r?.urlBuilder ?? false);
|
|
438
|
+
});
|
|
403
439
|
if (sampleOp) {
|
|
404
440
|
const plan = planOperation(sampleOp);
|
|
405
441
|
const method = resolveGoMethodName(sampleOp, resolvedName, ctx);
|
package/src/go/type-map.ts
CHANGED
|
@@ -13,8 +13,10 @@ export function mapTypeRef(ref: TypeRef, asPointer = false): string {
|
|
|
13
13
|
enum: (r) => className(r.name),
|
|
14
14
|
union: (_r, variants) => joinUnionVariants(_r, variants),
|
|
15
15
|
nullable: (_ref, inner) => {
|
|
16
|
-
//
|
|
17
|
-
|
|
16
|
+
// Slices, maps, and pointer types (models) don't get pointer-wrapped:
|
|
17
|
+
// nil slice/map + omitempty already handles absence, and double-pointers
|
|
18
|
+
// are confusing at the call site.
|
|
19
|
+
if (inner.startsWith('*') || inner.startsWith('[]') || inner.startsWith('map[')) return inner;
|
|
18
20
|
return `*${inner}`;
|
|
19
21
|
},
|
|
20
22
|
literal: (r) => {
|
|
@@ -43,7 +45,10 @@ export function mapTypeRefValue(ref: TypeRef): string {
|
|
|
43
45
|
model: (r) => className(r.name),
|
|
44
46
|
enum: (r) => className(r.name),
|
|
45
47
|
union: (_r, variants) => joinUnionVariants(_r, variants),
|
|
46
|
-
nullable: (_ref, inner) =>
|
|
48
|
+
nullable: (_ref, inner) => {
|
|
49
|
+
if (inner.startsWith('*') || inner.startsWith('[]') || inner.startsWith('map[')) return inner;
|
|
50
|
+
return `*${inner}`;
|
|
51
|
+
},
|
|
47
52
|
literal: (r) => {
|
|
48
53
|
if (r.value === null) return 'interface{}';
|
|
49
54
|
if (typeof r.value === 'string') return 'string';
|
package/src/go/wrappers.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
methodName as goMethodName,
|
|
6
6
|
unexportedName,
|
|
7
7
|
} from './naming.js';
|
|
8
|
-
import { sortPathParamsByTemplateOrder } from './resources.js';
|
|
8
|
+
import { sortPathParamsByTemplateOrder, paramsStructName } from './resources.js';
|
|
9
9
|
import { resolveWrapperParams, formatWrapperDescription, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
|
|
10
10
|
import { lowerFirstForDoc, fieldDocComment } from '../shared/naming-utils.js';
|
|
11
11
|
|
|
@@ -20,6 +20,7 @@ import { lowerFirstForDoc, fieldDocComment } from '../shared/naming-utils.js';
|
|
|
20
20
|
*/
|
|
21
21
|
export function generateWrapperMethods(
|
|
22
22
|
serviceType: string,
|
|
23
|
+
mountName: string,
|
|
23
24
|
resolvedOp: ResolvedOperation,
|
|
24
25
|
ctx: EmitterContext,
|
|
25
26
|
): string[] {
|
|
@@ -30,20 +31,72 @@ export function generateWrapperMethods(
|
|
|
30
31
|
for (const wrapper of resolvedOp.wrappers) {
|
|
31
32
|
const wrapperParams = resolveWrapperParams(wrapper, ctx);
|
|
32
33
|
lines.push('');
|
|
33
|
-
emitWrapperParamsStruct(lines, wrapper, wrapperParams);
|
|
34
|
+
emitWrapperParamsStruct(lines, mountName, wrapper, wrapperParams);
|
|
34
35
|
lines.push('');
|
|
35
|
-
|
|
36
|
+
emitWrapperBodyStruct(lines, wrapper, wrapperParams);
|
|
37
|
+
lines.push('');
|
|
38
|
+
emitWrapperMethod(lines, serviceType, mountName, resolvedOp, wrapper, wrapperParams);
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
return lines;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** Unexported struct name used as the typed JSON body for a wrapper method. */
|
|
45
|
+
function wrapperBodyStructName(wrapper: ResolvedWrapper): string {
|
|
46
|
+
return `${unexportedName(goMethodName(wrapper.name))}Body`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Emit the private body struct used to serialize the JSON request for a
|
|
51
|
+
* wrapper method. Fields come from: constant defaults, required exposed
|
|
52
|
+
* params, client-inferred fields, and optional exposed params (in that
|
|
53
|
+
* order to match how bodies are constructed at call sites).
|
|
54
|
+
*/
|
|
55
|
+
function emitWrapperBodyStruct(lines: string[], wrapper: ResolvedWrapper, wrapperParams: ResolvedWrapperParam[]): void {
|
|
56
|
+
const structName = wrapperBodyStructName(wrapper);
|
|
57
|
+
lines.push(`// ${structName} is the JSON request body for ${goMethodName(wrapper.name)}.`);
|
|
58
|
+
lines.push(`type ${structName} struct {`);
|
|
59
|
+
|
|
60
|
+
// Constant defaults (always sent — no omitempty so the wire format is deterministic)
|
|
61
|
+
for (const [key, value] of Object.entries(wrapper.defaults)) {
|
|
62
|
+
const goField = goFieldName(key);
|
|
63
|
+
const goType = typeof value === 'boolean' ? 'bool' : typeof value === 'number' ? 'int' : 'string';
|
|
64
|
+
lines.push(`\t${goField} ${goType} \`json:"${key}"\``);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Required exposed params
|
|
68
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
69
|
+
if (isOptional) continue;
|
|
70
|
+
const goField = goFieldName(paramName);
|
|
71
|
+
const goType = field ? resolveSimpleGoType(field.type) : 'string';
|
|
72
|
+
lines.push(`\t${goField} ${goType} \`json:"${paramName}"\``);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Inferred fields (from client config) — omit when empty
|
|
76
|
+
for (const inferred of wrapper.inferFromClient) {
|
|
77
|
+
const goField = goFieldName(inferred);
|
|
78
|
+
lines.push(`\t${goField} string \`json:"${inferred},omitempty"\``);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Optional exposed params
|
|
82
|
+
for (const { paramName, field, isOptional } of wrapperParams) {
|
|
83
|
+
if (!isOptional) continue;
|
|
84
|
+
const goField = goFieldName(paramName);
|
|
85
|
+
const baseType = field ? resolveSimpleGoType(field.type) : 'string';
|
|
86
|
+
const optType = baseType.startsWith('*') || baseType.startsWith('[]') ? baseType : `*${baseType}`;
|
|
87
|
+
lines.push(`\t${goField} ${optType} \`json:"${paramName},omitempty"\``);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push('}');
|
|
91
|
+
}
|
|
92
|
+
|
|
41
93
|
function emitWrapperParamsStruct(
|
|
42
94
|
lines: string[],
|
|
95
|
+
mountName: string,
|
|
43
96
|
wrapper: ResolvedWrapper,
|
|
44
97
|
wrapperParams: ResolvedWrapperParam[],
|
|
45
98
|
): void {
|
|
46
|
-
const structName =
|
|
99
|
+
const structName = paramsStructName(mountName, goMethodName(wrapper.name));
|
|
47
100
|
|
|
48
101
|
lines.push(`// ${structName} contains the parameters for ${goMethodName(wrapper.name)}.`);
|
|
49
102
|
lines.push(`type ${structName} struct {`);
|
|
@@ -73,13 +126,14 @@ function emitWrapperParamsStruct(
|
|
|
73
126
|
function emitWrapperMethod(
|
|
74
127
|
lines: string[],
|
|
75
128
|
serviceType: string,
|
|
129
|
+
mountName: string,
|
|
76
130
|
resolvedOp: ResolvedOperation,
|
|
77
131
|
wrapper: ResolvedWrapper,
|
|
78
132
|
wrapperParams: ResolvedWrapperParam[],
|
|
79
133
|
): void {
|
|
80
134
|
const op = resolvedOp.operation;
|
|
81
135
|
const method = goMethodName(wrapper.name);
|
|
82
|
-
const paramsStruct =
|
|
136
|
+
const paramsStruct = paramsStructName(mountName, method);
|
|
83
137
|
|
|
84
138
|
// Return type
|
|
85
139
|
const responseType = wrapper.responseModelName ? goClassName(wrapper.responseModelName) : null;
|
|
@@ -104,38 +158,37 @@ function emitWrapperMethod(
|
|
|
104
158
|
lines.push(`func (s *${serviceType}) ${method}(${sigParams.join(', ')}) error {`);
|
|
105
159
|
}
|
|
106
160
|
|
|
107
|
-
// Build body
|
|
108
|
-
|
|
161
|
+
// Build typed body struct — defaults + required params set at literal,
|
|
162
|
+
// inferred + optional fields assigned after (conditional on presence).
|
|
163
|
+
const bodyType = wrapperBodyStructName(wrapper);
|
|
164
|
+
lines.push(`\tbody := ${bodyType}{`);
|
|
109
165
|
|
|
110
166
|
// Constant defaults (e.g., grant_type)
|
|
111
167
|
for (const [key, value] of Object.entries(wrapper.defaults)) {
|
|
112
|
-
lines.push(`\t\t
|
|
168
|
+
lines.push(`\t\t${goFieldName(key)}: ${goLiteral(value)},`);
|
|
113
169
|
}
|
|
114
170
|
|
|
115
171
|
// Required exposed params
|
|
116
172
|
for (const { paramName, isOptional } of wrapperParams) {
|
|
117
173
|
if (isOptional) continue;
|
|
118
174
|
const goField = goFieldName(paramName);
|
|
119
|
-
lines.push(`\t\t
|
|
175
|
+
lines.push(`\t\t${goField}: params.${goField},`);
|
|
120
176
|
}
|
|
121
177
|
|
|
122
178
|
lines.push('\t}');
|
|
123
179
|
|
|
124
|
-
// Inferred fields from client config
|
|
125
|
-
for (const
|
|
126
|
-
const
|
|
127
|
-
lines.push(`\
|
|
128
|
-
lines.push(`\t\tbody["${field}"] = ${expr}`);
|
|
129
|
-
lines.push('\t}');
|
|
180
|
+
// Inferred fields from client config — omitempty handles the unset case
|
|
181
|
+
for (const inferred of wrapper.inferFromClient) {
|
|
182
|
+
const goField = goFieldName(inferred);
|
|
183
|
+
lines.push(`\tbody.${goField} = ${clientFieldExpression(inferred)}`);
|
|
130
184
|
}
|
|
131
185
|
|
|
132
|
-
// Optional exposed params
|
|
186
|
+
// Optional exposed params copy through as pointers — encoding/json +
|
|
187
|
+
// omitempty will drop nil fields on the wire.
|
|
133
188
|
for (const { paramName, isOptional } of wrapperParams) {
|
|
134
189
|
if (!isOptional) continue;
|
|
135
190
|
const goField = goFieldName(paramName);
|
|
136
|
-
lines.push(`\
|
|
137
|
-
lines.push(`\t\tbody["${paramName}"] = *params.${goField}`);
|
|
138
|
-
lines.push('\t}');
|
|
191
|
+
lines.push(`\tbody.${goField} = params.${goField}`);
|
|
139
192
|
}
|
|
140
193
|
|
|
141
194
|
// Build path expression
|
|
@@ -145,7 +198,7 @@ function emitWrapperMethod(
|
|
|
145
198
|
const fmtArgs: string[] = [];
|
|
146
199
|
for (const p of sortPathParamsByTemplateOrder(op)) {
|
|
147
200
|
fmtStr = fmtStr.replace(`{${p.name}}`, '%s');
|
|
148
|
-
fmtArgs.push(lowerFirstSafe(goFieldName(p.name)));
|
|
201
|
+
fmtArgs.push(`url.PathEscape(string(${lowerFirstSafe(goFieldName(p.name))}))`);
|
|
149
202
|
}
|
|
150
203
|
pathExpr = `fmt.Sprintf("${fmtStr}", ${fmtArgs.join(', ')})`;
|
|
151
204
|
} else {
|
|
@@ -207,7 +260,12 @@ function resolveSimpleGoType(ref: any): string {
|
|
|
207
260
|
return 'interface{}';
|
|
208
261
|
}
|
|
209
262
|
}
|
|
210
|
-
if (ref.kind === 'nullable')
|
|
263
|
+
if (ref.kind === 'nullable') {
|
|
264
|
+
const inner = resolveSimpleGoType(ref.inner);
|
|
265
|
+
// Slices, maps, and pointer types don't get pointer-wrapped (mirrors type-map.ts).
|
|
266
|
+
if (inner.startsWith('*') || inner.startsWith('[]') || inner.startsWith('map[')) return inner;
|
|
267
|
+
return `*${inner}`;
|
|
268
|
+
}
|
|
211
269
|
if (ref.kind === 'array') return `[]${resolveSimpleGoType(ref.items)}`;
|
|
212
270
|
if (ref.kind === 'model') return `*${goClassName(ref.name)}`;
|
|
213
271
|
if (ref.kind === 'enum') return goClassName(ref.name);
|
package/src/index.ts
CHANGED
|
@@ -3,3 +3,17 @@ export { pythonEmitter } from './python/index.js';
|
|
|
3
3
|
export { phpEmitter } from './php/index.js';
|
|
4
4
|
export { goEmitter } from './go/index.js';
|
|
5
5
|
export { dotnetEmitter } from './dotnet/index.js';
|
|
6
|
+
export { kotlinEmitter } from './kotlin/index.js';
|
|
7
|
+
export { rubyEmitter } from './ruby/index.js';
|
|
8
|
+
|
|
9
|
+
export { nodeExtractor } from './compat/extractors/node.js';
|
|
10
|
+
export { rubyExtractor } from './compat/extractors/ruby.js';
|
|
11
|
+
export { pythonExtractor } from './compat/extractors/python.js';
|
|
12
|
+
export { phpExtractor } from './compat/extractors/php.js';
|
|
13
|
+
export { goExtractor } from './compat/extractors/go.js';
|
|
14
|
+
export { rustExtractor } from './compat/extractors/rust.js';
|
|
15
|
+
export { kotlinExtractor } from './compat/extractors/kotlin.js';
|
|
16
|
+
export { dotnetExtractor } from './compat/extractors/dotnet.js';
|
|
17
|
+
export { elixirExtractor } from './compat/extractors/elixir.js';
|
|
18
|
+
|
|
19
|
+
export { workosEmittersPlugin } from './plugin.js';
|
package/src/kotlin/client.ts
CHANGED
|
@@ -25,9 +25,14 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
|
|
|
25
25
|
const fqn = `com.workos.${packageSegment(mount)}.${apiCls}`;
|
|
26
26
|
const prop = servicePropertyName(mount);
|
|
27
27
|
accessorLines.push('');
|
|
28
|
-
accessorLines.push(` /** Lazily-constructed [${
|
|
28
|
+
accessorLines.push(` /** Lazily-constructed [${fqn}] accessor for this [WorkOS] client. */`);
|
|
29
29
|
accessorLines.push(` val ${prop}: ${fqn}`);
|
|
30
|
-
accessorLines.push(
|
|
30
|
+
accessorLines.push(' get() =');
|
|
31
|
+
accessorLines.push(' service(');
|
|
32
|
+
accessorLines.push(` ${fqn}::class`);
|
|
33
|
+
accessorLines.push(' ) {');
|
|
34
|
+
accessorLines.push(` ${fqn}(this)`);
|
|
35
|
+
accessorLines.push(' }');
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
const lines: string[] = [];
|
package/src/kotlin/enums.ts
CHANGED
|
@@ -43,6 +43,12 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
43
43
|
const aliasOf = new Map<string, string>(); // enum name → canonical enum name
|
|
44
44
|
for (const [, group] of hashGroups) {
|
|
45
45
|
if (group.length <= 1) continue;
|
|
46
|
+
if (group.every(isSharedSortOrderEnum)) {
|
|
47
|
+
const [canonical, ...rest] = [...group].sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
enumCanonicalMap.set(canonical.name, canonical.name);
|
|
49
|
+
for (const enumDef of rest) enumCanonicalMap.set(enumDef.name, 'SortOrder');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
46
52
|
const sorted = [...group].sort(
|
|
47
53
|
(a, b) =>
|
|
48
54
|
className(a.name).length - className(b.name).length || className(a.name).localeCompare(className(b.name)),
|
|
@@ -59,17 +65,28 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
59
65
|
for (const enumDef of enums) {
|
|
60
66
|
if (enumDef.values.length === 0) continue;
|
|
61
67
|
|
|
62
|
-
const typeName =
|
|
68
|
+
const typeName = canonicalEnumTypeName(enumDef);
|
|
63
69
|
|
|
64
70
|
// Non-canonical enum: emit a typealias instead of a full enum class.
|
|
65
|
-
const
|
|
71
|
+
const sharedSortEmitter = isSharedSortOrderEnum(enumDef) && enumCanonicalMap.get(enumDef.name) === enumDef.name;
|
|
72
|
+
const canonicalName = sharedSortEmitter
|
|
73
|
+
? undefined
|
|
74
|
+
: (aliasOf.get(enumDef.name) ?? enumCanonicalMap.get(enumDef.name));
|
|
66
75
|
if (canonicalName) {
|
|
67
76
|
const canonicalType = className(canonicalName);
|
|
77
|
+
// Skip when different IR names collapse to the same output name
|
|
78
|
+
if (typeName === canonicalType) continue;
|
|
68
79
|
const aliasLine = `typealias ${typeName} = ${canonicalType}`;
|
|
69
80
|
// ktlint enforces a 140-char max line length. When the typealias
|
|
70
81
|
// exceeds that, add a @file:Suppress to avoid an unfixable violation.
|
|
71
82
|
const suppressLine = aliasLine.length > 140 ? `@file:Suppress("ktlint:standard:max-line-length")\n\n` : '';
|
|
72
|
-
const aliasContent = [
|
|
83
|
+
const aliasContent = [
|
|
84
|
+
`${suppressLine}package ${ENUMS_PACKAGE}`,
|
|
85
|
+
'',
|
|
86
|
+
`/** Alias for [${canonicalType}]. */`,
|
|
87
|
+
aliasLine,
|
|
88
|
+
'',
|
|
89
|
+
].join('\n');
|
|
73
90
|
files.push({
|
|
74
91
|
path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
|
|
75
92
|
content: aliasContent,
|
|
@@ -89,6 +106,7 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
89
106
|
// sentinel for values the server introduces after this SDK was built.
|
|
90
107
|
lines.push(`/** Enumeration of valid ${typeName} values returned or accepted by the API. */`);
|
|
91
108
|
lines.push(`enum class ${typeName}(`);
|
|
109
|
+
lines.push(' /** The wire value sent to and received from the API. */');
|
|
92
110
|
lines.push(' @JsonValue val value: String');
|
|
93
111
|
lines.push(') {');
|
|
94
112
|
// `@JsonEnumDefaultValue` makes Jackson's
|
|
@@ -149,6 +167,15 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
149
167
|
return files;
|
|
150
168
|
}
|
|
151
169
|
|
|
170
|
+
function canonicalEnumTypeName(enumDef: Enum): string {
|
|
171
|
+
return isSharedSortOrderEnum(enumDef) ? 'SortOrder' : className(enumDef.name);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isSharedSortOrderEnum(enumDef: Enum): boolean {
|
|
175
|
+
const wireValues = [...new Set(enumDef.values.map((value) => String(value.value).toLowerCase()))].sort();
|
|
176
|
+
return wireValues.length === 2 && wireValues[0] === 'asc' && wireValues[1] === 'desc';
|
|
177
|
+
}
|
|
178
|
+
|
|
152
179
|
/** Hash an enum by its sorted wire values so identical enums collide. */
|
|
153
180
|
function enumWireHash(enumDef: Enum): string {
|
|
154
181
|
return [...enumDef.values]
|
package/src/kotlin/models.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
|
|
2
2
|
import { mapTypeRef, discriminatedUnions } from './type-map.js';
|
|
3
|
-
import { className, propertyName, ktStringLiteral } from './naming.js';
|
|
3
|
+
import { className, propertyName, ktStringLiteral, humanize } from './naming.js';
|
|
4
4
|
import { enumCanonicalMap } from './enums.js';
|
|
5
5
|
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
6
6
|
|
|
@@ -86,10 +86,14 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
86
86
|
|
|
87
87
|
const canonical = aliasOf.get(model.name);
|
|
88
88
|
if (canonical) {
|
|
89
|
+
const canonicalType = className(canonical);
|
|
90
|
+
// Skip when different IR names collapse to the same output name
|
|
91
|
+
if (typeName === canonicalType) continue;
|
|
89
92
|
const aliasContent = [
|
|
90
93
|
`package ${MODELS_PACKAGE}`,
|
|
91
94
|
'',
|
|
92
|
-
|
|
95
|
+
`/** Alias for [${canonicalType}]. */`,
|
|
96
|
+
`typealias ${typeName} = ${canonicalType}`,
|
|
93
97
|
'',
|
|
94
98
|
].join('\n');
|
|
95
99
|
files.push({
|
|
@@ -103,6 +107,23 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
|
|
|
103
107
|
files.push(emitDataClass(model));
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
// Generate the sealed WorkOSEvent interface. Collect all event envelope
|
|
111
|
+
// models that have a literal `event` field and build the @JsonSubTypes
|
|
112
|
+
// mapping so Jackson can deserialize directly to the correct concrete type.
|
|
113
|
+
const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
|
|
114
|
+
for (const model of models) {
|
|
115
|
+
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
116
|
+
if (aliasOf.has(model.name)) continue;
|
|
117
|
+
if (!isEventEnvelopeModel(model)) continue;
|
|
118
|
+
const eventField = model.fields.find((f) => f.name === 'event');
|
|
119
|
+
if (eventField && eventField.type.kind === 'literal' && typeof eventField.type.value === 'string') {
|
|
120
|
+
eventMapping.push({ wireValue: eventField.type.value, modelName: model.name });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (eventMapping.length > 0) {
|
|
124
|
+
files.push(emitWorkOSEvent(eventMapping));
|
|
125
|
+
}
|
|
126
|
+
|
|
106
127
|
return files;
|
|
107
128
|
}
|
|
108
129
|
|
|
@@ -119,7 +140,7 @@ function emitDataClass(model: Model): GeneratedFile {
|
|
|
119
140
|
const typeName = className(model.name);
|
|
120
141
|
const imports = collectImports(model.fields);
|
|
121
142
|
const implementsEvent = isEventEnvelopeModel(model);
|
|
122
|
-
|
|
143
|
+
// WorkOSEvent sealed interface is generated in the same package — no import needed.
|
|
123
144
|
const lines: string[] = [];
|
|
124
145
|
lines.push(`package ${MODELS_PACKAGE}`);
|
|
125
146
|
lines.push('');
|
|
@@ -202,6 +223,74 @@ function emitSealedUnion(
|
|
|
202
223
|
};
|
|
203
224
|
}
|
|
204
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Emit the sealed `WorkOSEvent` interface with Jackson discriminated
|
|
228
|
+
* deserialization. Each concrete event model (UserCreated, DsyncUserUpdated,
|
|
229
|
+
* etc.) already extends this interface. The `@JsonSubTypes` annotation lets
|
|
230
|
+
* Jackson pick the right subclass when deserializing JSON with an `event`
|
|
231
|
+
* discriminator field. `EventSchema` is the fallback for unknown event types.
|
|
232
|
+
*/
|
|
233
|
+
function emitWorkOSEvent(eventMapping: Array<{ wireValue: string; modelName: string }>): GeneratedFile {
|
|
234
|
+
const lines: string[] = [];
|
|
235
|
+
lines.push(`package ${MODELS_PACKAGE}`);
|
|
236
|
+
lines.push('');
|
|
237
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
|
|
238
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
|
|
239
|
+
lines.push('import java.time.OffsetDateTime');
|
|
240
|
+
lines.push('');
|
|
241
|
+
|
|
242
|
+
lines.push('/**');
|
|
243
|
+
lines.push(' * Sealed interface for all webhook/event envelope models.');
|
|
244
|
+
lines.push(' *');
|
|
245
|
+
lines.push(' * Jackson deserializes incoming event JSON to the correct concrete type');
|
|
246
|
+
lines.push(' * based on the `event` discriminator field. Unknown event types fall back');
|
|
247
|
+
lines.push(' * to [EventSchema] with untyped `data: Map<String, Any>`.');
|
|
248
|
+
lines.push(' *');
|
|
249
|
+
lines.push(' * ```kotlin');
|
|
250
|
+
lines.push(' * val event: WorkOSEvent = objectMapper.readValue(json, WorkOSEvent::class.java)');
|
|
251
|
+
lines.push(' * when (event) {');
|
|
252
|
+
lines.push(' * is UserCreated -> println("User created: ${event.data.id}")');
|
|
253
|
+
lines.push(' * is EventSchema -> println("Unknown event: ${event.event}")');
|
|
254
|
+
lines.push(' * }');
|
|
255
|
+
lines.push(' * ```');
|
|
256
|
+
lines.push(' */');
|
|
257
|
+
|
|
258
|
+
lines.push('@JsonTypeInfo(');
|
|
259
|
+
lines.push(' use = JsonTypeInfo.Id.NAME,');
|
|
260
|
+
lines.push(' include = JsonTypeInfo.As.EXISTING_PROPERTY,');
|
|
261
|
+
lines.push(' property = "event",');
|
|
262
|
+
lines.push(' visible = true,');
|
|
263
|
+
lines.push(' defaultImpl = EventSchema::class');
|
|
264
|
+
lines.push(')');
|
|
265
|
+
lines.push('@JsonSubTypes(');
|
|
266
|
+
// Sort entries for stable output
|
|
267
|
+
const sorted = [...eventMapping].sort((a, b) => a.wireValue.localeCompare(b.wireValue));
|
|
268
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
269
|
+
const { wireValue, modelName } = sorted[i];
|
|
270
|
+
const typeName = className(modelName);
|
|
271
|
+
const suffix = i === sorted.length - 1 ? '' : ',';
|
|
272
|
+
lines.push(` JsonSubTypes.Type(value = ${typeName}::class, name = ${ktStringLiteral(wireValue)})${suffix}`);
|
|
273
|
+
}
|
|
274
|
+
lines.push(')');
|
|
275
|
+
lines.push('sealed interface WorkOSEvent {');
|
|
276
|
+
lines.push(' /** Unique identifier for this event. */');
|
|
277
|
+
lines.push(' val id: String');
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push(' /** The event type identifier. */');
|
|
280
|
+
lines.push(' val event: String');
|
|
281
|
+
lines.push('');
|
|
282
|
+
lines.push(' /** Timestamp when the event was created. */');
|
|
283
|
+
lines.push(' val createdAt: OffsetDateTime');
|
|
284
|
+
lines.push('}');
|
|
285
|
+
lines.push('');
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/WorkOSEvent.kt`,
|
|
289
|
+
content: lines.join('\n'),
|
|
290
|
+
overwriteExisting: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
205
294
|
function renderFields(fields: Field[], overrideFields: Set<string> = new Set()): string[] {
|
|
206
295
|
const seen = new Set<string>();
|
|
207
296
|
const lines: string[] = [];
|
|
@@ -234,9 +323,9 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
|
|
|
234
323
|
|
|
235
324
|
const isOverride = overrideFields.has(kotlinName);
|
|
236
325
|
const annotations: string[] = [];
|
|
237
|
-
// @JvmField
|
|
238
|
-
//
|
|
239
|
-
|
|
326
|
+
// Omit @JvmField so Kotlin generates proper getter methods (getId(),
|
|
327
|
+
// isEmailVerified(), etc.) for Java callers — matching the accessor
|
|
328
|
+
// convention used by Stripe, AWS SDK v2, and Twilio.
|
|
240
329
|
annotations.push(`@JsonProperty(${ktStringLiteral(field.name)})`);
|
|
241
330
|
if (field.deprecated) annotations.push('@Deprecated("Deprecated field")');
|
|
242
331
|
|
|
@@ -246,6 +335,8 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
|
|
|
246
335
|
lines.push(` /** ${escapeKdoc(line.trim())} */`);
|
|
247
336
|
} else if (literalDefault !== null) {
|
|
248
337
|
lines.push(` /** Always \`${literalDefault}\`. */`);
|
|
338
|
+
} else {
|
|
339
|
+
lines.push(` /** The ${humanize(field.name)}. */`);
|
|
249
340
|
}
|
|
250
341
|
for (const anno of annotations) lines.push(` ${anno}`);
|
|
251
342
|
|
package/src/kotlin/naming.ts
CHANGED
|
@@ -25,7 +25,12 @@ export function methodName(name: string): string {
|
|
|
25
25
|
|
|
26
26
|
/** camelCase Kotlin property / local variable name. */
|
|
27
27
|
export function propertyName(name: string): string {
|
|
28
|
-
|
|
28
|
+
const camel = toCamelCase(name);
|
|
29
|
+
// `object` is a Kotlin reserved word. Instead of backtick-escaping it
|
|
30
|
+
// (forcing callers to write `event.\`object\``), rename to `objectType`
|
|
31
|
+
// and rely on @JsonProperty("object") for wire mapping.
|
|
32
|
+
if (camel === 'object') return 'objectType';
|
|
33
|
+
return escapeReserved(camel);
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/** camelCase alias (kept for parity with other emitters). */
|
|
@@ -45,6 +50,7 @@ export function packageSegment(name: string): string {
|
|
|
45
50
|
|
|
46
51
|
/** Kotlin service class name for a mount group (e.g., `Organizations`). */
|
|
47
52
|
export function apiClassName(name: string): string {
|
|
53
|
+
if (className(name) === 'SSO') return 'Sso';
|
|
48
54
|
return className(name);
|
|
49
55
|
}
|
|
50
56
|
|