@workos/oagen-emitters 0.9.1 → 0.11.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.
@@ -152,6 +152,152 @@ describe('dotnet/enums', () => {
152
152
  expect(files).toHaveLength(0);
153
153
  });
154
154
 
155
+ it('promotes spec-default member to ordinal 0 and pushes Unknown to 99', () => {
156
+ const enums: Enum[] = [
157
+ {
158
+ name: 'PaginationOrder',
159
+ values: [
160
+ { name: 'NORMAL', value: 'normal' },
161
+ { name: 'DESC', value: 'desc' },
162
+ { name: 'ASC', value: 'asc' },
163
+ ],
164
+ default: 'desc',
165
+ },
166
+ ];
167
+
168
+ const service: Service = {
169
+ name: 'Test',
170
+ operations: [
171
+ {
172
+ name: 'test',
173
+ httpMethod: 'get',
174
+ path: '/test',
175
+ pathParams: [],
176
+ queryParams: [{ name: 'order', type: { kind: 'enum', name: 'PaginationOrder' }, required: false }],
177
+ headerParams: [],
178
+ response: { kind: 'primitive', type: 'unknown' },
179
+ errors: [],
180
+ injectIdempotencyKey: false,
181
+ },
182
+ ],
183
+ };
184
+
185
+ const files = generateEnums(enums, {
186
+ ...ctx,
187
+ spec: { ...emptySpec, services: [service], enums },
188
+ });
189
+ expect(files).toHaveLength(1);
190
+ expect(files[0].content).toMatchInlineSnapshot(`
191
+ "namespace WorkOS
192
+ {
193
+ using System.Runtime.Serialization;
194
+ using Newtonsoft.Json;
195
+ using STJS = System.Text.Json.Serialization;
196
+
197
+ /// <summary>Represents pagination order values.</summary>
198
+ [JsonConverter(typeof(WorkOSNewtonsoftStringEnumConverter))]
199
+ [STJS.JsonConverter(typeof(WorkOSStringEnumConverterFactory))]
200
+ public enum PaginationOrder
201
+ {
202
+ [EnumMember(Value = "desc")]
203
+ Desc = 0,
204
+ [EnumMember(Value = "normal")]
205
+ Normal = 1,
206
+ [EnumMember(Value = "asc")]
207
+ Asc = 2,
208
+
209
+ [EnumMember(Value = "unknown")]
210
+ Unknown = 99,
211
+ }
212
+ }"
213
+ `);
214
+ });
215
+
216
+ it('falls back to Unknown=0 layout when no default is set', () => {
217
+ const enums: Enum[] = [
218
+ {
219
+ name: 'Status',
220
+ values: [
221
+ { name: 'ACTIVE', value: 'active' },
222
+ { name: 'INACTIVE', value: 'inactive' },
223
+ ],
224
+ },
225
+ ];
226
+
227
+ const service: Service = {
228
+ name: 'Test',
229
+ operations: [
230
+ {
231
+ name: 'test',
232
+ httpMethod: 'get',
233
+ path: '/test',
234
+ pathParams: [],
235
+ queryParams: [{ name: 'status', type: { kind: 'enum', name: 'Status' }, required: false }],
236
+ headerParams: [],
237
+ response: { kind: 'primitive', type: 'unknown' },
238
+ errors: [],
239
+ injectIdempotencyKey: false,
240
+ },
241
+ ],
242
+ };
243
+
244
+ const files = generateEnums(enums, {
245
+ ...ctx,
246
+ spec: { ...emptySpec, services: [service], enums },
247
+ });
248
+ expect(files).toHaveLength(1);
249
+ const content = files[0].content;
250
+ // No explicit ordinals when no default
251
+ expect(content).not.toMatch(/= \d+,/);
252
+ // Unknown sentinel emitted first (no = N)
253
+ const unknownIdx = content.indexOf('Unknown,');
254
+ const activeIdx = content.indexOf('Active,');
255
+ expect(unknownIdx).toBeGreaterThan(0);
256
+ expect(unknownIdx).toBeLessThan(activeIdx);
257
+ });
258
+
259
+ it('drops a spec value that collides with the unknown sentinel even when default is set', () => {
260
+ const enums: Enum[] = [
261
+ {
262
+ name: 'WithUnknown',
263
+ values: [
264
+ { name: 'NORMAL', value: 'normal' },
265
+ { name: 'UNKNOWN', value: 'unknown' },
266
+ ],
267
+ default: 'normal',
268
+ },
269
+ ];
270
+
271
+ const service: Service = {
272
+ name: 'Test',
273
+ operations: [
274
+ {
275
+ name: 'test',
276
+ httpMethod: 'get',
277
+ path: '/test',
278
+ pathParams: [],
279
+ queryParams: [{ name: 'x', type: { kind: 'enum', name: 'WithUnknown' }, required: false }],
280
+ headerParams: [],
281
+ response: { kind: 'primitive', type: 'unknown' },
282
+ errors: [],
283
+ injectIdempotencyKey: false,
284
+ },
285
+ ],
286
+ };
287
+
288
+ const files = generateEnums(enums, {
289
+ ...ctx,
290
+ spec: { ...emptySpec, services: [service], enums },
291
+ });
292
+ expect(files).toHaveLength(1);
293
+ const content = files[0].content;
294
+ expect(content).toContain('Normal = 0,');
295
+ expect(content).toContain('Unknown = 99,');
296
+ // The spec's "unknown" wire value collides with the sentinel and is dropped,
297
+ // so the body should not contain a non-99 ordinal for Unknown.
298
+ expect(content).not.toMatch(/Unknown = [0-8],/);
299
+ });
300
+
155
301
  it('generates deprecated enum values with Obsolete attribute', () => {
156
302
  const enums: Enum[] = [
157
303
  {
@@ -256,10 +256,125 @@ describe('go/models', () => {
256
256
  After *string \`url:"after,omitempty" json:"-"\`
257
257
  // Limit is the maximum number of items to return per page.
258
258
  Limit *int \`url:"limit,omitempty" json:"-"\`
259
- // Order is the sort order for results (asc or desc).
259
+ // Order is the sort order for results.
260
260
  Order *string \`url:"order,omitempty" json:"-"\`
261
261
  }
262
262
  "
263
263
  `);
264
264
  });
265
+
266
+ it('types PaginationParams.Order with the spec enum when every list op shares the same enum', () => {
267
+ const specWithSharedEnum: ApiSpec = {
268
+ ...emptySpec,
269
+ enums: [
270
+ {
271
+ name: 'PaginationOrder',
272
+ values: [
273
+ { name: 'normal', value: 'normal' },
274
+ { name: 'desc', value: 'desc' },
275
+ { name: 'asc', value: 'asc' },
276
+ ],
277
+ },
278
+ ],
279
+ services: [
280
+ {
281
+ name: 'Connections',
282
+ operations: [
283
+ {
284
+ name: 'listConnections',
285
+ httpMethod: 'get',
286
+ path: '/connections',
287
+ pathParams: [],
288
+ queryParams: [
289
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
290
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
291
+ { name: 'order', type: { kind: 'enum', name: 'PaginationOrder' }, required: false },
292
+ ],
293
+ headerParams: [],
294
+ response: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
295
+ errors: [],
296
+ pagination: {
297
+ strategy: 'cursor',
298
+ param: 'after',
299
+ dataPath: 'data',
300
+ itemType: { kind: 'model', name: 'Organization' },
301
+ },
302
+ injectIdempotencyKey: false,
303
+ },
304
+ ],
305
+ },
306
+ ],
307
+ };
308
+ const ctxTyped: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: specWithSharedEnum };
309
+ const models: Model[] = [
310
+ {
311
+ name: 'Organization',
312
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
313
+ },
314
+ ];
315
+ const content = generateModels(models, ctxTyped)[0].content;
316
+ expect(content).toContain('Order *PaginationOrder `url:"order,omitempty" json:"-"`');
317
+ expect(content).not.toContain('Order *string `url:"order,omitempty"');
318
+ });
319
+
320
+ it('falls back to *string when not every list op uses the same Order enum', () => {
321
+ const specMixed: ApiSpec = {
322
+ ...emptySpec,
323
+ services: [
324
+ {
325
+ name: 'Connections',
326
+ operations: [
327
+ {
328
+ name: 'listConnections',
329
+ httpMethod: 'get',
330
+ path: '/connections',
331
+ pathParams: [],
332
+ queryParams: [
333
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
334
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
335
+ { name: 'order', type: { kind: 'enum', name: 'PaginationOrder' }, required: false },
336
+ ],
337
+ headerParams: [],
338
+ response: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
339
+ errors: [],
340
+ pagination: {
341
+ strategy: 'cursor',
342
+ param: 'after',
343
+ dataPath: 'data',
344
+ itemType: { kind: 'model', name: 'Organization' },
345
+ },
346
+ injectIdempotencyKey: false,
347
+ },
348
+ {
349
+ name: 'listOrganizations',
350
+ httpMethod: 'get',
351
+ path: '/organizations',
352
+ pathParams: [],
353
+ queryParams: [
354
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
355
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
356
+ { name: 'order', type: { kind: 'primitive', type: 'string' }, required: false },
357
+ ],
358
+ headerParams: [],
359
+ response: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
360
+ errors: [],
361
+ pagination: {
362
+ strategy: 'cursor',
363
+ param: 'after',
364
+ dataPath: 'data',
365
+ itemType: { kind: 'model', name: 'Organization' },
366
+ },
367
+ injectIdempotencyKey: false,
368
+ },
369
+ ],
370
+ },
371
+ ],
372
+ };
373
+ const ctxMixed: EmitterContext = { namespace: 'workos', namespacePascal: 'WorkOS', spec: specMixed };
374
+ const models: Model[] = [
375
+ { name: 'Organization', fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }] },
376
+ ];
377
+ const content = generateModels(models, ctxMixed)[0].content;
378
+ expect(content).toContain('Order *string `url:"order,omitempty" json:"-"`');
379
+ });
265
380
  });
@@ -168,6 +168,76 @@ describe('go/resources', () => {
168
168
  expect(content).toContain('newIterator[User](ctx, s.client, "GET", "/users", nil, "after", "data", opts,');
169
169
  });
170
170
 
171
+ it('propagates spec defaults into the newIterator defaults map', () => {
172
+ const services: Service[] = [
173
+ {
174
+ name: 'Users',
175
+ operations: [
176
+ makeOp({
177
+ name: 'listUsers',
178
+ httpMethod: 'get',
179
+ path: '/users',
180
+ queryParams: [
181
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
182
+ {
183
+ name: 'limit',
184
+ type: { kind: 'primitive', type: 'integer' },
185
+ required: false,
186
+ default: 10,
187
+ },
188
+ {
189
+ name: 'order',
190
+ type: { kind: 'primitive', type: 'string' },
191
+ required: false,
192
+ default: 'desc',
193
+ },
194
+ ],
195
+ pagination: {
196
+ strategy: 'cursor',
197
+ param: 'after',
198
+ dataPath: 'data',
199
+ itemType: { kind: 'model', name: 'User' },
200
+ },
201
+ }),
202
+ ],
203
+ },
204
+ ];
205
+ const spec = makeSpec(services);
206
+ const content = generateResources(services, makeCtx(spec))[0].content;
207
+ expect(content).toMatch(/newIterator\[User\][^\n]*map\[string\]string\{"limit": "10", "order": "desc"\}/);
208
+ });
209
+
210
+ it('omits defaults from newIterator when the spec carries no defaults (no client-side hardcode)', () => {
211
+ const services: Service[] = [
212
+ {
213
+ name: 'Users',
214
+ operations: [
215
+ makeOp({
216
+ name: 'listUsers',
217
+ httpMethod: 'get',
218
+ path: '/users',
219
+ queryParams: [
220
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
221
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
222
+ { name: 'order', type: { kind: 'primitive', type: 'string' }, required: false },
223
+ ],
224
+ pagination: {
225
+ strategy: 'cursor',
226
+ param: 'after',
227
+ dataPath: 'data',
228
+ itemType: { kind: 'model', name: 'User' },
229
+ },
230
+ }),
231
+ ],
232
+ },
233
+ ];
234
+ const spec = makeSpec(services);
235
+ const content = generateResources(services, makeCtx(spec))[0].content;
236
+ // Last argument to newIterator must be `nil`, not a map literal.
237
+ expect(content).toMatch(/newIterator\[User\][^\n]*opts, nil\)/);
238
+ expect(content).not.toContain('"order": "desc"');
239
+ });
240
+
171
241
  it('generates delete methods returning error', () => {
172
242
  const services: Service[] = [
173
243
  {
@@ -495,4 +495,81 @@ describe('generateModels', () => {
495
495
  expect(file!.content).toContain('@var array<string>|null');
496
496
  expect(file!.content).not.toContain('|null|null');
497
497
  });
498
+
499
+ it('emits ->toArray() for polymorphic union of model variants', () => {
500
+ const models: Model[] = [
501
+ {
502
+ name: 'ApiKey',
503
+ fields: [
504
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
505
+ {
506
+ name: 'owner',
507
+ type: {
508
+ kind: 'union',
509
+ variants: [
510
+ { kind: 'model', name: 'ApiKeyOwner' },
511
+ { kind: 'model', name: 'UserApiKeyOwner' },
512
+ ],
513
+ discriminator: {
514
+ property: 'type',
515
+ mapping: { apiKey: 'ApiKeyOwner', user: 'UserApiKeyOwner' },
516
+ },
517
+ },
518
+ required: true,
519
+ },
520
+ ],
521
+ },
522
+ {
523
+ name: 'ApiKeyOwner',
524
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
525
+ },
526
+ {
527
+ name: 'UserApiKeyOwner',
528
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
529
+ },
530
+ ];
531
+
532
+ const specWithModels = { ...emptySpec, models };
533
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
534
+
535
+ const file = findModel(result, 'ApiKey');
536
+ expect(file).toBeDefined();
537
+ // toArray must dispatch to the concrete instance, not emit the bare object.
538
+ expect(file!.content).toContain("'owner' => $this->owner->toArray()");
539
+ expect(file!.content).not.toMatch(/'owner' => \$this->owner,/);
540
+ // fromArray match on discriminator must throw on unknown values, not pass through raw.
541
+ expect(file!.content).toContain("match ($data['owner']['type'] ?? null)");
542
+ expect(file!.content).toContain('throw new \\UnexpectedValueException');
543
+ expect(file!.content).not.toMatch(/default => \$data\['owner'\]/);
544
+ });
545
+
546
+ it('throws at codegen time for heterogeneous union mixing model and scalar', () => {
547
+ const models: Model[] = [
548
+ {
549
+ name: 'Thing',
550
+ fields: [
551
+ {
552
+ name: 'value',
553
+ type: {
554
+ kind: 'union',
555
+ variants: [
556
+ { kind: 'model', name: 'SomeModel' },
557
+ { kind: 'primitive', type: 'string' },
558
+ ],
559
+ },
560
+ required: true,
561
+ },
562
+ ],
563
+ },
564
+ {
565
+ name: 'SomeModel',
566
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
567
+ },
568
+ ];
569
+
570
+ const specWithModels = { ...emptySpec, models };
571
+ expect(() => generateModels(models, { ...ctx, spec: specWithModels })).toThrow(
572
+ /heterogeneous union.*model:SomeModel \| primitive/,
573
+ );
574
+ });
498
575
  });
@@ -122,6 +122,101 @@ describe('generateResources', () => {
122
122
  expect(result[0].content).toContain('Organization::fromArray($response)');
123
123
  });
124
124
 
125
+ it('reads the order param default from the spec rather than hardcoding desc', () => {
126
+ const orderEnum = {
127
+ name: 'PaginationOrder',
128
+ values: [
129
+ { name: 'desc', value: 'desc' },
130
+ { name: 'asc', value: 'asc' },
131
+ ],
132
+ };
133
+ const specWithOrder: ApiSpec = {
134
+ ...emptySpec,
135
+ enums: [orderEnum],
136
+ services: [
137
+ {
138
+ name: 'Organizations',
139
+ operations: [
140
+ {
141
+ name: 'listOrganizations',
142
+ httpMethod: 'get',
143
+ path: '/organizations',
144
+ pathParams: [],
145
+ queryParams: [
146
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
147
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
148
+ {
149
+ name: 'order',
150
+ type: { kind: 'enum', name: 'PaginationOrder' },
151
+ required: false,
152
+ default: 'desc',
153
+ },
154
+ ],
155
+ headerParams: [],
156
+ response: { kind: 'model', name: 'Organization' },
157
+ errors: [],
158
+ pagination: {
159
+ strategy: 'cursor',
160
+ param: 'after',
161
+ dataPath: 'data',
162
+ itemType: { kind: 'model', name: 'Organization' },
163
+ },
164
+ injectIdempotencyKey: false,
165
+ },
166
+ ],
167
+ },
168
+ ],
169
+ };
170
+ const content = generateResources(specWithOrder.services, { ...ctx, spec: specWithOrder })[0].content;
171
+ // With a spec default, the param is non-nullable and defaults to the enum case.
172
+ expect(content).toMatch(/PaginationOrder \$order = .*PaginationOrder::Desc/);
173
+ });
174
+
175
+ it('emits ?order = null when the spec carries no default for `order`', () => {
176
+ const orderEnum = {
177
+ name: 'PaginationOrder',
178
+ values: [
179
+ { name: 'desc', value: 'desc' },
180
+ { name: 'asc', value: 'asc' },
181
+ ],
182
+ };
183
+ const specNoDefault: ApiSpec = {
184
+ ...emptySpec,
185
+ enums: [orderEnum],
186
+ services: [
187
+ {
188
+ name: 'Organizations',
189
+ operations: [
190
+ {
191
+ name: 'listOrganizations',
192
+ httpMethod: 'get',
193
+ path: '/organizations',
194
+ pathParams: [],
195
+ queryParams: [
196
+ { name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
197
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
198
+ { name: 'order', type: { kind: 'enum', name: 'PaginationOrder' }, required: false },
199
+ ],
200
+ headerParams: [],
201
+ response: { kind: 'model', name: 'Organization' },
202
+ errors: [],
203
+ pagination: {
204
+ strategy: 'cursor',
205
+ param: 'after',
206
+ dataPath: 'data',
207
+ itemType: { kind: 'model', name: 'Organization' },
208
+ },
209
+ injectIdempotencyKey: false,
210
+ },
211
+ ],
212
+ },
213
+ ],
214
+ };
215
+ const content = generateResources(specNoDefault.services, { ...ctx, spec: specNoDefault })[0].content;
216
+ expect(content).toMatch(/\?\\WorkOS\\Resource\\PaginationOrder \$order = null/);
217
+ expect(content).not.toMatch(/PaginationOrder::Desc/);
218
+ });
219
+
125
220
  it('generates paginated list method', () => {
126
221
  const result = generateResources(services, ctx);
127
222
 
@@ -110,6 +110,97 @@ describe('generateEnums', () => {
110
110
  expect(files[0].path).toBe('src/workos/organizations/models/org_status.py');
111
111
  });
112
112
 
113
+ it('places enum in common/ when referenced by 2+ services', () => {
114
+ const makeService = (name: string, opName: string): Service => ({
115
+ name,
116
+ operations: [
117
+ {
118
+ name: opName,
119
+ httpMethod: 'get',
120
+ path: `/${name.toLowerCase()}`,
121
+ pathParams: [],
122
+ queryParams: [
123
+ {
124
+ name: 'order',
125
+ type: { kind: 'enum', name: 'PaginationOrder' },
126
+ required: false,
127
+ },
128
+ ],
129
+ headerParams: [],
130
+ response: { kind: 'primitive', type: 'unknown' },
131
+ errors: [],
132
+ injectIdempotencyKey: false,
133
+ },
134
+ ],
135
+ });
136
+
137
+ const enums: Enum[] = [
138
+ {
139
+ name: 'PaginationOrder',
140
+ values: [
141
+ { name: 'NORMAL', value: 'normal' },
142
+ { name: 'DESC', value: 'desc' },
143
+ { name: 'ASC', value: 'asc' },
144
+ ],
145
+ },
146
+ ];
147
+
148
+ // Authorization comes alphabetically first; without the shared rule the
149
+ // enum would land under authorization/. Two services referencing it must
150
+ // route it to common/ instead.
151
+ const services = [makeService('Authorization', 'listAuthz'), makeService('Organizations', 'listOrgs')];
152
+
153
+ const files = generateEnums(enums, {
154
+ ...ctx,
155
+ spec: { ...emptySpec, services },
156
+ });
157
+
158
+ const enumFile = files.find((f) => f.path.endsWith('pagination_order.py'));
159
+ expect(enumFile).toBeDefined();
160
+ expect(enumFile!.path).toBe('src/workos/common/models/pagination_order.py');
161
+ // No service-local copy should exist.
162
+ expect(files.find((f) => f.path === 'src/workos/authorization/models/pagination_order.py')).toBeUndefined();
163
+ expect(files.find((f) => f.path === 'src/workos/organizations/models/pagination_order.py')).toBeUndefined();
164
+ });
165
+
166
+ it('keeps enum in service dir when only one service references it', () => {
167
+ const service: Service = {
168
+ name: 'Organizations',
169
+ operations: [
170
+ {
171
+ name: 'listOrgs',
172
+ httpMethod: 'get',
173
+ path: '/orgs',
174
+ pathParams: [],
175
+ queryParams: [
176
+ {
177
+ name: 'order',
178
+ type: { kind: 'enum', name: 'OnlyOrgsOrder' },
179
+ required: false,
180
+ },
181
+ ],
182
+ headerParams: [],
183
+ response: { kind: 'primitive', type: 'unknown' },
184
+ errors: [],
185
+ injectIdempotencyKey: false,
186
+ },
187
+ ],
188
+ };
189
+
190
+ const enums: Enum[] = [
191
+ {
192
+ name: 'OnlyOrgsOrder',
193
+ values: [{ name: 'ASC', value: 'asc' }],
194
+ },
195
+ ];
196
+
197
+ const files = generateEnums(enums, {
198
+ ...ctx,
199
+ spec: { ...emptySpec, services: [service] },
200
+ });
201
+ expect(files[0].path).toBe('src/workos/organizations/models/only_orgs_order.py');
202
+ });
203
+
113
204
  it('deduplicates values that produce the same string', () => {
114
205
  const enums: Enum[] = [
115
206
  {