@workos/oagen-emitters 0.3.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 +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- 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 +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- 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 +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- 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/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- 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 +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- 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 +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- 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
|
@@ -2,3 +2,18 @@ export { nodeEmitter } from './node/index.js';
|
|
|
2
2
|
export { pythonEmitter } from './python/index.js';
|
|
3
3
|
export { phpEmitter } from './php/index.js';
|
|
4
4
|
export { goEmitter } from './go/index.js';
|
|
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';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
+
import { apiClassName, packageSegment, servicePropertyName } from './naming.js';
|
|
3
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
+
|
|
5
|
+
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate service accessor properties for the hand-maintained `WorkOS` class.
|
|
9
|
+
*
|
|
10
|
+
* Each accessor is a `val` property with a custom getter that delegates to
|
|
11
|
+
* `WorkOS.service(...)` for lazy, cached construction. The generated file
|
|
12
|
+
* contains a `WorkOS` class stub with only these properties — the oagen
|
|
13
|
+
* merger deep-merges them into the existing hand-written `WorkOS.kt`.
|
|
14
|
+
*
|
|
15
|
+
* Accessors use fully-qualified type names so the merger doesn't need to
|
|
16
|
+
* inject imports into the hand-written file.
|
|
17
|
+
*/
|
|
18
|
+
export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
19
|
+
const targets = deduplicateByMount(spec.services, ctx);
|
|
20
|
+
if (targets.length === 0) return [];
|
|
21
|
+
|
|
22
|
+
const accessorLines: string[] = [];
|
|
23
|
+
for (const mount of targets) {
|
|
24
|
+
const apiCls = apiClassName(mount);
|
|
25
|
+
const fqn = `com.workos.${packageSegment(mount)}.${apiCls}`;
|
|
26
|
+
const prop = servicePropertyName(mount);
|
|
27
|
+
accessorLines.push('');
|
|
28
|
+
accessorLines.push(` /** Lazily-constructed [${fqn}] accessor for this [WorkOS] client. */`);
|
|
29
|
+
accessorLines.push(` val ${prop}: ${fqn}`);
|
|
30
|
+
accessorLines.push(' get() =');
|
|
31
|
+
accessorLines.push(' service(');
|
|
32
|
+
accessorLines.push(` ${fqn}::class`);
|
|
33
|
+
accessorLines.push(' ) {');
|
|
34
|
+
accessorLines.push(` ${fqn}(this)`);
|
|
35
|
+
accessorLines.push(' }');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
lines.push('package com.workos');
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push('open class WorkOS {');
|
|
42
|
+
for (const line of accessorLines) lines.push(line);
|
|
43
|
+
lines.push('}');
|
|
44
|
+
lines.push('');
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
path: `${KOTLIN_SRC_PREFIX}com/workos/WorkOS.kt`,
|
|
49
|
+
content: lines.join('\n'),
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function deduplicateByMount(services: Service[], ctx: EmitterContext): string[] {
|
|
55
|
+
const targets = new Set<string>();
|
|
56
|
+
for (const s of services) targets.add(getMountTarget(s, ctx));
|
|
57
|
+
return [...targets].sort();
|
|
58
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { className, ktStringLiteral } from './naming.js';
|
|
3
|
+
|
|
4
|
+
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
5
|
+
const ENUMS_PACKAGE = 'com.workos.types';
|
|
6
|
+
const ENUMS_DIR = 'com/workos/types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mapping from an IR enum name to its canonical enum name. When two enums
|
|
10
|
+
* share identical sorted wire values the shorter-named one is canonical and
|
|
11
|
+
* the others become `typealias` files. Downstream consumers (type-map,
|
|
12
|
+
* resources) use this map to resolve references to the canonical class.
|
|
13
|
+
*/
|
|
14
|
+
export const enumCanonicalMap = new Map<string, string>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate Kotlin `enum class` types from the IR enums. Each enum is emitted
|
|
18
|
+
* to its own file under `com.workos.types`, annotated with Jackson
|
|
19
|
+
* `@JsonValue` on the wire value. An `Unknown` sentinel is always the first
|
|
20
|
+
* constant so that responses with new variants still deserialize instead of
|
|
21
|
+
* throwing.
|
|
22
|
+
*
|
|
23
|
+
* Enums with identical sets of wire values are deduplicated: the one with the
|
|
24
|
+
* shortest PascalCase name becomes canonical and the rest emit `typealias`
|
|
25
|
+
* files pointing at the canonical class.
|
|
26
|
+
*/
|
|
27
|
+
export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
|
|
28
|
+
if (enums.length === 0) return [];
|
|
29
|
+
|
|
30
|
+
// Reset the canonical map on every generation run (guards against re-entry).
|
|
31
|
+
enumCanonicalMap.clear();
|
|
32
|
+
|
|
33
|
+
// --- Dedup: group enums by a hash of their sorted wire values. ---
|
|
34
|
+
const hashGroups = new Map<string, Enum[]>();
|
|
35
|
+
for (const enumDef of enums) {
|
|
36
|
+
if (enumDef.values.length === 0) continue;
|
|
37
|
+
const hash = enumWireHash(enumDef);
|
|
38
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
39
|
+
hashGroups.get(hash)!.push(enumDef);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Within each group, pick the shortest className as canonical.
|
|
43
|
+
const aliasOf = new Map<string, string>(); // enum name → canonical enum name
|
|
44
|
+
for (const [, group] of hashGroups) {
|
|
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
|
+
}
|
|
52
|
+
const sorted = [...group].sort(
|
|
53
|
+
(a, b) =>
|
|
54
|
+
className(a.name).length - className(b.name).length || className(a.name).localeCompare(className(b.name)),
|
|
55
|
+
);
|
|
56
|
+
const canonical = sorted[0];
|
|
57
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
58
|
+
aliasOf.set(sorted[i].name, canonical.name);
|
|
59
|
+
enumCanonicalMap.set(sorted[i].name, canonical.name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const files: GeneratedFile[] = [];
|
|
64
|
+
|
|
65
|
+
for (const enumDef of enums) {
|
|
66
|
+
if (enumDef.values.length === 0) continue;
|
|
67
|
+
|
|
68
|
+
const typeName = canonicalEnumTypeName(enumDef);
|
|
69
|
+
|
|
70
|
+
// Non-canonical enum: emit a typealias instead of a full enum class.
|
|
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));
|
|
75
|
+
if (canonicalName) {
|
|
76
|
+
const canonicalType = className(canonicalName);
|
|
77
|
+
// Skip when different IR names collapse to the same output name
|
|
78
|
+
if (typeName === canonicalType) continue;
|
|
79
|
+
const aliasLine = `typealias ${typeName} = ${canonicalType}`;
|
|
80
|
+
// ktlint enforces a 140-char max line length. When the typealias
|
|
81
|
+
// exceeds that, add a @file:Suppress to avoid an unfixable violation.
|
|
82
|
+
const suppressLine = aliasLine.length > 140 ? `@file:Suppress("ktlint:standard:max-line-length")\n\n` : '';
|
|
83
|
+
const aliasContent = [
|
|
84
|
+
`${suppressLine}package ${ENUMS_PACKAGE}`,
|
|
85
|
+
'',
|
|
86
|
+
`/** Alias for [${canonicalType}]. */`,
|
|
87
|
+
aliasLine,
|
|
88
|
+
'',
|
|
89
|
+
].join('\n');
|
|
90
|
+
files.push({
|
|
91
|
+
path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
|
|
92
|
+
content: aliasContent,
|
|
93
|
+
overwriteExisting: true,
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
lines.push(`package ${ENUMS_PACKAGE}`);
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonEnumDefaultValue');
|
|
102
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonValue');
|
|
103
|
+
lines.push('');
|
|
104
|
+
// Replace the tautological "Foo enum." docstring with a slightly more
|
|
105
|
+
// informative summary. `Unknown` is emitted as the forward-compatibility
|
|
106
|
+
// sentinel for values the server introduces after this SDK was built.
|
|
107
|
+
lines.push(`/** Enumeration of valid ${typeName} values returned or accepted by the API. */`);
|
|
108
|
+
lines.push(`enum class ${typeName}(`);
|
|
109
|
+
lines.push(' /** The wire value sent to and received from the API. */');
|
|
110
|
+
lines.push(' @JsonValue val value: String');
|
|
111
|
+
lines.push(') {');
|
|
112
|
+
// `@JsonEnumDefaultValue` makes Jackson's
|
|
113
|
+
// READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE feature map unrecognized
|
|
114
|
+
// wire values onto `Unknown` instead of throwing — required for forward
|
|
115
|
+
// compatibility when the API introduces new variants.
|
|
116
|
+
lines.push(' @JsonEnumDefaultValue');
|
|
117
|
+
lines.push(` Unknown(${ktStringLiteral('unknown')}),`);
|
|
118
|
+
|
|
119
|
+
const seenNames = new Set<string>(['Unknown']);
|
|
120
|
+
const seenWire = new Set<string>(['unknown']);
|
|
121
|
+
const members: string[] = [];
|
|
122
|
+
|
|
123
|
+
for (const v of enumDef.values) {
|
|
124
|
+
const wire = String(v.value);
|
|
125
|
+
if (seenWire.has(wire)) continue;
|
|
126
|
+
seenWire.add(wire);
|
|
127
|
+
|
|
128
|
+
let memberName = className(wire);
|
|
129
|
+
if (!memberName || /^[0-9]/.test(memberName)) memberName = `Value${memberName || wire}`;
|
|
130
|
+
if (memberName === typeName || seenNames.has(memberName)) {
|
|
131
|
+
let suffix = 2;
|
|
132
|
+
while (seenNames.has(`${memberName}${suffix}`)) suffix += 1;
|
|
133
|
+
memberName = `${memberName}${suffix}`;
|
|
134
|
+
}
|
|
135
|
+
seenNames.add(memberName);
|
|
136
|
+
|
|
137
|
+
if (v.description?.trim()) {
|
|
138
|
+
members.push(` /** ${escapeKdoc(v.description.split('\n')[0].trim())} */`);
|
|
139
|
+
}
|
|
140
|
+
if (v.deprecated) {
|
|
141
|
+
members.push(' @Deprecated("Deprecated enum value")');
|
|
142
|
+
}
|
|
143
|
+
members.push(` ${memberName}(${ktStringLiteral(wire)})`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < members.length; i++) {
|
|
147
|
+
const isLast = i === members.length - 1;
|
|
148
|
+
const line = members[i];
|
|
149
|
+
const trimmedStart = line.trimStart();
|
|
150
|
+
if (trimmedStart.startsWith('/**') || trimmedStart.startsWith('@')) {
|
|
151
|
+
lines.push(line);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
lines.push(isLast ? line : `${line},`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
lines.push('}');
|
|
158
|
+
lines.push('');
|
|
159
|
+
|
|
160
|
+
files.push({
|
|
161
|
+
path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
|
|
162
|
+
content: lines.join('\n'),
|
|
163
|
+
overwriteExisting: true,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return files;
|
|
168
|
+
}
|
|
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
|
+
|
|
179
|
+
/** Hash an enum by its sorted wire values so identical enums collide. */
|
|
180
|
+
function enumWireHash(enumDef: Enum): string {
|
|
181
|
+
return [...enumDef.values]
|
|
182
|
+
.map((v) => String(v.value))
|
|
183
|
+
.sort()
|
|
184
|
+
.join('|');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeKdoc(s: string): string {
|
|
188
|
+
return s.replace(/\*\//g, '*\u200b/');
|
|
189
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Emitter,
|
|
3
|
+
EmitterContext,
|
|
4
|
+
FormatCommand,
|
|
5
|
+
GeneratedFile,
|
|
6
|
+
ApiSpec,
|
|
7
|
+
Model,
|
|
8
|
+
Enum,
|
|
9
|
+
Service,
|
|
10
|
+
} from '@workos/oagen';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { generateModels } from './models.js';
|
|
15
|
+
import { generateEnums } from './enums.js';
|
|
16
|
+
import { generateResources } from './resources.js';
|
|
17
|
+
import { generateClient } from './client.js';
|
|
18
|
+
import { generateTests } from './tests.js';
|
|
19
|
+
import { generateManifest } from './manifest.js';
|
|
20
|
+
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
21
|
+
|
|
22
|
+
/** Ensure every generated file ends with a trailing newline. */
|
|
23
|
+
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
24
|
+
for (const f of files) {
|
|
25
|
+
if (f.content && !f.content.endsWith('\n')) {
|
|
26
|
+
f.content += '\n';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const kotlinEmitter: Emitter = {
|
|
33
|
+
language: 'kotlin',
|
|
34
|
+
|
|
35
|
+
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
36
|
+
const enriched = enrichModelsFromSpec(models);
|
|
37
|
+
return ensureTrailingNewlines(generateModels(enriched, ctx));
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
41
|
+
// Merge synthetic enums produced during model enrichment (inline oneOf
|
|
42
|
+
// definitions) so they get proper enum class files.
|
|
43
|
+
const syntheticEnums = getSyntheticEnums();
|
|
44
|
+
return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
48
|
+
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
52
|
+
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
generateErrors(_ctx: EmitterContext): GeneratedFile[] {
|
|
56
|
+
// Exception hierarchy is hand-maintained in workos-kotlin (see Phase 2).
|
|
57
|
+
return [];
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
generateTypeSignatures(): GeneratedFile[] {
|
|
61
|
+
return [];
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
65
|
+
// Pass enriched models so round-trip tests see the full field set
|
|
66
|
+
// (including optional oneOf-enriched fields) and can filter accurately.
|
|
67
|
+
const enrichedModels = enrichModelsFromSpec(spec.models);
|
|
68
|
+
const enrichedSpec: ApiSpec = { ...spec, models: enrichedModels };
|
|
69
|
+
return ensureTrailingNewlines(generateTests(enrichedSpec, { ...ctx, spec: enrichedSpec }));
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
73
|
+
return ensureTrailingNewlines(generateManifest(spec, ctx));
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
fileHeader(): string {
|
|
77
|
+
return '// This file is auto-generated by oagen. Do not edit.';
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
formatCommand(targetDir: string): FormatCommand | null {
|
|
81
|
+
// ktlint enforces a 140-char max line length that cannot be auto-corrected
|
|
82
|
+
// by the Gradle plugin alone, but `./gradlew ktlintFormat` fixes most
|
|
83
|
+
// violations (trailing whitespace, import ordering, etc.) across all
|
|
84
|
+
// generated files. The file list appended by oagen is ignored — Gradle
|
|
85
|
+
// reformats the whole source set.
|
|
86
|
+
if (!fs.existsSync(path.join(targetDir, 'gradlew'))) return null;
|
|
87
|
+
return {
|
|
88
|
+
cmd: 'bash',
|
|
89
|
+
args: ['-c', `cd ${JSON.stringify(targetDir)} && ./gradlew ktlintFormat --quiet 2>/dev/null; true`, '--'],
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
};
|