@workos/oagen-emitters 0.0.1 → 0.2.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/release-please.yml +9 -1
- package/.husky/commit-msg +0 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +54 -0
- package/README.md +2 -2
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3522 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/src/node/client.ts +437 -204
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +50 -6
- package/src/node/errors.ts +78 -3
- package/src/node/fixtures.ts +84 -15
- package/src/node/index.ts +2 -2
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +195 -79
- package/src/node/naming.ts +16 -1
- package/src/node/resources.ts +721 -106
- package/src/node/serializers.ts +510 -52
- package/src/node/tests.ts +621 -105
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +377 -114
- package/test/node/client.test.ts +979 -15
- package/test/node/enums.test.ts +0 -1
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +409 -2
- package/test/node/naming.test.ts +0 -3
- package/test/node/resources.test.ts +964 -7
- package/test/node/serializers.test.ts +212 -3
- package/tsconfig.json +2 -3
- package/{tsup.config.ts → tsdown.config.ts} +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -2158
package/src/node/client.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
1
|
+
import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
+
import { fileName, serviceDirName, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from './naming.js';
|
|
11
|
-
import { assignModelsToServices } from './utils.js';
|
|
4
|
+
docComment,
|
|
5
|
+
createServiceDirResolver,
|
|
6
|
+
isServiceCoveredByExisting,
|
|
7
|
+
isListMetadataModel,
|
|
8
|
+
isListWrapperModel,
|
|
9
|
+
} from './utils.js';
|
|
10
|
+
import { resolveResourceClassName } from './resources.js';
|
|
12
11
|
|
|
13
12
|
export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
14
13
|
const files: GeneratedFile[] = [];
|
|
15
14
|
|
|
16
15
|
files.push(generateWorkOSClient(spec, ctx));
|
|
16
|
+
files.push(...generateServiceBarrels(spec, ctx));
|
|
17
17
|
files.push(generateBarrel(spec, ctx));
|
|
18
|
+
files.push(generateWorkerBarrel(spec, ctx));
|
|
18
19
|
files.push(generatePackageJson(ctx));
|
|
19
20
|
files.push(generateTsConfig());
|
|
20
21
|
|
|
@@ -24,202 +25,261 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
|
|
|
24
25
|
function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
25
26
|
const lines: string[] = [];
|
|
26
27
|
|
|
27
|
-
//
|
|
28
|
+
// Only import WorkOSBase for fresh generation (no existing WorkOS class).
|
|
29
|
+
// When integrating into an existing SDK, the existing WorkOS already has its
|
|
30
|
+
// own base class and the WorkOSBase file may not exist.
|
|
31
|
+
const hasExistingWorkOS = !!ctx.apiSurface?.classes?.['WorkOS'];
|
|
32
|
+
if (!hasExistingWorkOS) {
|
|
33
|
+
lines.push("import { WorkOSBase } from './common/workos-base';");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Filter out services whose endpoints are already covered by existing
|
|
37
|
+
// hand-written service classes (e.g., Connections covered by SSO).
|
|
38
|
+
const coveredServices = new Set<string>();
|
|
28
39
|
for (const service of spec.services) {
|
|
29
|
-
|
|
40
|
+
if (isServiceCoveredByExisting(service, ctx)) {
|
|
41
|
+
coveredServices.add(service.name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Service imports — skip covered services
|
|
46
|
+
for (const service of spec.services) {
|
|
47
|
+
if (coveredServices.has(service.name)) continue;
|
|
48
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
30
49
|
const serviceDir = serviceDirName(resolvedName);
|
|
31
50
|
lines.push(`import { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
|
|
32
51
|
}
|
|
33
52
|
|
|
34
|
-
lines.push("import type { WorkOSOptions } from './common/interfaces/workos-options.interface';");
|
|
35
|
-
lines.push("import type { PostOptions } from './common/interfaces/post-options.interface';");
|
|
36
|
-
lines.push("import type { GetOptions } from './common/interfaces/get-options.interface';");
|
|
37
|
-
lines.push("import { NoApiKeyProvidedException } from './common/exceptions/no-api-key-provided.exception';");
|
|
38
|
-
lines.push("import { UnauthorizedException } from './common/exceptions/unauthorized.exception';");
|
|
39
|
-
lines.push("import { NotFoundException } from './common/exceptions/not-found.exception';");
|
|
40
|
-
lines.push("import { ConflictException } from './common/exceptions/conflict.exception';");
|
|
41
|
-
lines.push("import { UnprocessableEntityException } from './common/exceptions/unprocessable-entity.exception';");
|
|
42
|
-
lines.push("import { RateLimitExceededException } from './common/exceptions/rate-limit-exceeded.exception';");
|
|
43
|
-
lines.push("import { GenericServerException } from './common/exceptions/generic-server.exception';");
|
|
44
|
-
lines.push("import { BadRequestException } from './common/exceptions/bad-request.exception';");
|
|
45
|
-
|
|
46
|
-
lines.push('');
|
|
47
|
-
lines.push('export class WorkOS {');
|
|
48
|
-
lines.push(' readonly baseURL: string;');
|
|
49
|
-
lines.push(' readonly key: string;');
|
|
50
|
-
lines.push(' private readonly options: WorkOSOptions;');
|
|
51
53
|
lines.push('');
|
|
54
|
+
if (spec.description) {
|
|
55
|
+
lines.push(...docComment(spec.description));
|
|
56
|
+
}
|
|
57
|
+
const extendsClause = hasExistingWorkOS ? '' : ' extends WorkOSBase';
|
|
58
|
+
lines.push(`export class WorkOS${extendsClause} {`);
|
|
59
|
+
|
|
60
|
+
// Server URL constants from spec.servers
|
|
61
|
+
if (spec.servers && spec.servers.length > 0) {
|
|
62
|
+
for (const server of spec.servers) {
|
|
63
|
+
const constName = serverConstName(server.description ?? server.url);
|
|
64
|
+
if (server.description) {
|
|
65
|
+
lines.push(...docComment(server.description, 2));
|
|
66
|
+
}
|
|
67
|
+
lines.push(` static readonly ${constName} = '${server.url}';`);
|
|
68
|
+
}
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
52
71
|
|
|
53
|
-
// Resource accessors
|
|
72
|
+
// Resource accessors — skip services whose property already exists
|
|
73
|
+
// in the baseline WorkOS class (e.g., `portal` covers AdminPortal,
|
|
74
|
+
// `mfa` covers MultiFactorAuth).
|
|
75
|
+
const existingProps = new Set<string>();
|
|
76
|
+
const baselineWorkOS = ctx.apiSurface?.classes?.['WorkOS'] ?? ctx.apiSurface?.classes?.['WorkOSNode'];
|
|
77
|
+
if (baselineWorkOS?.properties) {
|
|
78
|
+
for (const name of Object.keys(baselineWorkOS.properties)) {
|
|
79
|
+
existingProps.add(name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Resource accessors — skip services whose endpoints are fully covered
|
|
83
|
+
// by existing hand-written services.
|
|
54
84
|
for (const service of spec.services) {
|
|
55
|
-
|
|
85
|
+
if (coveredServices.has(service.name)) continue;
|
|
86
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
56
87
|
const propName = servicePropertyName(resolvedName);
|
|
88
|
+
if (existingProps.has(propName)) continue;
|
|
57
89
|
lines.push(` readonly ${propName} = new ${resolvedName}(this);`);
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
lines.push(' this.options = maybeOptions ?? {};');
|
|
68
|
-
lines.push(' }');
|
|
69
|
-
lines.push('');
|
|
70
|
-
lines.push(' if (!this.key) {');
|
|
71
|
-
lines.push(" const envKey = typeof process !== 'undefined' ? process.env?.WORKOS_API_KEY : undefined;");
|
|
72
|
-
lines.push(' if (envKey) this.key = envKey;');
|
|
73
|
-
lines.push(' }');
|
|
74
|
-
lines.push('');
|
|
75
|
-
lines.push(" const protocol = this.options.https === false ? 'http' : 'https';");
|
|
76
|
-
lines.push(" const hostname = this.options.apiHostname ?? 'api.workos.com';");
|
|
77
|
-
lines.push(" const port = this.options.port ? `:${this.options.port}` : '';");
|
|
78
|
-
lines.push(' this.baseURL = `${protocol}://${hostname}${port}`;');
|
|
79
|
-
lines.push(' }');
|
|
92
|
+
// Auth override — only emit when auth is non-default (not bearer)
|
|
93
|
+
if (needsAuthOverride(spec.auth)) {
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push(' protected override setAuthHeaders(headers: Record<string, string>): void {');
|
|
96
|
+
renderAuthOverride(lines, spec.auth!);
|
|
97
|
+
lines.push(' }');
|
|
98
|
+
}
|
|
80
99
|
|
|
81
|
-
|
|
82
|
-
lines.push('');
|
|
83
|
-
lines.push(' async get<Result = any>(path: string, options: GetOptions = {}): Promise<{ data: Result }> {');
|
|
84
|
-
lines.push(' this.ensureApiKey(options);');
|
|
85
|
-
lines.push(' const url = this.buildUrl(path, options.query);');
|
|
86
|
-
lines.push(' const response = await fetch(url, {');
|
|
87
|
-
lines.push(" method: 'GET',");
|
|
88
|
-
lines.push(' headers: this.buildHeaders(options),');
|
|
89
|
-
lines.push(' });');
|
|
90
|
-
lines.push(' await this.handleHttpError(response, path);');
|
|
91
|
-
lines.push(' const data = await response.json() as Result;');
|
|
92
|
-
lines.push(' return { data };');
|
|
93
|
-
lines.push(' }');
|
|
100
|
+
lines.push('}');
|
|
94
101
|
|
|
95
|
-
lines.
|
|
96
|
-
|
|
97
|
-
' async post<Result = any, Entity = any>(path: string, entity: Entity, options: PostOptions = {}): Promise<{ data: Result }> {',
|
|
98
|
-
);
|
|
99
|
-
lines.push(' this.ensureApiKey(options);');
|
|
100
|
-
lines.push(' const url = this.buildUrl(path, options.query);');
|
|
101
|
-
lines.push(' const response = await fetch(url, {');
|
|
102
|
-
lines.push(" method: 'POST',");
|
|
103
|
-
lines.push(' headers: this.buildHeaders(options),');
|
|
104
|
-
lines.push(' body: JSON.stringify(entity),');
|
|
105
|
-
lines.push(' });');
|
|
106
|
-
lines.push(' await this.handleHttpError(response, path);');
|
|
107
|
-
lines.push(' const data = await response.json() as Result;');
|
|
108
|
-
lines.push(' return { data };');
|
|
109
|
-
lines.push(' }');
|
|
102
|
+
return { path: 'src/workos.ts', content: lines.join('\n'), skipIfExists: true };
|
|
103
|
+
}
|
|
110
104
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Generate per-service barrel files (interfaces/index.ts) that re-export
|
|
107
|
+
* all interface and enum files for each service directory. This reduces
|
|
108
|
+
* the root barrel from ~200+ individual type exports to one wildcard
|
|
109
|
+
* re-export per service.
|
|
110
|
+
*/
|
|
111
|
+
function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
112
|
+
const files: GeneratedFile[] = [];
|
|
113
|
+
const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
114
|
+
|
|
115
|
+
// Group interface files by directory, tracking exported symbol names
|
|
116
|
+
// to prevent TS2308 duplicate export errors when two files in the same
|
|
117
|
+
// directory export the same symbol (e.g., FooResponse as a wire type
|
|
118
|
+
// from one file and a domain type from another).
|
|
119
|
+
const dirExports = new Map<string, string[]>();
|
|
120
|
+
const dirSymbols = new Map<string, Set<string>>();
|
|
121
|
+
|
|
122
|
+
// Pre-seed dirSymbols with names already exported by existing interface files.
|
|
123
|
+
// When the existing SDK has an interface file in a directory that already
|
|
124
|
+
// exports a name (e.g., AuditLogSchema from create-audit-log-schema-options),
|
|
125
|
+
// the generated model with the same name must be skipped to prevent the
|
|
126
|
+
// merger from adding a duplicate `export *` that causes TS2308.
|
|
127
|
+
if (ctx.apiSurface?.interfaces) {
|
|
128
|
+
for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
|
|
129
|
+
const sourceFile = (iface as any).sourceFile as string | undefined;
|
|
130
|
+
if (!sourceFile) continue;
|
|
131
|
+
// Match paths like "src/audit-logs/interfaces/foo.interface.ts" to directory "audit-logs"
|
|
132
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
133
|
+
if (match) {
|
|
134
|
+
const dirName = match[1];
|
|
135
|
+
if (!dirSymbols.has(dirName)) {
|
|
136
|
+
dirSymbols.set(dirName, new Set());
|
|
137
|
+
}
|
|
138
|
+
dirSymbols.get(dirName)!.add(name);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (ctx.apiSurface?.enums) {
|
|
143
|
+
for (const [name, enumDef] of Object.entries(ctx.apiSurface.enums)) {
|
|
144
|
+
const sourceFile = (enumDef as any).sourceFile as string | undefined;
|
|
145
|
+
if (!sourceFile) continue;
|
|
146
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
147
|
+
if (match) {
|
|
148
|
+
const dirName = match[1];
|
|
149
|
+
if (!dirSymbols.has(dirName)) {
|
|
150
|
+
dirSymbols.set(dirName, new Set());
|
|
151
|
+
}
|
|
152
|
+
dirSymbols.get(dirName)!.add(name);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (ctx.apiSurface?.typeAliases) {
|
|
157
|
+
for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
|
|
158
|
+
const sourceFile = (alias as any).sourceFile as string | undefined;
|
|
159
|
+
if (!sourceFile) continue;
|
|
160
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
161
|
+
if (match) {
|
|
162
|
+
const dirName = match[1];
|
|
163
|
+
if (!dirSymbols.has(dirName)) {
|
|
164
|
+
dirSymbols.set(dirName, new Set());
|
|
165
|
+
}
|
|
166
|
+
dirSymbols.get(dirName)!.add(name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
126
170
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
lines.push(' await this.handleHttpError(response, path);');
|
|
139
|
-
lines.push(' const data = await response.json() as Result;');
|
|
140
|
-
lines.push(' return { data };');
|
|
141
|
-
lines.push(' }');
|
|
171
|
+
// Build a global set of all symbols across all directories for
|
|
172
|
+
// cross-directory deduplication. This prevents adding a model to one
|
|
173
|
+
// directory's barrel when the same symbol already exists in another
|
|
174
|
+
// directory's barrel (e.g., Event in common vs events, DirectoryState
|
|
175
|
+
// in directory-sync vs common).
|
|
176
|
+
const globalExistingSymbols = new Set<string>();
|
|
177
|
+
for (const symbols of dirSymbols.values()) {
|
|
178
|
+
for (const sym of symbols) {
|
|
179
|
+
globalExistingSymbols.add(sym);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
142
182
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
lines.push(' const url = new URL(path, this.baseURL);');
|
|
158
|
-
lines.push(' if (query) {');
|
|
159
|
-
lines.push(' for (const [key, value] of Object.entries(query)) {');
|
|
160
|
-
lines.push(" if (value !== null && value !== undefined && value !== '') {");
|
|
161
|
-
lines.push(' url.searchParams.set(key, String(value));');
|
|
162
|
-
lines.push(' }');
|
|
163
|
-
lines.push(' }');
|
|
164
|
-
lines.push(' }');
|
|
165
|
-
lines.push(' return url.toString();');
|
|
166
|
-
lines.push(' }');
|
|
183
|
+
// Models -> service directories
|
|
184
|
+
// Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
|
|
185
|
+
// from common utils, so no per-resource interface file is generated.
|
|
186
|
+
for (const model of spec.models) {
|
|
187
|
+
if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
|
|
188
|
+
const service = modelToService.get(model.name);
|
|
189
|
+
const dirName = resolveDir(service);
|
|
190
|
+
if (!dirExports.has(dirName)) {
|
|
191
|
+
dirExports.set(dirName, []);
|
|
192
|
+
// Only initialize dirSymbols if not already pre-seeded from baseline
|
|
193
|
+
if (!dirSymbols.has(dirName)) {
|
|
194
|
+
dirSymbols.set(dirName, new Set());
|
|
195
|
+
}
|
|
196
|
+
}
|
|
167
197
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
lines.push(' };');
|
|
174
|
-
lines.push(" if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey;");
|
|
175
|
-
lines.push(" if (options.warrantToken) headers['Warrant-Token'] = options.warrantToken;");
|
|
176
|
-
lines.push(' return headers;');
|
|
177
|
-
lines.push(' }');
|
|
198
|
+
// Each model file exports a domain interface and a wire interface.
|
|
199
|
+
// Track these symbols to detect cross-file collisions.
|
|
200
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
201
|
+
const wireName = wireInterfaceName(domainName);
|
|
202
|
+
const symbols = dirSymbols.get(dirName)!;
|
|
178
203
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
lines.push(' }');
|
|
204
|
+
if (globalExistingSymbols.has(domainName) || globalExistingSymbols.has(wireName)) {
|
|
205
|
+
// Skip this model's export to avoid duplicate symbol in the barrel
|
|
206
|
+
// (checks across ALL directories, not just the target one)
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
185
209
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
lines.push('');
|
|
195
|
-
lines.push(' switch (response.status) {');
|
|
196
|
-
lines.push(' case 400: throw new BadRequestException({ code, message, requestID });');
|
|
197
|
-
lines.push(' case 401: throw new UnauthorizedException(requestID);');
|
|
198
|
-
lines.push(' case 404: throw new NotFoundException({ code, message, path, requestID });');
|
|
199
|
-
lines.push(' case 409: throw new ConflictException({ message, requestID });');
|
|
200
|
-
lines.push(' case 422: throw new UnprocessableEntityException({ code, errors, message, requestID });');
|
|
201
|
-
lines.push(' case 429: {');
|
|
202
|
-
lines.push(" const retryAfter = Number(response.headers.get('retry-after')) || undefined;");
|
|
203
|
-
lines.push(" throw new RateLimitExceededException(message ?? 'Too many requests', requestID, retryAfter);");
|
|
204
|
-
lines.push(' }');
|
|
205
|
-
lines.push(" default: throw new GenericServerException(response.status, message ?? 'Server error', requestID);");
|
|
206
|
-
lines.push(' }');
|
|
207
|
-
lines.push(' }');
|
|
210
|
+
symbols.add(domainName);
|
|
211
|
+
symbols.add(wireName);
|
|
212
|
+
// Also track in the global set so subsequent models in other directories
|
|
213
|
+
// don't re-export the same symbol (intra-generation cross-directory dedup).
|
|
214
|
+
globalExistingSymbols.add(domainName);
|
|
215
|
+
globalExistingSymbols.add(wireName);
|
|
216
|
+
dirExports.get(dirName)!.push(`export * from './${fileName(model.name)}.interface';`);
|
|
217
|
+
}
|
|
208
218
|
|
|
209
|
-
|
|
219
|
+
// Enums -> service directories
|
|
220
|
+
for (const enumDef of spec.enums) {
|
|
221
|
+
const enumService = findEnumService(enumDef.name, spec.services);
|
|
222
|
+
const dirName = resolveDir(enumService);
|
|
223
|
+
if (!dirExports.has(dirName)) {
|
|
224
|
+
dirExports.set(dirName, []);
|
|
225
|
+
if (!dirSymbols.has(dirName)) {
|
|
226
|
+
dirSymbols.set(dirName, new Set());
|
|
227
|
+
}
|
|
228
|
+
}
|
|
210
229
|
|
|
211
|
-
|
|
212
|
-
|
|
230
|
+
const symbols = dirSymbols.get(dirName)!;
|
|
231
|
+
if (globalExistingSymbols.has(enumDef.name)) continue;
|
|
232
|
+
|
|
233
|
+
symbols.add(enumDef.name);
|
|
234
|
+
globalExistingSymbols.add(enumDef.name);
|
|
235
|
+
dirExports.get(dirName)!.push(`export * from './${fileName(enumDef.name)}.interface';`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const [dirName, exports] of dirExports) {
|
|
239
|
+
// Deduplicate (an enum and model could theoretically share a file name)
|
|
240
|
+
const uniqueExports = [...new Set(exports)];
|
|
241
|
+
uniqueExports.sort();
|
|
242
|
+
files.push({
|
|
243
|
+
path: `src/${dirName}/interfaces/index.ts`,
|
|
244
|
+
content: uniqueExports.join('\n'),
|
|
245
|
+
skipIfExists: true,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
213
248
|
|
|
214
|
-
|
|
215
|
-
|
|
249
|
+
return files;
|
|
250
|
+
}
|
|
216
251
|
|
|
217
252
|
function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
218
253
|
const lines: string[] = [];
|
|
219
|
-
const modelToService =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
254
|
+
const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
255
|
+
|
|
256
|
+
// Track all exported names to prevent duplicates.
|
|
257
|
+
// Pre-seed with names already exported by the existing SDK to avoid generating
|
|
258
|
+
// duplicate exports that would conflict with existing `export *` statements.
|
|
259
|
+
const exportedNames = new Set<string>();
|
|
260
|
+
if (ctx.apiSurface?.interfaces) {
|
|
261
|
+
for (const name of Object.keys(ctx.apiSurface.interfaces)) {
|
|
262
|
+
exportedNames.add(name);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (ctx.apiSurface?.classes) {
|
|
266
|
+
for (const name of Object.keys(ctx.apiSurface.classes)) {
|
|
267
|
+
exportedNames.add(name);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Collect names already exported by the existing SDK (via export * or named exports).
|
|
272
|
+
// When an explicit `export type { Foo }` would shadow a wildcard re-export that
|
|
273
|
+
// already provides a hand-written version of Foo (e.g., a discriminated union),
|
|
274
|
+
// we must skip the explicit export so the wildcard wins.
|
|
275
|
+
const existingSdkExports = new Set<string>();
|
|
276
|
+
if (ctx.apiSurface?.exports) {
|
|
277
|
+
for (const names of Object.values(ctx.apiSurface.exports)) {
|
|
278
|
+
for (const name of names) {
|
|
279
|
+
existingSdkExports.add(name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
223
283
|
|
|
224
284
|
// Common exports
|
|
225
285
|
lines.push("export * from './common/exceptions';");
|
|
@@ -230,51 +290,178 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
230
290
|
lines.push("export type { PostOptions } from './common/interfaces/post-options.interface';");
|
|
231
291
|
lines.push("export type { GetOptions } from './common/interfaces/get-options.interface';");
|
|
232
292
|
lines.push('');
|
|
293
|
+
for (const name of [
|
|
294
|
+
'AutoPaginatable',
|
|
295
|
+
'List',
|
|
296
|
+
'ListMetadata',
|
|
297
|
+
'ListResponse',
|
|
298
|
+
'PaginationOptions',
|
|
299
|
+
'WorkOSOptions',
|
|
300
|
+
'PostOptions',
|
|
301
|
+
'GetOptions',
|
|
302
|
+
]) {
|
|
303
|
+
exportedNames.add(name);
|
|
304
|
+
}
|
|
233
305
|
|
|
234
|
-
//
|
|
306
|
+
// Identify services whose endpoints are fully covered by existing hand-written
|
|
307
|
+
// classes — their resource class should not be re-exported from the barrel.
|
|
308
|
+
const coveredServicesBarrel = new Set<string>();
|
|
235
309
|
for (const service of spec.services) {
|
|
236
|
-
|
|
237
|
-
|
|
310
|
+
if (isServiceCoveredByExisting(service, ctx)) {
|
|
311
|
+
coveredServicesBarrel.add(service.name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
238
314
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
315
|
+
// Track directories that have already been wildcard-exported
|
|
316
|
+
const exportedDirs = new Set<string>();
|
|
317
|
+
|
|
318
|
+
// Per-service exports: service barrel + resource class
|
|
319
|
+
for (const service of spec.services) {
|
|
320
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
321
|
+
const serviceDir = serviceDirName(resolvedName);
|
|
322
|
+
// The interfaces directory may differ from the resource class directory when
|
|
323
|
+
// a service's class name is remapped (e.g., WebhooksEndpoints class lives in
|
|
324
|
+
// webhooks-endpoints/ but its model interfaces live in webhooks/).
|
|
325
|
+
const interfacesDir = resolveDir(service.name);
|
|
326
|
+
|
|
327
|
+
// Check if this service has any models or enums (i.e., a barrel was generated).
|
|
328
|
+
// Exclude list wrapper and list metadata models — these are skipped during
|
|
329
|
+
// interface generation (they use shared List<T>/ListMetadata), so they don't
|
|
330
|
+
// have corresponding .interface.ts files in the output.
|
|
331
|
+
const serviceModels = spec.models.filter((m) => {
|
|
332
|
+
if (modelToService.get(m.name) !== service.name) return false;
|
|
333
|
+
if (isListMetadataModel(m) || isListWrapperModel(m)) return false;
|
|
334
|
+
return true;
|
|
335
|
+
});
|
|
336
|
+
const serviceEnums = spec.enums.filter((e) => {
|
|
337
|
+
const enumService = findEnumService(e.name, spec.services);
|
|
338
|
+
return enumService === service.name;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Check whether any model or enum in this service conflicts with existingSdkExports.
|
|
342
|
+
// If so, fall back to individual exports to avoid shadowing hand-written types.
|
|
343
|
+
const hasConflict =
|
|
344
|
+
serviceModels.some((m) => existingSdkExports.has(resolveInterfaceName(m.name, ctx))) ||
|
|
345
|
+
serviceEnums.some((e) => existingSdkExports.has(e.name));
|
|
346
|
+
|
|
347
|
+
if ((serviceModels.length > 0 || serviceEnums.length > 0) && !exportedDirs.has(interfacesDir) && !hasConflict) {
|
|
348
|
+
exportedDirs.add(interfacesDir);
|
|
349
|
+
lines.push(`export * from './${interfacesDir}/interfaces';`);
|
|
350
|
+
// Track the individual names so they don't get re-exported below
|
|
351
|
+
for (const model of serviceModels) {
|
|
352
|
+
exportedNames.add(resolveInterfaceName(model.name, ctx));
|
|
353
|
+
exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
|
|
354
|
+
}
|
|
355
|
+
for (const enumDef of serviceEnums) {
|
|
356
|
+
exportedNames.add(enumDef.name);
|
|
357
|
+
}
|
|
358
|
+
} else if (!hasConflict) {
|
|
359
|
+
// Fallback: emit individual model exports (e.g., when no models/enums exist)
|
|
360
|
+
for (const model of serviceModels) {
|
|
361
|
+
const name = resolveInterfaceName(model.name, ctx);
|
|
362
|
+
const wireName = wireInterfaceName(name);
|
|
363
|
+
if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
|
|
364
|
+
if (existingSdkExports.has(name)) continue;
|
|
365
|
+
exportedNames.add(name);
|
|
366
|
+
exportedNames.add(wireName);
|
|
367
|
+
lines.push(
|
|
368
|
+
`export type { ${name}, ${wireName} } from './${interfacesDir}/interfaces/${fileName(model.name)}.interface';`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
248
371
|
}
|
|
249
372
|
|
|
250
|
-
// Resource class
|
|
251
|
-
|
|
373
|
+
// Resource class — skip if already exported or if service is fully covered
|
|
374
|
+
// by existing hand-written classes
|
|
375
|
+
if (coveredServicesBarrel.has(service.name)) {
|
|
376
|
+
// Emit a comment indicating this service is covered by an existing class
|
|
377
|
+
lines.push(`// ${resolvedName} is covered by an existing hand-written class — not re-exported.`);
|
|
378
|
+
} else if (!exportedNames.has(resolvedName)) {
|
|
379
|
+
exportedNames.add(resolvedName);
|
|
380
|
+
lines.push(`export { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
|
|
381
|
+
}
|
|
252
382
|
lines.push('');
|
|
253
383
|
}
|
|
254
384
|
|
|
255
|
-
// Unassigned models (common)
|
|
385
|
+
// Unassigned models (common) — use barrel if any exist
|
|
256
386
|
const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name));
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
387
|
+
const commonEnums = spec.enums.filter((e) => {
|
|
388
|
+
const enumService = findEnumService(e.name, spec.services);
|
|
389
|
+
return !enumService;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const commonHasConflict =
|
|
393
|
+
unassignedModels.some((m) => existingSdkExports.has(resolveInterfaceName(m.name, ctx))) ||
|
|
394
|
+
commonEnums.some((e) => existingSdkExports.has(e.name));
|
|
395
|
+
|
|
396
|
+
if ((unassignedModels.length > 0 || commonEnums.length > 0) && !exportedDirs.has('common') && !commonHasConflict) {
|
|
397
|
+
exportedDirs.add('common');
|
|
398
|
+
lines.push("export * from './common/interfaces';");
|
|
399
|
+
for (const model of unassignedModels) {
|
|
400
|
+
exportedNames.add(resolveInterfaceName(model.name, ctx));
|
|
401
|
+
exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
|
|
402
|
+
}
|
|
403
|
+
for (const enumDef of commonEnums) {
|
|
404
|
+
exportedNames.add(enumDef.name);
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
// Fallback: individual model exports
|
|
408
|
+
for (const model of unassignedModels) {
|
|
409
|
+
const name = resolveInterfaceName(model.name, ctx);
|
|
410
|
+
const wireName = wireInterfaceName(name);
|
|
411
|
+
if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
|
|
412
|
+
if (existingSdkExports.has(name)) continue;
|
|
413
|
+
exportedNames.add(name);
|
|
414
|
+
exportedNames.add(wireName);
|
|
415
|
+
lines.push(`export type { ${name}, ${wireName} } from './common/interfaces/${fileName(model.name)}.interface';`);
|
|
416
|
+
}
|
|
262
417
|
}
|
|
263
418
|
|
|
264
|
-
// Enum exports
|
|
419
|
+
// Enum exports — only for enums not already covered by a service/common barrel.
|
|
420
|
+
// Skip duplicates and names already covered by existing SDK wildcards.
|
|
421
|
+
// Use value export (`export { ... }`) for actual TS enums so consumers
|
|
422
|
+
// can use them as runtime values (e.g., ConnectionType.GoogleOAuth).
|
|
423
|
+
// Use type-only export (`export type { ... }`) for string literal unions.
|
|
265
424
|
for (const enumDef of spec.enums) {
|
|
266
|
-
|
|
425
|
+
if (exportedNames.has(enumDef.name)) continue;
|
|
426
|
+
if (existingSdkExports.has(enumDef.name)) continue;
|
|
427
|
+
exportedNames.add(enumDef.name);
|
|
267
428
|
const enumService = findEnumService(enumDef.name, spec.services);
|
|
268
429
|
const dir = resolveDir(enumService);
|
|
269
|
-
|
|
430
|
+
if (!exportedDirs.has(dir)) {
|
|
431
|
+
const baselineEnum = ctx.apiSurface?.enums?.[enumDef.name];
|
|
432
|
+
const exportKeyword = baselineEnum?.members ? 'export' : 'export type';
|
|
433
|
+
lines.push(
|
|
434
|
+
`${exportKeyword} { ${enumDef.name} } from './${dir}/interfaces/${fileName(enumDef.name)}.interface';`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
270
437
|
}
|
|
271
438
|
|
|
272
439
|
lines.push('');
|
|
273
|
-
|
|
440
|
+
// Only emit the WorkOS re-export for standalone generation (no existing SDK).
|
|
441
|
+
// When integrating into an existing SDK, the existing barrel already exports
|
|
442
|
+
// WorkOS (often as a subclass alias like `export { WorkOSNode as WorkOS }`),
|
|
443
|
+
// and adding a second export with the same name causes a duplicate identifier error.
|
|
444
|
+
if (!ctx.apiSurface && !exportedNames.has('WorkOS')) {
|
|
445
|
+
exportedNames.add('WorkOS');
|
|
446
|
+
lines.push("export { WorkOS } from './workos';");
|
|
447
|
+
}
|
|
274
448
|
|
|
275
449
|
return { path: 'src/index.ts', content: lines.join('\n'), skipIfExists: true };
|
|
276
450
|
}
|
|
277
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Generate a worker-compatible barrel file that re-exports everything from
|
|
454
|
+
* the main barrel. This keeps type exports in sync automatically.
|
|
455
|
+
*/
|
|
456
|
+
function generateWorkerBarrel(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile {
|
|
457
|
+
const lines: string[] = [];
|
|
458
|
+
|
|
459
|
+
// Re-export everything from the main index — keeps type exports in sync
|
|
460
|
+
lines.push("export * from './index';");
|
|
461
|
+
|
|
462
|
+
return { path: 'src/index.worker.ts', content: lines.join('\n'), skipIfExists: true };
|
|
463
|
+
}
|
|
464
|
+
|
|
278
465
|
function findEnumService(enumName: string, services: Service[]): string | undefined {
|
|
279
466
|
for (const service of services) {
|
|
280
467
|
for (const op of service.operations) {
|
|
@@ -297,6 +484,51 @@ function findEnumService(enumName: string, services: Service[]): string | undefi
|
|
|
297
484
|
return undefined;
|
|
298
485
|
}
|
|
299
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Determine whether the spec's auth scheme requires overriding the
|
|
489
|
+
* default bearer auth in WorkOSBase.setAuthHeaders().
|
|
490
|
+
*/
|
|
491
|
+
function needsAuthOverride(auth?: AuthScheme[]): boolean {
|
|
492
|
+
if (!auth || auth.length === 0) return false;
|
|
493
|
+
const scheme = auth[0];
|
|
494
|
+
// bearer and oauth2 match the base class default — no override needed
|
|
495
|
+
return scheme.kind === 'apiKey';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Render the body of a setAuthHeaders override for non-default auth schemes.
|
|
500
|
+
* Only called when needsAuthOverride() returns true.
|
|
501
|
+
*/
|
|
502
|
+
function renderAuthOverride(lines: string[], auth: AuthScheme[]): void {
|
|
503
|
+
const scheme = auth[0];
|
|
504
|
+
if (scheme.kind !== 'apiKey') return;
|
|
505
|
+
switch (scheme.in) {
|
|
506
|
+
case 'header':
|
|
507
|
+
lines.push(` headers['${scheme.name}'] = this.key;`);
|
|
508
|
+
break;
|
|
509
|
+
case 'query':
|
|
510
|
+
lines.push(` // Auth key sent as query parameter '${scheme.name}' (see buildUrl)`);
|
|
511
|
+
break;
|
|
512
|
+
case 'cookie':
|
|
513
|
+
lines.push(` headers['Cookie'] = \`${scheme.name}=\${this.key}\`;`);
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Convert a server description or URL into a SCREAMING_SNAKE_CASE constant name.
|
|
520
|
+
*/
|
|
521
|
+
function serverConstName(description: string): string {
|
|
522
|
+
return (
|
|
523
|
+
'SERVER_' +
|
|
524
|
+
description
|
|
525
|
+
.replace(/https?:\/\//g, '')
|
|
526
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
527
|
+
.replace(/^_|_$/g, '')
|
|
528
|
+
.toUpperCase()
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
300
532
|
function generatePackageJson(ctx: EmitterContext): GeneratedFile {
|
|
301
533
|
const pkg = {
|
|
302
534
|
name: `@${ctx.namespace}/sdk`,
|
|
@@ -336,6 +568,7 @@ function generateTsConfig(): GeneratedFile {
|
|
|
336
568
|
lib: ['ES2020'],
|
|
337
569
|
declaration: true,
|
|
338
570
|
strict: true,
|
|
571
|
+
exactOptionalPropertyTypes: true,
|
|
339
572
|
esModuleInterop: true,
|
|
340
573
|
skipLibCheck: true,
|
|
341
574
|
forceConsistentCasingInFileNames: true,
|