@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,123 @@
|
|
|
1
|
+
import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
|
|
2
|
+
import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
|
|
3
|
+
import { className } from './naming.js';
|
|
4
|
+
import { enumCanonicalMap } from './enums.js';
|
|
5
|
+
|
|
6
|
+
/** Resolve an enum name through the canonical map (identity when no alias). */
|
|
7
|
+
function resolveEnumName(name: string): string {
|
|
8
|
+
return className(enumCanonicalMap.get(name) ?? name);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map an IR TypeRef to a non-nullable Kotlin type expression.
|
|
13
|
+
*
|
|
14
|
+
* Kotlin's type system marks nullability at use-sites (`T?`). Optional fields
|
|
15
|
+
* on generated models append `?` to the output of this function.
|
|
16
|
+
*/
|
|
17
|
+
export function mapTypeRef(ref: TypeRef): string {
|
|
18
|
+
return irMapTypeRef<string>(ref, {
|
|
19
|
+
primitive: mapPrimitive,
|
|
20
|
+
array: (_ref, items) => `List<${items}>`,
|
|
21
|
+
model: (r) => className(r.name),
|
|
22
|
+
enum: (r) => resolveEnumName(r.name),
|
|
23
|
+
union: (r, variants) => joinUnionVariants(r, variants),
|
|
24
|
+
nullable: (_ref, inner) => (inner.endsWith('?') ? inner : `${inner}?`),
|
|
25
|
+
literal: (r) => {
|
|
26
|
+
if (r.value === null) return 'Any?';
|
|
27
|
+
if (typeof r.value === 'string') return 'String';
|
|
28
|
+
if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'Long' : 'Double';
|
|
29
|
+
if (typeof r.value === 'boolean') return 'Boolean';
|
|
30
|
+
return 'Any';
|
|
31
|
+
},
|
|
32
|
+
map: (_ref, value) => `Map<String, ${value}>`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Map an IR TypeRef to a nullable Kotlin type expression (always appends `?`).
|
|
38
|
+
* Useful for optional fields on models / request options.
|
|
39
|
+
*/
|
|
40
|
+
export function mapTypeRefOptional(ref: TypeRef): string {
|
|
41
|
+
const baseType = mapTypeRef(ref);
|
|
42
|
+
return baseType.endsWith('?') ? baseType : `${baseType}?`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Is the given IR TypeRef a primitive value? Useful when deciding whether a
|
|
47
|
+
* nullable field needs an explicit `= null` default.
|
|
48
|
+
*/
|
|
49
|
+
export function isValueTypeRef(ref: TypeRef): boolean {
|
|
50
|
+
if (ref.kind === 'enum') return true;
|
|
51
|
+
if (ref.kind === 'primitive') {
|
|
52
|
+
if (ref.format === 'date-time') return true;
|
|
53
|
+
switch (ref.type) {
|
|
54
|
+
case 'integer':
|
|
55
|
+
case 'number':
|
|
56
|
+
case 'boolean':
|
|
57
|
+
return true;
|
|
58
|
+
default:
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mapPrimitive(ref: PrimitiveType): string {
|
|
66
|
+
if (ref.format === 'binary') return 'ByteArray';
|
|
67
|
+
if (ref.format === 'int32') return 'Int';
|
|
68
|
+
if (ref.format === 'int64') return 'Long';
|
|
69
|
+
if (ref.format === 'date-time') return 'OffsetDateTime';
|
|
70
|
+
switch (ref.type) {
|
|
71
|
+
case 'string':
|
|
72
|
+
return 'String';
|
|
73
|
+
case 'integer':
|
|
74
|
+
return 'Long';
|
|
75
|
+
case 'number':
|
|
76
|
+
return 'Double';
|
|
77
|
+
case 'boolean':
|
|
78
|
+
return 'Boolean';
|
|
79
|
+
case 'unknown':
|
|
80
|
+
return 'Any';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Track discriminated unions so the model generator can emit the appropriate
|
|
86
|
+
* Jackson `@JsonTypeInfo` / `@JsonSubTypes` annotations on the sealed parent.
|
|
87
|
+
*/
|
|
88
|
+
export const discriminatedUnions = new Map<
|
|
89
|
+
string,
|
|
90
|
+
{ property: string; mapping: Record<string, string>; variantTypes: string[] }
|
|
91
|
+
>();
|
|
92
|
+
|
|
93
|
+
function joinUnionVariants(ref: UnionType, variants: string[]): string {
|
|
94
|
+
if (ref.compositionKind === 'allOf') {
|
|
95
|
+
return variants[0] ?? 'Any';
|
|
96
|
+
}
|
|
97
|
+
const unique = [...new Set(variants)];
|
|
98
|
+
if (unique.length === 1) return unique[0];
|
|
99
|
+
|
|
100
|
+
if (ref.discriminator && ref.discriminator.mapping) {
|
|
101
|
+
const baseName = unique[0];
|
|
102
|
+
discriminatedUnions.set(baseName, {
|
|
103
|
+
property: ref.discriminator.property,
|
|
104
|
+
mapping: ref.discriminator.mapping,
|
|
105
|
+
variantTypes: unique,
|
|
106
|
+
});
|
|
107
|
+
// Use the base sealed type; Jackson @JsonTypeInfo handles variant selection.
|
|
108
|
+
return baseName;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Non-discriminated unions fall back to the Kotlin top type. A generic
|
|
112
|
+
// AnyOf<> is planned for a future phase if emitter tests prove it necessary.
|
|
113
|
+
return 'Any';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Kotlin imports implied by a given type expression. Caller collects into a set. */
|
|
117
|
+
export function implicitImportsFor(kotlinType: string): string[] {
|
|
118
|
+
const imports: string[] = [];
|
|
119
|
+
if (/\bOffsetDateTime\b/.test(kotlinType)) {
|
|
120
|
+
imports.push('java.time.OffsetDateTime');
|
|
121
|
+
}
|
|
122
|
+
return imports;
|
|
123
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { EmitterContext, ResolvedOperation, ResolvedWrapper, Parameter } from '@workos/oagen';
|
|
2
|
+
import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
|
|
3
|
+
import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
|
|
4
|
+
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
5
|
+
import { sortPathParamsByTemplateOrder } from './resources.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Emit Kotlin wrapper methods for a union-split operation. Each wrapper
|
|
9
|
+
* method takes only the fields it needs for its variant, fills in the
|
|
10
|
+
* operation-level defaults and client-inferred values, and posts to the
|
|
11
|
+
* underlying operation.
|
|
12
|
+
*
|
|
13
|
+
* Returns a list of lines (with leading indentation suitable for inclusion
|
|
14
|
+
* inside the service class body).
|
|
15
|
+
*/
|
|
16
|
+
export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
|
|
17
|
+
if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
|
|
18
|
+
|
|
19
|
+
const out: string[] = [];
|
|
20
|
+
for (const wrapper of resolvedOp.wrappers) {
|
|
21
|
+
if (out.length > 0) out.push('');
|
|
22
|
+
for (const line of emitWrapperMethod(resolvedOp, wrapper, ctx)) out.push(line);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapper, ctx: EmitterContext): string[] {
|
|
28
|
+
const op = resolvedOp.operation;
|
|
29
|
+
const method = propertyName(wrapper.name);
|
|
30
|
+
const resolvedParams = resolveWrapperParams(wrapper, ctx);
|
|
31
|
+
const responseClass = wrapper.responseModelName ? className(wrapper.responseModelName) : null;
|
|
32
|
+
|
|
33
|
+
const pathParams = sortPathParamsByTemplateOrder(op);
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
|
|
37
|
+
// Build KDoc from operation description + @param docs for each wrapper param.
|
|
38
|
+
const kdocLines: string[] = [];
|
|
39
|
+
const opDesc = (op.description ?? '').trim();
|
|
40
|
+
const wrapperHumanName = method.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
|
|
41
|
+
if (opDesc) {
|
|
42
|
+
kdocLines.push(opDesc.split('\n')[0]);
|
|
43
|
+
} else {
|
|
44
|
+
kdocLines.push(`${wrapperHumanName.charAt(0).toUpperCase()}${wrapperHumanName.slice(1)}.`);
|
|
45
|
+
}
|
|
46
|
+
const paramDocs: string[] = [];
|
|
47
|
+
for (const pp of pathParams) {
|
|
48
|
+
if (pp.description?.trim()) {
|
|
49
|
+
paramDocs.push(`@param ${propertyName(pp.name)} ${escapeKdoc(pp.description.split('\n')[0].trim())}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const rp of resolvedParams) {
|
|
53
|
+
const desc = rp.field?.description?.trim();
|
|
54
|
+
if (desc) {
|
|
55
|
+
paramDocs.push(`@param ${propertyName(rp.paramName)} ${escapeKdoc(desc.split('\n')[0])}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (responseClass) {
|
|
59
|
+
paramDocs.push(`@return the ${responseClass}`);
|
|
60
|
+
}
|
|
61
|
+
if (paramDocs.length > 0 || kdocLines.length > 0) {
|
|
62
|
+
lines.push(' /**');
|
|
63
|
+
for (const l of kdocLines) lines.push(` * ${escapeKdoc(l)}`);
|
|
64
|
+
if (paramDocs.length > 0) {
|
|
65
|
+
lines.push(' *');
|
|
66
|
+
for (const p of paramDocs) lines.push(` * ${p}`);
|
|
67
|
+
}
|
|
68
|
+
lines.push(' */');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
lines.push(' @JvmOverloads');
|
|
72
|
+
|
|
73
|
+
// Build the method parameter list: path params, wrapper params, requestOptions
|
|
74
|
+
const params: string[] = [];
|
|
75
|
+
for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
|
|
76
|
+
for (const rp of resolvedParams) {
|
|
77
|
+
const paramName = propertyName(rp.paramName);
|
|
78
|
+
const kotlinType = rp.field
|
|
79
|
+
? rp.isOptional
|
|
80
|
+
? mapTypeRefOptional(rp.field.type)
|
|
81
|
+
: mapTypeRef(rp.field.type)
|
|
82
|
+
: rp.isOptional
|
|
83
|
+
? 'String?'
|
|
84
|
+
: 'String';
|
|
85
|
+
const trailer = rp.isOptional ? ' = null' : '';
|
|
86
|
+
params.push(` ${paramName}: ${kotlinType}${trailer}`);
|
|
87
|
+
}
|
|
88
|
+
params.push(' requestOptions: RequestOptions? = null');
|
|
89
|
+
|
|
90
|
+
const returnClause = responseClass ? `: ${responseClass}` : '';
|
|
91
|
+
if (params.length === 1) {
|
|
92
|
+
const single = params[0].replace(/^\s+/, '');
|
|
93
|
+
lines.push(` fun ${escapeReserved(method)}(${single})${returnClause} {`);
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(` fun ${escapeReserved(method)}(`);
|
|
96
|
+
for (let i = 0; i < params.length; i++) {
|
|
97
|
+
const suffix = i === params.length - 1 ? '' : ',';
|
|
98
|
+
lines.push(`${params[i]}${suffix}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push(` )${returnClause} {`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build body using bodyOf() — consistent with non-wrapper methods.
|
|
104
|
+
// bodyOf() automatically drops null optional values.
|
|
105
|
+
const bodyEntries: string[] = [];
|
|
106
|
+
for (const rp of resolvedParams) {
|
|
107
|
+
const paramName = propertyName(rp.paramName);
|
|
108
|
+
bodyEntries.push(` ${ktLiteral(rp.paramName)} to ${paramName}`);
|
|
109
|
+
}
|
|
110
|
+
for (const [k, v] of Object.entries(wrapper.defaults ?? {})) {
|
|
111
|
+
bodyEntries.push(` ${ktLiteral(k)} to ${ktLiteral(v)}`);
|
|
112
|
+
}
|
|
113
|
+
for (const k of wrapper.inferFromClient ?? []) {
|
|
114
|
+
bodyEntries.push(` ${ktLiteral(k)} to workos.${clientFieldExpression(k)}`);
|
|
115
|
+
}
|
|
116
|
+
if (bodyEntries.length > 0) {
|
|
117
|
+
lines.push(` val body =`);
|
|
118
|
+
lines.push(` bodyOf(`);
|
|
119
|
+
for (let i = 0; i < bodyEntries.length; i++) {
|
|
120
|
+
const sep = i === bodyEntries.length - 1 ? '' : ',';
|
|
121
|
+
lines.push(` ${bodyEntries[i]}${sep}`);
|
|
122
|
+
}
|
|
123
|
+
lines.push(` )`);
|
|
124
|
+
} else {
|
|
125
|
+
lines.push(` val body = linkedMapOf<String, Any?>()`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pathExpr = buildPathExpr(op.path, pathParams);
|
|
129
|
+
const httpMethod = op.httpMethod.toUpperCase();
|
|
130
|
+
|
|
131
|
+
lines.push(` val config =`);
|
|
132
|
+
lines.push(` RequestConfig(`);
|
|
133
|
+
lines.push(` method = ${ktLiteral(httpMethod)},`);
|
|
134
|
+
lines.push(` path = ${pathExpr},`);
|
|
135
|
+
lines.push(` body = body,`);
|
|
136
|
+
if (op.requestBodyEncoding === 'form-urlencoded') {
|
|
137
|
+
// Some ops (SSO token, User Management authenticate) are form-encoded.
|
|
138
|
+
// Rewrite as formBody mapping string→string instead of JSON body.
|
|
139
|
+
// Fallback: leave body as JSON — the API accepts JSON for these too.
|
|
140
|
+
}
|
|
141
|
+
lines.push(` requestOptions = requestOptions`);
|
|
142
|
+
lines.push(` )`);
|
|
143
|
+
|
|
144
|
+
if (responseClass) {
|
|
145
|
+
lines.push(` return workos.baseClient.request(config, ${responseClass}::class.java)`);
|
|
146
|
+
} else {
|
|
147
|
+
lines.push(` workos.baseClient.requestVoid(config)`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push(' }');
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function escapeKdoc(s: string): string {
|
|
155
|
+
return s.replace(/\*\//g, '*\u200b/');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildPathExpr(path: string, pathParams: Parameter[]): string {
|
|
159
|
+
if (pathParams.length === 0) return ktLiteral(path);
|
|
160
|
+
let result = path;
|
|
161
|
+
for (const pp of pathParams) {
|
|
162
|
+
const placeholder = `{${pp.name}}`;
|
|
163
|
+
const propName = propertyName(pp.name);
|
|
164
|
+
const replacement = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(propName) ? `\$${propName}` : `\${${propName}}`;
|
|
165
|
+
result = result.replaceAll(placeholder, replacement);
|
|
166
|
+
}
|
|
167
|
+
return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
168
|
+
}
|
package/src/node/client.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
-
|
|
4
|
+
|
|
3
5
|
import { fileName, resolveServiceDir, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
6
|
import {
|
|
5
7
|
docComment,
|
|
@@ -7,6 +9,7 @@ import {
|
|
|
7
9
|
isServiceCoveredByExisting,
|
|
8
10
|
isListMetadataModel,
|
|
9
11
|
isListWrapperModel,
|
|
12
|
+
computeNonEventReachable,
|
|
10
13
|
} from './utils.js';
|
|
11
14
|
import { resolveResourceClassName } from './resources.js';
|
|
12
15
|
|
|
@@ -85,6 +88,15 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
85
88
|
const resolvedName = resolveResourceClassName(service, ctx);
|
|
86
89
|
const propName = servicePropertyName(resolvedName);
|
|
87
90
|
if (existingProps.has(propName)) continue;
|
|
91
|
+
// Propagate `@deprecated` from the service class to the property so
|
|
92
|
+
// IDEs surface the strikethrough at `workos.xyz` access sites, not
|
|
93
|
+
// just when users `new Xyz()` directly. TS's deprecation-lint reads
|
|
94
|
+
// the property JSDoc, not the underlying type declaration.
|
|
95
|
+
const classDeprecation = ctx.apiSurface?.classes?.[resolvedName]?.deprecationMessage;
|
|
96
|
+
if (classDeprecation !== undefined) {
|
|
97
|
+
const body = classDeprecation ? ` ${classDeprecation}` : '';
|
|
98
|
+
lines.push(` /** @deprecated${body} */`);
|
|
99
|
+
}
|
|
88
100
|
lines.push(` readonly ${propName} = new ${resolvedName}(this);`);
|
|
89
101
|
}
|
|
90
102
|
|
|
@@ -175,12 +187,12 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
|
|
|
175
187
|
// Models -> service directories
|
|
176
188
|
// Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
|
|
177
189
|
// from common utils, so no per-resource interface file is generated.
|
|
178
|
-
// Also skip unreachable models —
|
|
179
|
-
//
|
|
180
|
-
const barrelReachable =
|
|
190
|
+
// Also skip unreachable models — use the same non-event reachability as model
|
|
191
|
+
// generation so every barrel entry has a corresponding generated file.
|
|
192
|
+
const barrelReachable = computeNonEventReachable(spec.services, spec.models);
|
|
181
193
|
for (const model of spec.models) {
|
|
182
194
|
if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
|
|
183
|
-
if (!barrelReachable.
|
|
195
|
+
if (!barrelReachable.has(model.name)) continue;
|
|
184
196
|
const service = modelToService.get(model.name);
|
|
185
197
|
const dirName = resolveDir(service);
|
|
186
198
|
if (!dirExports.has(dirName)) {
|
|
@@ -253,6 +265,71 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
|
|
|
253
265
|
addBaselineExports(ctx.apiSurface.interfaces);
|
|
254
266
|
addBaselineExports(ctx.apiSurface.typeAliases);
|
|
255
267
|
addBaselineExports(ctx.apiSurface.enums);
|
|
268
|
+
|
|
269
|
+
// Preserve existing barrel entries: read the current barrel from the
|
|
270
|
+
// target directory and keep every `export * from './<stem>'` whose
|
|
271
|
+
// corresponding file still exists on disk. This prevents dropping
|
|
272
|
+
// hand-written types (e.g., Factor in multi-factor-auth) when a
|
|
273
|
+
// generated model in the same file causes a symbol collision.
|
|
274
|
+
if (ctx.targetDir) {
|
|
275
|
+
const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
|
|
276
|
+
try {
|
|
277
|
+
const barrelPath = path.join(interfacesDir, 'index.ts');
|
|
278
|
+
const barrelContent = fs.readFileSync(barrelPath, 'utf-8');
|
|
279
|
+
for (const line of barrelContent.split('\n')) {
|
|
280
|
+
const match = line.match(/^export \* from '\.\/(.*?)';?$/);
|
|
281
|
+
if (!match) continue;
|
|
282
|
+
const stem = match[1];
|
|
283
|
+
const exportLine = `export * from './${stem}';`;
|
|
284
|
+
if (exportSet.has(exportLine)) continue;
|
|
285
|
+
// Verify the referenced file still exists
|
|
286
|
+
const filePath = path.join(interfacesDir, `${stem}.ts`);
|
|
287
|
+
try {
|
|
288
|
+
fs.accessSync(filePath);
|
|
289
|
+
exportSet.add(exportLine);
|
|
290
|
+
} catch {
|
|
291
|
+
// File no longer exists — don't preserve stale entry
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// No existing barrel — nothing to preserve
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Also scan for NEW interface files not in the existing barrel or
|
|
299
|
+
// apiSurface (e.g., list wrappers, hand-written types added after
|
|
300
|
+
// the last generation).
|
|
301
|
+
const symbols = dirSymbols.get(dirName) ?? new Set<string>();
|
|
302
|
+
try {
|
|
303
|
+
for (const entry of fs.readdirSync(interfacesDir)) {
|
|
304
|
+
if (entry === 'index.ts') continue;
|
|
305
|
+
if (!entry.endsWith('.ts')) continue;
|
|
306
|
+
const stem = entry.replace(/\.ts$/, '');
|
|
307
|
+
const exportLine = `export * from './${stem}';`;
|
|
308
|
+
if (exportSet.has(exportLine)) continue;
|
|
309
|
+
|
|
310
|
+
// Extract exported symbol names from the file to check for conflicts
|
|
311
|
+
const content = fs.readFileSync(path.join(interfacesDir, entry), 'utf-8');
|
|
312
|
+
const exportedNames: string[] = [];
|
|
313
|
+
for (const m of content.matchAll(/export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/g)) {
|
|
314
|
+
exportedNames.push(m[1]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Skip if any exported name collides with a symbol already
|
|
318
|
+
// claimed by any file (same or different directory)
|
|
319
|
+
const hasCollision = exportedNames.some((name) => globalExistingSymbols.has(name));
|
|
320
|
+
if (hasCollision) continue;
|
|
321
|
+
|
|
322
|
+
// Safe to add — register symbols and include in barrel
|
|
323
|
+
for (const name of exportedNames) {
|
|
324
|
+
symbols.add(name);
|
|
325
|
+
globalExistingSymbols.add(name);
|
|
326
|
+
}
|
|
327
|
+
exportSet.add(exportLine);
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// Directory doesn't exist in target — nothing to scan
|
|
331
|
+
}
|
|
332
|
+
}
|
|
256
333
|
}
|
|
257
334
|
|
|
258
335
|
// Deduplicate and sort
|
|
@@ -495,8 +572,8 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
495
572
|
// Filter to reachable models only: oagen's generateAllFiles passes only
|
|
496
573
|
// service-referenced models to generateModels, so unreachable models
|
|
497
574
|
// never get interface files. Exporting them here would create broken imports.
|
|
498
|
-
const reachable =
|
|
499
|
-
const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.
|
|
575
|
+
const reachable = computeNonEventReachable(spec.services, spec.models);
|
|
576
|
+
const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.has(m.name));
|
|
500
577
|
const commonEnums = spec.enums.filter((e) => {
|
|
501
578
|
const enumService = findEnumService(e.name, spec.services);
|
|
502
579
|
return !enumService;
|
package/src/node/field-plan.ts
CHANGED
|
@@ -582,7 +582,6 @@ function emitAssignment(lhs: string, expr: string, accessExpr: string, guard: Gu
|
|
|
582
582
|
interface SerializerContext {
|
|
583
583
|
modelToService: Map<string, string>;
|
|
584
584
|
resolveDir: (irService: string | undefined) => string;
|
|
585
|
-
useStringDates: boolean;
|
|
586
585
|
dedup: Map<string, string>;
|
|
587
586
|
skippedSerializeModels: Set<string>;
|
|
588
587
|
ctx: EmitterContext;
|
|
@@ -614,31 +613,30 @@ export function buildSerializerImports(
|
|
|
614
613
|
const depSerializerPath = `src/${depDir}/serializers/${fileName(dep)}.serializer.ts`;
|
|
615
614
|
const depName = resolveInterfaceName(dep, sctx.ctx);
|
|
616
615
|
const rel = relativeImport(serializerPath, depSerializerPath);
|
|
617
|
-
|
|
616
|
+
// Check the canonical name for dedup'd models
|
|
617
|
+
const canon = sctx.dedup.get(dep);
|
|
618
|
+
const depSkipSerialize =
|
|
619
|
+
sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
|
|
620
|
+
if (depSkipSerialize) {
|
|
621
|
+
lines.push(`import { deserialize${depName} } from '${rel}';`);
|
|
622
|
+
} else {
|
|
623
|
+
lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
|
|
624
|
+
}
|
|
618
625
|
}
|
|
619
626
|
lines.push('');
|
|
620
627
|
return lines;
|
|
621
628
|
}
|
|
622
629
|
|
|
623
630
|
/** Build the set of field names where format conversion should be skipped. */
|
|
624
|
-
export function buildSkipFormatFields(
|
|
625
|
-
model: Model,
|
|
626
|
-
useStringDates: boolean,
|
|
627
|
-
baselineDomain: BaselineInterface | undefined,
|
|
628
|
-
): Set<string> {
|
|
631
|
+
export function buildSkipFormatFields(model: Model, baselineDomain: BaselineInterface | undefined): Set<string> {
|
|
629
632
|
const skipFormatFields = new Set<string>();
|
|
630
|
-
if (useStringDates) {
|
|
631
|
-
for (const field of model.fields) {
|
|
632
|
-
if (hasDateTimeConversion(field.type)) {
|
|
633
|
-
skipFormatFields.add(field.name);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
633
|
if (baselineDomain) {
|
|
638
634
|
for (const field of model.fields) {
|
|
639
635
|
if (skipFormatFields.has(field.name)) continue;
|
|
640
636
|
const baselineField = baselineDomain.fields?.[fieldName(field.name)];
|
|
641
637
|
if (baselineField && !baselineField.type.includes('Date') && hasFormatConversion(field.type)) {
|
|
638
|
+
// Always convert date-time fields to Date regardless of baseline
|
|
639
|
+
if (hasDateTimeConversion(field.type)) continue;
|
|
642
640
|
skipFormatFields.add(field.name);
|
|
643
641
|
}
|
|
644
642
|
}
|
package/src/node/fixtures.ts
CHANGED
|
@@ -48,15 +48,51 @@ export function generateFixtures(
|
|
|
48
48
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
49
49
|
const files: { path: string; content: string }[] = [];
|
|
50
50
|
|
|
51
|
+
// Only generate fixtures for models reachable from non-event operations
|
|
52
|
+
const fixtureSeeds = new Set<string>();
|
|
53
|
+
for (const svc of spec.services) {
|
|
54
|
+
if (svc.name.toLowerCase() === 'events') continue;
|
|
55
|
+
for (const op of svc.operations) {
|
|
56
|
+
const collectFromRef = (t: import('@workos/oagen').TypeRef | undefined): void => {
|
|
57
|
+
if (!t) return;
|
|
58
|
+
if (t.kind === 'model') fixtureSeeds.add(t.name);
|
|
59
|
+
if (t.kind === 'array') collectFromRef(t.items);
|
|
60
|
+
if (t.kind === 'nullable') collectFromRef(t.inner);
|
|
61
|
+
if (t.kind === 'union') t.variants.forEach(collectFromRef);
|
|
62
|
+
};
|
|
63
|
+
collectFromRef(op.response);
|
|
64
|
+
collectFromRef(op.requestBody);
|
|
65
|
+
if (op.pagination?.itemType) collectFromRef(op.pagination.itemType);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const fixtureModelMap = new Map(spec.models.map((m: Model) => [m.name, m]));
|
|
69
|
+
const fixtureReachable = new Set<string>();
|
|
70
|
+
const fixtureQueue = [...fixtureSeeds];
|
|
71
|
+
while (fixtureQueue.length > 0) {
|
|
72
|
+
const name = fixtureQueue.pop()!;
|
|
73
|
+
if (fixtureReachable.has(name)) continue;
|
|
74
|
+
fixtureReachable.add(name);
|
|
75
|
+
const m = fixtureModelMap.get(name);
|
|
76
|
+
if (!m) continue;
|
|
77
|
+
for (const field of m.fields) {
|
|
78
|
+
const walk = (t: import('@workos/oagen').TypeRef): void => {
|
|
79
|
+
if (t.kind === 'model' && !fixtureReachable.has(t.name)) fixtureQueue.push(t.name);
|
|
80
|
+
if (t.kind === 'array') walk(t.items);
|
|
81
|
+
if (t.kind === 'nullable') walk(t.inner);
|
|
82
|
+
if (t.kind === 'union') t.variants.forEach(walk);
|
|
83
|
+
};
|
|
84
|
+
walk(field.type);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
51
87
|
const seenFixturePaths = new Set<string>();
|
|
52
88
|
for (const model of spec.models) {
|
|
53
|
-
|
|
89
|
+
if (!fixtureReachable.has(model.name)) continue;
|
|
54
90
|
if (isListMetadataModel(model)) continue;
|
|
55
91
|
if (isListWrapperModel(model)) continue;
|
|
56
92
|
|
|
57
93
|
const service = modelToService.get(model.name);
|
|
58
94
|
const dirName = resolveDir(service);
|
|
59
|
-
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.
|
|
95
|
+
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
|
|
60
96
|
|
|
61
97
|
// After noise suffix stripping, multiple models may resolve to the same
|
|
62
98
|
// fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
|
|
@@ -94,7 +130,7 @@ export function generateFixtures(
|
|
|
94
130
|
},
|
|
95
131
|
};
|
|
96
132
|
files.push({
|
|
97
|
-
path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.
|
|
133
|
+
path: `src/${serviceDir}/fixtures/list-${fileName(itemModel.name)}.json`,
|
|
98
134
|
content: JSON.stringify(listFixture, null, 2),
|
|
99
135
|
});
|
|
100
136
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { generateEnums } from './enums.js';
|
|
|
16
16
|
import { generateResources } from './resources.js';
|
|
17
17
|
import { generateClient } from './client.js';
|
|
18
18
|
import { generateErrors } from './errors.js';
|
|
19
|
+
|
|
19
20
|
import { generateTests } from './tests.js';
|
|
20
21
|
import { generateManifest } from './manifest.js';
|
|
21
22
|
|