@workos/oagen-emitters 0.0.1 → 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/.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/.oxfmtrc.json +8 -1
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +61 -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 +4070 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +21 -4
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/node/client.ts +521 -206
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +53 -9
- package/src/node/errors.ts +82 -3
- package/src/node/fixtures.ts +87 -16
- package/src/node/index.ts +66 -10
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +251 -124
- package/src/node/naming.ts +107 -3
- package/src/node/resources.ts +1162 -108
- package/src/node/serializers.ts +512 -52
- package/src/node/tests.ts +650 -110
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +426 -113
- package/test/node/client.test.ts +1083 -20
- package/test/node/enums.test.ts +73 -4
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +499 -5
- package/test/node/naming.test.ts +14 -7
- package/test/node/resources.test.ts +1568 -9
- package/test/node/serializers.test.ts +241 -5
- 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, resolveServiceDir, 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,265 @@ 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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
if (isServiceCoveredByExisting(service, ctx)) {
|
|
41
|
+
coveredServices.add(service.name);
|
|
42
|
+
}
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
// 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);
|
|
49
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
50
|
+
lines.push(`import { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
|
|
51
|
+
}
|
|
45
52
|
|
|
46
53
|
lines.push('');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 {
|
|
103
|
+
path: 'src/workos.ts',
|
|
104
|
+
content: lines.join('\n'),
|
|
105
|
+
skipIfExists: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
110
108
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Generate per-service barrel files (interfaces/index.ts) that re-export
|
|
111
|
+
* all interface and enum files for each service directory. This reduces
|
|
112
|
+
* the root barrel from ~200+ individual type exports to one wildcard
|
|
113
|
+
* re-export per service.
|
|
114
|
+
*/
|
|
115
|
+
function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
116
|
+
const files: GeneratedFile[] = [];
|
|
117
|
+
const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
118
|
+
|
|
119
|
+
// Group interface files by directory, tracking exported symbol names
|
|
120
|
+
// to prevent TS2308 duplicate export errors when two files in the same
|
|
121
|
+
// directory export the same symbol (e.g., FooResponse as a wire type
|
|
122
|
+
// from one file and a domain type from another).
|
|
123
|
+
const dirExports = new Map<string, string[]>();
|
|
124
|
+
const dirSymbols = new Map<string, Set<string>>();
|
|
125
|
+
|
|
126
|
+
// Pre-seed dirSymbols with names already exported by existing interface files.
|
|
127
|
+
// When the existing SDK has an interface file in a directory that already
|
|
128
|
+
// exports a name (e.g., AuditLogSchema from create-audit-log-schema-options),
|
|
129
|
+
// the generated model with the same name must be skipped to prevent the
|
|
130
|
+
// merger from adding a duplicate `export *` that causes TS2308.
|
|
131
|
+
if (ctx.apiSurface?.interfaces) {
|
|
132
|
+
for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
|
|
133
|
+
const sourceFile = (iface as any).sourceFile as string | undefined;
|
|
134
|
+
if (!sourceFile) continue;
|
|
135
|
+
// Match paths like "src/audit-logs/interfaces/foo.interface.ts" to directory "audit-logs"
|
|
136
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
137
|
+
if (match) {
|
|
138
|
+
const dirName = match[1];
|
|
139
|
+
if (!dirSymbols.has(dirName)) {
|
|
140
|
+
dirSymbols.set(dirName, new Set());
|
|
141
|
+
}
|
|
142
|
+
dirSymbols.get(dirName)!.add(name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (ctx.apiSurface?.enums) {
|
|
147
|
+
for (const [name, enumDef] of Object.entries(ctx.apiSurface.enums)) {
|
|
148
|
+
const sourceFile = (enumDef as any).sourceFile as string | undefined;
|
|
149
|
+
if (!sourceFile) continue;
|
|
150
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
151
|
+
if (match) {
|
|
152
|
+
const dirName = match[1];
|
|
153
|
+
if (!dirSymbols.has(dirName)) {
|
|
154
|
+
dirSymbols.set(dirName, new Set());
|
|
155
|
+
}
|
|
156
|
+
dirSymbols.get(dirName)!.add(name);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (ctx.apiSurface?.typeAliases) {
|
|
161
|
+
for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
|
|
162
|
+
const sourceFile = (alias as any).sourceFile as string | undefined;
|
|
163
|
+
if (!sourceFile) continue;
|
|
164
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
165
|
+
if (match) {
|
|
166
|
+
const dirName = match[1];
|
|
167
|
+
if (!dirSymbols.has(dirName)) {
|
|
168
|
+
dirSymbols.set(dirName, new Set());
|
|
169
|
+
}
|
|
170
|
+
dirSymbols.get(dirName)!.add(name);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
126
174
|
|
|
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(' }');
|
|
175
|
+
// Build a global set of all symbols across all directories for
|
|
176
|
+
// cross-directory deduplication. This prevents adding a model to one
|
|
177
|
+
// directory's barrel when the same symbol already exists in another
|
|
178
|
+
// directory's barrel (e.g., Event in common vs events, DirectoryState
|
|
179
|
+
// in directory-sync vs common).
|
|
180
|
+
const globalExistingSymbols = new Set<string>();
|
|
181
|
+
for (const symbols of dirSymbols.values()) {
|
|
182
|
+
for (const sym of symbols) {
|
|
183
|
+
globalExistingSymbols.add(sym);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
142
186
|
|
|
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(' }');
|
|
187
|
+
// Models -> service directories
|
|
188
|
+
// Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
|
|
189
|
+
// from common utils, so no per-resource interface file is generated.
|
|
190
|
+
for (const model of spec.models) {
|
|
191
|
+
if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
|
|
192
|
+
const service = modelToService.get(model.name);
|
|
193
|
+
const dirName = resolveDir(service);
|
|
194
|
+
if (!dirExports.has(dirName)) {
|
|
195
|
+
dirExports.set(dirName, []);
|
|
196
|
+
// Only initialize dirSymbols if not already pre-seeded from baseline
|
|
197
|
+
if (!dirSymbols.has(dirName)) {
|
|
198
|
+
dirSymbols.set(dirName, new Set());
|
|
199
|
+
}
|
|
200
|
+
}
|
|
167
201
|
|
|
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(' }');
|
|
202
|
+
// Each model file exports a domain interface and a wire interface.
|
|
203
|
+
// Track these symbols to detect cross-file collisions.
|
|
204
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
205
|
+
const wireName = wireInterfaceName(domainName);
|
|
206
|
+
const symbols = dirSymbols.get(dirName)!;
|
|
178
207
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
lines.push(' }');
|
|
208
|
+
if (globalExistingSymbols.has(domainName) || globalExistingSymbols.has(wireName)) {
|
|
209
|
+
// Skip this model's export to avoid duplicate symbol in the barrel
|
|
210
|
+
// (checks across ALL directories, not just the target one)
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
185
213
|
|
|
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(' }');
|
|
214
|
+
symbols.add(domainName);
|
|
215
|
+
symbols.add(wireName);
|
|
216
|
+
// Also track in the global set so subsequent models in other directories
|
|
217
|
+
// don't re-export the same symbol (intra-generation cross-directory dedup).
|
|
218
|
+
globalExistingSymbols.add(domainName);
|
|
219
|
+
globalExistingSymbols.add(wireName);
|
|
220
|
+
dirExports.get(dirName)!.push(`export * from './${fileName(model.name)}.interface';`);
|
|
221
|
+
}
|
|
208
222
|
|
|
209
|
-
|
|
223
|
+
// Enums -> service directories
|
|
224
|
+
for (const enumDef of spec.enums) {
|
|
225
|
+
const enumService = findEnumService(enumDef.name, spec.services);
|
|
226
|
+
const dirName = resolveDir(enumService);
|
|
227
|
+
if (!dirExports.has(dirName)) {
|
|
228
|
+
dirExports.set(dirName, []);
|
|
229
|
+
if (!dirSymbols.has(dirName)) {
|
|
230
|
+
dirSymbols.set(dirName, new Set());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
210
233
|
|
|
211
|
-
|
|
212
|
-
|
|
234
|
+
const symbols = dirSymbols.get(dirName)!;
|
|
235
|
+
if (globalExistingSymbols.has(enumDef.name)) continue;
|
|
213
236
|
|
|
214
|
-
|
|
215
|
-
|
|
237
|
+
symbols.add(enumDef.name);
|
|
238
|
+
globalExistingSymbols.add(enumDef.name);
|
|
239
|
+
dirExports.get(dirName)!.push(`export * from './${fileName(enumDef.name)}.interface';`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const [dirName, exports] of dirExports) {
|
|
243
|
+
// Deduplicate (an enum and model could theoretically share a file name)
|
|
244
|
+
const uniqueExports = [...new Set(exports)];
|
|
245
|
+
uniqueExports.sort();
|
|
246
|
+
files.push({
|
|
247
|
+
path: `src/${dirName}/interfaces/index.ts`,
|
|
248
|
+
content: uniqueExports.join('\n'),
|
|
249
|
+
skipIfExists: true,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return files;
|
|
254
|
+
}
|
|
216
255
|
|
|
217
256
|
function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
218
257
|
const lines: string[] = [];
|
|
219
|
-
const modelToService =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
258
|
+
const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
259
|
+
|
|
260
|
+
// Track all exported names to prevent duplicates.
|
|
261
|
+
// Pre-seed with names already exported by the existing SDK to avoid generating
|
|
262
|
+
// duplicate exports that would conflict with existing `export *` statements.
|
|
263
|
+
const exportedNames = new Set<string>();
|
|
264
|
+
if (ctx.apiSurface?.interfaces) {
|
|
265
|
+
for (const name of Object.keys(ctx.apiSurface.interfaces)) {
|
|
266
|
+
exportedNames.add(name);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (ctx.apiSurface?.classes) {
|
|
270
|
+
for (const name of Object.keys(ctx.apiSurface.classes)) {
|
|
271
|
+
exportedNames.add(name);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Collect names already exported by the existing SDK (via export * or named exports).
|
|
276
|
+
// When an explicit `export type { Foo }` would shadow a wildcard re-export that
|
|
277
|
+
// already provides a hand-written version of Foo (e.g., a discriminated union),
|
|
278
|
+
// we must skip the explicit export so the wildcard wins.
|
|
279
|
+
const existingSdkExports = new Set<string>();
|
|
280
|
+
if (ctx.apiSurface?.exports) {
|
|
281
|
+
for (const names of Object.values(ctx.apiSurface.exports)) {
|
|
282
|
+
for (const name of names) {
|
|
283
|
+
existingSdkExports.add(name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
223
287
|
|
|
224
288
|
// Common exports
|
|
225
289
|
lines.push("export * from './common/exceptions';");
|
|
@@ -230,49 +294,254 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
|
230
294
|
lines.push("export type { PostOptions } from './common/interfaces/post-options.interface';");
|
|
231
295
|
lines.push("export type { GetOptions } from './common/interfaces/get-options.interface';");
|
|
232
296
|
lines.push('');
|
|
297
|
+
for (const name of [
|
|
298
|
+
'AutoPaginatable',
|
|
299
|
+
'List',
|
|
300
|
+
'ListMetadata',
|
|
301
|
+
'ListResponse',
|
|
302
|
+
'PaginationOptions',
|
|
303
|
+
'WorkOSOptions',
|
|
304
|
+
'PostOptions',
|
|
305
|
+
'GetOptions',
|
|
306
|
+
]) {
|
|
307
|
+
exportedNames.add(name);
|
|
308
|
+
}
|
|
233
309
|
|
|
234
|
-
//
|
|
310
|
+
// Identify services whose endpoints are fully covered by existing hand-written
|
|
311
|
+
// classes — their resource class should not be re-exported from the barrel.
|
|
312
|
+
const coveredServicesBarrel = new Set<string>();
|
|
235
313
|
for (const service of spec.services) {
|
|
236
|
-
|
|
237
|
-
|
|
314
|
+
if (isServiceCoveredByExisting(service, ctx)) {
|
|
315
|
+
coveredServicesBarrel.add(service.name);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
238
318
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
319
|
+
// Track directories that have already been wildcard-exported
|
|
320
|
+
const exportedDirs = new Set<string>();
|
|
321
|
+
|
|
322
|
+
// Pre-compute all names per interfaces directory (generated + baseline) to detect
|
|
323
|
+
// cross-directory conflicts before emitting any star exports. A star export is
|
|
324
|
+
// unsafe when two different directories export the same name (e.g., Factor in
|
|
325
|
+
// both mfa/interfaces and user-management/interfaces).
|
|
326
|
+
const dirAllNames = new Map<string, Set<string>>();
|
|
327
|
+
for (const service of spec.services) {
|
|
328
|
+
const iDir = resolveDir(service.name);
|
|
329
|
+
if (!dirAllNames.has(iDir)) dirAllNames.set(iDir, new Set());
|
|
330
|
+
const names = dirAllNames.get(iDir)!;
|
|
331
|
+
for (const model of spec.models) {
|
|
332
|
+
if (modelToService.get(model.name) !== service.name) continue;
|
|
333
|
+
if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
|
|
334
|
+
names.add(resolveInterfaceName(model.name, ctx));
|
|
335
|
+
names.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Add baseline names per directory
|
|
339
|
+
if (ctx.apiSurface?.interfaces) {
|
|
340
|
+
for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
|
|
341
|
+
const sourceFile = (iface as any).sourceFile as string | undefined;
|
|
342
|
+
if (!sourceFile) continue;
|
|
343
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
344
|
+
if (match) {
|
|
345
|
+
const dirName = match[1];
|
|
346
|
+
if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, new Set());
|
|
347
|
+
dirAllNames.get(dirName)!.add(name);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (ctx.apiSurface?.typeAliases) {
|
|
352
|
+
for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
|
|
353
|
+
const sourceFile = (alias as any).sourceFile as string | undefined;
|
|
354
|
+
if (!sourceFile) continue;
|
|
355
|
+
const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
|
|
356
|
+
if (match) {
|
|
357
|
+
const dirName = match[1];
|
|
358
|
+
if (!dirAllNames.has(dirName)) dirAllNames.set(dirName, new Set());
|
|
359
|
+
dirAllNames.get(dirName)!.add(name);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Detect directories with cross-directory name conflicts
|
|
364
|
+
const unsafeStarDirs = new Set<string>();
|
|
365
|
+
const allDirEntries = [...dirAllNames.entries()];
|
|
366
|
+
for (let i = 0; i < allDirEntries.length; i++) {
|
|
367
|
+
for (let j = i + 1; j < allDirEntries.length; j++) {
|
|
368
|
+
const [dirA, namesA] = allDirEntries[i];
|
|
369
|
+
const [dirB, namesB] = allDirEntries[j];
|
|
370
|
+
for (const name of namesA) {
|
|
371
|
+
if (namesB.has(name)) {
|
|
372
|
+
unsafeStarDirs.add(dirA);
|
|
373
|
+
unsafeStarDirs.add(dirB);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Per-service exports: service barrel + resource class
|
|
381
|
+
for (const service of spec.services) {
|
|
382
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
383
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
384
|
+
// The interfaces directory may differ from the resource class directory when
|
|
385
|
+
// a service's class name is remapped (e.g., WebhooksEndpoints class lives in
|
|
386
|
+
// webhooks-endpoints/ but its model interfaces live in webhooks/).
|
|
387
|
+
const interfacesDir = resolveDir(service.name);
|
|
388
|
+
|
|
389
|
+
// Check if this service has any models or enums (i.e., a barrel was generated).
|
|
390
|
+
// Exclude list wrapper and list metadata models — these are skipped during
|
|
391
|
+
// interface generation (they use shared List<T>/ListMetadata), so they don't
|
|
392
|
+
// have corresponding .interface.ts files in the output.
|
|
393
|
+
const serviceModels = spec.models.filter((m) => {
|
|
394
|
+
if (modelToService.get(m.name) !== service.name) return false;
|
|
395
|
+
if (isListMetadataModel(m) || isListWrapperModel(m)) return false;
|
|
396
|
+
return true;
|
|
397
|
+
});
|
|
398
|
+
const serviceEnums = spec.enums.filter((e) => {
|
|
399
|
+
const enumService = findEnumService(e.name, spec.services);
|
|
400
|
+
return enumService === service.name;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Check whether any model or enum in this service conflicts with names already
|
|
404
|
+
// exported (from earlier star exports or the existing SDK baseline). If so, fall
|
|
405
|
+
// back to individual named exports to avoid duplicate-export TS2308 errors.
|
|
406
|
+
const hasConflict =
|
|
407
|
+
serviceModels.some((m) => {
|
|
408
|
+
const name = resolveInterfaceName(m.name, ctx);
|
|
409
|
+
return existingSdkExports.has(name) || exportedNames.has(name) || exportedNames.has(wireInterfaceName(name));
|
|
410
|
+
}) || serviceEnums.some((e) => existingSdkExports.has(e.name) || exportedNames.has(e.name));
|
|
411
|
+
|
|
412
|
+
// Skip star export for covered services — their directory may have hand-written types
|
|
413
|
+
// (e.g., Factor in mfa/) that conflict with types in the covering service's directory.
|
|
414
|
+
const isCovered = coveredServicesBarrel.has(service.name);
|
|
415
|
+
if (
|
|
416
|
+
(serviceModels.length > 0 || serviceEnums.length > 0) &&
|
|
417
|
+
!exportedDirs.has(interfacesDir) &&
|
|
418
|
+
!hasConflict &&
|
|
419
|
+
!unsafeStarDirs.has(interfacesDir) &&
|
|
420
|
+
!isCovered
|
|
421
|
+
) {
|
|
422
|
+
exportedDirs.add(interfacesDir);
|
|
423
|
+
lines.push(`export * from './${interfacesDir}/interfaces';`);
|
|
424
|
+
// Track the individual names so they don't get re-exported below
|
|
425
|
+
for (const model of serviceModels) {
|
|
426
|
+
exportedNames.add(resolveInterfaceName(model.name, ctx));
|
|
427
|
+
exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
|
|
428
|
+
}
|
|
429
|
+
for (const enumDef of serviceEnums) {
|
|
430
|
+
exportedNames.add(enumDef.name);
|
|
431
|
+
}
|
|
432
|
+
} else if (!hasConflict) {
|
|
433
|
+
// Fallback: emit individual model exports (e.g., when no models/enums exist)
|
|
434
|
+
for (const model of serviceModels) {
|
|
435
|
+
const name = resolveInterfaceName(model.name, ctx);
|
|
436
|
+
const wireName = wireInterfaceName(name);
|
|
437
|
+
if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
|
|
438
|
+
if (existingSdkExports.has(name)) continue;
|
|
439
|
+
exportedNames.add(name);
|
|
440
|
+
exportedNames.add(wireName);
|
|
441
|
+
lines.push(
|
|
442
|
+
`export type { ${name}, ${wireName} } from './${interfacesDir}/interfaces/${fileName(model.name)}.interface';`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
248
445
|
}
|
|
249
446
|
|
|
250
|
-
// Resource class
|
|
251
|
-
|
|
447
|
+
// Resource class — skip if already exported or if service is fully covered
|
|
448
|
+
// by existing hand-written classes
|
|
449
|
+
if (coveredServicesBarrel.has(service.name)) {
|
|
450
|
+
// Emit a comment indicating this service is covered by an existing class
|
|
451
|
+
lines.push(`// ${resolvedName} is covered by an existing hand-written class — not re-exported.`);
|
|
452
|
+
} else if (!exportedNames.has(resolvedName)) {
|
|
453
|
+
exportedNames.add(resolvedName);
|
|
454
|
+
lines.push(`export { ${resolvedName} } from './${serviceDir}/${fileName(resolvedName)}';`);
|
|
455
|
+
}
|
|
252
456
|
lines.push('');
|
|
253
457
|
}
|
|
254
458
|
|
|
255
|
-
// Unassigned models (common)
|
|
459
|
+
// Unassigned models (common) — use barrel if any exist
|
|
256
460
|
const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name));
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
461
|
+
const commonEnums = spec.enums.filter((e) => {
|
|
462
|
+
const enumService = findEnumService(e.name, spec.services);
|
|
463
|
+
return !enumService;
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const commonHasConflict =
|
|
467
|
+
unassignedModels.some((m) => existingSdkExports.has(resolveInterfaceName(m.name, ctx))) ||
|
|
468
|
+
commonEnums.some((e) => existingSdkExports.has(e.name));
|
|
469
|
+
|
|
470
|
+
if ((unassignedModels.length > 0 || commonEnums.length > 0) && !exportedDirs.has('common') && !commonHasConflict) {
|
|
471
|
+
exportedDirs.add('common');
|
|
472
|
+
lines.push("export * from './common/interfaces';");
|
|
473
|
+
for (const model of unassignedModels) {
|
|
474
|
+
exportedNames.add(resolveInterfaceName(model.name, ctx));
|
|
475
|
+
exportedNames.add(wireInterfaceName(resolveInterfaceName(model.name, ctx)));
|
|
476
|
+
}
|
|
477
|
+
for (const enumDef of commonEnums) {
|
|
478
|
+
exportedNames.add(enumDef.name);
|
|
479
|
+
}
|
|
480
|
+
} else {
|
|
481
|
+
// Fallback: individual model exports
|
|
482
|
+
for (const model of unassignedModels) {
|
|
483
|
+
const name = resolveInterfaceName(model.name, ctx);
|
|
484
|
+
const wireName = wireInterfaceName(name);
|
|
485
|
+
if (exportedNames.has(name) || exportedNames.has(wireName)) continue;
|
|
486
|
+
if (existingSdkExports.has(name)) continue;
|
|
487
|
+
exportedNames.add(name);
|
|
488
|
+
exportedNames.add(wireName);
|
|
489
|
+
lines.push(`export type { ${name}, ${wireName} } from './common/interfaces/${fileName(model.name)}.interface';`);
|
|
490
|
+
}
|
|
262
491
|
}
|
|
263
492
|
|
|
264
|
-
// Enum exports
|
|
493
|
+
// Enum exports — only for enums not already covered by a service/common barrel.
|
|
494
|
+
// Skip duplicates and names already covered by existing SDK wildcards.
|
|
495
|
+
// Use value export (`export { ... }`) for actual TS enums so consumers
|
|
496
|
+
// can use them as runtime values (e.g., ConnectionType.GoogleOAuth).
|
|
497
|
+
// Use type-only export (`export type { ... }`) for string literal unions.
|
|
265
498
|
for (const enumDef of spec.enums) {
|
|
266
|
-
|
|
499
|
+
if (exportedNames.has(enumDef.name)) continue;
|
|
500
|
+
if (existingSdkExports.has(enumDef.name)) continue;
|
|
501
|
+
exportedNames.add(enumDef.name);
|
|
267
502
|
const enumService = findEnumService(enumDef.name, spec.services);
|
|
268
503
|
const dir = resolveDir(enumService);
|
|
269
|
-
|
|
504
|
+
if (!exportedDirs.has(dir)) {
|
|
505
|
+
const baselineEnum = ctx.apiSurface?.enums?.[enumDef.name];
|
|
506
|
+
const exportKeyword = baselineEnum?.members ? 'export' : 'export type';
|
|
507
|
+
lines.push(
|
|
508
|
+
`${exportKeyword} { ${enumDef.name} } from './${dir}/interfaces/${fileName(enumDef.name)}.interface';`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
270
511
|
}
|
|
271
512
|
|
|
272
513
|
lines.push('');
|
|
273
|
-
|
|
514
|
+
// Only emit the WorkOS re-export for standalone generation (no existing SDK).
|
|
515
|
+
// When integrating into an existing SDK, the existing barrel already exports
|
|
516
|
+
// WorkOS (often as a subclass alias like `export { WorkOSNode as WorkOS }`),
|
|
517
|
+
// and adding a second export with the same name causes a duplicate identifier error.
|
|
518
|
+
if (!ctx.apiSurface && !exportedNames.has('WorkOS')) {
|
|
519
|
+
exportedNames.add('WorkOS');
|
|
520
|
+
lines.push("export { WorkOS } from './workos';");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
path: 'src/index.ts',
|
|
525
|
+
content: lines.join('\n'),
|
|
526
|
+
skipIfExists: true,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Generate a worker-compatible barrel file that re-exports everything from
|
|
532
|
+
* the main barrel. This keeps type exports in sync automatically.
|
|
533
|
+
*/
|
|
534
|
+
function generateWorkerBarrel(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile {
|
|
535
|
+
const lines: string[] = [];
|
|
274
536
|
|
|
275
|
-
|
|
537
|
+
// Re-export everything from the main index — keeps type exports in sync
|
|
538
|
+
lines.push("export * from './index';");
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
path: 'src/index.worker.ts',
|
|
542
|
+
content: lines.join('\n'),
|
|
543
|
+
skipIfExists: true,
|
|
544
|
+
};
|
|
276
545
|
}
|
|
277
546
|
|
|
278
547
|
function findEnumService(enumName: string, services: Service[]): string | undefined {
|
|
@@ -297,6 +566,51 @@ function findEnumService(enumName: string, services: Service[]): string | undefi
|
|
|
297
566
|
return undefined;
|
|
298
567
|
}
|
|
299
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Determine whether the spec's auth scheme requires overriding the
|
|
571
|
+
* default bearer auth in WorkOSBase.setAuthHeaders().
|
|
572
|
+
*/
|
|
573
|
+
function needsAuthOverride(auth?: AuthScheme[]): boolean {
|
|
574
|
+
if (!auth || auth.length === 0) return false;
|
|
575
|
+
const scheme = auth[0];
|
|
576
|
+
// bearer and oauth2 match the base class default — no override needed
|
|
577
|
+
return scheme.kind === 'apiKey';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Render the body of a setAuthHeaders override for non-default auth schemes.
|
|
582
|
+
* Only called when needsAuthOverride() returns true.
|
|
583
|
+
*/
|
|
584
|
+
function renderAuthOverride(lines: string[], auth: AuthScheme[]): void {
|
|
585
|
+
const scheme = auth[0];
|
|
586
|
+
if (scheme.kind !== 'apiKey') return;
|
|
587
|
+
switch (scheme.in) {
|
|
588
|
+
case 'header':
|
|
589
|
+
lines.push(` headers['${scheme.name}'] = this.key;`);
|
|
590
|
+
break;
|
|
591
|
+
case 'query':
|
|
592
|
+
lines.push(` // Auth key sent as query parameter '${scheme.name}' (see buildUrl)`);
|
|
593
|
+
break;
|
|
594
|
+
case 'cookie':
|
|
595
|
+
lines.push(` headers['Cookie'] = \`${scheme.name}=\${this.key}\`;`);
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Convert a server description or URL into a SCREAMING_SNAKE_CASE constant name.
|
|
602
|
+
*/
|
|
603
|
+
function serverConstName(description: string): string {
|
|
604
|
+
return (
|
|
605
|
+
'SERVER_' +
|
|
606
|
+
description
|
|
607
|
+
.replace(/https?:\/\//g, '')
|
|
608
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
609
|
+
.replace(/^_|_$/g, '')
|
|
610
|
+
.toUpperCase()
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
300
614
|
function generatePackageJson(ctx: EmitterContext): GeneratedFile {
|
|
301
615
|
const pkg = {
|
|
302
616
|
name: `@${ctx.namespace}/sdk`,
|
|
@@ -336,6 +650,7 @@ function generateTsConfig(): GeneratedFile {
|
|
|
336
650
|
lib: ['ES2020'],
|
|
337
651
|
declaration: true,
|
|
338
652
|
strict: true,
|
|
653
|
+
exactOptionalPropertyTypes: true,
|
|
339
654
|
esModuleInterop: true,
|
|
340
655
|
skipLibCheck: true,
|
|
341
656
|
forceConsistentCasingInFileNames: true,
|