@workos/oagen-emitters 0.12.0 → 0.12.2
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-pr-title.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/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
- package/dist/plugin-eCuvoL1T.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/package.json +10 -10
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/src/rust/fixtures.ts +87 -1
- package/src/rust/models.ts +17 -2
- package/src/rust/resources.ts +697 -62
- package/src/rust/tests.ts +540 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +376 -2036
- package/test/node/tests.test.ts +119 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/models.test.ts +38 -0
- package/test/rust/resources.test.ts +505 -2
- package/test/rust/tests.test.ts +504 -0
- package/dist/plugin-C408Wh-o.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
package/src/node/naming.ts
CHANGED
|
@@ -34,11 +34,66 @@ export function wireFieldName(name: string): string {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
37
|
+
* Active set of `Serialized${Name}` interfaces in the live SDK, harvested
|
|
38
|
+
* from `ctx.apiSurface` once per generation run. When non-empty, the
|
|
39
|
+
* legacy wire-naming scheme wins so existing hand-written serializer files
|
|
40
|
+
* continue to compile.
|
|
41
|
+
*
|
|
42
|
+
* Set by `index.ts` immediately after `getSurface(ctx)` runs.
|
|
43
|
+
*/
|
|
44
|
+
let baselineSerializedNames: Set<string> = new Set();
|
|
45
|
+
export function setBaselineSerializedNames(names: Set<string>): void {
|
|
46
|
+
baselineSerializedNames = names;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set of every interface name present in the baseline live SDK, regardless
|
|
51
|
+
* of naming convention. Used to detect single-form baselines (where one
|
|
52
|
+
* `*Response`-suffixed interface stands for both the domain and wire shape)
|
|
53
|
+
* so we don't synthesize a non-existent `*Wire` variant.
|
|
54
|
+
*/
|
|
55
|
+
let baselineInterfaceNames: Set<string> = new Set();
|
|
56
|
+
export function setBaselineInterfaceNames(names: Set<string>): void {
|
|
57
|
+
baselineInterfaceNames = names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* IR models that belong to newly-adopted services should not be renamed by
|
|
62
|
+
* structural baseline matches from unrelated hand-written services.
|
|
63
|
+
*/
|
|
64
|
+
let adoptedModelNames: Set<string> = new Set();
|
|
65
|
+
export function setAdoptedModelNames(names: Set<string>): void {
|
|
66
|
+
adoptedModelNames = names;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wire/response interface name.
|
|
71
|
+
*
|
|
72
|
+
* Resolution order:
|
|
73
|
+
* 1. `Serialized${domainName}` if it exists in the baseline (legacy
|
|
74
|
+
* workos-node convention; lets hand-written serializer files keep
|
|
75
|
+
* compiling).
|
|
76
|
+
* 2. `${domainName}Wire` when the domain ends in `Response` AND the
|
|
77
|
+
* baseline actually has a `*Wire` interface (avoids
|
|
78
|
+
* `FooResponseResponse` stutter).
|
|
79
|
+
* 3. The bare `domainName` itself when it already ends in `Response` and
|
|
80
|
+
* no `*Wire` variant exists — this happens when the structural matcher
|
|
81
|
+
* maps an IR model to a baseline-wire-shaped interface
|
|
82
|
+
* (`AuditLogSchemaJson` → `AuditLogSchemaResponse`) and the baseline
|
|
83
|
+
* has no separate domain/wire split.
|
|
84
|
+
* 4. `${domainName}Response` for the standard fresh-emit case.
|
|
39
85
|
*/
|
|
40
86
|
export function wireInterfaceName(domainName: string): string {
|
|
41
|
-
|
|
87
|
+
const serialized = `Serialized${domainName}`;
|
|
88
|
+
if (baselineSerializedNames.has(serialized)) return serialized;
|
|
89
|
+
|
|
90
|
+
if (domainName.endsWith('Response')) {
|
|
91
|
+
const wireForm = `${domainName}Wire`;
|
|
92
|
+
if (baselineInterfaceNames.has(wireForm)) return wireForm;
|
|
93
|
+
if (baselineInterfaceNames.has(domainName)) return domainName;
|
|
94
|
+
return wireForm;
|
|
95
|
+
}
|
|
96
|
+
return `${domainName}Response`;
|
|
42
97
|
}
|
|
43
98
|
|
|
44
99
|
/** kebab-case service directory name. */
|
|
@@ -53,17 +108,14 @@ export function servicePropertyName(name: string): string {
|
|
|
53
108
|
|
|
54
109
|
/**
|
|
55
110
|
* Resolve the effective service name, using the overlay-resolved class name
|
|
56
|
-
* when available.
|
|
57
|
-
* all derive from the same resolved name (e.g., "Mfa" instead of "MultiFactorAuth").
|
|
111
|
+
* when available.
|
|
58
112
|
*/
|
|
59
113
|
export function resolveServiceName(service: Service, ctx: EmitterContext): string {
|
|
60
114
|
return resolveClassName(service, ctx);
|
|
61
115
|
}
|
|
62
116
|
|
|
63
117
|
/**
|
|
64
|
-
* Build a map from IR service name
|
|
65
|
-
* Used to translate modelToService/enumToService map values to overlay-resolved
|
|
66
|
-
* directory names when the code only has the IR service name string.
|
|
118
|
+
* Build a map from IR service name -> resolved service name.
|
|
67
119
|
*/
|
|
68
120
|
export function buildServiceNameMap(services: Service[], ctx: EmitterContext): Map<string, string> {
|
|
69
121
|
const map = new Map<string, string>();
|
|
@@ -73,10 +125,7 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
|
|
|
73
125
|
return map;
|
|
74
126
|
}
|
|
75
127
|
|
|
76
|
-
/**
|
|
77
|
-
* Resolve the output directory for a service.
|
|
78
|
-
* Mount rules already handle directory placement, so this is a simple kebab-case conversion.
|
|
79
|
-
*/
|
|
128
|
+
/** Resolve the output directory for a service. */
|
|
80
129
|
export function resolveServiceDir(resolvedServiceName: string): string {
|
|
81
130
|
return serviceDirName(resolvedServiceName);
|
|
82
131
|
}
|
|
@@ -86,7 +135,6 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
|
|
|
86
135
|
const lookup = buildResolvedLookup(ctx);
|
|
87
136
|
const resolved = lookupMethodName(op, lookup);
|
|
88
137
|
if (resolved) return toCamelCase(resolved);
|
|
89
|
-
// Fallback to overlay, then spec-derived
|
|
90
138
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
91
139
|
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
92
140
|
if (existing) return existing.methodName;
|
|
@@ -95,11 +143,9 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
|
|
|
95
143
|
|
|
96
144
|
/** Resolve the SDK class name for a service, using resolved ops mountOn as canonical. */
|
|
97
145
|
export function resolveClassName(service: Service, ctx: EmitterContext): string {
|
|
98
|
-
// Use resolved ops mountOn as canonical class name
|
|
99
146
|
for (const r of ctx.resolvedOperations ?? []) {
|
|
100
147
|
if (r.service.name === service.name) return r.mountOn;
|
|
101
148
|
}
|
|
102
|
-
// Fallback to overlay
|
|
103
149
|
if (ctx.overlayLookup?.methodByOperation) {
|
|
104
150
|
for (const op of service.operations) {
|
|
105
151
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
@@ -110,19 +156,48 @@ export function resolveClassName(service: Service, ctx: EmitterContext): string
|
|
|
110
156
|
return toPascalCase(service.name);
|
|
111
157
|
}
|
|
112
158
|
|
|
113
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Resolve the interface name for a model, checking overlay first.
|
|
114
161
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
162
|
+
* Lookup order:
|
|
163
|
+
* 1. `overlayLookup.interfaceByName` — exact-name overrides from the live SDK.
|
|
164
|
+
* 2. `overlayLookup.modelNameByIR` — structurally-inferred matches (e.g., IR
|
|
165
|
+
* `ValidateApiKey` with one field `value: string` → live SDK interface
|
|
166
|
+
* `ValidateApiKeyOptions`).
|
|
167
|
+
* 3. Type-alias resolution (when an alias points to an interface).
|
|
168
|
+
* 4. Suffix-fallback heuristic for the workos-node `*Options` convention:
|
|
169
|
+
* when the IR name `X` has no baseline match but `XOptions` does, use
|
|
170
|
+
* `XOptions`. The convention is widely used for request-body interfaces
|
|
171
|
+
* in workos-node (CreateOrganizationOptions, ListUsersOptions, etc.).
|
|
172
|
+
* 5. Default — clean and PascalCase the IR name.
|
|
118
173
|
*/
|
|
119
174
|
export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: { skipTypeAlias?: boolean }): string {
|
|
120
175
|
const existing = ctx.overlayLookup?.interfaceByName?.get(name);
|
|
121
176
|
if (existing) return existing;
|
|
122
177
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
178
|
+
let inferred = adoptedModelNames.has(name) ? undefined : ctx.overlayLookup?.modelNameByIR?.get(name);
|
|
179
|
+
if (inferred) {
|
|
180
|
+
if (inferred.startsWith('Serialized')) {
|
|
181
|
+
const stripped = inferred.slice('Serialized'.length);
|
|
182
|
+
if (stripped && ctx.apiSurface?.interfaces?.[stripped]) {
|
|
183
|
+
inferred = stripped;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Structural matchers tend to lock onto the wire-shaped baseline
|
|
187
|
+
// interface (`AuditLogSchemaResponse`) because the IR carries
|
|
188
|
+
// snake_case fields. Prefer the corresponding domain name (without
|
|
189
|
+
// the `Response` suffix) when both exist in baseline — domain refs
|
|
190
|
+
// belong on the domain side, the wire/serialize path picks up the
|
|
191
|
+
// `*Response` variant via `wireInterfaceName`.
|
|
192
|
+
if (inferred.endsWith('Response') && ctx.apiSurface?.interfaces) {
|
|
193
|
+
const stripped = inferred.slice(0, -'Response'.length);
|
|
194
|
+
if (stripped && ctx.apiSurface.interfaces[stripped]) {
|
|
195
|
+
inferred = stripped;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return inferred;
|
|
199
|
+
}
|
|
200
|
+
|
|
126
201
|
if (!opts?.skipTypeAlias && ctx.apiSurface?.typeAliases) {
|
|
127
202
|
const alias = ctx.apiSurface.typeAliases[name] as { value?: string } | undefined;
|
|
128
203
|
if (alias?.value && ctx.apiSurface.interfaces?.[alias.value]) {
|
|
@@ -130,9 +205,28 @@ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: {
|
|
|
130
205
|
}
|
|
131
206
|
}
|
|
132
207
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
208
|
+
// Suffix-fallback for the workos-node `*Options` convention, restricted to
|
|
209
|
+
// the case where the baseline `${name}Options` interface lives in the file
|
|
210
|
+
// we'd compute from the IR name itself (e.g. `validate-api-key.interface.ts`
|
|
211
|
+
// exports `ValidateApiKeyOptions`). Without this restriction, we'd re-point
|
|
212
|
+
// every IR name with an `*Options` baseline to a different file (e.g.
|
|
213
|
+
// `CreateOrganizationApiKey` → `…Options` lives in
|
|
214
|
+
// `create-organization-api-key-options.interface.ts`, NOT
|
|
215
|
+
// `create-organization-api-key.interface.ts`).
|
|
216
|
+
if (ctx.apiSurface?.interfaces) {
|
|
217
|
+
const ifaces = ctx.apiSurface.interfaces;
|
|
218
|
+
if (!ifaces[name]) {
|
|
219
|
+
const optionsCandidate = `${name}Options`;
|
|
220
|
+
const optionsInfo = ifaces[optionsCandidate] as { sourceFile?: string } | undefined;
|
|
221
|
+
if (optionsInfo?.sourceFile) {
|
|
222
|
+
const expectedStem = toKebabCase(stripUrnPrefix(name));
|
|
223
|
+
if (optionsInfo.sourceFile.endsWith(`/${expectedStem}.interface.ts`)) {
|
|
224
|
+
return optionsCandidate;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
136
230
|
const cleaned = ctx.apiSurface ? name : stripNoiseSuffixes(name);
|
|
137
231
|
return toPascalCase(stripUrnPrefix(cleaned));
|
|
138
232
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { EmitterContext, ResolvedOperation } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
type OperationOverride = {
|
|
4
|
+
methodName?: string;
|
|
5
|
+
mountOn?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const OPERATION_OVERRIDES: Record<string, OperationOverride> = {
|
|
9
|
+
'POST /organizations/{organizationId}/groups': {
|
|
10
|
+
methodName: 'create_group',
|
|
11
|
+
},
|
|
12
|
+
'GET /organizations/{organizationId}/groups': {
|
|
13
|
+
methodName: 'list_groups',
|
|
14
|
+
},
|
|
15
|
+
'GET /organizations/{organizationId}/groups/{groupId}': {
|
|
16
|
+
methodName: 'get_group',
|
|
17
|
+
},
|
|
18
|
+
'PATCH /organizations/{organizationId}/groups/{groupId}': {
|
|
19
|
+
methodName: 'update_group',
|
|
20
|
+
},
|
|
21
|
+
'DELETE /organizations/{organizationId}/groups/{groupId}': {
|
|
22
|
+
methodName: 'delete_group',
|
|
23
|
+
},
|
|
24
|
+
'POST /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
|
|
25
|
+
methodName: 'add_organization_membership',
|
|
26
|
+
},
|
|
27
|
+
'GET /organizations/{organizationId}/groups/{groupId}/organization-memberships': {
|
|
28
|
+
methodName: 'list_organization_memberships',
|
|
29
|
+
},
|
|
30
|
+
'DELETE /organizations/{organizationId}/groups/{groupId}/organization-memberships/{omId}': {
|
|
31
|
+
methodName: 'remove_organization_membership',
|
|
32
|
+
},
|
|
33
|
+
'GET /user_management/organization_memberships/{omId}/groups': {
|
|
34
|
+
methodName: 'list_groups_for_organization_membership',
|
|
35
|
+
mountOn: 'UserManagement',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const contextCache = new WeakMap<EmitterContext, EmitterContext>();
|
|
40
|
+
|
|
41
|
+
function operationKey(resolved: ResolvedOperation): string {
|
|
42
|
+
return `${resolved.operation.httpMethod.toUpperCase()} ${resolved.operation.path}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function withNodeOperationOverrides(ctx: EmitterContext): EmitterContext {
|
|
46
|
+
const cached = contextCache.get(ctx);
|
|
47
|
+
if (cached) return cached;
|
|
48
|
+
|
|
49
|
+
const resolvedOperations = ctx.resolvedOperations;
|
|
50
|
+
if (!resolvedOperations?.length) {
|
|
51
|
+
contextCache.set(ctx, ctx);
|
|
52
|
+
return ctx;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let changed = false;
|
|
56
|
+
const nextResolved = resolvedOperations.map((resolved) => {
|
|
57
|
+
const override = OPERATION_OVERRIDES[operationKey(resolved)];
|
|
58
|
+
if (!override) return resolved;
|
|
59
|
+
|
|
60
|
+
const methodName = override.methodName ?? resolved.methodName;
|
|
61
|
+
const mountOn = override.mountOn ?? resolved.mountOn;
|
|
62
|
+
if (methodName === resolved.methodName && mountOn === resolved.mountOn) {
|
|
63
|
+
return resolved;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
changed = true;
|
|
67
|
+
return {
|
|
68
|
+
...resolved,
|
|
69
|
+
methodName,
|
|
70
|
+
mountOn,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const next = changed ? { ...ctx, resolvedOperations: nextResolved } : ctx;
|
|
75
|
+
contextCache.set(ctx, next);
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { EmitterContext } from '@workos/oagen';
|
|
2
|
+
|
|
3
|
+
export interface NodeEmitterOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Existing SDK mode normally drops brand-new paths to avoid large accidental
|
|
6
|
+
* generations. When enabled, brand-new top-level service directories whose
|
|
7
|
+
* services are absent from the SDK are adopted from the spec.
|
|
8
|
+
*/
|
|
9
|
+
adoptMissingServices?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Existing tracked service directories to move under oagen ownership.
|
|
12
|
+
*
|
|
13
|
+
* Entries may be IR service names or resolved resource class names
|
|
14
|
+
* (for example, "Groups"). Owned service files are allowed to overwrite
|
|
15
|
+
* tracked, non-autogenerated files so the service can be migrated into the
|
|
16
|
+
* manifest one service at a time.
|
|
17
|
+
*/
|
|
18
|
+
ownedServices?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Regenerate tests and fixtures for owned services. Non-owned service tests
|
|
21
|
+
* and fixtures remain hand-owned.
|
|
22
|
+
*/
|
|
23
|
+
regenerateOwnedTests?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function nodeOptions(ctx: EmitterContext): NodeEmitterOptions {
|
|
27
|
+
return ((ctx as EmitterContext & { emitterOptions?: Record<string, unknown> }).emitterOptions ??
|
|
28
|
+
{}) as NodeEmitterOptions;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeServiceName(name: string): string {
|
|
32
|
+
return name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isNodeOwnedService(ctx: EmitterContext, ...names: Array<string | undefined>): boolean {
|
|
36
|
+
const configured = nodeOptions(ctx).ownedServices ?? [];
|
|
37
|
+
if (configured.length === 0) return false;
|
|
38
|
+
|
|
39
|
+
const owned = new Set(configured.map(normalizeServiceName));
|
|
40
|
+
return names.some((name) => name !== undefined && owned.has(normalizeServiceName(name)));
|
|
41
|
+
}
|