@workos/oagen-emitters 0.2.0 → 0.2.1

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/src/node/index.ts CHANGED
@@ -1,4 +1,15 @@
1
- import type { Emitter, EmitterContext, GeneratedFile, ApiSpec, Model, Enum, Service } from '@workos/oagen';
1
+ import type {
2
+ Emitter,
3
+ EmitterContext,
4
+ FormatCommand,
5
+ GeneratedFile,
6
+ ApiSpec,
7
+ Model,
8
+ Enum,
9
+ Service,
10
+ } from '@workos/oagen';
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
2
13
 
3
14
  import { generateModels } from './models.js';
4
15
  import { generateEnums } from './enums.js';
@@ -11,31 +22,41 @@ import { generateCommon } from './common.js';
11
22
  import { generateTests } from './tests.js';
12
23
  import { generateManifest } from './manifest.js';
13
24
 
25
+ /** Ensure every generated file's content ends with a trailing newline. */
26
+ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
27
+ for (const f of files) {
28
+ if (f.content && !f.content.endsWith('\n')) {
29
+ f.content += '\n';
30
+ }
31
+ }
32
+ return files;
33
+ }
34
+
14
35
  export const nodeEmitter: Emitter = {
15
36
  language: 'node',
16
37
 
17
38
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
18
- return [...generateModels(models, ctx), ...generateSerializers(models, ctx)];
39
+ return ensureTrailingNewlines([...generateModels(models, ctx), ...generateSerializers(models, ctx)]);
19
40
  },
20
41
 
21
42
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
22
- return generateEnums(enums, ctx);
43
+ return ensureTrailingNewlines(generateEnums(enums, ctx));
23
44
  },
24
45
 
25
46
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
26
- return generateResources(services, ctx);
47
+ return ensureTrailingNewlines(generateResources(services, ctx));
27
48
  },
28
49
 
29
50
  generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
30
- return generateClient(spec, ctx);
51
+ return ensureTrailingNewlines(generateClient(spec, ctx));
31
52
  },
32
53
 
33
54
  generateErrors(ctx: EmitterContext): GeneratedFile[] {
34
- return generateErrors(ctx);
55
+ return ensureTrailingNewlines(generateErrors(ctx));
35
56
  },
36
57
 
37
58
  generateConfig(_ctx: EmitterContext): GeneratedFile[] {
38
- return [...generateConfig(), ...generateCommon()];
59
+ return ensureTrailingNewlines([...generateConfig(), ...generateCommon()]);
39
60
  },
40
61
 
41
62
  generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
@@ -44,14 +65,49 @@ export const nodeEmitter: Emitter = {
44
65
  },
45
66
 
46
67
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
47
- return generateTests(spec, ctx);
68
+ return ensureTrailingNewlines(generateTests(spec, ctx));
48
69
  },
49
70
 
50
71
  generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
51
- return generateManifest(spec, ctx);
72
+ return ensureTrailingNewlines(generateManifest(spec, ctx));
52
73
  },
53
74
 
54
75
  fileHeader(): string {
55
76
  return '// This file is auto-generated by oagen. Do not edit.';
56
77
  },
78
+
79
+ formatCommand(targetDir: string): FormatCommand | null {
80
+ const hasPrettier = fs.existsSync(path.join(targetDir, '.prettierrc'));
81
+ const hasEslint =
82
+ fs.existsSync(path.join(targetDir, 'eslint.config.mjs')) ||
83
+ fs.existsSync(path.join(targetDir, 'eslint.config.js')) ||
84
+ fs.existsSync(path.join(targetDir, '.eslintrc.json')) ||
85
+ fs.existsSync(path.join(targetDir, '.eslintrc.js'));
86
+
87
+ if (hasPrettier && hasEslint) {
88
+ // Chain ESLint autofix (e.g. unused-import removal) then prettier.
89
+ // ESLint errors are suppressed so formatting still runs on lint failure.
90
+ return {
91
+ cmd: 'bash',
92
+ args: [
93
+ '-c',
94
+ 'npx eslint --fix --no-error-on-unmatched-pattern "$@" 2>/dev/null; npx prettier --write --log-level silent "$@"',
95
+ '--',
96
+ ],
97
+ };
98
+ }
99
+ if (hasPrettier) {
100
+ return {
101
+ cmd: 'npx',
102
+ args: ['prettier', '--write', '--log-level', 'silent'],
103
+ };
104
+ }
105
+ if (hasEslint) {
106
+ return {
107
+ cmd: 'npx',
108
+ args: ['eslint', '--fix', '--no-error-on-unmatched-pattern'],
109
+ };
110
+ }
111
+ return null;
112
+ },
57
113
  };
@@ -252,87 +252,98 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
252
252
  if (model.description) {
253
253
  lines.push(...docComment(model.description));
254
254
  }
255
- lines.push(`export interface ${domainName}${typeParams} {`);
256
- for (const field of model.fields) {
257
- const domainFieldName = fieldName(field.name);
258
- if (seenDomainFields.has(domainFieldName)) continue;
259
- seenDomainFields.add(domainFieldName);
260
- if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
261
- const parts: string[] = [];
262
- if (field.description) parts.push(field.description);
263
- if (field.readOnly) parts.push('@readonly');
264
- if (field.writeOnly) parts.push('@writeonly');
265
- if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
266
- if (field.deprecated) parts.push('@deprecated');
267
- lines.push(...docComment(parts.join('\n'), 2));
268
- }
269
- const baselineField = baselineDomain?.fields?.[domainFieldName];
270
- // For the domain interface, also check that the response baseline's optionality
271
- // is compatible — the serializer reads from the response type and assigns to the domain type.
272
- // If the domain baseline says required but the response baseline says optional,
273
- // the serializer would produce T | undefined for a field expecting T.
274
- const domainWireField = wireFieldName(field.name);
275
- const responseBaselineField = baselineResponse?.fields?.[domainWireField];
276
- const domainResponseOptionalMismatch =
277
- baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
278
- const readonlyPrefix = field.readOnly ? 'readonly ' : '';
279
- if (
280
- baselineField &&
281
- !domainResponseOptionalMismatch &&
282
- baselineTypeResolvable(baselineField.type, importableNames) &&
283
- baselineFieldCompatible(baselineField, field)
284
- ) {
285
- const opt = baselineField.optional ? '?' : '';
286
- lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
287
- } else {
288
- // When a baseline exists for this model, new fields (not present in the
289
- // baseline) are generated as optional. The merger can deep-merge new
290
- // fields into existing interfaces, but it cannot update existing
291
- // deserializer function bodies. Making the field optional prevents a
292
- // type error where the interface requires a field that the preserved
293
- // deserializer never populates.
294
- const isNewFieldOnExistingModel = baselineDomain && !baselineField;
295
- // Also make the field optional when the response baseline has it as optional
296
- // but the domain baseline has it as required — the deserializer reads from
297
- // the response type, so if the response field is optional, the domain value
298
- // may be undefined.
299
- // Additionally, when a baseline exists for the RESPONSE interface but NOT the
300
- // domain interface, fields that are new on the response baseline become optional
301
- // in the wire type. The domain type must also be optional to match, otherwise
302
- // the deserializer produces T | undefined for a field typed as T.
303
- const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
304
- const opt =
305
- !field.required || isNewFieldOnExistingModel || domainResponseOptionalMismatch || isNewFieldOnExistingResponse
306
- ? '?'
307
- : '';
308
- lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
255
+ if (model.fields.length === 0) {
256
+ lines.push(`export type ${domainName}${typeParams} = object;`);
257
+ } else {
258
+ lines.push(`export interface ${domainName}${typeParams} {`);
259
+ for (const field of model.fields) {
260
+ const domainFieldName = fieldName(field.name);
261
+ if (seenDomainFields.has(domainFieldName)) continue;
262
+ seenDomainFields.add(domainFieldName);
263
+ if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
264
+ const parts: string[] = [];
265
+ if (field.description) parts.push(field.description);
266
+ if (field.readOnly) parts.push('@readonly');
267
+ if (field.writeOnly) parts.push('@writeonly');
268
+ if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
269
+ if (field.deprecated) parts.push('@deprecated');
270
+ lines.push(...docComment(parts.join('\n'), 2));
271
+ }
272
+ const baselineField = baselineDomain?.fields?.[domainFieldName];
273
+ // For the domain interface, also check that the response baseline's optionality
274
+ // is compatible — the serializer reads from the response type and assigns to the domain type.
275
+ // If the domain baseline says required but the response baseline says optional,
276
+ // the serializer would produce T | undefined for a field expecting T.
277
+ const domainWireField = wireFieldName(field.name);
278
+ const responseBaselineField = baselineResponse?.fields?.[domainWireField];
279
+ const domainResponseOptionalMismatch =
280
+ baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
281
+ const readonlyPrefix = field.readOnly ? 'readonly ' : '';
282
+ if (
283
+ baselineField &&
284
+ !domainResponseOptionalMismatch &&
285
+ baselineTypeResolvable(baselineField.type, importableNames) &&
286
+ baselineFieldCompatible(baselineField, field)
287
+ ) {
288
+ const opt = baselineField.optional ? '?' : '';
289
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
290
+ } else {
291
+ // When a baseline exists for this model, new fields (not present in the
292
+ // baseline) are generated as optional. The merger can deep-merge new
293
+ // fields into existing interfaces, but it cannot update existing
294
+ // deserializer function bodies. Making the field optional prevents a
295
+ // type error where the interface requires a field that the preserved
296
+ // deserializer never populates.
297
+ const isNewFieldOnExistingModel = baselineDomain && !baselineField;
298
+ // Also make the field optional when the response baseline has it as optional
299
+ // but the domain baseline has it as required the deserializer reads from
300
+ // the response type, so if the response field is optional, the domain value
301
+ // may be undefined.
302
+ // Additionally, when a baseline exists for the RESPONSE interface but NOT the
303
+ // domain interface, fields that are new on the response baseline become optional
304
+ // in the wire type. The domain type must also be optional to match, otherwise
305
+ // the deserializer produces T | undefined for a field typed as T.
306
+ const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
307
+ const opt =
308
+ !field.required ||
309
+ isNewFieldOnExistingModel ||
310
+ domainResponseOptionalMismatch ||
311
+ isNewFieldOnExistingResponse
312
+ ? '?'
313
+ : '';
314
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
315
+ }
309
316
  }
310
- }
311
- lines.push('}');
317
+ lines.push('}');
318
+ } // close else for non-empty domain interface
312
319
  lines.push('');
313
320
 
314
321
  // Wire/response interface (snake_case fields) — deduplicate by snake_case name
315
322
  const seenWireFields = new Set<string>();
316
- lines.push(`export interface ${responseName}${typeParams} {`);
317
- for (const field of model.fields) {
318
- const wireField = wireFieldName(field.name);
319
- if (seenWireFields.has(wireField)) continue;
320
- seenWireFields.add(wireField);
321
- const baselineField = baselineResponse?.fields?.[wireField];
322
- if (
323
- baselineField &&
324
- baselineTypeResolvable(baselineField.type, importableNames) &&
325
- baselineFieldCompatible(baselineField, field)
326
- ) {
327
- const opt = baselineField.optional ? '?' : '';
328
- lines.push(` ${wireField}${opt}: ${baselineField.type};`);
329
- } else {
330
- const isNewFieldOnExistingModel = baselineResponse && !baselineField;
331
- const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
332
- lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
323
+ if (model.fields.length === 0) {
324
+ lines.push(`export type ${responseName}${typeParams} = object;`);
325
+ } else {
326
+ lines.push(`export interface ${responseName}${typeParams} {`);
327
+ for (const field of model.fields) {
328
+ const wireField = wireFieldName(field.name);
329
+ if (seenWireFields.has(wireField)) continue;
330
+ seenWireFields.add(wireField);
331
+ const baselineField = baselineResponse?.fields?.[wireField];
332
+ if (
333
+ baselineField &&
334
+ baselineTypeResolvable(baselineField.type, importableNames) &&
335
+ baselineFieldCompatible(baselineField, field)
336
+ ) {
337
+ const opt = baselineField.optional ? '?' : '';
338
+ lines.push(` ${wireField}${opt}: ${baselineField.type};`);
339
+ } else {
340
+ const isNewFieldOnExistingModel = baselineResponse && !baselineField;
341
+ const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
342
+ lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
343
+ }
333
344
  }
334
- }
335
- lines.push('}');
345
+ lines.push('}');
346
+ } // close else for non-empty wire interface
336
347
 
337
348
  files.push({
338
349
  path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
@@ -66,9 +66,93 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
66
66
  return map;
67
67
  }
68
68
 
69
+ /**
70
+ * Explicit method name overrides for operations where the spec's operationId
71
+ * does not match the desired SDK method name and the spec cannot be changed.
72
+ * Key: "HTTP_METHOD /path", Value: camelCase method name.
73
+ */
74
+ const METHOD_NAME_OVERRIDES: Record<string, string> = {
75
+ 'POST /portal/generate_link': 'generatePortalLink',
76
+ };
77
+
78
+ /**
79
+ * Explicit service directory overrides. Maps a resolved PascalCase service name
80
+ * to a target directory (kebab-case). Use this when the spec's tag grouping
81
+ * does not match the desired SDK directory layout and the spec cannot be changed.
82
+ */
83
+ const SERVICE_DIR_OVERRIDES: Record<string, string> = {
84
+ ApplicationClientSecrets: 'workos-connect',
85
+ Applications: 'workos-connect',
86
+ Connections: 'sso',
87
+ Directories: 'directory-sync',
88
+ DirectoryGroups: 'directory-sync',
89
+ DirectoryUsers: 'directory-sync',
90
+ FeatureFlagsTargets: 'feature-flags',
91
+ MultiFactorAuth: 'mfa',
92
+ MultiFactorAuthChallenges: 'mfa',
93
+ OrganizationsApiKeys: 'organizations',
94
+ WebhooksEndpoints: 'webhooks',
95
+ UserManagementAuthentication: 'user-management',
96
+ UserManagementCorsOrigins: 'user-management',
97
+ UserManagementDataProviders: 'user-management',
98
+ UserManagementInvitations: 'user-management',
99
+ UserManagementJWTTemplate: 'user-management',
100
+ UserManagementMagicAuth: 'user-management',
101
+ UserManagementMultiFactorAuthentication: 'user-management',
102
+ UserManagementOrganizationMembership: 'user-management',
103
+ UserManagementRedirectUris: 'user-management',
104
+ UserManagementSessionTokens: 'user-management',
105
+ UserManagementUsers: 'user-management',
106
+ UserManagementUsersAuthorizedApplications: 'user-management',
107
+ WorkOSConnect: 'workos-connect',
108
+ };
109
+
110
+ /**
111
+ * Maps a service (by PascalCase name) to the existing hand-written class that
112
+ * already covers its endpoints. When a service appears here:
113
+ * - `resolveClassName` returns the target class (so generated code merges in)
114
+ * - `isServiceCoveredByExisting` returns true
115
+ * - `hasMethodsAbsentFromBaseline` checks the target class for missing methods,
116
+ * so new endpoints are added to the existing class rather than silently dropped
117
+ */
118
+ export const SERVICE_COVERED_BY: Record<string, string> = {
119
+ Connections: 'SSO',
120
+ Directories: 'DirectorySync',
121
+ DirectoryGroups: 'DirectorySync',
122
+ DirectoryUsers: 'DirectorySync',
123
+ FeatureFlagsTargets: 'FeatureFlags',
124
+ MultiFactorAuth: 'Mfa',
125
+ MultiFactorAuthChallenges: 'Mfa',
126
+ OrganizationsApiKeys: 'Organizations',
127
+ UserManagementAuthentication: 'UserManagement',
128
+ UserManagementInvitations: 'UserManagement',
129
+ UserManagementMagicAuth: 'UserManagement',
130
+ UserManagementMultiFactorAuthentication: 'UserManagement',
131
+ UserManagementOrganizationMembership: 'UserManagement',
132
+ UserManagementUsers: 'UserManagement',
133
+ };
134
+
135
+ /**
136
+ * Explicit class name overrides. Maps the default PascalCase service name
137
+ * to the desired SDK class name when toPascalCase produces the wrong casing.
138
+ */
139
+ const CLASS_NAME_OVERRIDES: Record<string, string> = {
140
+ WorkosConnect: 'WorkOSConnect',
141
+ };
142
+
143
+ /**
144
+ * Resolve the output directory for a service, checking overrides first.
145
+ * Falls back to the standard kebab-case conversion.
146
+ */
147
+ export function resolveServiceDir(resolvedServiceName: string): string {
148
+ return SERVICE_DIR_OVERRIDES[resolvedServiceName] ?? serviceDirName(resolvedServiceName);
149
+ }
150
+
69
151
  /** Resolve the SDK method name for an operation, checking overlay first. */
70
152
  export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
71
153
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
154
+ const override = METHOD_NAME_OVERRIDES[httpKey];
155
+ if (override) return override;
72
156
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
73
157
  if (existing) {
74
158
  // Fix: when the path ends with a path parameter (single-resource operation)
@@ -91,16 +175,21 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
91
175
 
92
176
  /** Resolve the SDK class name for a service, checking overlay for existing names. */
93
177
  export function resolveClassName(service: Service, ctx: EmitterContext): string {
178
+ // Explicit coverage: this service's endpoints belong to an existing class
179
+ const coveredBy = SERVICE_COVERED_BY[toPascalCase(service.name)];
180
+ if (coveredBy) return coveredBy;
181
+
94
182
  // Check overlay's methodByOperation for any operation in this service
95
183
  // to find the existing class name
96
184
  if (ctx.overlayLookup?.methodByOperation) {
97
185
  for (const op of service.operations) {
98
186
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
99
187
  const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
100
- if (existing) return existing.className;
188
+ if (existing) return CLASS_NAME_OVERRIDES[existing.className] ?? existing.className;
101
189
  }
102
190
  }
103
- return toPascalCase(service.name);
191
+ const defaultName = toPascalCase(service.name);
192
+ return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
104
193
  }
105
194
 
106
195
  /** Resolve the interface name for a model, checking overlay first. */