@workos/oagen-emitters 0.10.0 → 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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-H0KhxbN7.mjs → plugin-DW3cnedr.mjs} +549 -202
- package/dist/plugin-DW3cnedr.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +2 -2
- package/src/go/models.ts +48 -3
- package/src/php/models.ts +27 -3
- package/src/php/resources.ts +16 -16
- package/src/python/enums.ts +11 -54
- package/src/python/models.ts +204 -219
- package/src/python/resources.ts +19 -35
- package/src/python/shared-schemas.ts +488 -0
- package/src/python/tests.ts +9 -7
- package/src/ruby/resources.ts +13 -1
- package/test/go/models.test.ts +116 -1
- package/test/go/resources.test.ts +70 -0
- package/test/php/models.test.ts +77 -0
- package/test/php/resources.test.ts +95 -0
- package/test/python/enums.test.ts +91 -0
- package/test/python/models.test.ts +225 -0
- package/test/python/resources.test.ts +45 -0
- package/test/ruby/resources.test.ts +58 -0
- package/dist/plugin-H0KhxbN7.mjs.map +0 -1
|
@@ -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
|
{
|
package/test/php/models.test.ts
CHANGED
|
@@ -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
|
{
|
|
@@ -709,6 +709,86 @@ describe('generateModels', () => {
|
|
|
709
709
|
expect(barrel!.content).toContain('EventSchemaVariant');
|
|
710
710
|
});
|
|
711
711
|
|
|
712
|
+
it('emits strict dispatch (no raw-dict fallback, no hasattr) for fields typed as a discriminated union', () => {
|
|
713
|
+
const service: Service = {
|
|
714
|
+
name: 'ApiKeys',
|
|
715
|
+
operations: [
|
|
716
|
+
{
|
|
717
|
+
name: 'getApiKey',
|
|
718
|
+
httpMethod: 'get',
|
|
719
|
+
path: '/api_keys/{id}',
|
|
720
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
721
|
+
queryParams: [],
|
|
722
|
+
headerParams: [],
|
|
723
|
+
response: { kind: 'model', name: 'ApiKeyCreatedData' },
|
|
724
|
+
errors: [],
|
|
725
|
+
injectIdempotencyKey: false,
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const models: Model[] = [
|
|
731
|
+
{
|
|
732
|
+
name: 'ApiKeyCreatedData',
|
|
733
|
+
fields: [
|
|
734
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
735
|
+
{
|
|
736
|
+
name: 'owner',
|
|
737
|
+
type: {
|
|
738
|
+
kind: 'union',
|
|
739
|
+
variants: [
|
|
740
|
+
{ kind: 'model', name: 'ApiKeyCreatedDataOwner' },
|
|
741
|
+
{ kind: 'model', name: 'UserApiKeyCreatedDataOwner' },
|
|
742
|
+
],
|
|
743
|
+
discriminator: {
|
|
744
|
+
property: 'type',
|
|
745
|
+
mapping: {
|
|
746
|
+
organization: 'ApiKeyCreatedDataOwner',
|
|
747
|
+
user: 'UserApiKeyCreatedDataOwner',
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
required: true,
|
|
752
|
+
},
|
|
753
|
+
],
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
name: 'ApiKeyCreatedDataOwner',
|
|
757
|
+
fields: [
|
|
758
|
+
{ name: 'type', type: { kind: 'literal', value: 'organization' }, required: true },
|
|
759
|
+
{ name: 'organization_id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
760
|
+
],
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
name: 'UserApiKeyCreatedDataOwner',
|
|
764
|
+
fields: [
|
|
765
|
+
{ name: 'type', type: { kind: 'literal', value: 'user' }, required: true },
|
|
766
|
+
{ name: 'user_id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
767
|
+
],
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
const files = generateModels(models, {
|
|
772
|
+
...ctx,
|
|
773
|
+
spec: { ...emptySpec, services: [service], models },
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const parent = files.find((f) => f.path.endsWith('api_key_created_data.py'))!;
|
|
777
|
+
expect(parent).toBeDefined();
|
|
778
|
+
|
|
779
|
+
// from_dict performs strict dispatch and raises on unknown discriminator
|
|
780
|
+
expect(parent.content).toContain('"Unknown discriminator');
|
|
781
|
+
expect(parent.content).toContain('ApiKeyCreatedData.owner');
|
|
782
|
+
expect(parent.content).toContain('Expected one of {sorted(');
|
|
783
|
+
// Old lax fallback patterns are gone
|
|
784
|
+
expect(parent.content).not.toContain('else data["owner"]');
|
|
785
|
+
expect(parent.content).not.toContain('else data[');
|
|
786
|
+
|
|
787
|
+
// to_dict no longer needs the hasattr workaround
|
|
788
|
+
expect(parent.content).not.toContain('hasattr');
|
|
789
|
+
expect(parent.content).toContain('result["owner"] = self.owner.to_dict()');
|
|
790
|
+
});
|
|
791
|
+
|
|
712
792
|
it('deduplicates models with recursively identical sub-model references', () => {
|
|
713
793
|
const service: Service = {
|
|
714
794
|
name: 'Events',
|
|
@@ -812,4 +892,149 @@ describe('generateModels', () => {
|
|
|
812
892
|
expect(contextBFile.content).toContain('TypeAlias');
|
|
813
893
|
expect(contextBFile.content).not.toContain('@dataclass');
|
|
814
894
|
});
|
|
895
|
+
|
|
896
|
+
it('places a model in common/ when referenced by 2+ services', () => {
|
|
897
|
+
const sharedModel: Model = {
|
|
898
|
+
name: 'PageInfo',
|
|
899
|
+
fields: [
|
|
900
|
+
{ name: 'page_number', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
901
|
+
{ name: 'page_size', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
902
|
+
],
|
|
903
|
+
};
|
|
904
|
+
const orgsService: Service = {
|
|
905
|
+
name: 'Authorization',
|
|
906
|
+
operations: [
|
|
907
|
+
{
|
|
908
|
+
name: 'listAuthz',
|
|
909
|
+
httpMethod: 'get',
|
|
910
|
+
path: '/authz',
|
|
911
|
+
pathParams: [],
|
|
912
|
+
queryParams: [],
|
|
913
|
+
headerParams: [],
|
|
914
|
+
response: { kind: 'model', name: 'PageInfo' },
|
|
915
|
+
errors: [],
|
|
916
|
+
injectIdempotencyKey: false,
|
|
917
|
+
},
|
|
918
|
+
],
|
|
919
|
+
};
|
|
920
|
+
const usersService: Service = {
|
|
921
|
+
name: 'Organizations',
|
|
922
|
+
operations: [
|
|
923
|
+
{
|
|
924
|
+
name: 'listOrgs',
|
|
925
|
+
httpMethod: 'get',
|
|
926
|
+
path: '/orgs',
|
|
927
|
+
pathParams: [],
|
|
928
|
+
queryParams: [],
|
|
929
|
+
headerParams: [],
|
|
930
|
+
response: { kind: 'model', name: 'PageInfo' },
|
|
931
|
+
errors: [],
|
|
932
|
+
injectIdempotencyKey: false,
|
|
933
|
+
},
|
|
934
|
+
],
|
|
935
|
+
};
|
|
936
|
+
const models: Model[] = [sharedModel];
|
|
937
|
+
|
|
938
|
+
const ctxWithServices: EmitterContext = {
|
|
939
|
+
...ctx,
|
|
940
|
+
spec: { ...emptySpec, services: [orgsService, usersService], models },
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const files = generateModels(models, ctxWithServices);
|
|
944
|
+
const sharedFile = files.find((f) => f.path.endsWith('/page_info.py'));
|
|
945
|
+
expect(sharedFile).toBeDefined();
|
|
946
|
+
expect(sharedFile!.path).toBe('src/workos/common/models/page_info.py');
|
|
947
|
+
// No service-local copy of the shared model.
|
|
948
|
+
expect(files.find((f) => f.path === 'src/workos/authorization/models/page_info.py')).toBeUndefined();
|
|
949
|
+
expect(files.find((f) => f.path === 'src/workos/organizations/models/page_info.py')).toBeUndefined();
|
|
950
|
+
// common/__init__.py and common/models/__init__.py both re-export PageInfo.
|
|
951
|
+
const commonInit = files.find((f) => f.path === 'src/workos/common/__init__.py');
|
|
952
|
+
expect(commonInit).toBeDefined();
|
|
953
|
+
expect(commonInit!.content).toContain('from .models import PageInfo as PageInfo');
|
|
954
|
+
const commonModelsInit = files.find((f) => f.path === 'src/workos/common/models/__init__.py');
|
|
955
|
+
expect(commonModelsInit).toBeDefined();
|
|
956
|
+
expect(commonModelsInit!.content).toContain('from .page_info import PageInfo as PageInfo');
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('keeps a model in service dir when only one service references it', () => {
|
|
960
|
+
const onlyModel: Model = {
|
|
961
|
+
name: 'OnlyOrg',
|
|
962
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
963
|
+
};
|
|
964
|
+
const service: Service = {
|
|
965
|
+
name: 'Organizations',
|
|
966
|
+
operations: [
|
|
967
|
+
{
|
|
968
|
+
name: 'getOrg',
|
|
969
|
+
httpMethod: 'get',
|
|
970
|
+
path: '/orgs/{id}',
|
|
971
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
972
|
+
queryParams: [],
|
|
973
|
+
headerParams: [],
|
|
974
|
+
response: { kind: 'model', name: 'OnlyOrg' },
|
|
975
|
+
errors: [],
|
|
976
|
+
injectIdempotencyKey: false,
|
|
977
|
+
},
|
|
978
|
+
],
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
const ctxWithServices: EmitterContext = {
|
|
982
|
+
...ctx,
|
|
983
|
+
spec: { ...emptySpec, services: [service], models: [onlyModel] },
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
const files = generateModels([onlyModel], ctxWithServices);
|
|
987
|
+
const file = files.find((f) => f.path.endsWith('/only_org.py'));
|
|
988
|
+
expect(file!.path).toBe('src/workos/organizations/models/only_org.py');
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
it('respects modelHints over the shared rule', () => {
|
|
992
|
+
const sharedModel: Model = {
|
|
993
|
+
name: 'PinnedThing',
|
|
994
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
995
|
+
};
|
|
996
|
+
const a: Service = {
|
|
997
|
+
name: 'Authorization',
|
|
998
|
+
operations: [
|
|
999
|
+
{
|
|
1000
|
+
name: 'a',
|
|
1001
|
+
httpMethod: 'get',
|
|
1002
|
+
path: '/a',
|
|
1003
|
+
pathParams: [],
|
|
1004
|
+
queryParams: [],
|
|
1005
|
+
headerParams: [],
|
|
1006
|
+
response: { kind: 'model', name: 'PinnedThing' },
|
|
1007
|
+
errors: [],
|
|
1008
|
+
injectIdempotencyKey: false,
|
|
1009
|
+
},
|
|
1010
|
+
],
|
|
1011
|
+
};
|
|
1012
|
+
const b: Service = {
|
|
1013
|
+
name: 'Organizations',
|
|
1014
|
+
operations: [
|
|
1015
|
+
{
|
|
1016
|
+
name: 'b',
|
|
1017
|
+
httpMethod: 'get',
|
|
1018
|
+
path: '/b',
|
|
1019
|
+
pathParams: [],
|
|
1020
|
+
queryParams: [],
|
|
1021
|
+
headerParams: [],
|
|
1022
|
+
response: { kind: 'model', name: 'PinnedThing' },
|
|
1023
|
+
errors: [],
|
|
1024
|
+
injectIdempotencyKey: false,
|
|
1025
|
+
},
|
|
1026
|
+
],
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
// PinnedThing is referenced by 2 services but explicitly pinned to Authorization.
|
|
1030
|
+
const ctxWithServices: EmitterContext = {
|
|
1031
|
+
...ctx,
|
|
1032
|
+
spec: { ...emptySpec, services: [a, b], models: [sharedModel] },
|
|
1033
|
+
modelHints: { PinnedThing: 'Authorization' },
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
const files = generateModels([sharedModel], ctxWithServices);
|
|
1037
|
+
expect(files.find((f) => f.path === 'src/workos/authorization/models/pinned_thing.py')).toBeDefined();
|
|
1038
|
+
expect(files.find((f) => f.path === 'src/workos/common/models/pinned_thing.py')).toBeUndefined();
|
|
1039
|
+
});
|
|
815
1040
|
});
|
|
@@ -167,6 +167,51 @@ describe('generateResources', () => {
|
|
|
167
167
|
'after: An object ID that defines your place in the list. When the ID is not present, you are at the end of the list.',
|
|
168
168
|
);
|
|
169
169
|
expect(content).toContain('order: Order the results by the creation time.');
|
|
170
|
+
// The spec has no `default` for `order` here, so the SDK must NOT
|
|
171
|
+
// hardcode 'desc' on the client. Server's default applies instead.
|
|
172
|
+
expect(content).toContain('order: Optional[str] = None,');
|
|
173
|
+
expect(content).not.toContain('order: Optional[str] = "desc"');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('reads pagination order default from the spec rather than hardcoding "desc"', () => {
|
|
177
|
+
const models: Model[] = [
|
|
178
|
+
{ name: 'Organization', fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }] },
|
|
179
|
+
];
|
|
180
|
+
const services: Service[] = [
|
|
181
|
+
{
|
|
182
|
+
name: 'Organizations',
|
|
183
|
+
operations: [
|
|
184
|
+
{
|
|
185
|
+
name: 'listOrganizations',
|
|
186
|
+
httpMethod: 'get',
|
|
187
|
+
path: '/organizations',
|
|
188
|
+
pathParams: [],
|
|
189
|
+
queryParams: [
|
|
190
|
+
{ name: 'limit', type: { kind: 'primitive', type: 'integer' }, required: false },
|
|
191
|
+
{ name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
|
|
192
|
+
{
|
|
193
|
+
name: 'order',
|
|
194
|
+
type: { kind: 'primitive', type: 'string' },
|
|
195
|
+
required: false,
|
|
196
|
+
default: 'desc',
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
headerParams: [],
|
|
200
|
+
response: { kind: 'model', name: 'OrganizationList' },
|
|
201
|
+
errors: [],
|
|
202
|
+
injectIdempotencyKey: false,
|
|
203
|
+
pagination: {
|
|
204
|
+
strategy: 'cursor',
|
|
205
|
+
param: 'after',
|
|
206
|
+
dataPath: 'data',
|
|
207
|
+
itemType: { kind: 'model', name: 'Organization' },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
const content = generateResources(services, { ...ctx, spec: { ...emptySpec, services, models } })[0].content;
|
|
214
|
+
expect(content).toContain('order: Optional[str] = "desc",');
|
|
170
215
|
});
|
|
171
216
|
|
|
172
217
|
it('indents multiline argument descriptions in docstrings', () => {
|