@workos/oagen-emitters 0.12.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint-pr-title.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-CmfzawTp.mjs → plugin-eCuvoL1T.mjs} +2508 -1474
- package/dist/plugin-eCuvoL1T.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +376 -2036
- package/test/node/tests.test.ts +119 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/dist/plugin-CmfzawTp.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
package/test/node/models.test.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
import type { EmitterContext, ApiSpec, Model, Service } from '@workos/oagen';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
|
|
4
3
|
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
|
+
import { generateModels, generateSerializers } from '../../src/node/models.js';
|
|
5
|
+
import { nodeEmitter } from '../../src/node/index.js';
|
|
6
|
+
import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface } from '../../src/node/live-surface.js';
|
|
7
|
+
import { setBaselineInterfaceNames, setBaselineSerializedNames } from '../../src/node/naming.js';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
5
12
|
|
|
6
13
|
const emptySpec: ApiSpec = {
|
|
7
14
|
name: 'Test',
|
|
@@ -19,912 +26,536 @@ const ctx: EmitterContext = {
|
|
|
19
26
|
spec: emptySpec,
|
|
20
27
|
};
|
|
21
28
|
|
|
29
|
+
function makeSpec(models: Model[], services?: any[]): ApiSpec {
|
|
30
|
+
return {
|
|
31
|
+
...emptySpec,
|
|
32
|
+
models,
|
|
33
|
+
services: services ?? [
|
|
34
|
+
{
|
|
35
|
+
name: 'Organizations',
|
|
36
|
+
operations: [
|
|
37
|
+
{
|
|
38
|
+
name: 'getOrganization',
|
|
39
|
+
httpMethod: 'get',
|
|
40
|
+
path: '/organizations/{id}',
|
|
41
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
42
|
+
queryParams: [],
|
|
43
|
+
headerParams: [],
|
|
44
|
+
response: { kind: 'model', name: 'Organization' },
|
|
45
|
+
errors: [],
|
|
46
|
+
injectIdempotencyKey: false,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
describe('generateModels', () => {
|
|
23
55
|
it('returns empty for no models', () => {
|
|
24
56
|
expect(generateModels([], ctx)).toEqual([]);
|
|
25
57
|
});
|
|
26
58
|
|
|
27
59
|
it('generates domain and response interfaces for a model', () => {
|
|
28
|
-
const service: Service = {
|
|
29
|
-
name: 'Organizations',
|
|
30
|
-
operations: [
|
|
31
|
-
{
|
|
32
|
-
name: 'getOrganization',
|
|
33
|
-
httpMethod: 'get',
|
|
34
|
-
path: '/organizations/{id}',
|
|
35
|
-
pathParams: [
|
|
36
|
-
{
|
|
37
|
-
name: 'id',
|
|
38
|
-
type: { kind: 'primitive', type: 'string' },
|
|
39
|
-
required: true,
|
|
40
|
-
},
|
|
41
|
-
],
|
|
42
|
-
queryParams: [],
|
|
43
|
-
headerParams: [],
|
|
44
|
-
response: { kind: 'model', name: 'Organization' },
|
|
45
|
-
errors: [],
|
|
46
|
-
injectIdempotencyKey: false,
|
|
47
|
-
},
|
|
48
|
-
],
|
|
49
|
-
};
|
|
50
|
-
|
|
51
60
|
const models: Model[] = [
|
|
52
61
|
{
|
|
53
62
|
name: 'Organization',
|
|
54
63
|
fields: [
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
required: true,
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
name: 'name',
|
|
62
|
-
type: { kind: 'primitive', type: 'string' },
|
|
63
|
-
required: true,
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
name: 'created_at',
|
|
67
|
-
type: { kind: 'primitive', type: 'string', format: 'date-time' },
|
|
68
|
-
required: true,
|
|
69
|
-
},
|
|
64
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
65
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
66
|
+
{ name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
|
|
70
67
|
{
|
|
71
68
|
name: 'external_id',
|
|
72
|
-
type: {
|
|
73
|
-
|
|
74
|
-
inner: { kind: 'primitive', type: 'string' },
|
|
75
|
-
},
|
|
76
|
-
required: false,
|
|
69
|
+
type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
|
|
70
|
+
required: true,
|
|
77
71
|
},
|
|
78
72
|
],
|
|
79
73
|
},
|
|
80
74
|
];
|
|
81
75
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
};
|
|
76
|
+
const spec = makeSpec(models);
|
|
77
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
78
|
+
const result = generateModels(models, ctxWithModels);
|
|
86
79
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
expect(
|
|
80
|
+
expect(result.length).toBeGreaterThan(0);
|
|
81
|
+
const file = result[0];
|
|
82
|
+
expect(file.path).toBe('src/organizations/interfaces/organization.interface.ts');
|
|
90
83
|
|
|
91
84
|
// Domain interface has camelCase fields
|
|
92
|
-
expect(
|
|
93
|
-
expect(
|
|
94
|
-
expect(
|
|
95
|
-
expect(
|
|
96
|
-
expect(
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
expect(
|
|
100
|
-
expect(
|
|
101
|
-
expect(
|
|
85
|
+
expect(file.content).toContain('export interface Organization {');
|
|
86
|
+
expect(file.content).toContain('id: string;');
|
|
87
|
+
expect(file.content).toContain('name: string;');
|
|
88
|
+
expect(file.content).toContain('createdAt: Date;');
|
|
89
|
+
expect(file.content).toContain('externalId: string | null;');
|
|
90
|
+
|
|
91
|
+
// Wire interface has snake_case fields
|
|
92
|
+
expect(file.content).toContain('export interface OrganizationResponse {');
|
|
93
|
+
expect(file.content).toContain('created_at: string;');
|
|
94
|
+
expect(file.content).toContain('external_id: string | null;');
|
|
102
95
|
});
|
|
103
96
|
|
|
104
97
|
it('generates imports for referenced models', () => {
|
|
105
|
-
const service: Service = {
|
|
106
|
-
name: 'Organizations',
|
|
107
|
-
operations: [
|
|
108
|
-
{
|
|
109
|
-
name: 'getOrganization',
|
|
110
|
-
httpMethod: 'get',
|
|
111
|
-
path: '/organizations/{id}',
|
|
112
|
-
pathParams: [
|
|
113
|
-
{
|
|
114
|
-
name: 'id',
|
|
115
|
-
type: { kind: 'primitive', type: 'string' },
|
|
116
|
-
required: true,
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
queryParams: [],
|
|
120
|
-
headerParams: [],
|
|
121
|
-
response: { kind: 'model', name: 'Organization' },
|
|
122
|
-
errors: [],
|
|
123
|
-
injectIdempotencyKey: false,
|
|
124
|
-
},
|
|
125
|
-
],
|
|
126
|
-
};
|
|
127
|
-
|
|
128
98
|
const models: Model[] = [
|
|
129
99
|
{
|
|
130
100
|
name: 'Organization',
|
|
131
101
|
fields: [
|
|
132
|
-
{
|
|
133
|
-
|
|
134
|
-
type: { kind: 'primitive', type: 'string' },
|
|
135
|
-
required: true,
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
name: 'domains',
|
|
139
|
-
type: {
|
|
140
|
-
kind: 'array',
|
|
141
|
-
items: { kind: 'model', name: 'OrganizationDomain' },
|
|
142
|
-
},
|
|
143
|
-
required: true,
|
|
144
|
-
},
|
|
102
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
103
|
+
{ name: 'domain', type: { kind: 'model', name: 'OrganizationDomain' }, required: true },
|
|
145
104
|
],
|
|
146
105
|
},
|
|
147
106
|
{
|
|
148
107
|
name: 'OrganizationDomain',
|
|
149
|
-
fields: [
|
|
150
|
-
{
|
|
151
|
-
name: 'id',
|
|
152
|
-
type: { kind: 'primitive', type: 'string' },
|
|
153
|
-
required: true,
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
name: 'domain',
|
|
157
|
-
type: { kind: 'primitive', type: 'string' },
|
|
158
|
-
required: true,
|
|
159
|
-
},
|
|
160
|
-
],
|
|
108
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
161
109
|
},
|
|
162
110
|
];
|
|
163
111
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const files = generateModels(models, ctxWithServices);
|
|
112
|
+
const spec = makeSpec(models);
|
|
113
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
114
|
+
const result = generateModels(models, ctxWithModels);
|
|
170
115
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
expect(orgFile.content).toContain(
|
|
116
|
+
const orgFile = result.find((f) => f.path.includes('organization.interface.ts'));
|
|
117
|
+
expect(orgFile?.content).toContain(
|
|
174
118
|
"import type { OrganizationDomain, OrganizationDomainResponse } from './organization-domain.interface';",
|
|
175
119
|
);
|
|
176
|
-
|
|
177
|
-
// Domain interface uses OrganizationDomain[]
|
|
178
|
-
expect(orgFile.content).toContain(' domains: OrganizationDomain[];');
|
|
179
|
-
|
|
180
|
-
// Response interface uses OrganizationDomainResponse[]
|
|
181
|
-
expect(orgFile.content).toContain(' domains: OrganizationDomainResponse[];');
|
|
182
120
|
});
|
|
183
121
|
|
|
184
|
-
it('
|
|
185
|
-
const service: Service = {
|
|
186
|
-
name: 'DirectorySync',
|
|
187
|
-
operations: [
|
|
188
|
-
{
|
|
189
|
-
name: 'getDirectoryUser',
|
|
190
|
-
httpMethod: 'get',
|
|
191
|
-
path: '/directory_users/{id}',
|
|
192
|
-
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
193
|
-
queryParams: [],
|
|
194
|
-
headerParams: [],
|
|
195
|
-
response: { kind: 'model', name: 'DirectoryUser' },
|
|
196
|
-
errors: [],
|
|
197
|
-
injectIdempotencyKey: false,
|
|
198
|
-
},
|
|
199
|
-
],
|
|
200
|
-
};
|
|
201
|
-
|
|
122
|
+
it('uses Wire suffix for models already ending in Response', () => {
|
|
202
123
|
const models: Model[] = [
|
|
203
124
|
{
|
|
204
|
-
name: '
|
|
205
|
-
|
|
206
|
-
{
|
|
207
|
-
name: 'TCustom',
|
|
208
|
-
default: {
|
|
209
|
-
kind: 'map',
|
|
210
|
-
valueType: { kind: 'primitive', type: 'unknown' },
|
|
211
|
-
},
|
|
212
|
-
},
|
|
213
|
-
],
|
|
214
|
-
fields: [
|
|
215
|
-
{
|
|
216
|
-
name: 'id',
|
|
217
|
-
type: { kind: 'primitive', type: 'string' },
|
|
218
|
-
required: true,
|
|
219
|
-
},
|
|
220
|
-
],
|
|
125
|
+
name: 'PortalSessionsCreateResponse',
|
|
126
|
+
fields: [{ name: 'link', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
221
127
|
},
|
|
222
128
|
];
|
|
223
129
|
|
|
224
|
-
const
|
|
225
|
-
...ctx,
|
|
226
|
-
spec: { ...emptySpec, services: [service], models },
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
const files = generateModels(models, ctxWithServices);
|
|
230
|
-
expect(files[0].content).toContain('export interface DirectoryUser<TCustom = Record<string, any>> {');
|
|
231
|
-
expect(files[0].content).toContain('export interface DirectoryUserResponse<TCustom = Record<string, any>> {');
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('uses Wire suffix for models already ending in Response', () => {
|
|
235
|
-
const service: Service = {
|
|
236
|
-
name: 'PortalSessions',
|
|
237
|
-
operations: [
|
|
238
|
-
{
|
|
239
|
-
name: 'createPortalSession',
|
|
240
|
-
httpMethod: 'post',
|
|
241
|
-
path: '/portal/sessions',
|
|
242
|
-
pathParams: [],
|
|
243
|
-
queryParams: [],
|
|
244
|
-
headerParams: [],
|
|
245
|
-
response: { kind: 'model', name: 'PortalSessionsCreateResponse' },
|
|
246
|
-
errors: [],
|
|
247
|
-
injectIdempotencyKey: false,
|
|
248
|
-
},
|
|
249
|
-
],
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const models: Model[] = [
|
|
130
|
+
const spec = makeSpec(models, [
|
|
253
131
|
{
|
|
254
|
-
name: '
|
|
255
|
-
|
|
256
|
-
{
|
|
257
|
-
name: '
|
|
258
|
-
|
|
259
|
-
|
|
132
|
+
name: 'Portal',
|
|
133
|
+
operations: [
|
|
134
|
+
{
|
|
135
|
+
name: 'createSession',
|
|
136
|
+
httpMethod: 'post',
|
|
137
|
+
path: '/portal/sessions',
|
|
138
|
+
pathParams: [],
|
|
139
|
+
queryParams: [],
|
|
140
|
+
headerParams: [],
|
|
141
|
+
response: { kind: 'model', name: 'PortalSessionsCreateResponse' },
|
|
142
|
+
errors: [],
|
|
143
|
+
injectIdempotencyKey: false,
|
|
260
144
|
},
|
|
261
145
|
],
|
|
262
146
|
},
|
|
263
|
-
];
|
|
147
|
+
]);
|
|
148
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
149
|
+
const result = generateModels(models, ctxWithModels);
|
|
264
150
|
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
spec: { ...emptySpec, services: [service], models },
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
const files = generateModels(models, ctxWithServices);
|
|
271
|
-
const content = files[0].content;
|
|
272
|
-
|
|
273
|
-
// Should use Wire suffix, not ResponseResponse
|
|
274
|
-
expect(content).toContain('export interface PortalSessionsCreateResponseWire {');
|
|
275
|
-
expect(content).not.toContain('PortalSessionsCreateResponseResponse');
|
|
151
|
+
const file = result[0];
|
|
152
|
+
expect(file.content).toContain('export interface PortalSessionsCreateResponseWire {');
|
|
276
153
|
});
|
|
277
154
|
|
|
278
155
|
it('renders @deprecated on fields', () => {
|
|
279
|
-
const service: Service = {
|
|
280
|
-
name: 'Organizations',
|
|
281
|
-
operations: [
|
|
282
|
-
{
|
|
283
|
-
name: 'getOrganization',
|
|
284
|
-
httpMethod: 'get',
|
|
285
|
-
path: '/organizations/{id}',
|
|
286
|
-
pathParams: [
|
|
287
|
-
{
|
|
288
|
-
name: 'id',
|
|
289
|
-
type: { kind: 'primitive', type: 'string' },
|
|
290
|
-
required: true,
|
|
291
|
-
},
|
|
292
|
-
],
|
|
293
|
-
queryParams: [],
|
|
294
|
-
headerParams: [],
|
|
295
|
-
response: { kind: 'model', name: 'Organization' },
|
|
296
|
-
errors: [],
|
|
297
|
-
injectIdempotencyKey: false,
|
|
298
|
-
},
|
|
299
|
-
],
|
|
300
|
-
};
|
|
301
|
-
|
|
302
156
|
const models: Model[] = [
|
|
303
157
|
{
|
|
304
158
|
name: 'Organization',
|
|
305
159
|
fields: [
|
|
160
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
161
|
+
{ name: 'old_field', type: { kind: 'primitive', type: 'string' }, required: false, deprecated: true },
|
|
306
162
|
{
|
|
307
|
-
name: '
|
|
308
|
-
type: { kind: 'primitive', type: 'string' },
|
|
309
|
-
required: true,
|
|
310
|
-
},
|
|
311
|
-
{
|
|
312
|
-
name: 'legacy_slug',
|
|
313
|
-
type: { kind: 'primitive', type: 'string' },
|
|
314
|
-
required: false,
|
|
315
|
-
description: 'Use external_id instead.',
|
|
316
|
-
deprecated: true,
|
|
317
|
-
},
|
|
318
|
-
{
|
|
319
|
-
name: 'old_field',
|
|
163
|
+
name: 'legacy',
|
|
320
164
|
type: { kind: 'primitive', type: 'string' },
|
|
321
165
|
required: false,
|
|
322
166
|
deprecated: true,
|
|
167
|
+
description: 'Use external_id instead.',
|
|
323
168
|
},
|
|
324
169
|
],
|
|
325
170
|
},
|
|
326
171
|
];
|
|
327
172
|
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
const files = generateModels(models, ctxWithServices);
|
|
334
|
-
const content = files[0].content;
|
|
173
|
+
const spec = makeSpec(models);
|
|
174
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
175
|
+
const result = generateModels(models, ctxWithModels);
|
|
335
176
|
|
|
336
|
-
|
|
337
|
-
expect(content).toContain('
|
|
338
|
-
|
|
339
|
-
// Field with only deprecated gets single-line JSDoc
|
|
340
|
-
expect(content).toContain(' /** @deprecated */');
|
|
177
|
+
expect(result[0].content).toContain('@deprecated');
|
|
178
|
+
expect(result[0].content).toContain('Use external_id instead.');
|
|
341
179
|
});
|
|
342
180
|
|
|
343
|
-
it('
|
|
344
|
-
const service: Service = {
|
|
345
|
-
name: 'Organizations',
|
|
346
|
-
operations: [
|
|
347
|
-
{
|
|
348
|
-
name: 'getOrganization',
|
|
349
|
-
httpMethod: 'get',
|
|
350
|
-
path: '/organizations/{id}',
|
|
351
|
-
pathParams: [
|
|
352
|
-
{
|
|
353
|
-
name: 'id',
|
|
354
|
-
type: { kind: 'primitive', type: 'string' },
|
|
355
|
-
required: true,
|
|
356
|
-
},
|
|
357
|
-
],
|
|
358
|
-
queryParams: [],
|
|
359
|
-
headerParams: [],
|
|
360
|
-
response: { kind: 'model', name: 'Organization' },
|
|
361
|
-
errors: [],
|
|
362
|
-
injectIdempotencyKey: false,
|
|
363
|
-
},
|
|
364
|
-
],
|
|
365
|
-
};
|
|
366
|
-
|
|
181
|
+
it('skips per-domain ListMetadata models', () => {
|
|
367
182
|
const models: Model[] = [
|
|
368
183
|
{
|
|
369
184
|
name: 'Organization',
|
|
370
|
-
|
|
185
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'OrganizationListMetadata',
|
|
371
189
|
fields: [
|
|
372
|
-
{
|
|
373
|
-
|
|
374
|
-
type: { kind: 'primitive', type: 'string' },
|
|
375
|
-
required: true,
|
|
376
|
-
description: 'Unique identifier for the organization.',
|
|
377
|
-
},
|
|
378
|
-
{
|
|
379
|
-
name: 'name',
|
|
380
|
-
type: { kind: 'primitive', type: 'string' },
|
|
381
|
-
required: true,
|
|
382
|
-
description: 'The display name of the organization.',
|
|
383
|
-
},
|
|
384
|
-
{
|
|
385
|
-
name: 'created_at',
|
|
386
|
-
type: { kind: 'primitive', type: 'string', format: 'date-time' },
|
|
387
|
-
required: true,
|
|
388
|
-
// No description — should not get JSDoc
|
|
389
|
-
},
|
|
390
|
-
{
|
|
391
|
-
name: 'allow_profiles_outside_organization',
|
|
392
|
-
type: { kind: 'primitive', type: 'boolean' },
|
|
393
|
-
required: false,
|
|
394
|
-
description:
|
|
395
|
-
'Whether connections within the organization allow profiles\nthat do not have a domain that is verified by the organization.',
|
|
396
|
-
},
|
|
190
|
+
{ name: 'before', type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } }, required: false },
|
|
191
|
+
{ name: 'after', type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } }, required: false },
|
|
397
192
|
],
|
|
398
193
|
},
|
|
399
194
|
];
|
|
400
195
|
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
};
|
|
196
|
+
const spec = makeSpec(models);
|
|
197
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
198
|
+
const result = generateModels(models, ctxWithModels);
|
|
405
199
|
|
|
406
|
-
|
|
407
|
-
|
|
200
|
+
expect(result.every((f) => !f.path.includes('list-metadata'))).toBe(true);
|
|
201
|
+
});
|
|
408
202
|
|
|
409
|
-
|
|
410
|
-
|
|
203
|
+
it('handles generic type params', () => {
|
|
204
|
+
const models: Model[] = [
|
|
205
|
+
{
|
|
206
|
+
name: 'DirectoryUser',
|
|
207
|
+
typeParams: [{ name: 'TCustom', default: { kind: 'map', valueType: { kind: 'primitive', type: 'unknown' } } }],
|
|
208
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
209
|
+
},
|
|
210
|
+
];
|
|
411
211
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
212
|
+
const spec = makeSpec(models, [
|
|
213
|
+
{
|
|
214
|
+
name: 'DirectorySync',
|
|
215
|
+
operations: [
|
|
216
|
+
{
|
|
217
|
+
name: 'getUser',
|
|
218
|
+
httpMethod: 'get',
|
|
219
|
+
path: '/directory_users/{id}',
|
|
220
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
221
|
+
queryParams: [],
|
|
222
|
+
headerParams: [],
|
|
223
|
+
response: { kind: 'model', name: 'DirectoryUser' },
|
|
224
|
+
errors: [],
|
|
225
|
+
injectIdempotencyKey: false,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
231
|
+
const result = generateModels(models, ctxWithModels);
|
|
415
232
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
' /**\n * Whether connections within the organization allow profiles\n * that do not have a domain that is verified by the organization.\n */',
|
|
419
|
-
);
|
|
233
|
+
expect(result[0].content).toContain('export interface DirectoryUser<TCustom = Record<string, any>>');
|
|
234
|
+
});
|
|
420
235
|
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
236
|
+
it('does not emit brand-new files into an existing git-tracked SDK', () => {
|
|
237
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-emitter-live-'));
|
|
238
|
+
try {
|
|
239
|
+
const ifaceDir = path.join(tmpRoot, 'src', 'organizations', 'interfaces');
|
|
240
|
+
fs.mkdirSync(ifaceDir, { recursive: true });
|
|
241
|
+
fs.writeFileSync(
|
|
242
|
+
path.join(ifaceDir, 'organization.interface.ts'),
|
|
243
|
+
['export interface Organization {', ' id: string;', '}'].join('\n'),
|
|
244
|
+
);
|
|
245
|
+
execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
246
|
+
execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
247
|
+
|
|
248
|
+
const models: Model[] = [
|
|
249
|
+
{
|
|
250
|
+
name: 'Organization',
|
|
251
|
+
fields: [
|
|
252
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
253
|
+
{ name: 'domain', type: { kind: 'model', name: 'OrganizationDomain' }, required: false },
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'OrganizationDomain',
|
|
258
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
const spec = makeSpec(models);
|
|
263
|
+
const files = nodeEmitter.generateModels(models, { ...ctx, spec, outputDir: tmpRoot });
|
|
264
|
+
|
|
265
|
+
expect(files).toHaveLength(0);
|
|
266
|
+
expect(files.some((f) => f.path.includes('organization-domain.interface.ts'))).toBe(false);
|
|
267
|
+
expect(files.some((f) => f.path.includes('/serializers/'))).toBe(false);
|
|
268
|
+
} finally {
|
|
269
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
270
|
+
}
|
|
427
271
|
});
|
|
428
272
|
|
|
429
|
-
it('
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
273
|
+
it('keeps spec model names for manifest-managed adopted services on rerun', () => {
|
|
274
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-adopted-model-rerun-'));
|
|
275
|
+
try {
|
|
276
|
+
fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
|
|
277
|
+
fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), '// @oagen-ignore-file\nexport class WorkOS {}\n');
|
|
278
|
+
fs.writeFileSync(path.join(tmpRoot, 'src', 'index.ts'), '// @oagen-ignore-file\n');
|
|
279
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'connect'), { recursive: true });
|
|
280
|
+
fs.writeFileSync(
|
|
281
|
+
path.join(tmpRoot, 'src', 'connect', 'connect.ts'),
|
|
282
|
+
['// This file is auto-generated by oagen. Do not edit.', '', 'export class Connect {}'].join('\n'),
|
|
283
|
+
);
|
|
284
|
+
execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
285
|
+
execFileSync('git', ['add', 'src/workos.ts', 'src/index.ts'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
286
|
+
|
|
287
|
+
const models: Model[] = [
|
|
433
288
|
{
|
|
434
|
-
name: '
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
289
|
+
name: 'CreateM2MApplication',
|
|
290
|
+
fields: [
|
|
291
|
+
{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
292
|
+
{ name: 'application_type', type: { kind: 'literal', value: 'm2m' }, required: true },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
const spec = makeSpec(models, [
|
|
297
|
+
{
|
|
298
|
+
name: 'Connect',
|
|
299
|
+
operations: [
|
|
438
300
|
{
|
|
439
|
-
name: '
|
|
440
|
-
|
|
441
|
-
|
|
301
|
+
name: 'createApplication',
|
|
302
|
+
httpMethod: 'post',
|
|
303
|
+
path: '/connect/applications',
|
|
304
|
+
pathParams: [],
|
|
305
|
+
queryParams: [],
|
|
306
|
+
headerParams: [],
|
|
307
|
+
requestBody: { kind: 'model', name: 'CreateM2MApplication' },
|
|
308
|
+
response: { kind: 'primitive', type: 'unknown' },
|
|
309
|
+
errors: [],
|
|
310
|
+
injectIdempotencyKey: false,
|
|
442
311
|
},
|
|
443
312
|
],
|
|
444
|
-
queryParams: [],
|
|
445
|
-
headerParams: [],
|
|
446
|
-
response: { kind: 'model', name: 'Organization' },
|
|
447
|
-
errors: [],
|
|
448
|
-
injectIdempotencyKey: false,
|
|
449
313
|
},
|
|
450
|
-
]
|
|
451
|
-
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
const result = nodeEmitter.generateModels(models, {
|
|
317
|
+
...ctx,
|
|
318
|
+
spec,
|
|
319
|
+
outputDir: tmpRoot,
|
|
320
|
+
emitterOptions: { adoptMissingServices: true },
|
|
321
|
+
priorTargetManifestPaths: new Set(['src/connect/connect.ts']),
|
|
322
|
+
apiSurface: {
|
|
323
|
+
language: 'node',
|
|
324
|
+
extractedFrom: tmpRoot,
|
|
325
|
+
extractedAt: '2026-05-12T00:00:00Z',
|
|
326
|
+
classes: {},
|
|
327
|
+
interfaces: {
|
|
328
|
+
CreateGroupOptions: {
|
|
329
|
+
name: 'CreateGroupOptions',
|
|
330
|
+
fields: { name: { type: 'string', optional: false } },
|
|
331
|
+
extends: [],
|
|
332
|
+
sourceFile: 'src/groups/interfaces/create-group-options.interface.ts',
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
typeAliases: {},
|
|
336
|
+
enums: {},
|
|
337
|
+
exports: {},
|
|
338
|
+
} as any,
|
|
339
|
+
overlayLookup: {
|
|
340
|
+
methodByOperation: new Map(),
|
|
341
|
+
interfaceByName: new Map(),
|
|
342
|
+
modelNameByIR: new Map([['CreateM2MApplication', 'CreateGroupOptions']]),
|
|
343
|
+
} as any,
|
|
344
|
+
} as EmitterContext);
|
|
345
|
+
|
|
346
|
+
const file = result.find((f) => f.path === 'src/connect/interfaces/create-m2m-application.interface.ts');
|
|
347
|
+
expect(file?.content).toContain('export interface CreateM2MApplication');
|
|
348
|
+
expect(file?.content).not.toContain('CreateGroupOptions');
|
|
349
|
+
} finally {
|
|
350
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
});
|
|
452
354
|
|
|
355
|
+
describe('generateSerializers', () => {
|
|
356
|
+
it('generates deserializer with camelCase mapping', () => {
|
|
453
357
|
const models: Model[] = [
|
|
454
358
|
{
|
|
455
359
|
name: 'Organization',
|
|
456
360
|
fields: [
|
|
457
|
-
{
|
|
458
|
-
|
|
459
|
-
type: { kind: 'primitive', type: 'string' },
|
|
460
|
-
required: true,
|
|
461
|
-
readOnly: true,
|
|
462
|
-
},
|
|
463
|
-
{
|
|
464
|
-
name: 'secret_key',
|
|
465
|
-
type: { kind: 'primitive', type: 'string' },
|
|
466
|
-
required: true,
|
|
467
|
-
writeOnly: true,
|
|
468
|
-
},
|
|
469
|
-
{
|
|
470
|
-
name: 'status',
|
|
471
|
-
type: { kind: 'primitive', type: 'string' },
|
|
472
|
-
required: false,
|
|
473
|
-
default: 'active',
|
|
474
|
-
},
|
|
361
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
362
|
+
{ name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
|
|
475
363
|
],
|
|
476
364
|
},
|
|
477
365
|
];
|
|
478
366
|
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
const files = generateModels(models, ctxWithServices);
|
|
485
|
-
const content = files[0].content;
|
|
367
|
+
const spec = makeSpec(models);
|
|
368
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
369
|
+
const result = generateSerializers(models, ctxWithModels);
|
|
486
370
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
expect(
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
expect(content).toContain('/** @writeonly */');
|
|
493
|
-
|
|
494
|
-
// default field gets @default JSDoc
|
|
495
|
-
expect(content).toContain('@default "active"');
|
|
371
|
+
expect(result.length).toBeGreaterThan(0);
|
|
372
|
+
const file = result[0];
|
|
373
|
+
expect(file.path).toContain('.serializer.ts');
|
|
374
|
+
expect(file.content).toContain('deserializeOrganization');
|
|
375
|
+
expect(file.content).toContain('createdAt: new Date(response.created_at)');
|
|
496
376
|
});
|
|
497
377
|
|
|
498
|
-
it('
|
|
499
|
-
const service: Service = {
|
|
500
|
-
name: 'Connections',
|
|
501
|
-
operations: [
|
|
502
|
-
{
|
|
503
|
-
name: 'listConnections',
|
|
504
|
-
httpMethod: 'get',
|
|
505
|
-
path: '/connections',
|
|
506
|
-
pathParams: [],
|
|
507
|
-
queryParams: [],
|
|
508
|
-
headerParams: [],
|
|
509
|
-
response: { kind: 'model', name: 'ConnectionList' },
|
|
510
|
-
errors: [],
|
|
511
|
-
injectIdempotencyKey: false,
|
|
512
|
-
pagination: {
|
|
513
|
-
strategy: 'cursor',
|
|
514
|
-
param: 'after',
|
|
515
|
-
itemType: { kind: 'model', name: 'Connection' },
|
|
516
|
-
},
|
|
517
|
-
},
|
|
518
|
-
],
|
|
519
|
-
};
|
|
520
|
-
|
|
378
|
+
it('generates nested model deserialization', () => {
|
|
521
379
|
const models: Model[] = [
|
|
522
380
|
{
|
|
523
|
-
name: '
|
|
381
|
+
name: 'Organization',
|
|
524
382
|
fields: [
|
|
383
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
525
384
|
{
|
|
526
|
-
name: '
|
|
527
|
-
type: {
|
|
528
|
-
|
|
529
|
-
inner: { kind: 'primitive', type: 'string' },
|
|
530
|
-
},
|
|
531
|
-
required: false,
|
|
532
|
-
},
|
|
533
|
-
{
|
|
534
|
-
name: 'after',
|
|
535
|
-
type: {
|
|
536
|
-
kind: 'nullable',
|
|
537
|
-
inner: { kind: 'primitive', type: 'string' },
|
|
538
|
-
},
|
|
539
|
-
required: false,
|
|
385
|
+
name: 'domains',
|
|
386
|
+
type: { kind: 'array', items: { kind: 'model', name: 'OrganizationDomain' } },
|
|
387
|
+
required: true,
|
|
540
388
|
},
|
|
541
389
|
],
|
|
542
390
|
},
|
|
543
391
|
{
|
|
544
|
-
name: '
|
|
545
|
-
fields: [
|
|
546
|
-
{
|
|
547
|
-
name: 'id',
|
|
548
|
-
type: { kind: 'primitive', type: 'string' },
|
|
549
|
-
required: true,
|
|
550
|
-
},
|
|
551
|
-
],
|
|
392
|
+
name: 'OrganizationDomain',
|
|
393
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
552
394
|
},
|
|
553
395
|
];
|
|
554
396
|
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
};
|
|
559
|
-
|
|
560
|
-
const files = generateModels(models, ctxWithServices);
|
|
561
|
-
|
|
562
|
-
// The ListMetadata model should be skipped entirely
|
|
563
|
-
const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
|
|
564
|
-
expect(listMetadataFile).toBeUndefined();
|
|
397
|
+
const spec = makeSpec(models);
|
|
398
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
399
|
+
const result = generateSerializers(models, ctxWithModels);
|
|
565
400
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
401
|
+
const orgSerializer = result.find(
|
|
402
|
+
(f) => f.path.includes('organization.serializer.ts') && !f.path.includes('domain'),
|
|
403
|
+
);
|
|
404
|
+
expect(orgSerializer?.content).toContain('domains: response.domains.map(deserializeOrganizationDomain)');
|
|
569
405
|
});
|
|
570
406
|
|
|
571
|
-
it('
|
|
572
|
-
const service: Service = {
|
|
573
|
-
name: 'Connections',
|
|
574
|
-
operations: [
|
|
575
|
-
{
|
|
576
|
-
name: 'listConnections',
|
|
577
|
-
httpMethod: 'get',
|
|
578
|
-
path: '/connections',
|
|
579
|
-
pathParams: [],
|
|
580
|
-
queryParams: [],
|
|
581
|
-
headerParams: [],
|
|
582
|
-
response: { kind: 'model', name: 'ConnectionList' },
|
|
583
|
-
errors: [],
|
|
584
|
-
injectIdempotencyKey: false,
|
|
585
|
-
pagination: {
|
|
586
|
-
strategy: 'cursor',
|
|
587
|
-
param: 'after',
|
|
588
|
-
itemType: { kind: 'model', name: 'Connection' },
|
|
589
|
-
},
|
|
590
|
-
},
|
|
591
|
-
],
|
|
592
|
-
};
|
|
593
|
-
|
|
407
|
+
it('preserves null fallback for optional nullable model fields', () => {
|
|
594
408
|
const models: Model[] = [
|
|
595
409
|
{
|
|
596
|
-
name: '
|
|
597
|
-
fields: [
|
|
598
|
-
{
|
|
599
|
-
name: 'object',
|
|
600
|
-
type: { kind: 'literal', value: 'list' },
|
|
601
|
-
required: true,
|
|
602
|
-
},
|
|
603
|
-
{
|
|
604
|
-
name: 'data',
|
|
605
|
-
type: {
|
|
606
|
-
kind: 'array',
|
|
607
|
-
items: { kind: 'model', name: 'Connection' },
|
|
608
|
-
},
|
|
609
|
-
required: true,
|
|
610
|
-
},
|
|
611
|
-
{
|
|
612
|
-
name: 'list_metadata',
|
|
613
|
-
type: { kind: 'model', name: 'ConnectionListListMetadata' },
|
|
614
|
-
required: true,
|
|
615
|
-
},
|
|
616
|
-
],
|
|
617
|
-
},
|
|
618
|
-
{
|
|
619
|
-
name: 'ConnectionListListMetadata',
|
|
410
|
+
name: 'Organization',
|
|
620
411
|
fields: [
|
|
621
|
-
{
|
|
622
|
-
|
|
623
|
-
type: {
|
|
624
|
-
kind: 'nullable',
|
|
625
|
-
inner: { kind: 'primitive', type: 'string' },
|
|
626
|
-
},
|
|
627
|
-
required: false,
|
|
628
|
-
},
|
|
629
|
-
{
|
|
630
|
-
name: 'after',
|
|
631
|
-
type: {
|
|
632
|
-
kind: 'nullable',
|
|
633
|
-
inner: { kind: 'primitive', type: 'string' },
|
|
634
|
-
},
|
|
635
|
-
required: false,
|
|
636
|
-
},
|
|
412
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
413
|
+
{ name: 'parent', type: { kind: 'nullable', inner: { kind: 'model', name: 'ParentOrg' } }, required: false },
|
|
637
414
|
],
|
|
638
415
|
},
|
|
639
416
|
{
|
|
640
|
-
name: '
|
|
641
|
-
fields: [
|
|
642
|
-
{
|
|
643
|
-
name: 'id',
|
|
644
|
-
type: { kind: 'primitive', type: 'string' },
|
|
645
|
-
required: true,
|
|
646
|
-
},
|
|
647
|
-
],
|
|
417
|
+
name: 'ParentOrg',
|
|
418
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
648
419
|
},
|
|
649
420
|
];
|
|
650
421
|
|
|
651
|
-
const
|
|
652
|
-
...ctx,
|
|
653
|
-
spec: { ...emptySpec, services: [service], models },
|
|
654
|
-
};
|
|
655
|
-
|
|
656
|
-
const files = generateModels(models, ctxWithServices);
|
|
657
|
-
|
|
658
|
-
// The list wrapper model should be skipped
|
|
659
|
-
const listFile = files.find((f) => f.path.includes('connection-list.interface.ts'));
|
|
660
|
-
expect(listFile).toBeUndefined();
|
|
661
|
-
|
|
662
|
-
// The ListMetadata model should also be skipped
|
|
663
|
-
const listMetadataFile = files.find((f) => f.path.includes('list-metadata'));
|
|
664
|
-
expect(listMetadataFile).toBeUndefined();
|
|
665
|
-
|
|
666
|
-
// The Connection model should still be generated
|
|
667
|
-
const connectionFile = files.find((f) => f.path.includes('connection.interface.ts'));
|
|
668
|
-
expect(connectionFile).toBeDefined();
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
it('does not skip models that only partially match list-metadata shape', () => {
|
|
672
|
-
const service: Service = {
|
|
673
|
-
name: 'Organizations',
|
|
674
|
-
operations: [
|
|
675
|
-
{
|
|
676
|
-
name: 'getOrganization',
|
|
677
|
-
httpMethod: 'get',
|
|
678
|
-
path: '/organizations/{id}',
|
|
679
|
-
pathParams: [
|
|
680
|
-
{
|
|
681
|
-
name: 'id',
|
|
682
|
-
type: { kind: 'primitive', type: 'string' },
|
|
683
|
-
required: true,
|
|
684
|
-
},
|
|
685
|
-
],
|
|
686
|
-
queryParams: [],
|
|
687
|
-
headerParams: [],
|
|
688
|
-
response: { kind: 'model', name: 'Pagination' },
|
|
689
|
-
errors: [],
|
|
690
|
-
injectIdempotencyKey: false,
|
|
691
|
-
},
|
|
692
|
-
],
|
|
693
|
-
};
|
|
694
|
-
|
|
695
|
-
const models: Model[] = [
|
|
422
|
+
const spec = makeSpec(models, [
|
|
696
423
|
{
|
|
697
|
-
name: '
|
|
698
|
-
|
|
699
|
-
{
|
|
700
|
-
name: '
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
type: {
|
|
710
|
-
kind: 'nullable',
|
|
711
|
-
inner: { kind: 'primitive', type: 'string' },
|
|
712
|
-
},
|
|
713
|
-
required: false,
|
|
714
|
-
},
|
|
715
|
-
{
|
|
716
|
-
name: 'total',
|
|
717
|
-
type: { kind: 'primitive', type: 'integer' },
|
|
718
|
-
required: true,
|
|
424
|
+
name: 'Organizations',
|
|
425
|
+
operations: [
|
|
426
|
+
{
|
|
427
|
+
name: 'getOrganization',
|
|
428
|
+
httpMethod: 'get',
|
|
429
|
+
path: '/organizations/{id}',
|
|
430
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
431
|
+
queryParams: [],
|
|
432
|
+
headerParams: [],
|
|
433
|
+
response: { kind: 'model', name: 'Organization' },
|
|
434
|
+
errors: [],
|
|
435
|
+
injectIdempotencyKey: false,
|
|
719
436
|
},
|
|
720
437
|
],
|
|
721
438
|
},
|
|
722
|
-
];
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
...ctx,
|
|
726
|
-
spec: { ...emptySpec, services: [service], models },
|
|
727
|
-
};
|
|
439
|
+
]);
|
|
440
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
441
|
+
const result = generateSerializers(models, ctxWithModels);
|
|
728
442
|
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
expect(
|
|
443
|
+
const orgSerializer = result.find(
|
|
444
|
+
(f) => f.path.includes('organization.serializer.ts') && !f.path.includes('parent'),
|
|
445
|
+
);
|
|
446
|
+
expect(orgSerializer?.content).toContain(
|
|
447
|
+
'parent: response.parent != null ? deserializeParentOrg(response.parent) : null',
|
|
448
|
+
);
|
|
733
449
|
});
|
|
734
|
-
});
|
|
735
450
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
451
|
+
it('skips parent serialization when a structurally matched baseline dependency has no serializer', () => {
|
|
452
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-serializer-baseline-'));
|
|
453
|
+
try {
|
|
454
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'api-keys', 'interfaces'), { recursive: true });
|
|
455
|
+
fs.mkdirSync(path.join(tmpRoot, 'src', 'api-keys', 'serializers'), { recursive: true });
|
|
456
|
+
fs.writeFileSync(
|
|
457
|
+
path.join(tmpRoot, 'src', 'api-keys', 'interfaces', 'created-api-key.interface.ts'),
|
|
458
|
+
[
|
|
459
|
+
'export interface CreatedApiKey {',
|
|
460
|
+
' id: string;',
|
|
461
|
+
'}',
|
|
462
|
+
'',
|
|
463
|
+
'export interface SerializedCreatedApiKey {',
|
|
464
|
+
' id: string;',
|
|
465
|
+
'}',
|
|
466
|
+
].join('\n'),
|
|
467
|
+
);
|
|
468
|
+
fs.writeFileSync(
|
|
469
|
+
path.join(tmpRoot, 'src', 'api-keys', 'serializers', 'created-api-key.serializer.ts'),
|
|
470
|
+
[
|
|
471
|
+
"import type { CreatedApiKey, SerializedCreatedApiKey } from '../interfaces/created-api-key.interface';",
|
|
472
|
+
'export function deserializeCreatedApiKey(apiKey: SerializedCreatedApiKey): CreatedApiKey {',
|
|
473
|
+
' return { id: apiKey.id };',
|
|
474
|
+
'}',
|
|
475
|
+
].join('\n'),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
setActiveLiveSurface(buildLiveSurface(tmpRoot));
|
|
479
|
+
setBaselineSerializedNames(new Set(['SerializedCreatedApiKey']));
|
|
480
|
+
setBaselineInterfaceNames(new Set(['CreatedApiKey', 'SerializedCreatedApiKey']));
|
|
481
|
+
|
|
482
|
+
const models: Model[] = [
|
|
741
483
|
{
|
|
742
|
-
name: '
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
484
|
+
name: 'OrganizationApiKey',
|
|
485
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'OrganizationApiKeyList',
|
|
489
|
+
fields: [
|
|
490
|
+
{ name: 'object', type: { kind: 'literal', value: 'list' }, required: true },
|
|
746
491
|
{
|
|
747
|
-
name: '
|
|
748
|
-
type: { kind: '
|
|
492
|
+
name: 'data',
|
|
493
|
+
type: { kind: 'array', items: { kind: 'model', name: 'OrganizationApiKey' } },
|
|
749
494
|
required: true,
|
|
750
495
|
},
|
|
751
496
|
],
|
|
752
|
-
queryParams: [],
|
|
753
|
-
headerParams: [],
|
|
754
|
-
response: { kind: 'model', name: 'EnvironmentRole' },
|
|
755
|
-
errors: [],
|
|
756
|
-
injectIdempotencyKey: false,
|
|
757
497
|
},
|
|
498
|
+
];
|
|
499
|
+
const spec = makeSpec(models, [
|
|
758
500
|
{
|
|
759
|
-
name: '
|
|
760
|
-
|
|
761
|
-
path: '/organization_roles/{id}',
|
|
762
|
-
pathParams: [
|
|
501
|
+
name: 'ApiKeys',
|
|
502
|
+
operations: [
|
|
763
503
|
{
|
|
764
|
-
name: '
|
|
765
|
-
|
|
766
|
-
|
|
504
|
+
name: 'listOrganizationApiKeys',
|
|
505
|
+
httpMethod: 'get',
|
|
506
|
+
path: '/api_keys',
|
|
507
|
+
pathParams: [],
|
|
508
|
+
queryParams: [],
|
|
509
|
+
headerParams: [],
|
|
510
|
+
response: { kind: 'model', name: 'OrganizationApiKeyList' },
|
|
511
|
+
errors: [],
|
|
512
|
+
injectIdempotencyKey: false,
|
|
767
513
|
},
|
|
768
514
|
],
|
|
769
|
-
queryParams: [],
|
|
770
|
-
headerParams: [],
|
|
771
|
-
response: { kind: 'model', name: 'OrganizationRole' },
|
|
772
|
-
errors: [],
|
|
773
|
-
injectIdempotencyKey: false,
|
|
774
515
|
},
|
|
775
|
-
]
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
},
|
|
792
|
-
{
|
|
793
|
-
name: 'type',
|
|
794
|
-
type: { kind: 'literal', value: 'environment_role' },
|
|
795
|
-
required: true,
|
|
796
|
-
},
|
|
797
|
-
],
|
|
798
|
-
},
|
|
799
|
-
{
|
|
800
|
-
name: 'OrganizationRole',
|
|
801
|
-
fields: [
|
|
802
|
-
{
|
|
803
|
-
name: 'id',
|
|
804
|
-
type: { kind: 'primitive', type: 'string' },
|
|
805
|
-
required: true,
|
|
806
|
-
},
|
|
807
|
-
{
|
|
808
|
-
name: 'name',
|
|
809
|
-
type: { kind: 'primitive', type: 'string' },
|
|
810
|
-
required: true,
|
|
811
|
-
},
|
|
812
|
-
{
|
|
813
|
-
name: 'type',
|
|
814
|
-
type: { kind: 'literal', value: 'environment_role' },
|
|
815
|
-
required: true,
|
|
816
|
-
},
|
|
817
|
-
],
|
|
818
|
-
},
|
|
819
|
-
];
|
|
820
|
-
|
|
821
|
-
const ctxWithServices: EmitterContext = {
|
|
822
|
-
...ctx,
|
|
823
|
-
spec: { ...emptySpec, services: [service], models },
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
const files = generateModels(models, ctxWithServices);
|
|
827
|
-
expect(files.length).toBe(2);
|
|
828
|
-
|
|
829
|
-
// First model: full interface
|
|
830
|
-
expect(files[0].content).toContain('export interface EnvironmentRole');
|
|
831
|
-
|
|
832
|
-
// Second model: type alias referencing canonical
|
|
833
|
-
expect(files[1].content).toContain('export type OrganizationRole = EnvironmentRole');
|
|
834
|
-
expect(files[1].content).toContain('export type OrganizationRoleResponse = EnvironmentRoleResponse');
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
it('generates Date type for date-time fields even when baseline says string', () => {
|
|
838
|
-
const service: Service = {
|
|
839
|
-
name: 'Authorization',
|
|
840
|
-
operations: [
|
|
841
|
-
{
|
|
842
|
-
name: 'getRoleAssignment',
|
|
843
|
-
httpMethod: 'get',
|
|
844
|
-
path: '/role_assignments/{id}',
|
|
845
|
-
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
846
|
-
queryParams: [],
|
|
847
|
-
headerParams: [],
|
|
848
|
-
response: { kind: 'model', name: 'RoleAssignment' },
|
|
849
|
-
errors: [],
|
|
850
|
-
injectIdempotencyKey: false,
|
|
851
|
-
},
|
|
852
|
-
],
|
|
853
|
-
};
|
|
854
|
-
|
|
855
|
-
const models: Model[] = [
|
|
856
|
-
{
|
|
857
|
-
name: 'RoleAssignment',
|
|
858
|
-
fields: [
|
|
859
|
-
{
|
|
860
|
-
name: 'id',
|
|
861
|
-
type: { kind: 'primitive', type: 'string' },
|
|
862
|
-
required: true,
|
|
863
|
-
},
|
|
864
|
-
{
|
|
865
|
-
name: 'created_at',
|
|
866
|
-
type: { kind: 'primitive', type: 'string', format: 'date-time' },
|
|
867
|
-
required: true,
|
|
868
|
-
},
|
|
869
|
-
{
|
|
870
|
-
name: 'updated_at',
|
|
871
|
-
type: { kind: 'primitive', type: 'string', format: 'date-time' },
|
|
872
|
-
required: true,
|
|
873
|
-
},
|
|
874
|
-
// Extra field not in baseline so modelHasNewFields returns true
|
|
875
|
-
// (allowing the dedup test to proceed with generation)
|
|
876
|
-
{
|
|
877
|
-
name: 'role_name',
|
|
878
|
-
type: { kind: 'primitive', type: 'string' },
|
|
879
|
-
required: true,
|
|
880
|
-
},
|
|
881
|
-
],
|
|
882
|
-
},
|
|
883
|
-
];
|
|
884
|
-
|
|
885
|
-
const ctxWithBaseline: EmitterContext = {
|
|
886
|
-
...ctx,
|
|
887
|
-
spec: { ...emptySpec, services: [service], models },
|
|
888
|
-
apiSurface: {
|
|
889
|
-
language: 'node',
|
|
890
|
-
extractedFrom: 'test',
|
|
891
|
-
extractedAt: '2024-01-01',
|
|
892
|
-
classes: {},
|
|
893
|
-
typeAliases: {},
|
|
894
|
-
enums: {},
|
|
895
|
-
exports: {},
|
|
896
|
-
interfaces: {
|
|
897
|
-
RoleAssignment: {
|
|
898
|
-
name: 'RoleAssignment',
|
|
899
|
-
fields: {
|
|
900
|
-
id: { name: 'id', type: 'string', optional: false },
|
|
901
|
-
createdAt: { name: 'createdAt', type: 'string', optional: false },
|
|
902
|
-
updatedAt: { name: 'updatedAt', type: 'string', optional: false },
|
|
516
|
+
]);
|
|
517
|
+
const result = generateSerializers(models, {
|
|
518
|
+
...ctx,
|
|
519
|
+
spec,
|
|
520
|
+
outputDir: tmpRoot,
|
|
521
|
+
apiSurface: {
|
|
522
|
+
language: 'node',
|
|
523
|
+
extractedFrom: tmpRoot,
|
|
524
|
+
extractedAt: '2026-05-12T00:00:00Z',
|
|
525
|
+
classes: {},
|
|
526
|
+
interfaces: {
|
|
527
|
+
CreatedApiKey: {
|
|
528
|
+
name: 'CreatedApiKey',
|
|
529
|
+
fields: { id: { type: 'string', optional: false } },
|
|
530
|
+
extends: [],
|
|
531
|
+
sourceFile: 'src/api-keys/interfaces/created-api-key.interface.ts',
|
|
903
532
|
},
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
id: { name: 'id', type: 'string', optional: false },
|
|
910
|
-
created_at: { name: 'created_at', type: 'string', optional: false },
|
|
911
|
-
updated_at: { name: 'updated_at', type: 'string', optional: false },
|
|
533
|
+
SerializedCreatedApiKey: {
|
|
534
|
+
name: 'SerializedCreatedApiKey',
|
|
535
|
+
fields: { id: { type: 'string', optional: false } },
|
|
536
|
+
extends: [],
|
|
537
|
+
sourceFile: 'src/api-keys/interfaces/created-api-key.interface.ts',
|
|
912
538
|
},
|
|
913
|
-
extends: [],
|
|
914
539
|
},
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
540
|
+
typeAliases: {},
|
|
541
|
+
enums: {},
|
|
542
|
+
exports: {},
|
|
543
|
+
} as any,
|
|
544
|
+
overlayLookup: {
|
|
545
|
+
methodByOperation: new Map(),
|
|
546
|
+
interfaceByName: new Map(),
|
|
547
|
+
modelNameByIR: new Map([['OrganizationApiKey', 'SerializedCreatedApiKey']]),
|
|
548
|
+
} as any,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const listSerializer = result.find((f) => f.path.endsWith('organization-api-key-list.serializer.ts'));
|
|
552
|
+
expect(listSerializer?.content).toContain('deserializeOrganizationApiKeyList');
|
|
553
|
+
expect(listSerializer?.content).not.toContain('export const serializeOrganizationApiKeyList');
|
|
554
|
+
} finally {
|
|
555
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
556
|
+
setBaselineSerializedNames(new Set());
|
|
557
|
+
setBaselineInterfaceNames(new Set());
|
|
558
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
559
|
+
}
|
|
929
560
|
});
|
|
930
561
|
});
|