@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/common.ts
CHANGED
|
@@ -12,7 +12,7 @@ export function generateCommon(): GeneratedFile[] {
|
|
|
12
12
|
path: 'src/common/utils/fetch-and-deserialize.ts',
|
|
13
13
|
content: fetchAndDeserializeContent(),
|
|
14
14
|
skipIfExists: true,
|
|
15
|
-
integrateTarget:
|
|
15
|
+
integrateTarget: true,
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
path: 'src/common/serializers/list.serializer.ts',
|
|
@@ -24,7 +24,7 @@ export function generateCommon(): GeneratedFile[] {
|
|
|
24
24
|
path: 'src/common/utils/test-utils.ts',
|
|
25
25
|
content: testUtilsContent(),
|
|
26
26
|
skipIfExists: true,
|
|
27
|
-
integrateTarget:
|
|
27
|
+
integrateTarget: true,
|
|
28
28
|
},
|
|
29
29
|
];
|
|
30
30
|
}
|
|
@@ -108,7 +108,7 @@ export class AutoPaginatable<
|
|
|
108
108
|
function fetchAndDeserializeContent(): string {
|
|
109
109
|
return `import type { WorkOS } from '../../workos';
|
|
110
110
|
import type { PaginationOptions } from '../interfaces/pagination-options.interface';
|
|
111
|
-
import type
|
|
111
|
+
import { AutoPaginatable, type List, type ListResponse } from './pagination';
|
|
112
112
|
|
|
113
113
|
function setDefaultOptions(
|
|
114
114
|
options?: PaginationOptions,
|
|
@@ -142,7 +142,20 @@ export const fetchAndDeserialize = async <T, U>(
|
|
|
142
142
|
query: setDefaultOptions(options),
|
|
143
143
|
});
|
|
144
144
|
return deserializeList(data, deserializeFn);
|
|
145
|
-
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export async function createPaginatedList<TResponse, TModel, TOptions extends PaginationOptions>(
|
|
148
|
+
workos: WorkOS,
|
|
149
|
+
endpoint: string,
|
|
150
|
+
deserializeFn: (r: TResponse) => TModel,
|
|
151
|
+
options?: TOptions,
|
|
152
|
+
): Promise<AutoPaginatable<TModel, TOptions>> {
|
|
153
|
+
return new AutoPaginatable(
|
|
154
|
+
await fetchAndDeserialize<TResponse, TModel>(workos, endpoint, deserializeFn, options),
|
|
155
|
+
(params) => fetchAndDeserialize<TResponse, TModel>(workos, endpoint, deserializeFn, params),
|
|
156
|
+
options,
|
|
157
|
+
);
|
|
158
|
+
}`;
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
function listSerializerContent(): string {
|
|
@@ -185,6 +198,10 @@ export function fetchURL(): string {
|
|
|
185
198
|
return String(fetch.mock.calls[0][0]);
|
|
186
199
|
}
|
|
187
200
|
|
|
201
|
+
export function fetchMethod(): string {
|
|
202
|
+
return String(fetch.mock.calls[0][1]?.method ?? 'GET');
|
|
203
|
+
}
|
|
204
|
+
|
|
188
205
|
export function fetchSearchParams(): Record<string, string> {
|
|
189
206
|
return Object.fromEntries(new URL(fetchURL()).searchParams);
|
|
190
207
|
}
|
|
@@ -199,5 +216,58 @@ export function fetchBody({ raw = false } = {}): any {
|
|
|
199
216
|
if (body instanceof URLSearchParams) return body.toString();
|
|
200
217
|
if (raw) return body;
|
|
201
218
|
return JSON.parse(String(body));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Shared test helper: asserts that the given async function throws when the
|
|
223
|
+
* server responds with 401 Unauthorized.
|
|
224
|
+
*/
|
|
225
|
+
export function testUnauthorized(fn: () => Promise<any>) {
|
|
226
|
+
it('throws on unauthorized', async () => {
|
|
227
|
+
fetchOnce({ message: 'Unauthorized' }, { status: 401 });
|
|
228
|
+
await expect(fn()).rejects.toThrow('Unauthorized');
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Shared test helper: asserts that a paginated list call returns the expected
|
|
234
|
+
* shape (data array + listMetadata) and hits the correct endpoint.
|
|
235
|
+
*/
|
|
236
|
+
export function testPaginatedList(
|
|
237
|
+
fn: () => Promise<any>,
|
|
238
|
+
pathContains: string,
|
|
239
|
+
) {
|
|
240
|
+
it('returns paginated results', async () => {
|
|
241
|
+
// Caller must have called fetchOnce with the list fixture before invoking fn
|
|
242
|
+
const { data, listMetadata } = await fn();
|
|
243
|
+
expect(fetchURL()).toContain(pathContains);
|
|
244
|
+
expect(fetchSearchParams()).toHaveProperty('order');
|
|
245
|
+
expect(Array.isArray(data)).toBe(true);
|
|
246
|
+
expect(listMetadata).toBeDefined();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Shared test helper: asserts that a paginated list call returns empty data
|
|
252
|
+
* when the server responds with an empty list.
|
|
253
|
+
*/
|
|
254
|
+
export function testEmptyResults(fn: () => Promise<any>) {
|
|
255
|
+
it('handles empty results', async () => {
|
|
256
|
+
fetchOnce({ data: [], list_metadata: { before: null, after: null } });
|
|
257
|
+
const { data } = await fn();
|
|
258
|
+
expect(data).toEqual([]);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Shared test helper: asserts that pagination params are forwarded correctly.
|
|
264
|
+
*/
|
|
265
|
+
export function testPaginationParams(fn: (opts: any) => Promise<any>, fixture: any) {
|
|
266
|
+
it('forwards pagination params', async () => {
|
|
267
|
+
fetchOnce(fixture);
|
|
268
|
+
await fn({ limit: 10, after: 'cursor_abc' });
|
|
269
|
+
expect(fetchSearchParams()['limit']).toBe('10');
|
|
270
|
+
expect(fetchSearchParams()['after']).toBe('cursor_abc');
|
|
271
|
+
});
|
|
202
272
|
}`;
|
|
203
273
|
}
|
package/src/node/config.ts
CHANGED
package/src/node/enums.ts
CHANGED
|
@@ -21,17 +21,44 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
21
21
|
const baselineAlias = ctx.apiSurface?.typeAliases?.[enumDef.name];
|
|
22
22
|
const lines: string[] = [];
|
|
23
23
|
|
|
24
|
+
// Track whether the generated content has new values not in the baseline.
|
|
25
|
+
// When it does, skipIfExists must be false so the file gets updated.
|
|
26
|
+
let hasNewValues = false;
|
|
27
|
+
|
|
24
28
|
if (baselineEnum?.members) {
|
|
25
|
-
// Generate TS `enum` using baseline member names and values
|
|
29
|
+
// Generate TS `enum` using baseline member names and values, merging
|
|
30
|
+
// any new IR values that the baseline is missing.
|
|
31
|
+
const existingValues = new Set(Object.values(baselineEnum.members).map(String));
|
|
32
|
+
const irValues = enumDef.values.map((v) => String(v.value));
|
|
33
|
+
const missingValues = irValues.filter((v) => !existingValues.has(v));
|
|
34
|
+
hasNewValues = missingValues.length > 0;
|
|
35
|
+
|
|
26
36
|
lines.push(`export enum ${enumDef.name} {`);
|
|
27
37
|
for (const [memberName, memberValue] of Object.entries(baselineEnum.members)) {
|
|
28
38
|
const valueStr = typeof memberValue === 'string' ? `'${memberValue}'` : String(memberValue);
|
|
29
39
|
lines.push(` ${memberName} = ${valueStr},`);
|
|
30
40
|
}
|
|
41
|
+
// Append new values from the spec that the baseline is missing
|
|
42
|
+
for (const val of missingValues) {
|
|
43
|
+
// Derive a PascalCase member name from the value
|
|
44
|
+
const memberName = val.replace(/[^a-zA-Z0-9]+/g, '');
|
|
45
|
+
lines.push(` ${memberName} = '${val}',`);
|
|
46
|
+
}
|
|
31
47
|
lines.push('}');
|
|
32
48
|
} else if (baselineAlias?.value) {
|
|
33
|
-
// Use the
|
|
34
|
-
|
|
49
|
+
// Use the baseline type alias value, but merge in any new IR values the baseline is missing.
|
|
50
|
+
const baselineValues = extractLiteralUnionValues(baselineAlias.value);
|
|
51
|
+
const irValues = enumDef.values.map((v) => String(v.value));
|
|
52
|
+
const missing = irValues.filter((v) => !baselineValues.has(v));
|
|
53
|
+
hasNewValues = missing.length > 0;
|
|
54
|
+
if (missing.length > 0) {
|
|
55
|
+
// Baseline is missing values from the spec — regenerate with all values merged
|
|
56
|
+
const allValues = [...baselineValues, ...missing];
|
|
57
|
+
const parts = allValues.map((v) => `'${v}'`);
|
|
58
|
+
lines.push(`export type ${enumDef.name} = ${parts.join(' | ')};`);
|
|
59
|
+
} else {
|
|
60
|
+
lines.push(`export type ${enumDef.name} = ${baselineAlias.value};`);
|
|
61
|
+
}
|
|
35
62
|
} else {
|
|
36
63
|
// No baseline — generate string literal union from IR values
|
|
37
64
|
const values = enumDef.values;
|
|
@@ -53,14 +80,31 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
53
80
|
files.push({
|
|
54
81
|
path: `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`,
|
|
55
82
|
content: lines.join('\n'),
|
|
56
|
-
|
|
83
|
+
// When the spec has new values the baseline is missing, allow the file
|
|
84
|
+
// to be updated so the SDK picks up the full set of enum values.
|
|
85
|
+
skipIfExists: !hasNewValues,
|
|
57
86
|
});
|
|
58
87
|
}
|
|
59
88
|
|
|
60
89
|
return files;
|
|
61
90
|
}
|
|
62
91
|
|
|
63
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Parse a TypeScript string literal union type alias value (e.g., "'a' | 'b' | 'c'")
|
|
94
|
+
* into a set of its string values.
|
|
95
|
+
*/
|
|
96
|
+
function extractLiteralUnionValues(aliasValue: string): Set<string> {
|
|
97
|
+
const values = new Set<string>();
|
|
98
|
+
// Match all single-quoted string literals in the union
|
|
99
|
+
const regex = /'([^']+)'/g;
|
|
100
|
+
let match;
|
|
101
|
+
while ((match = regex.exec(aliasValue)) !== null) {
|
|
102
|
+
values.add(match[1]);
|
|
103
|
+
}
|
|
104
|
+
return values;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
|
|
64
108
|
const enumToService = new Map<string, string>();
|
|
65
109
|
const enumNames = new Set(enums.map((e) => e.name));
|
|
66
110
|
|
|
@@ -72,7 +116,7 @@ function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string,
|
|
|
72
116
|
};
|
|
73
117
|
if (op.requestBody) collect(op.requestBody);
|
|
74
118
|
collect(op.response);
|
|
75
|
-
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
119
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
76
120
|
collect(p.type);
|
|
77
121
|
}
|
|
78
122
|
for (const name of refs) {
|
package/src/node/errors.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { GeneratedFile } from '@workos/oagen';
|
|
1
|
+
import type { EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
+
import { fileName } from './naming.js';
|
|
2
3
|
|
|
3
|
-
export function generateErrors(): GeneratedFile[] {
|
|
4
|
-
|
|
4
|
+
export function generateErrors(ctx?: EmitterContext): GeneratedFile[] {
|
|
5
|
+
const files: GeneratedFile[] = [
|
|
5
6
|
{
|
|
6
7
|
path: 'src/common/exceptions/bad-request.exception.ts',
|
|
7
8
|
content: `export class BadRequestException extends Error {
|
|
@@ -202,4 +203,78 @@ export { NoApiKeyProvidedException } from './no-api-key-provided.exception';`,
|
|
|
202
203
|
integrateTarget: false,
|
|
203
204
|
},
|
|
204
205
|
];
|
|
206
|
+
|
|
207
|
+
// Fix 6: Generate typed exception classes from ErrorResponse.type
|
|
208
|
+
if (ctx?.spec) {
|
|
209
|
+
const typedErrors = collectTypedErrors(ctx);
|
|
210
|
+
for (const { modelName, statusCode, baseException } of typedErrors) {
|
|
211
|
+
const exceptionClassName = `${modelName}Exception`;
|
|
212
|
+
const filePath = `src/common/exceptions/${fileName(modelName)}.exception.ts`;
|
|
213
|
+
const baseImport = baseException
|
|
214
|
+
? `import { ${baseException} } from './${fileName(baseException.replace(/Exception$/, '')).replace(/^/, '')}.exception';\n\n`
|
|
215
|
+
: '';
|
|
216
|
+
const baseClass = baseException ?? 'Error';
|
|
217
|
+
|
|
218
|
+
files.push({
|
|
219
|
+
path: filePath,
|
|
220
|
+
content: `${baseImport}export class ${exceptionClassName} extends ${baseClass} {
|
|
221
|
+
readonly status = ${statusCode};
|
|
222
|
+
readonly name = '${exceptionClassName}';
|
|
223
|
+
readonly requestID: string;
|
|
224
|
+
readonly data?: any;
|
|
225
|
+
|
|
226
|
+
constructor({ message, requestID, data }: { message?: string; requestID: string; data?: any }) {
|
|
227
|
+
${baseException ? `super(${baseException === 'UnauthorizedException' ? 'requestID' : `{ message: message ?? '${modelName} error', requestID }`});` : 'super();'}
|
|
228
|
+
this.message = message ?? '${modelName} error';
|
|
229
|
+
this.requestID = requestID;
|
|
230
|
+
if (data) this.data = data;
|
|
231
|
+
}
|
|
232
|
+
}`,
|
|
233
|
+
skipIfExists: true,
|
|
234
|
+
integrateTarget: false,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Update the barrel index to export the new exception
|
|
238
|
+
const existingIndex = files.find((f) => f.path === 'src/common/exceptions/index.ts');
|
|
239
|
+
if (existingIndex) {
|
|
240
|
+
existingIndex.content += `\nexport { ${exceptionClassName} } from './${fileName(modelName)}.exception';`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return files;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const STATUS_TO_BASE_EXCEPTION: Record<number, string> = {
|
|
249
|
+
400: 'BadRequestException',
|
|
250
|
+
401: 'UnauthorizedException',
|
|
251
|
+
404: 'NotFoundException',
|
|
252
|
+
409: 'ConflictException',
|
|
253
|
+
422: 'UnprocessableEntityException',
|
|
254
|
+
429: 'RateLimitExceededException',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
function collectTypedErrors(
|
|
258
|
+
ctx: EmitterContext,
|
|
259
|
+
): { modelName: string; statusCode: number; baseException: string | null }[] {
|
|
260
|
+
const seen = new Set<string>();
|
|
261
|
+
const results: { modelName: string; statusCode: number; baseException: string | null }[] = [];
|
|
262
|
+
|
|
263
|
+
for (const service of ctx.spec.services) {
|
|
264
|
+
for (const op of service.operations) {
|
|
265
|
+
for (const err of op.errors) {
|
|
266
|
+
if (err.type?.kind === 'model' && !seen.has(err.type.name)) {
|
|
267
|
+
seen.add(err.type.name);
|
|
268
|
+
results.push({
|
|
269
|
+
modelName: err.type.name,
|
|
270
|
+
statusCode: err.statusCode,
|
|
271
|
+
baseException:
|
|
272
|
+
STATUS_TO_BASE_EXCEPTION[err.statusCode] ?? (err.statusCode >= 500 ? 'GenericServerException' : null),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return results;
|
|
205
280
|
}
|
package/src/node/fixtures.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
|
|
2
|
-
import { wireFieldName, fileName, serviceDirName
|
|
3
|
-
import {
|
|
2
|
+
import { wireFieldName, fileName, serviceDirName } from './naming.js';
|
|
3
|
+
import { resolveResourceClassName } from './resources.js';
|
|
4
|
+
import { createServiceDirResolver, assignModelsToServices, isListMetadataModel, isListWrapperModel } from './utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prefix mapping for generating realistic ID fixture values.
|
|
8
|
+
* When a field named "id" belongs to a model whose name matches a key here,
|
|
9
|
+
* the generated ID will be prefixed accordingly (e.g. "conn_01234").
|
|
10
|
+
*/
|
|
11
|
+
export const ID_PREFIXES: Record<string, string> = {
|
|
12
|
+
Connection: 'conn_',
|
|
13
|
+
Organization: 'org_',
|
|
14
|
+
OrganizationMembership: 'om_',
|
|
15
|
+
User: 'user_',
|
|
16
|
+
Directory: 'directory_',
|
|
17
|
+
DirectoryGroup: 'dir_grp_',
|
|
18
|
+
DirectoryUser: 'dir_usr_',
|
|
19
|
+
Invitation: 'inv_',
|
|
20
|
+
Session: 'session_',
|
|
21
|
+
AuthenticationFactor: 'auth_factor_',
|
|
22
|
+
EmailVerification: 'email_verification_',
|
|
23
|
+
MagicAuth: 'magic_auth_',
|
|
24
|
+
PasswordReset: 'password_reset_',
|
|
25
|
+
};
|
|
4
26
|
|
|
5
27
|
/**
|
|
6
28
|
* Generate JSON fixture files for test data.
|
|
@@ -16,15 +38,21 @@ export function generateFixtures(
|
|
|
16
38
|
): { path: string; content: string }[] {
|
|
17
39
|
if (spec.models.length === 0) return [];
|
|
18
40
|
|
|
19
|
-
const modelToService =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
const { modelToService, resolveDir } = ctx
|
|
42
|
+
? createServiceDirResolver(spec.models, ctx.spec.services, ctx)
|
|
43
|
+
: {
|
|
44
|
+
modelToService: assignModelsToServices(spec.models, spec.services),
|
|
45
|
+
resolveDir: (irService: string | undefined) => (irService ? serviceDirName(irService) : 'common'),
|
|
46
|
+
};
|
|
23
47
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
24
48
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
25
49
|
const files: { path: string; content: string }[] = [];
|
|
26
50
|
|
|
27
51
|
for (const model of spec.models) {
|
|
52
|
+
// Skip redundant list-metadata and list-wrapper models (handled by shared types)
|
|
53
|
+
if (isListMetadataModel(model)) continue;
|
|
54
|
+
if (isListWrapperModel(model)) continue;
|
|
55
|
+
|
|
28
56
|
const service = modelToService.get(model.name);
|
|
29
57
|
const dirName = resolveDir(service);
|
|
30
58
|
const fixture = generateModelFixture(model, modelMap, enumMap);
|
|
@@ -37,12 +65,18 @@ export function generateFixtures(
|
|
|
37
65
|
|
|
38
66
|
// Generate list fixtures for models that appear in paginated responses
|
|
39
67
|
for (const service of spec.services) {
|
|
40
|
-
const resolvedName = ctx ?
|
|
68
|
+
const resolvedName = ctx ? resolveResourceClassName(service, ctx) : service.name;
|
|
41
69
|
const serviceDir = serviceDirName(resolvedName);
|
|
42
70
|
for (const op of service.operations) {
|
|
43
71
|
if (op.pagination) {
|
|
44
|
-
|
|
72
|
+
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
45
73
|
if (itemModel) {
|
|
74
|
+
// Detect if the "item" model is actually a list wrapper (has `data` array + `list_metadata`).
|
|
75
|
+
// If so, unwrap to the actual item type to avoid double-nesting in fixtures.
|
|
76
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
77
|
+
if (unwrapped) {
|
|
78
|
+
itemModel = unwrapped;
|
|
79
|
+
}
|
|
46
80
|
const fixture = generateModelFixture(itemModel, modelMap, enumMap);
|
|
47
81
|
const listFixture = {
|
|
48
82
|
data: [fixture],
|
|
@@ -63,6 +97,24 @@ export function generateFixtures(
|
|
|
63
97
|
return files;
|
|
64
98
|
}
|
|
65
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Detect if a model is a list wrapper (has a `data` array field and a `list_metadata` field).
|
|
102
|
+
* If so, return the inner item model from the `data` array. Otherwise return null.
|
|
103
|
+
* This prevents double-nesting when the pagination itemType points to a list wrapper
|
|
104
|
+
* instead of the actual item model.
|
|
105
|
+
*/
|
|
106
|
+
export function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
|
|
107
|
+
const dataField = model.fields.find((f) => f.name === 'data');
|
|
108
|
+
const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
|
|
109
|
+
if (dataField && hasListMetadata && dataField.type.kind === 'array') {
|
|
110
|
+
const itemType = dataField.type.items;
|
|
111
|
+
if (itemType.kind === 'model') {
|
|
112
|
+
return modelMap.get(itemType.name) ?? null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
66
118
|
function generateModelFixture(
|
|
67
119
|
model: Model,
|
|
68
120
|
modelMap: Map<string, Model>,
|
|
@@ -72,7 +124,12 @@ function generateModelFixture(
|
|
|
72
124
|
|
|
73
125
|
for (const field of model.fields) {
|
|
74
126
|
const wireName = wireFieldName(field.name);
|
|
75
|
-
|
|
127
|
+
// Prefer the OpenAPI example value when available on the field
|
|
128
|
+
if (field.example !== undefined) {
|
|
129
|
+
fixture[wireName] = field.example;
|
|
130
|
+
} else {
|
|
131
|
+
fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
|
|
132
|
+
}
|
|
76
133
|
}
|
|
77
134
|
|
|
78
135
|
return fixture;
|
|
@@ -81,12 +138,13 @@ function generateModelFixture(
|
|
|
81
138
|
function generateFieldValue(
|
|
82
139
|
ref: TypeRef,
|
|
83
140
|
fieldName: string,
|
|
141
|
+
modelName: string,
|
|
84
142
|
modelMap: Map<string, Model>,
|
|
85
143
|
enumMap: Map<string, Enum>,
|
|
86
144
|
): any {
|
|
87
145
|
switch (ref.kind) {
|
|
88
146
|
case 'primitive':
|
|
89
|
-
return generatePrimitiveValue(ref.type, ref.format, fieldName);
|
|
147
|
+
return generatePrimitiveValue(ref.type, ref.format, fieldName, modelName);
|
|
90
148
|
case 'literal':
|
|
91
149
|
return ref.value;
|
|
92
150
|
case 'enum': {
|
|
@@ -99,27 +157,38 @@ function generateFieldValue(
|
|
|
99
157
|
return {};
|
|
100
158
|
}
|
|
101
159
|
case 'array': {
|
|
102
|
-
|
|
160
|
+
// For array<enum>, use actual enum values instead of a single generated item
|
|
161
|
+
if (ref.items.kind === 'enum') {
|
|
162
|
+
const e = enumMap.get(ref.items.name);
|
|
163
|
+
if (e && e.values.length > 0) {
|
|
164
|
+
return e.values.map((v) => v.value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const item = generateFieldValue(ref.items, fieldName, modelName, modelMap, enumMap);
|
|
103
168
|
return [item];
|
|
104
169
|
}
|
|
105
170
|
case 'nullable':
|
|
106
|
-
return generateFieldValue(ref.inner, fieldName, modelMap, enumMap);
|
|
171
|
+
return generateFieldValue(ref.inner, fieldName, modelName, modelMap, enumMap);
|
|
107
172
|
case 'union':
|
|
108
173
|
if (ref.variants.length > 0) {
|
|
109
|
-
return generateFieldValue(ref.variants[0], fieldName, modelMap, enumMap);
|
|
174
|
+
return generateFieldValue(ref.variants[0], fieldName, modelName, modelMap, enumMap);
|
|
110
175
|
}
|
|
111
176
|
return null;
|
|
112
177
|
case 'map':
|
|
113
|
-
return { key: generateFieldValue(ref.valueType, 'value', modelMap, enumMap) };
|
|
178
|
+
return { key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap) };
|
|
114
179
|
}
|
|
115
180
|
}
|
|
116
181
|
|
|
117
|
-
function generatePrimitiveValue(type: string, format: string | undefined, name: string): any {
|
|
182
|
+
function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
|
|
118
183
|
switch (type) {
|
|
119
184
|
case 'string':
|
|
120
185
|
if (format === 'date-time') return '2023-01-01T00:00:00.000Z';
|
|
121
186
|
if (format === 'date') return '2023-01-01';
|
|
122
187
|
if (format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
188
|
+
if (name === 'id') {
|
|
189
|
+
const prefix = ID_PREFIXES[modelName] ?? '';
|
|
190
|
+
return `${prefix}01234`;
|
|
191
|
+
}
|
|
123
192
|
if (name.includes('id')) return `${name}_01234`;
|
|
124
193
|
if (name.includes('email')) return 'test@example.com';
|
|
125
194
|
if (name.includes('url') || name.includes('uri')) return 'https://example.com';
|
package/src/node/index.ts
CHANGED
|
@@ -30,8 +30,8 @@ export const nodeEmitter: Emitter = {
|
|
|
30
30
|
return generateClient(spec, ctx);
|
|
31
31
|
},
|
|
32
32
|
|
|
33
|
-
generateErrors(
|
|
34
|
-
return generateErrors();
|
|
33
|
+
generateErrors(ctx: EmitterContext): GeneratedFile[] {
|
|
34
|
+
return generateErrors(ctx);
|
|
35
35
|
},
|
|
36
36
|
|
|
37
37
|
generateConfig(_ctx: EmitterContext): GeneratedFile[] {
|
package/src/node/manifest.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
|
-
import { resolveMethodName, servicePropertyName
|
|
2
|
+
import { resolveMethodName, servicePropertyName } from './naming.js';
|
|
3
|
+
import { resolveResourceClassName } from './resources.js';
|
|
3
4
|
|
|
4
5
|
export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
5
6
|
const manifest: Record<string, { sdkMethod: string; service: string }> = {};
|
|
6
7
|
|
|
7
8
|
for (const service of spec.services) {
|
|
8
|
-
const propName = servicePropertyName(
|
|
9
|
+
const propName = servicePropertyName(resolveResourceClassName(service, ctx));
|
|
9
10
|
for (const op of service.operations) {
|
|
10
11
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
11
12
|
const method = resolveMethodName(op, service, ctx);
|
|
@@ -18,6 +19,7 @@ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedF
|
|
|
18
19
|
path: 'smoke-manifest.json',
|
|
19
20
|
content: JSON.stringify(manifest, null, 2),
|
|
20
21
|
integrateTarget: false,
|
|
22
|
+
overwriteExisting: true,
|
|
21
23
|
},
|
|
22
24
|
];
|
|
23
25
|
}
|