@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.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +328 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- 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 +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +4 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +128 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +179 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +189 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- 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 +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +682 -0
- package/test/php/tests.test.ts +185 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { resolveMethodName, servicePropertyName, resolveClassName } from './naming.js';
|
|
3
|
+
import { buildResolvedLookup, lookupResolved, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
+
import { propertyName } from './naming.js';
|
|
5
|
+
import { isHandwrittenOverride } from './overrides.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate the smoke-test manifest mapping `"HTTP_METHOD /path"` to
|
|
9
|
+
* `{ sdkMethod, service }`. The `service` is the camelCase accessor property
|
|
10
|
+
* on the main `WorkOS` client (e.g., `organizations`).
|
|
11
|
+
*
|
|
12
|
+
* For polymorphic/split operations (e.g., authenticate -> 8 methods), the
|
|
13
|
+
* manifest emits one entry per wrapper method so each variant is addressable.
|
|
14
|
+
*/
|
|
15
|
+
export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
16
|
+
const manifest: Record<string, { sdkMethod: string; service: string } | { sdkMethod: string; service: string }[]> =
|
|
17
|
+
{};
|
|
18
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
19
|
+
|
|
20
|
+
for (const service of spec.services) {
|
|
21
|
+
const mountTarget = getMountTarget(service, ctx);
|
|
22
|
+
const prop = servicePropertyName(resolveClassName(service, ctx) || mountTarget);
|
|
23
|
+
for (const op of service.operations) {
|
|
24
|
+
if (isHandwrittenOverride(op)) continue;
|
|
25
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
26
|
+
|
|
27
|
+
// Check if this operation is split into wrapper methods
|
|
28
|
+
const resolved = lookupResolved(op, resolvedLookup);
|
|
29
|
+
const wrappers = resolved?.wrappers ?? [];
|
|
30
|
+
|
|
31
|
+
// Use the resolved mount target for correct service attribution
|
|
32
|
+
const resolvedProp = resolved ? servicePropertyName(resolved.mountOn) : prop;
|
|
33
|
+
|
|
34
|
+
if (wrappers.length > 0) {
|
|
35
|
+
// Polymorphic operation: emit an array of methods
|
|
36
|
+
const methods = wrappers.map((w) => ({
|
|
37
|
+
sdkMethod: propertyName(w.name),
|
|
38
|
+
service: resolvedProp,
|
|
39
|
+
}));
|
|
40
|
+
manifest[httpKey] = methods;
|
|
41
|
+
} else {
|
|
42
|
+
const method = resolveMethodName(op, service, ctx);
|
|
43
|
+
manifest[httpKey] = { sdkMethod: method, service: resolvedProp };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
path: 'smoke-manifest.json',
|
|
51
|
+
content: JSON.stringify(manifest, null, 2),
|
|
52
|
+
integrateTarget: false,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef, discriminatedUnions } from './type-map.js';
|
|
3
|
+
import { className, propertyName, ktStringLiteral } from './naming.js';
|
|
4
|
+
import { enumCanonicalMap } from './enums.js';
|
|
5
|
+
import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
|
|
6
|
+
|
|
7
|
+
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
8
|
+
const MODELS_PACKAGE = 'com.workos.models';
|
|
9
|
+
const MODELS_DIR = 'com/workos/models';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate Kotlin `data class` models. Each model becomes a separate `.kt`
|
|
13
|
+
* file under `com.workos.models`. Discriminated unions emit a sealed class
|
|
14
|
+
* with Jackson `@JsonTypeInfo` / `@JsonSubTypes` annotations so the base type
|
|
15
|
+
* picks the right variant at deserialization time.
|
|
16
|
+
*
|
|
17
|
+
* List wrappers (`{ data, list_metadata }`) and the shared `ListMetadata`
|
|
18
|
+
* model are skipped — the hand-maintained runtime provides [Page]/[ListMetadata].
|
|
19
|
+
*/
|
|
20
|
+
export function generateModels(models: Model[], _ctx: EmitterContext): GeneratedFile[] {
|
|
21
|
+
if (models.length === 0) return [];
|
|
22
|
+
|
|
23
|
+
// First pass: call mapTypeRef on every model field so discriminator info is
|
|
24
|
+
// registered before we start emitting parents.
|
|
25
|
+
for (const model of models) {
|
|
26
|
+
for (const field of model.fields) mapTypeRef(field.type);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const files: GeneratedFile[] = [];
|
|
30
|
+
|
|
31
|
+
// Deduplication: identical structures become typealiases.
|
|
32
|
+
// Pass 1: hash without nested-alias resolution.
|
|
33
|
+
modelAliasMap = null;
|
|
34
|
+
const hashGroupsPass1 = new Map<string, string[]>();
|
|
35
|
+
for (const model of models) {
|
|
36
|
+
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
37
|
+
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
38
|
+
const hash = structuralHash(model);
|
|
39
|
+
if (!hashGroupsPass1.has(hash)) hashGroupsPass1.set(hash, []);
|
|
40
|
+
hashGroupsPass1.get(hash)!.push(model.name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const aliasOf = new Map<string, string>();
|
|
44
|
+
for (const [hash, names] of hashGroupsPass1) {
|
|
45
|
+
if (names.length <= 1 || hash === '') continue;
|
|
46
|
+
const sorted = [...names].sort(preferShorterCanonical);
|
|
47
|
+
const canonical = sorted[0];
|
|
48
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
49
|
+
if (hasRequestSuffix(sorted[i]) !== hasRequestSuffix(canonical)) continue;
|
|
50
|
+
aliasOf.set(sorted[i], canonical);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Pass 2: re-hash with the alias map so models whose only difference was
|
|
55
|
+
// referencing aliased vs canonical nested types now collide.
|
|
56
|
+
modelAliasMap = aliasOf;
|
|
57
|
+
const hashGroupsPass2 = new Map<string, string[]>();
|
|
58
|
+
for (const model of models) {
|
|
59
|
+
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
60
|
+
if (model.fields.length === 0 && discriminatedUnions.has(className(model.name))) continue;
|
|
61
|
+
if (aliasOf.has(model.name)) continue; // already aliased in pass 1
|
|
62
|
+
const hash = structuralHash(model);
|
|
63
|
+
if (!hashGroupsPass2.has(hash)) hashGroupsPass2.set(hash, []);
|
|
64
|
+
hashGroupsPass2.get(hash)!.push(model.name);
|
|
65
|
+
}
|
|
66
|
+
for (const [hash, names] of hashGroupsPass2) {
|
|
67
|
+
if (names.length <= 1 || hash === '') continue;
|
|
68
|
+
const sorted = [...names].sort(preferShorterCanonical);
|
|
69
|
+
const canonical = sorted[0];
|
|
70
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
71
|
+
if (aliasOf.has(sorted[i])) continue;
|
|
72
|
+
if (hasRequestSuffix(sorted[i]) !== hasRequestSuffix(canonical)) continue;
|
|
73
|
+
aliasOf.set(sorted[i], canonical);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const model of models) {
|
|
78
|
+
if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
|
|
79
|
+
const typeName = className(model.name);
|
|
80
|
+
|
|
81
|
+
// Parent of a discriminated union: emit a sealed class.
|
|
82
|
+
if (model.fields.length === 0 && discriminatedUnions.has(typeName)) {
|
|
83
|
+
files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const canonical = aliasOf.get(model.name);
|
|
88
|
+
if (canonical) {
|
|
89
|
+
const aliasContent = [
|
|
90
|
+
`package ${MODELS_PACKAGE}`,
|
|
91
|
+
'',
|
|
92
|
+
`typealias ${typeName} = ${className(canonical)}`,
|
|
93
|
+
'',
|
|
94
|
+
].join('\n');
|
|
95
|
+
files.push({
|
|
96
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
|
|
97
|
+
content: aliasContent,
|
|
98
|
+
overwriteExisting: true,
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
files.push(emitDataClass(model));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect whether a model follows the webhook event envelope pattern:
|
|
111
|
+
* has required `id`, `event`, `created_at` fields plus a `data` field.
|
|
112
|
+
*/
|
|
113
|
+
function isEventEnvelopeModel(model: Model): boolean {
|
|
114
|
+
const fieldNames = new Set(model.fields.map((f) => f.name));
|
|
115
|
+
return fieldNames.has('id') && fieldNames.has('event') && fieldNames.has('created_at') && fieldNames.has('data');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function emitDataClass(model: Model): GeneratedFile {
|
|
119
|
+
const typeName = className(model.name);
|
|
120
|
+
const imports = collectImports(model.fields);
|
|
121
|
+
const implementsEvent = isEventEnvelopeModel(model);
|
|
122
|
+
if (implementsEvent) imports.add('com.workos.common.http.WorkOSEvent');
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
lines.push(`package ${MODELS_PACKAGE}`);
|
|
125
|
+
lines.push('');
|
|
126
|
+
for (const imp of [...imports].sort()) lines.push(`import ${imp}`);
|
|
127
|
+
if (imports.size > 0) lines.push('');
|
|
128
|
+
|
|
129
|
+
appendKdoc(lines, model.description ?? `${typeName} model.`, 0);
|
|
130
|
+
|
|
131
|
+
if (model.fields.length === 0) {
|
|
132
|
+
// Kotlin data classes require at least one primary constructor param.
|
|
133
|
+
// Use a regular empty class instead.
|
|
134
|
+
lines.push(`class ${typeName}`);
|
|
135
|
+
lines.push('');
|
|
136
|
+
} else {
|
|
137
|
+
const implClause = implementsEvent ? ' : WorkOSEvent' : '';
|
|
138
|
+
lines.push(`data class ${typeName}(`);
|
|
139
|
+
|
|
140
|
+
// Emit non-defaulted params first, then defaulted — Kotlin requires
|
|
141
|
+
// non-defaulted params before defaulted ones. Literal-typed fields always
|
|
142
|
+
// receive a default, so they sort after plain required fields.
|
|
143
|
+
const hasDefault = (f: Field): boolean => !f.required || f.type.kind === 'literal';
|
|
144
|
+
const ordered = [...model.fields].sort((a, b) => {
|
|
145
|
+
const aDef = hasDefault(a);
|
|
146
|
+
const bDef = hasDefault(b);
|
|
147
|
+
if (aDef === bDef) return 0;
|
|
148
|
+
return aDef ? 1 : -1;
|
|
149
|
+
});
|
|
150
|
+
// When implementing WorkOSEvent, matching fields need `override`.
|
|
151
|
+
const overrideFields = implementsEvent ? new Set(['id', 'event', 'createdAt']) : new Set<string>();
|
|
152
|
+
const rendered = renderFields(ordered, overrideFields);
|
|
153
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
154
|
+
const suffix = i === rendered.length - 1 ? '' : ',';
|
|
155
|
+
lines.push(`${rendered[i]}${suffix}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
lines.push(`)${implClause}`);
|
|
159
|
+
lines.push('');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
|
|
164
|
+
content: lines.join('\n'),
|
|
165
|
+
overwriteExisting: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function emitSealedUnion(
|
|
170
|
+
typeName: string,
|
|
171
|
+
disc: { property: string; mapping: Record<string, string>; variantTypes: string[] },
|
|
172
|
+
): GeneratedFile {
|
|
173
|
+
const lines: string[] = [];
|
|
174
|
+
lines.push(`package ${MODELS_PACKAGE}`);
|
|
175
|
+
lines.push('');
|
|
176
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
|
|
177
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
|
|
178
|
+
lines.push('');
|
|
179
|
+
appendKdoc(lines, `Discriminated union over ${typeName} variants. Selected by \`${disc.property}\`.`, 0);
|
|
180
|
+
lines.push('@JsonTypeInfo(');
|
|
181
|
+
lines.push(' use = JsonTypeInfo.Id.NAME,');
|
|
182
|
+
lines.push(' include = JsonTypeInfo.As.EXISTING_PROPERTY,');
|
|
183
|
+
lines.push(` property = ${ktStringLiteral(disc.property)},`);
|
|
184
|
+
lines.push(' visible = true');
|
|
185
|
+
lines.push(')');
|
|
186
|
+
lines.push('@JsonSubTypes(');
|
|
187
|
+
const entries = Object.entries(disc.mapping);
|
|
188
|
+
for (let i = 0; i < entries.length; i++) {
|
|
189
|
+
const [wireValue, modelName] = entries[i];
|
|
190
|
+
const variantType = className(modelName);
|
|
191
|
+
const suffix = i === entries.length - 1 ? '' : ',';
|
|
192
|
+
lines.push(` JsonSubTypes.Type(value = ${variantType}::class, name = ${ktStringLiteral(wireValue)})${suffix}`);
|
|
193
|
+
}
|
|
194
|
+
lines.push(')');
|
|
195
|
+
lines.push(`sealed class ${typeName}`);
|
|
196
|
+
lines.push('');
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
|
|
200
|
+
content: lines.join('\n'),
|
|
201
|
+
overwriteExisting: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderFields(fields: Field[], overrideFields: Set<string> = new Set()): string[] {
|
|
206
|
+
const seen = new Set<string>();
|
|
207
|
+
const lines: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (const field of fields) {
|
|
210
|
+
const kotlinName = propertyName(field.name);
|
|
211
|
+
if (seen.has(kotlinName)) continue;
|
|
212
|
+
seen.add(kotlinName);
|
|
213
|
+
|
|
214
|
+
const baseType = mapTypeRef(field.type);
|
|
215
|
+
let kotlinType: string;
|
|
216
|
+
let defaultExpr: string | null = null;
|
|
217
|
+
|
|
218
|
+
// Const literal fields: always emit a hardcoded default matching the
|
|
219
|
+
// literal value so callers don't have to pass it.
|
|
220
|
+
const literalDefault = literalDefaultExpr(field.type);
|
|
221
|
+
|
|
222
|
+
if (literalDefault !== null) {
|
|
223
|
+
kotlinType = baseType;
|
|
224
|
+
defaultExpr = literalDefault;
|
|
225
|
+
} else if (!field.required) {
|
|
226
|
+
kotlinType = baseType.endsWith('?') ? baseType : `${baseType}?`;
|
|
227
|
+
defaultExpr = 'null';
|
|
228
|
+
} else if (baseType.endsWith('?')) {
|
|
229
|
+
// Required field whose underlying type is already nullable.
|
|
230
|
+
kotlinType = baseType;
|
|
231
|
+
} else {
|
|
232
|
+
kotlinType = baseType;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const isOverride = overrideFields.has(kotlinName);
|
|
236
|
+
const annotations: string[] = [];
|
|
237
|
+
// @JvmField cannot be applied to override properties in Kotlin.
|
|
238
|
+
// Java callers can still reach the field through the generated getter.
|
|
239
|
+
if (!isOverride) annotations.push('@JvmField');
|
|
240
|
+
annotations.push(`@JsonProperty(${ktStringLiteral(field.name)})`);
|
|
241
|
+
if (field.deprecated) annotations.push('@Deprecated("Deprecated field")');
|
|
242
|
+
|
|
243
|
+
const paramParts: string[] = [];
|
|
244
|
+
if (field.description?.trim()) {
|
|
245
|
+
const line = field.description.split('\n').find((l) => l.trim()) ?? '';
|
|
246
|
+
lines.push(` /** ${escapeKdoc(line.trim())} */`);
|
|
247
|
+
} else if (literalDefault !== null) {
|
|
248
|
+
lines.push(` /** Always \`${literalDefault}\`. */`);
|
|
249
|
+
}
|
|
250
|
+
for (const anno of annotations) lines.push(` ${anno}`);
|
|
251
|
+
|
|
252
|
+
const overridePrefix = isOverride ? 'override ' : '';
|
|
253
|
+
const rendered = ` ${overridePrefix}val ${kotlinName}: ${kotlinType}`;
|
|
254
|
+
paramParts.push(rendered);
|
|
255
|
+
if (defaultExpr !== null) paramParts[0] = `${paramParts[0]} = ${defaultExpr}`;
|
|
256
|
+
lines.push(paramParts[0]);
|
|
257
|
+
}
|
|
258
|
+
// Collapse annotation + val pairs into a list where each contiguous block
|
|
259
|
+
// becomes one field entry, preserving order.
|
|
260
|
+
return collapseFieldEntries(lines);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function collapseFieldEntries(rawLines: string[]): string[] {
|
|
264
|
+
// `rawLines` intermixes kdoc comments, annotations, and `val` declarations.
|
|
265
|
+
// Group them so each field is a single multi-line entry, so the caller can
|
|
266
|
+
// append a trailing comma at the right spot.
|
|
267
|
+
const entries: string[] = [];
|
|
268
|
+
let current: string[] = [];
|
|
269
|
+
for (const line of rawLines) {
|
|
270
|
+
const trimmed = line.trimStart();
|
|
271
|
+
const isDeclaration = trimmed.startsWith('val ') || trimmed.startsWith('override val ');
|
|
272
|
+
current.push(line);
|
|
273
|
+
if (isDeclaration) {
|
|
274
|
+
entries.push(current.join('\n'));
|
|
275
|
+
current = [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (current.length > 0) entries.push(current.join('\n'));
|
|
279
|
+
return entries;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* If the TypeRef is a literal (const) with a string, number, or boolean value,
|
|
284
|
+
* return the Kotlin expression for that default. Otherwise return null.
|
|
285
|
+
*/
|
|
286
|
+
function literalDefaultExpr(ref: TypeRef): string | null {
|
|
287
|
+
if (ref.kind !== 'literal' || ref.value === null) return null;
|
|
288
|
+
if (typeof ref.value === 'string') return ktStringLiteral(ref.value);
|
|
289
|
+
if (typeof ref.value === 'number') return Number.isInteger(ref.value) ? `${ref.value}L` : String(ref.value);
|
|
290
|
+
if (typeof ref.value === 'boolean') return ref.value ? 'true' : 'false';
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function collectImports(fields: Field[]): Set<string> {
|
|
295
|
+
const imports = new Set<string>();
|
|
296
|
+
if (fields.length === 0) return imports;
|
|
297
|
+
imports.add('com.fasterxml.jackson.annotation.JsonProperty');
|
|
298
|
+
for (const field of fields) {
|
|
299
|
+
const mapped = mapTypeRef(field.type);
|
|
300
|
+
if (/\bOffsetDateTime\b/.test(mapped)) imports.add('java.time.OffsetDateTime');
|
|
301
|
+
for (const enumName of collectEnumNames(field.type)) {
|
|
302
|
+
// Resolve through the canonical map so imports point at the actual
|
|
303
|
+
// enum class, not an alias that may not have its own file.
|
|
304
|
+
const canonical = enumCanonicalMap.get(enumName) ?? enumName;
|
|
305
|
+
imports.add(`com.workos.types.${className(canonical)}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return imports;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function collectEnumNames(ref: TypeRef, acc: Set<string> = new Set()): Set<string> {
|
|
312
|
+
if (ref.kind === 'enum') acc.add(ref.name);
|
|
313
|
+
else if (ref.kind === 'array') collectEnumNames(ref.items, acc);
|
|
314
|
+
else if (ref.kind === 'map') collectEnumNames(ref.valueType, acc);
|
|
315
|
+
else if (ref.kind === 'nullable') collectEnumNames(ref.inner, acc);
|
|
316
|
+
else if (ref.kind === 'union') for (const v of ref.variants) collectEnumNames(v, acc);
|
|
317
|
+
return acc;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function appendKdoc(lines: string[], text: string, indent: number): void {
|
|
321
|
+
const pad = ' '.repeat(indent);
|
|
322
|
+
const firstLine = text.split('\n').find((l) => l.trim()) ?? '';
|
|
323
|
+
lines.push(`${pad}/** ${escapeKdoc(firstLine.trim())} */`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function escapeKdoc(s: string): string {
|
|
327
|
+
return s.replace(/\*\//g, '*\u200b/');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Re-exported so downstream emitters (resources, tests) can filter wrapper models.
|
|
331
|
+
export { isListWrapperModel, isListMetadataModel };
|
|
332
|
+
|
|
333
|
+
// --- Canonical name selection ---
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* When picking which model name should be the concrete class (canonical) vs.
|
|
337
|
+
* a typealias, prefer shorter names first (they tend to be the public-facing
|
|
338
|
+
* names like `User`, `Role`), then fall back to alphabetical order for
|
|
339
|
+
* stability. This avoids situations where `User = EmailChangeConfirmationUser`
|
|
340
|
+
* or `SlimRole = AddRolePermission`.
|
|
341
|
+
*/
|
|
342
|
+
function preferShorterCanonical(a: string, b: string): number {
|
|
343
|
+
const aName = className(a);
|
|
344
|
+
const bName = className(b);
|
|
345
|
+
if (aName.length !== bName.length) return aName.length - bName.length;
|
|
346
|
+
return aName.localeCompare(bName);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --- Unsafe typealias guard ---
|
|
350
|
+
|
|
351
|
+
/** Suffixes that indicate a request / mutation DTO. */
|
|
352
|
+
const REQUEST_SUFFIXES = /(?:Dto|Request|Create|Update|Add|Remove|Set)$/i;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Returns true when [name] looks like a request DTO based on its suffix.
|
|
356
|
+
* Used to prevent aliasing request DTOs to response models that happen to
|
|
357
|
+
* share the same field shapes today.
|
|
358
|
+
*/
|
|
359
|
+
function hasRequestSuffix(name: string): boolean {
|
|
360
|
+
return REQUEST_SUFFIXES.test(className(name));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// --- Structural dedup ---
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Resolve a model name through the alias map so that two models referencing
|
|
367
|
+
* aliased nested types produce the same structural hash. Called after the
|
|
368
|
+
* first alias pass to catch transitive matches.
|
|
369
|
+
*/
|
|
370
|
+
let modelAliasMap: Map<string, string> | null = null;
|
|
371
|
+
|
|
372
|
+
function normalizeTypeForHash(ref: TypeRef): unknown {
|
|
373
|
+
if (ref.kind === 'enum') {
|
|
374
|
+
const vals = ref.values ? [...ref.values].sort() : [];
|
|
375
|
+
return { kind: 'enum', values: vals };
|
|
376
|
+
}
|
|
377
|
+
if (ref.kind === 'model') {
|
|
378
|
+
// Resolve through the alias map so `FooData` and `BarData` (aliased to
|
|
379
|
+
// FooData) produce the same hash when referenced as nested types.
|
|
380
|
+
const resolved = modelAliasMap?.get(ref.name) ?? ref.name;
|
|
381
|
+
return { kind: 'model', name: resolved };
|
|
382
|
+
}
|
|
383
|
+
if (ref.kind === 'nullable') return { kind: 'nullable', inner: normalizeTypeForHash(ref.inner) };
|
|
384
|
+
if (ref.kind === 'array') return { kind: 'array', items: normalizeTypeForHash(ref.items) };
|
|
385
|
+
if (ref.kind === 'union') return { kind: 'union', variants: ref.variants.map(normalizeTypeForHash) };
|
|
386
|
+
if (ref.kind === 'map') return { kind: 'map', valueType: normalizeTypeForHash(ref.valueType) };
|
|
387
|
+
return ref;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function structuralHash(model: Model): string {
|
|
391
|
+
return model.fields
|
|
392
|
+
.map((f) => `${f.name}:${JSON.stringify(normalizeTypeForHash(f.type))}:${f.required}`)
|
|
393
|
+
.sort()
|
|
394
|
+
.join('|');
|
|
395
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Operation, Service, EmitterContext } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
|
|
3
|
+
import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
|
|
4
|
+
import { stripUrnPrefix } from '../shared/naming-utils.js';
|
|
5
|
+
|
|
6
|
+
/** PascalCase class/type name. */
|
|
7
|
+
export function className(name: string): string {
|
|
8
|
+
return toPascalCase(stripUrnPrefix(name));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** PascalCase file name (matches the primary class). */
|
|
12
|
+
export function fileName(name: string): string {
|
|
13
|
+
return toPascalCase(stripUrnPrefix(name));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** snake_case file name for fixtures/test data. */
|
|
17
|
+
export function fixtureFileName(name: string): string {
|
|
18
|
+
return toSnakeCase(stripUrnPrefix(name));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** camelCase method name. */
|
|
22
|
+
export function methodName(name: string): string {
|
|
23
|
+
return toCamelCase(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** camelCase Kotlin property / local variable name. */
|
|
27
|
+
export function propertyName(name: string): string {
|
|
28
|
+
return escapeReserved(toCamelCase(name));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** camelCase alias (kept for parity with other emitters). */
|
|
32
|
+
export const fieldName = propertyName;
|
|
33
|
+
export const localName = propertyName;
|
|
34
|
+
|
|
35
|
+
/** PascalCase directory segment for a service / mount group. */
|
|
36
|
+
export function moduleName(name: string): string {
|
|
37
|
+
return toPascalCase(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Lower-case Kotlin package segment for a service / mount group. */
|
|
41
|
+
export function packageSegment(name: string): string {
|
|
42
|
+
// Kotlin package convention: all-lowercase, no separators.
|
|
43
|
+
return toPascalCase(name).toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Kotlin service class name for a mount group (e.g., `Organizations`). */
|
|
47
|
+
export function apiClassName(name: string): string {
|
|
48
|
+
return className(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Accessor property exposed on the WorkOS client (camelCase). */
|
|
52
|
+
export function servicePropertyName(name: string): string {
|
|
53
|
+
return toCamelCase(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolve the effective service (mount target) name. */
|
|
57
|
+
export function resolveServiceName(service: Service, ctx: EmitterContext): string {
|
|
58
|
+
return resolveClassName(service, ctx);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a map from IR service name -> resolved mount-target name (PascalCase). */
|
|
62
|
+
export function buildServiceNameMap(services: Service[], ctx: EmitterContext): Map<string, string> {
|
|
63
|
+
const map = new Map<string, string>();
|
|
64
|
+
for (const service of services) {
|
|
65
|
+
map.set(service.name, resolveServiceName(service, ctx));
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve the SDK method name (camelCase) for an operation. */
|
|
71
|
+
export function resolveMethodName(op: Operation, service: Service, ctx: EmitterContext): string {
|
|
72
|
+
const lookup = buildResolvedLookup(ctx);
|
|
73
|
+
const resolved = lookupMethodName(op, lookup);
|
|
74
|
+
if (resolved) {
|
|
75
|
+
return trimMountedResourceFromMethod(methodName(resolved), resolveClassName(service, ctx));
|
|
76
|
+
}
|
|
77
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
78
|
+
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
79
|
+
if (existing) {
|
|
80
|
+
return trimMountedResourceFromMethod(methodName(existing.methodName), resolveClassName(service, ctx));
|
|
81
|
+
}
|
|
82
|
+
return trimMountedResourceFromMethod(methodName(op.name), resolveClassName(service, ctx));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve the SDK class name (PascalCase) for a service. */
|
|
86
|
+
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
87
|
+
for (const r of ctx.resolvedOperations ?? []) {
|
|
88
|
+
if (r.service.name === service.name) return className(r.mountOn);
|
|
89
|
+
}
|
|
90
|
+
if (ctx.overlayLookup?.methodByOperation) {
|
|
91
|
+
for (const op of service.operations) {
|
|
92
|
+
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
93
|
+
const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
|
|
94
|
+
if (existing) return className(existing.className);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return className(service.name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Build a map from IR service name -> mount-target directory (PascalCase). */
|
|
101
|
+
export function buildMountDirMap(ctx: EmitterContext): Map<string, string> {
|
|
102
|
+
const map = new Map<string, string>();
|
|
103
|
+
for (const service of ctx.spec.services) {
|
|
104
|
+
const target = getMountTarget(service, ctx);
|
|
105
|
+
map.set(service.name, moduleName(target));
|
|
106
|
+
}
|
|
107
|
+
return map;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function splitPascalWords(name: string): string[] {
|
|
111
|
+
return name.match(/[A-Z]+(?:[a-z]+|(?=[A-Z]|$))|[A-Z]?[a-z]+|[0-9]+/g) ?? [name];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function singularize(word: string): string {
|
|
115
|
+
if (word.endsWith('ies') && word.length > 3) {
|
|
116
|
+
return `${word.slice(0, -3)}y`;
|
|
117
|
+
}
|
|
118
|
+
if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
119
|
+
return word.slice(0, -1);
|
|
120
|
+
}
|
|
121
|
+
return word;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function wordsMatch(left: string, right: string): boolean {
|
|
125
|
+
return singularize(left.toLowerCase()) === singularize(right.toLowerCase());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Trim the mount-target resource words from the start of a method name.
|
|
130
|
+
* E.g. `listOrganizations` on OrganizationsApi becomes `list`.
|
|
131
|
+
*/
|
|
132
|
+
function trimMountedResourceFromMethod(method: string, mountName: string): string {
|
|
133
|
+
const methodWords = splitPascalWords(method);
|
|
134
|
+
if (methodWords.length < 2) return method;
|
|
135
|
+
|
|
136
|
+
const mountWords = splitPascalWords(className(mountName));
|
|
137
|
+
if (mountWords.length === 0) return method;
|
|
138
|
+
|
|
139
|
+
let matched = 0;
|
|
140
|
+
while (
|
|
141
|
+
matched < mountWords.length &&
|
|
142
|
+
matched + 1 < methodWords.length &&
|
|
143
|
+
wordsMatch(methodWords[matched + 1], mountWords[matched])
|
|
144
|
+
) {
|
|
145
|
+
matched++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (matched === 0) return method;
|
|
149
|
+
|
|
150
|
+
return [methodWords[0], ...methodWords.slice(matched + 1)].join('');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Kotlin hard/soft keywords that must be back-ticked when used as identifiers. */
|
|
154
|
+
const KOTLIN_RESERVED = new Set([
|
|
155
|
+
'as',
|
|
156
|
+
'break',
|
|
157
|
+
'class',
|
|
158
|
+
'continue',
|
|
159
|
+
'do',
|
|
160
|
+
'else',
|
|
161
|
+
'false',
|
|
162
|
+
'for',
|
|
163
|
+
'fun',
|
|
164
|
+
'if',
|
|
165
|
+
'in',
|
|
166
|
+
'interface',
|
|
167
|
+
'is',
|
|
168
|
+
'null',
|
|
169
|
+
'object',
|
|
170
|
+
'package',
|
|
171
|
+
'return',
|
|
172
|
+
'super',
|
|
173
|
+
'this',
|
|
174
|
+
'throw',
|
|
175
|
+
'true',
|
|
176
|
+
'try',
|
|
177
|
+
'typealias',
|
|
178
|
+
'typeof',
|
|
179
|
+
'val',
|
|
180
|
+
'var',
|
|
181
|
+
'when',
|
|
182
|
+
'while',
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
/** Escape a Kotlin identifier if it collides with a reserved word. */
|
|
186
|
+
export function escapeReserved(name: string): string {
|
|
187
|
+
return KOTLIN_RESERVED.has(name) ? `\`${name}\`` : name;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Escape a string literal for Kotlin source. */
|
|
191
|
+
export function ktStringLiteral(value: string): string {
|
|
192
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Escape any scalar as a Kotlin literal expression. */
|
|
196
|
+
export function ktLiteral(value: string | number | boolean): string {
|
|
197
|
+
if (typeof value === 'string') return ktStringLiteral(value);
|
|
198
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
199
|
+
return String(value);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Map a wire field name to the expression that reads it off a WorkOS client
|
|
204
|
+
* instance (used for inferFromClient fields).
|
|
205
|
+
*/
|
|
206
|
+
export function clientFieldExpression(field: string): string {
|
|
207
|
+
switch (field) {
|
|
208
|
+
case 'client_id':
|
|
209
|
+
return 'clientId';
|
|
210
|
+
case 'client_secret':
|
|
211
|
+
return 'apiKey';
|
|
212
|
+
default:
|
|
213
|
+
return propertyName(field);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Convert snake_case / camelCase to a human-readable lowercase phrase for docs. */
|
|
218
|
+
export function humanize(name: string): string {
|
|
219
|
+
return name
|
|
220
|
+
.replace(/_/g, ' ')
|
|
221
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
222
|
+
.toLowerCase();
|
|
223
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Operation } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Operations whose generated implementation would be wrong (URL builders
|
|
5
|
+
* served as HTTP calls). Hand-maintained code in `workos-kotlin` owns these
|
|
6
|
+
* method names instead; the emitter must skip resources, tests, and the
|
|
7
|
+
* smoke manifest for any operation in this set.
|
|
8
|
+
*
|
|
9
|
+
* Keys are `"METHOD path"` in the same form used by the smoke manifest.
|
|
10
|
+
*/
|
|
11
|
+
export const HANDWRITTEN_OVERRIDE_OPS: ReadonlySet<string> = new Set([
|
|
12
|
+
// AuthKit authorization URL (H09 / H10 are hand-built).
|
|
13
|
+
'GET /user_management/authorize',
|
|
14
|
+
// AuthKit logout URL (H17 — URL, not HTTP call).
|
|
15
|
+
'GET /user_management/sessions/logout',
|
|
16
|
+
// SSO authorization URL (H14 / H15 are hand-built).
|
|
17
|
+
'GET /sso/authorize',
|
|
18
|
+
// SSO logout URL (H17 — URL, not HTTP call).
|
|
19
|
+
'GET /sso/logout',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** True if the operation is owned by a hand-maintained override. */
|
|
23
|
+
export function isHandwrittenOverride(op: Operation): boolean {
|
|
24
|
+
return HANDWRITTEN_OVERRIDE_OPS.has(`${op.httpMethod.toUpperCase()} ${op.path}`);
|
|
25
|
+
}
|