@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
|
@@ -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,486 @@
|
|
|
1
|
+
import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef, discriminatedUnions } from './type-map.js';
|
|
3
|
+
import { className, propertyName, ktStringLiteral, humanize } 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 canonicalType = className(canonical);
|
|
90
|
+
// Skip when different IR names collapse to the same output name
|
|
91
|
+
if (typeName === canonicalType) continue;
|
|
92
|
+
const aliasContent = [
|
|
93
|
+
`package ${MODELS_PACKAGE}`,
|
|
94
|
+
'',
|
|
95
|
+
`/** Alias for [${canonicalType}]. */`,
|
|
96
|
+
`typealias ${typeName} = ${canonicalType}`,
|
|
97
|
+
'',
|
|
98
|
+
].join('\n');
|
|
99
|
+
files.push({
|
|
100
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
|
|
101
|
+
content: aliasContent,
|
|
102
|
+
overwriteExisting: true,
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
files.push(emitDataClass(model));
|
|
108
|
+
}
|
|
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
|
+
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect whether a model follows the webhook event envelope pattern:
|
|
132
|
+
* has required `id`, `event`, `created_at` fields plus a `data` field.
|
|
133
|
+
*/
|
|
134
|
+
function isEventEnvelopeModel(model: Model): boolean {
|
|
135
|
+
const fieldNames = new Set(model.fields.map((f) => f.name));
|
|
136
|
+
return fieldNames.has('id') && fieldNames.has('event') && fieldNames.has('created_at') && fieldNames.has('data');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function emitDataClass(model: Model): GeneratedFile {
|
|
140
|
+
const typeName = className(model.name);
|
|
141
|
+
const imports = collectImports(model.fields);
|
|
142
|
+
const implementsEvent = isEventEnvelopeModel(model);
|
|
143
|
+
// WorkOSEvent sealed interface is generated in the same package — no import needed.
|
|
144
|
+
const lines: string[] = [];
|
|
145
|
+
lines.push(`package ${MODELS_PACKAGE}`);
|
|
146
|
+
lines.push('');
|
|
147
|
+
for (const imp of [...imports].sort()) lines.push(`import ${imp}`);
|
|
148
|
+
if (imports.size > 0) lines.push('');
|
|
149
|
+
|
|
150
|
+
appendKdoc(lines, model.description ?? `${typeName} model.`, 0);
|
|
151
|
+
|
|
152
|
+
if (model.fields.length === 0) {
|
|
153
|
+
// Kotlin data classes require at least one primary constructor param.
|
|
154
|
+
// Use a regular empty class instead.
|
|
155
|
+
lines.push(`class ${typeName}`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
} else {
|
|
158
|
+
const implClause = implementsEvent ? ' : WorkOSEvent' : '';
|
|
159
|
+
lines.push(`data class ${typeName}(`);
|
|
160
|
+
|
|
161
|
+
// Emit non-defaulted params first, then defaulted — Kotlin requires
|
|
162
|
+
// non-defaulted params before defaulted ones. Literal-typed fields always
|
|
163
|
+
// receive a default, so they sort after plain required fields.
|
|
164
|
+
const hasDefault = (f: Field): boolean => !f.required || f.type.kind === 'literal';
|
|
165
|
+
const ordered = [...model.fields].sort((a, b) => {
|
|
166
|
+
const aDef = hasDefault(a);
|
|
167
|
+
const bDef = hasDefault(b);
|
|
168
|
+
if (aDef === bDef) return 0;
|
|
169
|
+
return aDef ? 1 : -1;
|
|
170
|
+
});
|
|
171
|
+
// When implementing WorkOSEvent, matching fields need `override`.
|
|
172
|
+
const overrideFields = implementsEvent ? new Set(['id', 'event', 'createdAt']) : new Set<string>();
|
|
173
|
+
const rendered = renderFields(ordered, overrideFields);
|
|
174
|
+
for (let i = 0; i < rendered.length; i++) {
|
|
175
|
+
const suffix = i === rendered.length - 1 ? '' : ',';
|
|
176
|
+
lines.push(`${rendered[i]}${suffix}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
lines.push(`)${implClause}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
|
|
185
|
+
content: lines.join('\n'),
|
|
186
|
+
overwriteExisting: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function emitSealedUnion(
|
|
191
|
+
typeName: string,
|
|
192
|
+
disc: { property: string; mapping: Record<string, string>; variantTypes: string[] },
|
|
193
|
+
): GeneratedFile {
|
|
194
|
+
const lines: string[] = [];
|
|
195
|
+
lines.push(`package ${MODELS_PACKAGE}`);
|
|
196
|
+
lines.push('');
|
|
197
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
|
|
198
|
+
lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
|
|
199
|
+
lines.push('');
|
|
200
|
+
appendKdoc(lines, `Discriminated union over ${typeName} variants. Selected by \`${disc.property}\`.`, 0);
|
|
201
|
+
lines.push('@JsonTypeInfo(');
|
|
202
|
+
lines.push(' use = JsonTypeInfo.Id.NAME,');
|
|
203
|
+
lines.push(' include = JsonTypeInfo.As.EXISTING_PROPERTY,');
|
|
204
|
+
lines.push(` property = ${ktStringLiteral(disc.property)},`);
|
|
205
|
+
lines.push(' visible = true');
|
|
206
|
+
lines.push(')');
|
|
207
|
+
lines.push('@JsonSubTypes(');
|
|
208
|
+
const entries = Object.entries(disc.mapping);
|
|
209
|
+
for (let i = 0; i < entries.length; i++) {
|
|
210
|
+
const [wireValue, modelName] = entries[i];
|
|
211
|
+
const variantType = className(modelName);
|
|
212
|
+
const suffix = i === entries.length - 1 ? '' : ',';
|
|
213
|
+
lines.push(` JsonSubTypes.Type(value = ${variantType}::class, name = ${ktStringLiteral(wireValue)})${suffix}`);
|
|
214
|
+
}
|
|
215
|
+
lines.push(')');
|
|
216
|
+
lines.push(`sealed class ${typeName}`);
|
|
217
|
+
lines.push('');
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
|
|
221
|
+
content: lines.join('\n'),
|
|
222
|
+
overwriteExisting: true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
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
|
+
|
|
294
|
+
function renderFields(fields: Field[], overrideFields: Set<string> = new Set()): string[] {
|
|
295
|
+
const seen = new Set<string>();
|
|
296
|
+
const lines: string[] = [];
|
|
297
|
+
|
|
298
|
+
for (const field of fields) {
|
|
299
|
+
const kotlinName = propertyName(field.name);
|
|
300
|
+
if (seen.has(kotlinName)) continue;
|
|
301
|
+
seen.add(kotlinName);
|
|
302
|
+
|
|
303
|
+
const baseType = mapTypeRef(field.type);
|
|
304
|
+
let kotlinType: string;
|
|
305
|
+
let defaultExpr: string | null = null;
|
|
306
|
+
|
|
307
|
+
// Const literal fields: always emit a hardcoded default matching the
|
|
308
|
+
// literal value so callers don't have to pass it.
|
|
309
|
+
const literalDefault = literalDefaultExpr(field.type);
|
|
310
|
+
|
|
311
|
+
if (literalDefault !== null) {
|
|
312
|
+
kotlinType = baseType;
|
|
313
|
+
defaultExpr = literalDefault;
|
|
314
|
+
} else if (!field.required) {
|
|
315
|
+
kotlinType = baseType.endsWith('?') ? baseType : `${baseType}?`;
|
|
316
|
+
defaultExpr = 'null';
|
|
317
|
+
} else if (baseType.endsWith('?')) {
|
|
318
|
+
// Required field whose underlying type is already nullable.
|
|
319
|
+
kotlinType = baseType;
|
|
320
|
+
} else {
|
|
321
|
+
kotlinType = baseType;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const isOverride = overrideFields.has(kotlinName);
|
|
325
|
+
const annotations: string[] = [];
|
|
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.
|
|
329
|
+
annotations.push(`@JsonProperty(${ktStringLiteral(field.name)})`);
|
|
330
|
+
if (field.deprecated) annotations.push('@Deprecated("Deprecated field")');
|
|
331
|
+
|
|
332
|
+
const paramParts: string[] = [];
|
|
333
|
+
if (field.description?.trim()) {
|
|
334
|
+
const line = field.description.split('\n').find((l) => l.trim()) ?? '';
|
|
335
|
+
lines.push(` /** ${escapeKdoc(line.trim())} */`);
|
|
336
|
+
} else if (literalDefault !== null) {
|
|
337
|
+
lines.push(` /** Always \`${literalDefault}\`. */`);
|
|
338
|
+
} else {
|
|
339
|
+
lines.push(` /** The ${humanize(field.name)}. */`);
|
|
340
|
+
}
|
|
341
|
+
for (const anno of annotations) lines.push(` ${anno}`);
|
|
342
|
+
|
|
343
|
+
const overridePrefix = isOverride ? 'override ' : '';
|
|
344
|
+
const rendered = ` ${overridePrefix}val ${kotlinName}: ${kotlinType}`;
|
|
345
|
+
paramParts.push(rendered);
|
|
346
|
+
if (defaultExpr !== null) paramParts[0] = `${paramParts[0]} = ${defaultExpr}`;
|
|
347
|
+
lines.push(paramParts[0]);
|
|
348
|
+
}
|
|
349
|
+
// Collapse annotation + val pairs into a list where each contiguous block
|
|
350
|
+
// becomes one field entry, preserving order.
|
|
351
|
+
return collapseFieldEntries(lines);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function collapseFieldEntries(rawLines: string[]): string[] {
|
|
355
|
+
// `rawLines` intermixes kdoc comments, annotations, and `val` declarations.
|
|
356
|
+
// Group them so each field is a single multi-line entry, so the caller can
|
|
357
|
+
// append a trailing comma at the right spot.
|
|
358
|
+
const entries: string[] = [];
|
|
359
|
+
let current: string[] = [];
|
|
360
|
+
for (const line of rawLines) {
|
|
361
|
+
const trimmed = line.trimStart();
|
|
362
|
+
const isDeclaration = trimmed.startsWith('val ') || trimmed.startsWith('override val ');
|
|
363
|
+
current.push(line);
|
|
364
|
+
if (isDeclaration) {
|
|
365
|
+
entries.push(current.join('\n'));
|
|
366
|
+
current = [];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (current.length > 0) entries.push(current.join('\n'));
|
|
370
|
+
return entries;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* If the TypeRef is a literal (const) with a string, number, or boolean value,
|
|
375
|
+
* return the Kotlin expression for that default. Otherwise return null.
|
|
376
|
+
*/
|
|
377
|
+
function literalDefaultExpr(ref: TypeRef): string | null {
|
|
378
|
+
if (ref.kind !== 'literal' || ref.value === null) return null;
|
|
379
|
+
if (typeof ref.value === 'string') return ktStringLiteral(ref.value);
|
|
380
|
+
if (typeof ref.value === 'number') return Number.isInteger(ref.value) ? `${ref.value}L` : String(ref.value);
|
|
381
|
+
if (typeof ref.value === 'boolean') return ref.value ? 'true' : 'false';
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function collectImports(fields: Field[]): Set<string> {
|
|
386
|
+
const imports = new Set<string>();
|
|
387
|
+
if (fields.length === 0) return imports;
|
|
388
|
+
imports.add('com.fasterxml.jackson.annotation.JsonProperty');
|
|
389
|
+
for (const field of fields) {
|
|
390
|
+
const mapped = mapTypeRef(field.type);
|
|
391
|
+
if (/\bOffsetDateTime\b/.test(mapped)) imports.add('java.time.OffsetDateTime');
|
|
392
|
+
for (const enumName of collectEnumNames(field.type)) {
|
|
393
|
+
// Resolve through the canonical map so imports point at the actual
|
|
394
|
+
// enum class, not an alias that may not have its own file.
|
|
395
|
+
const canonical = enumCanonicalMap.get(enumName) ?? enumName;
|
|
396
|
+
imports.add(`com.workos.types.${className(canonical)}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return imports;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function collectEnumNames(ref: TypeRef, acc: Set<string> = new Set()): Set<string> {
|
|
403
|
+
if (ref.kind === 'enum') acc.add(ref.name);
|
|
404
|
+
else if (ref.kind === 'array') collectEnumNames(ref.items, acc);
|
|
405
|
+
else if (ref.kind === 'map') collectEnumNames(ref.valueType, acc);
|
|
406
|
+
else if (ref.kind === 'nullable') collectEnumNames(ref.inner, acc);
|
|
407
|
+
else if (ref.kind === 'union') for (const v of ref.variants) collectEnumNames(v, acc);
|
|
408
|
+
return acc;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function appendKdoc(lines: string[], text: string, indent: number): void {
|
|
412
|
+
const pad = ' '.repeat(indent);
|
|
413
|
+
const firstLine = text.split('\n').find((l) => l.trim()) ?? '';
|
|
414
|
+
lines.push(`${pad}/** ${escapeKdoc(firstLine.trim())} */`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function escapeKdoc(s: string): string {
|
|
418
|
+
return s.replace(/\*\//g, '*\u200b/');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Re-exported so downstream emitters (resources, tests) can filter wrapper models.
|
|
422
|
+
export { isListWrapperModel, isListMetadataModel };
|
|
423
|
+
|
|
424
|
+
// --- Canonical name selection ---
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* When picking which model name should be the concrete class (canonical) vs.
|
|
428
|
+
* a typealias, prefer shorter names first (they tend to be the public-facing
|
|
429
|
+
* names like `User`, `Role`), then fall back to alphabetical order for
|
|
430
|
+
* stability. This avoids situations where `User = EmailChangeConfirmationUser`
|
|
431
|
+
* or `SlimRole = AddRolePermission`.
|
|
432
|
+
*/
|
|
433
|
+
function preferShorterCanonical(a: string, b: string): number {
|
|
434
|
+
const aName = className(a);
|
|
435
|
+
const bName = className(b);
|
|
436
|
+
if (aName.length !== bName.length) return aName.length - bName.length;
|
|
437
|
+
return aName.localeCompare(bName);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- Unsafe typealias guard ---
|
|
441
|
+
|
|
442
|
+
/** Suffixes that indicate a request / mutation DTO. */
|
|
443
|
+
const REQUEST_SUFFIXES = /(?:Dto|Request|Create|Update|Add|Remove|Set)$/i;
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Returns true when [name] looks like a request DTO based on its suffix.
|
|
447
|
+
* Used to prevent aliasing request DTOs to response models that happen to
|
|
448
|
+
* share the same field shapes today.
|
|
449
|
+
*/
|
|
450
|
+
function hasRequestSuffix(name: string): boolean {
|
|
451
|
+
return REQUEST_SUFFIXES.test(className(name));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// --- Structural dedup ---
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Resolve a model name through the alias map so that two models referencing
|
|
458
|
+
* aliased nested types produce the same structural hash. Called after the
|
|
459
|
+
* first alias pass to catch transitive matches.
|
|
460
|
+
*/
|
|
461
|
+
let modelAliasMap: Map<string, string> | null = null;
|
|
462
|
+
|
|
463
|
+
function normalizeTypeForHash(ref: TypeRef): unknown {
|
|
464
|
+
if (ref.kind === 'enum') {
|
|
465
|
+
const vals = ref.values ? [...ref.values].sort() : [];
|
|
466
|
+
return { kind: 'enum', values: vals };
|
|
467
|
+
}
|
|
468
|
+
if (ref.kind === 'model') {
|
|
469
|
+
// Resolve through the alias map so `FooData` and `BarData` (aliased to
|
|
470
|
+
// FooData) produce the same hash when referenced as nested types.
|
|
471
|
+
const resolved = modelAliasMap?.get(ref.name) ?? ref.name;
|
|
472
|
+
return { kind: 'model', name: resolved };
|
|
473
|
+
}
|
|
474
|
+
if (ref.kind === 'nullable') return { kind: 'nullable', inner: normalizeTypeForHash(ref.inner) };
|
|
475
|
+
if (ref.kind === 'array') return { kind: 'array', items: normalizeTypeForHash(ref.items) };
|
|
476
|
+
if (ref.kind === 'union') return { kind: 'union', variants: ref.variants.map(normalizeTypeForHash) };
|
|
477
|
+
if (ref.kind === 'map') return { kind: 'map', valueType: normalizeTypeForHash(ref.valueType) };
|
|
478
|
+
return ref;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function structuralHash(model: Model): string {
|
|
482
|
+
return model.fields
|
|
483
|
+
.map((f) => `${f.name}:${JSON.stringify(normalizeTypeForHash(f.type))}:${f.required}`)
|
|
484
|
+
.sort()
|
|
485
|
+
.join('|');
|
|
486
|
+
}
|