@workos/oagen-emitters 0.14.4 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-BGVaMGqe.mjs → plugin-C2Hp2Vs2.mjs} +1039 -274
- package/dist/plugin-C2Hp2Vs2.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/renovate.json +1 -61
- package/src/go/client.ts +1 -1
- package/src/go/enums.ts +77 -0
- package/src/kotlin/enums.ts +11 -4
- package/src/node/client.ts +158 -2
- package/src/node/discriminated-models.ts +68 -24
- package/src/node/field-plan.ts +64 -8
- package/src/node/index.ts +59 -3
- package/src/node/models.ts +73 -30
- package/src/node/naming.ts +14 -1
- package/src/node/node-overrides.ts +4 -37
- package/src/node/options.ts +29 -1
- package/src/node/resources.ts +553 -89
- package/src/node/tests.ts +108 -7
- package/src/php/fixtures.ts +4 -1
- package/src/php/models.ts +3 -1
- package/src/php/resources.ts +40 -11
- package/src/php/tests.ts +22 -12
- package/src/python/client.ts +0 -8
- package/src/python/enums.ts +41 -15
- package/src/python/fixtures.ts +23 -7
- package/src/python/models.ts +26 -5
- package/src/python/resources.ts +71 -3
- package/src/python/tests.ts +70 -12
- package/src/python/wrappers.ts +25 -4
- package/src/ruby/client.ts +0 -1
- package/src/rust/resources.ts +10 -7
- package/src/shared/non-spec-services.ts +0 -5
- package/test/go/enums.test.ts +24 -0
- package/test/node/resources.test.ts +11 -1
- package/test/node/tests.test.ts +3 -3
- package/test/php/client.test.ts +0 -1
- package/test/php/resources.test.ts +50 -0
- package/test/rust/resources.test.ts +9 -0
- package/dist/plugin-BGVaMGqe.mjs.map +0 -1
package/src/node/tests.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
4
|
-
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
4
|
+
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
5
5
|
import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
|
|
6
6
|
import {
|
|
7
7
|
fieldName,
|
|
@@ -27,13 +27,17 @@ import {
|
|
|
27
27
|
computeNonEventReachable,
|
|
28
28
|
} from './utils.js';
|
|
29
29
|
import { groupByMount } from '../shared/resolved-ops.js';
|
|
30
|
-
import { isNodeOwnedService } from './options.js';
|
|
30
|
+
import { isNodeOwnedService, nodeOptions } from './options.js';
|
|
31
31
|
|
|
32
32
|
type BaselineMethod = {
|
|
33
33
|
params: Array<{ name: string; type: string; optional?: boolean; passingStyle?: string }>;
|
|
34
34
|
returnType?: string;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
function operationOverrideFor(ctx: EmitterContext, op: Operation) {
|
|
38
|
+
return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
|
|
38
42
|
const serviceClass = resolveResourceClassName(service, ctx);
|
|
39
43
|
return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
|
|
@@ -48,6 +52,48 @@ function optionsObjectParam(method: BaselineMethod | undefined): { name: string;
|
|
|
48
52
|
return { name: param.name, type: param.type };
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
function configuredOptionsMethod(ctx: EmitterContext, op: Operation): BaselineMethod | undefined {
|
|
56
|
+
const optionsType = operationOverrideFor(ctx, op)?.optionsType;
|
|
57
|
+
if (!optionsType) return undefined;
|
|
58
|
+
return {
|
|
59
|
+
params: [{ name: 'options', type: optionsType, passingStyle: 'options_object' }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function methodOptionsName(method: string, serviceClass: string): string {
|
|
64
|
+
if (method === 'list') return `${toPascalCase(serviceClass)}ListOptions`;
|
|
65
|
+
return `${toPascalCase(method)}Options`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasOptionsInput(op: Operation, plan: any): boolean {
|
|
69
|
+
return op.pathParams.length > 0 || plan.hasBody || plan.isPaginated || op.queryParams.length > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function optionsMethodFor(
|
|
73
|
+
service: Service,
|
|
74
|
+
method: string,
|
|
75
|
+
op: Operation,
|
|
76
|
+
plan: any,
|
|
77
|
+
ctx: EmitterContext,
|
|
78
|
+
): BaselineMethod | undefined {
|
|
79
|
+
const baseline = baselineMethodFor(service, method, ctx);
|
|
80
|
+
if (optionsObjectParam(baseline)) return baseline;
|
|
81
|
+
|
|
82
|
+
const configured = configuredOptionsMethod(ctx, op);
|
|
83
|
+
if (configured) return configured;
|
|
84
|
+
|
|
85
|
+
if (!hasOptionsInput(op, plan)) return baseline;
|
|
86
|
+
return {
|
|
87
|
+
params: [
|
|
88
|
+
{
|
|
89
|
+
name: 'options',
|
|
90
|
+
type: methodOptionsName(method, resolveResourceClassName(service, ctx)),
|
|
91
|
+
passingStyle: 'options_object',
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
51
97
|
function autoPaginatableItemType(returnType: string | undefined): string | undefined {
|
|
52
98
|
return returnType?.match(/\bAutoPaginatable<\s*([A-Za-z_$][\w$]*)/)?.[1];
|
|
53
99
|
}
|
|
@@ -249,7 +295,7 @@ function generateServiceTest(
|
|
|
249
295
|
lines.push(' beforeEach(() => fetch.resetMocks());');
|
|
250
296
|
|
|
251
297
|
for (const { op, plan, method } of plans) {
|
|
252
|
-
const existingMethod =
|
|
298
|
+
const existingMethod = optionsMethodFor(service, method, op, plan, ctx);
|
|
253
299
|
lines.push('');
|
|
254
300
|
lines.push(` describe('${method}', () => {`);
|
|
255
301
|
|
|
@@ -447,6 +493,13 @@ function renderBodyTest(
|
|
|
447
493
|
lines.push(' expect(Array.isArray(result)).toBe(true);');
|
|
448
494
|
}
|
|
449
495
|
|
|
496
|
+
const override = ctx ? operationOverrideFor(ctx, op) : undefined;
|
|
497
|
+
if (override?.returnExpression || override?.returnDataProperty) {
|
|
498
|
+
lines.push(' expect(result).toBeDefined();');
|
|
499
|
+
lines.push(' });');
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
450
503
|
// Use entity helper if available, otherwise inline assertions
|
|
451
504
|
const bodyHelperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
|
|
452
505
|
if (bodyHelperName && entityHelpers?.has(bodyHelperName)) {
|
|
@@ -503,6 +556,19 @@ function renderGetTest(
|
|
|
503
556
|
lines.push(' expect(Array.isArray(result)).toBe(true);');
|
|
504
557
|
}
|
|
505
558
|
|
|
559
|
+
const returnDataProperty = ctx ? operationOverrideFor(ctx, op)?.returnDataProperty : undefined;
|
|
560
|
+
if (returnDataProperty) {
|
|
561
|
+
const responseModel = modelMap.get(responseModelName);
|
|
562
|
+
const returnedField = responseModel?.fields.find((field) => fieldName(field.name) === returnDataProperty);
|
|
563
|
+
if (returnedField?.type.kind === 'array') {
|
|
564
|
+
lines.push(' expect(Array.isArray(result)).toBe(true);');
|
|
565
|
+
} else {
|
|
566
|
+
lines.push(' expect(result).toBeDefined();');
|
|
567
|
+
}
|
|
568
|
+
lines.push(' });');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
506
572
|
// Use entity helper if available, otherwise inline assertions
|
|
507
573
|
const helperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
|
|
508
574
|
if (helperName && entityHelpers?.has(helperName)) {
|
|
@@ -575,14 +641,43 @@ function buildOptionsObjectTestArg(
|
|
|
575
641
|
entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
|
|
576
642
|
}
|
|
577
643
|
|
|
644
|
+
if (plan.isPaginated) {
|
|
645
|
+
entries.push("order: 'desc'");
|
|
646
|
+
}
|
|
647
|
+
const queryParams = plan.isPaginated
|
|
648
|
+
? op.queryParams.filter((param) => !['limit', 'before', 'after', 'order'].includes(param.name))
|
|
649
|
+
: op.queryParams;
|
|
650
|
+
for (const param of queryParams) {
|
|
651
|
+
const localName = fieldName(param.name);
|
|
652
|
+
const value = fixtureValueForType(param.type, param.name, 'Options', modelMap) ?? "'test'";
|
|
653
|
+
entries.push(`${localName}: ${value}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
578
656
|
if (plan.hasBody) {
|
|
579
657
|
const payload = buildTestPayload(op, modelMap);
|
|
580
|
-
if (payload)
|
|
658
|
+
if (payload) {
|
|
659
|
+
const bodyFieldMap = ctx ? operationOverrideFor(ctx, op)?.bodyFieldMap : undefined;
|
|
660
|
+
entries.push(...mapBodyOptionEntries(objectLiteralEntries(payload.camelCaseObj), bodyFieldMap));
|
|
661
|
+
} else if (entries.length === 0) {
|
|
662
|
+
return '({} as any)';
|
|
663
|
+
}
|
|
581
664
|
}
|
|
582
665
|
|
|
583
666
|
return `{ ${entries.join(', ')} }`;
|
|
584
667
|
}
|
|
585
668
|
|
|
669
|
+
function mapBodyOptionEntries(entries: string[], bodyFieldMap: Record<string, string> | undefined): string[] {
|
|
670
|
+
const reverseMap = new Map(Object.entries(bodyFieldMap ?? {}).map(([source, target]) => [target, source]));
|
|
671
|
+
if (reverseMap.size === 0) return entries;
|
|
672
|
+
|
|
673
|
+
return entries.map((entry) => {
|
|
674
|
+
const match = entry.match(/^([A-Za-z_$][\w$]*)\s*:/);
|
|
675
|
+
if (!match) return entry;
|
|
676
|
+
const source = reverseMap.get(match[1]);
|
|
677
|
+
return source ? entry.replace(match[1], source) : entry;
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
586
681
|
function objectLiteralEntries(literal: string): string[] {
|
|
587
682
|
const trimmed = literal.trim();
|
|
588
683
|
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return [];
|
|
@@ -693,7 +788,12 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
|
|
|
693
788
|
// Objects and arrays need toEqual with JSON serialization
|
|
694
789
|
assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
|
|
695
790
|
} else {
|
|
696
|
-
const exampleLiteral =
|
|
791
|
+
const exampleLiteral =
|
|
792
|
+
isDateTime && typeof field.example === 'string'
|
|
793
|
+
? `'${new Date(field.example).toISOString()}'`
|
|
794
|
+
: typeof field.example === 'string'
|
|
795
|
+
? `'${field.example}'`
|
|
796
|
+
: String(field.example);
|
|
697
797
|
assertions.push(`expect(${fieldAccessor}).toBe(${exampleLiteral});`);
|
|
698
798
|
}
|
|
699
799
|
continue;
|
|
@@ -739,7 +839,7 @@ function fixtureValueForType(
|
|
|
739
839
|
): string | null {
|
|
740
840
|
switch (ref.kind) {
|
|
741
841
|
case 'primitive':
|
|
742
|
-
return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
|
|
842
|
+
return fixtureValueForPrimitive(ref.type, ref.format, name, modelName, wire);
|
|
743
843
|
case 'literal':
|
|
744
844
|
return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
|
|
745
845
|
case 'enum':
|
|
@@ -781,10 +881,11 @@ function fixtureValueForPrimitive(
|
|
|
781
881
|
format: string | undefined,
|
|
782
882
|
name: string,
|
|
783
883
|
modelName: string,
|
|
884
|
+
wire?: boolean,
|
|
784
885
|
): string | null {
|
|
785
886
|
switch (type) {
|
|
786
887
|
case 'string':
|
|
787
|
-
if (format === 'date-time') return "'2023-01-01T00:00:00.000Z'";
|
|
888
|
+
if (format === 'date-time') return wire ? "'2023-01-01T00:00:00.000Z'" : "new Date('2023-01-01T00:00:00.000Z')";
|
|
788
889
|
if (format === 'date') return "'2023-01-01'";
|
|
789
890
|
if (format === 'uuid') return "'00000000-0000-0000-0000-000000000000'";
|
|
790
891
|
if (name === 'id') {
|
package/src/php/fixtures.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
2
2
|
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
3
|
+
import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
|
|
3
4
|
import { snakeName } from './naming.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -34,9 +35,11 @@ export function generateFixtures(spec: {
|
|
|
34
35
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
35
36
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
36
37
|
const files: { path: string; content: string }[] = [];
|
|
38
|
+
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
|
|
39
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
|
|
37
40
|
|
|
38
41
|
for (const model of spec.models) {
|
|
39
|
-
if (isListMetadataModel(model)) continue;
|
|
42
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
40
43
|
if (isListWrapperModel(model)) continue;
|
|
41
44
|
|
|
42
45
|
const fixture = generateModelFixture(model, modelMap, enumMap);
|
package/src/php/models.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
isListMetadataModel,
|
|
9
9
|
isListWrapperModel,
|
|
10
10
|
collectNonPaginatedResponseModelNames,
|
|
11
|
+
collectReferencedListMetadataModels,
|
|
11
12
|
} from '../shared/model-utils.js';
|
|
12
13
|
export { isListMetadataModel, isListWrapperModel };
|
|
13
14
|
|
|
@@ -40,9 +41,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
40
41
|
// for `GET /vault/v1/kv/{id}/versions`) must still be emitted — the resource
|
|
41
42
|
// code references them by name and the pagination iterator doesn't unwrap them.
|
|
42
43
|
const nonPaginatedRefs = collectNonPaginatedResponseModelNames(ctx.spec.services);
|
|
44
|
+
const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
|
|
43
45
|
|
|
44
46
|
for (const model of models) {
|
|
45
|
-
if (isListMetadataModel(model)) continue;
|
|
47
|
+
if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
|
|
46
48
|
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
47
49
|
const name = className(model.name);
|
|
48
50
|
const lines: string[] = [];
|
package/src/php/resources.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Service,
|
|
3
|
+
Operation,
|
|
4
|
+
Model,
|
|
5
|
+
EmitterContext,
|
|
6
|
+
GeneratedFile,
|
|
7
|
+
ResolvedOperation,
|
|
8
|
+
Parameter,
|
|
9
|
+
} from '@workos/oagen';
|
|
2
10
|
import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
|
|
3
11
|
import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
|
|
4
12
|
import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
|
|
@@ -243,7 +251,8 @@ function generateMethod(
|
|
|
243
251
|
]);
|
|
244
252
|
|
|
245
253
|
const isRedirect = isRedirectEndpoint(op, resolvedOp);
|
|
246
|
-
const
|
|
254
|
+
const materializeQueryDefaults = !isRedirect;
|
|
255
|
+
const params = buildMethodParams(op, plan, modelMap, ctx, hiddenParams, { materializeQueryDefaults });
|
|
247
256
|
const returnType = isRedirect ? 'string' : getReturnType(plan, ctx);
|
|
248
257
|
|
|
249
258
|
// PHPDoc block
|
|
@@ -302,9 +311,10 @@ function generateMethod(
|
|
|
302
311
|
const phpName = fieldName(q.name);
|
|
303
312
|
if (seenDocParams.has(phpName)) continue;
|
|
304
313
|
seenDocParams.add(phpName);
|
|
305
|
-
// Spec-defaulted enum params are non-nullable
|
|
306
|
-
//
|
|
307
|
-
|
|
314
|
+
// Spec-defaulted enum params on HTTP calls are non-nullable because the
|
|
315
|
+
// signature default is the enum case. URL builders keep them nullable so
|
|
316
|
+
// omitted optional query params stay omitted from the generated URL.
|
|
317
|
+
const hasEnumDefault = shouldMaterializeQueryDefault(q, materializeQueryDefaults);
|
|
308
318
|
const nullSuffix = !q.required && !hasEnumDefault && !docType.endsWith('|null') ? '|null' : '';
|
|
309
319
|
const prefix = q.deprecated ? '(deprecated) ' : '';
|
|
310
320
|
let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
|
|
@@ -358,7 +368,7 @@ function generateMethod(
|
|
|
358
368
|
|
|
359
369
|
if (isRedirect) {
|
|
360
370
|
// Redirect endpoint: construct URL client-side instead of making HTTP request
|
|
361
|
-
const queryLines = buildQueryArray(op, hiddenParams);
|
|
371
|
+
const queryLines = buildQueryArray(op, hiddenParams, { materializeQueryDefaults });
|
|
362
372
|
const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
|
|
363
373
|
const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
|
|
364
374
|
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
|
|
@@ -617,6 +627,7 @@ function buildMethodParams(
|
|
|
617
627
|
modelMap: Map<string, Model>,
|
|
618
628
|
ctx: EmitterContext,
|
|
619
629
|
hiddenParams?: Set<string>,
|
|
630
|
+
opts?: { materializeQueryDefaults?: boolean },
|
|
620
631
|
): string[] {
|
|
621
632
|
// Collect all params into required/optional buckets to avoid
|
|
622
633
|
// PHP's "required after optional" deprecation.
|
|
@@ -684,7 +695,7 @@ function buildMethodParams(
|
|
|
684
695
|
usedNames.add(phpName);
|
|
685
696
|
if (q.required) {
|
|
686
697
|
required.push(`${phpType} $${phpName}`);
|
|
687
|
-
} else if (q
|
|
698
|
+
} else if (shouldMaterializeQueryDefault(q, opts?.materializeQueryDefaults ?? true)) {
|
|
688
699
|
// Spec-provided default for an enum-typed param: emit a non-nullable
|
|
689
700
|
// typed default (e.g. PaginationOrder $order = PaginationOrder::Desc).
|
|
690
701
|
// Only enums are safe to default this way — primitives stay nullable so
|
|
@@ -748,7 +759,17 @@ function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
|
|
|
748
759
|
return false;
|
|
749
760
|
}
|
|
750
761
|
|
|
751
|
-
function
|
|
762
|
+
function isDateTimeType(ref: import('@workos/oagen').TypeRef): boolean {
|
|
763
|
+
if (ref.kind === 'primitive' && ref.format === 'date-time') return true;
|
|
764
|
+
if (ref.kind === 'nullable') return isDateTimeType(ref.inner);
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function buildQueryArray(
|
|
769
|
+
op: Operation,
|
|
770
|
+
hiddenParams?: Set<string>,
|
|
771
|
+
opts?: { materializeQueryDefaults?: boolean },
|
|
772
|
+
): string[] {
|
|
752
773
|
const hidden = hiddenParams ?? new Set();
|
|
753
774
|
const groupedParams = collectGroupedParamNames(op);
|
|
754
775
|
return op.queryParams
|
|
@@ -756,16 +777,24 @@ function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
|
|
|
756
777
|
.map((q) => {
|
|
757
778
|
const phpName = fieldName(q.name);
|
|
758
779
|
if (isEnumType(q.type)) {
|
|
759
|
-
// Mirrors the signature:
|
|
760
|
-
// non-nullable, so
|
|
761
|
-
const hasEnumDefault = q
|
|
780
|
+
// Mirrors the signature: only materialized enum defaults are
|
|
781
|
+
// non-nullable, so other optional enum params use the nullsafe op.
|
|
782
|
+
const hasEnumDefault = shouldMaterializeQueryDefault(q, opts?.materializeQueryDefaults ?? true);
|
|
762
783
|
const nullsafe = q.required || hasEnumDefault ? '' : '?';
|
|
763
784
|
return `'${q.name}' => $${phpName}${nullsafe}->value,`;
|
|
764
785
|
}
|
|
786
|
+
if (isDateTimeType(q.type)) {
|
|
787
|
+
const nullsafe = q.required ? '' : '?';
|
|
788
|
+
return `'${q.name}' => $${phpName}${nullsafe}->format(\\DateTimeInterface::RFC3339_EXTENDED),`;
|
|
789
|
+
}
|
|
765
790
|
return `'${q.name}' => $${phpName},`;
|
|
766
791
|
});
|
|
767
792
|
}
|
|
768
793
|
|
|
794
|
+
function shouldMaterializeQueryDefault(param: Parameter, enabled: boolean): boolean {
|
|
795
|
+
return enabled && param.default != null && param.type.kind === 'enum';
|
|
796
|
+
}
|
|
797
|
+
|
|
769
798
|
function phpLiteral(value: unknown): string {
|
|
770
799
|
if (typeof value === 'string') return `'${value}'`;
|
|
771
800
|
if (typeof value === 'number') return String(value);
|
package/src/php/tests.ts
CHANGED
|
@@ -375,9 +375,15 @@ function buildTestArgs(
|
|
|
375
375
|
return args.join(', ');
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
-
function generateTestValue(
|
|
378
|
+
function generateTestValue(
|
|
379
|
+
ref: { kind: string; type?: string; name?: string; format?: string },
|
|
380
|
+
ctx?: EmitterContext,
|
|
381
|
+
): string {
|
|
379
382
|
switch (ref.kind) {
|
|
380
383
|
case 'primitive':
|
|
384
|
+
if (ref.format === 'date-time') {
|
|
385
|
+
return "new \\DateTimeImmutable('2023-01-01T00:00:00Z')";
|
|
386
|
+
}
|
|
381
387
|
switch (ref.type) {
|
|
382
388
|
case 'string':
|
|
383
389
|
return "'test_value'";
|
|
@@ -529,17 +535,21 @@ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext
|
|
|
529
535
|
lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
|
|
530
536
|
}
|
|
531
537
|
} else if (innerType.kind === 'primitive') {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
538
|
+
if ((innerType as { format?: string }).format === 'date-time') {
|
|
539
|
+
lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
|
|
540
|
+
} else {
|
|
541
|
+
switch (innerType.type) {
|
|
542
|
+
case 'string':
|
|
543
|
+
lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
|
|
544
|
+
break;
|
|
545
|
+
case 'integer':
|
|
546
|
+
case 'number':
|
|
547
|
+
lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
|
|
548
|
+
break;
|
|
549
|
+
case 'boolean':
|
|
550
|
+
lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
543
553
|
}
|
|
544
554
|
}
|
|
545
555
|
}
|
package/src/python/client.ts
CHANGED
|
@@ -32,14 +32,6 @@ const PYTHON_NON_SPEC_WIRING: Record<string, PythonNonSpecWiring> = {
|
|
|
32
32
|
ctorArg: 'self',
|
|
33
33
|
docstring: 'Passwordless authentication sessions.',
|
|
34
34
|
},
|
|
35
|
-
vault: {
|
|
36
|
-
importLine: 'from .vault import AsyncVault, Vault',
|
|
37
|
-
prop: 'vault',
|
|
38
|
-
syncClass: 'Vault',
|
|
39
|
-
asyncClass: 'AsyncVault',
|
|
40
|
-
ctorArg: 'self',
|
|
41
|
-
docstring: 'Vault encryption, key management, and secret storage.',
|
|
42
|
-
},
|
|
43
35
|
actions: {
|
|
44
36
|
importLine: 'from .actions import Actions, AsyncActions',
|
|
45
37
|
prop: 'actions',
|
package/src/python/enums.ts
CHANGED
|
@@ -49,18 +49,29 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
49
49
|
const canonicalCls = className(canonicalName);
|
|
50
50
|
const aliasCls = className(enumDef.name);
|
|
51
51
|
const lines: string[] = [];
|
|
52
|
-
lines.push('from typing import TypeAlias');
|
|
53
52
|
// Use explicit __all__ to prevent ruff F401 from stripping the re-export
|
|
54
53
|
// Always use direct file import to avoid barrel dependency on the canonical
|
|
55
54
|
if (canonicalDir === dirName) {
|
|
55
|
+
lines.push('from typing import TypeAlias');
|
|
56
56
|
lines.push(`from .${fileName(canonicalName)} import ${canonicalCls}`);
|
|
57
|
+
lines.push('');
|
|
58
|
+
lines.push(`${aliasCls}: TypeAlias = ${canonicalCls}`);
|
|
57
59
|
} else {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
60
|
+
// Cross-service enum aliases use TYPE_CHECKING + __getattr__ to avoid
|
|
61
|
+
// circular imports caused by service __init__.py eagerly re-exporting
|
|
62
|
+
// all models (e.g. common → radar → common cycle).
|
|
63
|
+
const modPath = `${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)}`;
|
|
64
|
+
lines.push('from typing import TYPE_CHECKING');
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push('if TYPE_CHECKING:');
|
|
67
|
+
lines.push(` from ${modPath} import ${canonicalCls} as ${aliasCls}`);
|
|
68
|
+
lines.push('else:');
|
|
69
|
+
lines.push(' def __getattr__(name: str):');
|
|
70
|
+
lines.push(` if name == "${aliasCls}":`);
|
|
71
|
+
lines.push(` from ${modPath} import ${canonicalCls}`);
|
|
72
|
+
lines.push(` return ${canonicalCls}`);
|
|
73
|
+
lines.push(' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")');
|
|
61
74
|
}
|
|
62
|
-
lines.push('');
|
|
63
|
-
lines.push(`${aliasCls}: TypeAlias = ${canonicalCls}`);
|
|
64
75
|
lines.push(`__all__ = ["${aliasCls}"]`);
|
|
65
76
|
files.push({
|
|
66
77
|
path: `src/${ctx.namespace}/${dirName}/models/${fileName(enumDef.name)}.py`,
|
|
@@ -71,19 +82,34 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
|
|
|
71
82
|
|
|
72
83
|
// Also generate compat alias files for dedup aliases (they may have compat aliases too)
|
|
73
84
|
for (const aliasName of compatAliases.get(enumDef.name) ?? []) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
: `from ${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)} import ${canonicalCls}`;
|
|
78
|
-
files.push({
|
|
79
|
-
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
80
|
-
content: [
|
|
85
|
+
let compatContent: string;
|
|
86
|
+
if (canonicalDir === dirName) {
|
|
87
|
+
compatContent = [
|
|
81
88
|
'from typing import TypeAlias',
|
|
82
|
-
|
|
89
|
+
`from .${fileName(canonicalName)} import ${canonicalCls}`,
|
|
83
90
|
'',
|
|
84
91
|
`${aliasName}: TypeAlias = ${canonicalCls}`,
|
|
85
92
|
`__all__ = ["${aliasName}"]`,
|
|
86
|
-
].join('\n')
|
|
93
|
+
].join('\n');
|
|
94
|
+
} else {
|
|
95
|
+
const modPath = `${ctx.namespace}.${dirToModule(canonicalDir)}.models.${fileName(canonicalName)}`;
|
|
96
|
+
compatContent = [
|
|
97
|
+
'from typing import TYPE_CHECKING',
|
|
98
|
+
'',
|
|
99
|
+
'if TYPE_CHECKING:',
|
|
100
|
+
` from ${modPath} import ${canonicalCls} as ${aliasName}`,
|
|
101
|
+
'else:',
|
|
102
|
+
' def __getattr__(name: str):',
|
|
103
|
+
` if name == "${aliasName}":`,
|
|
104
|
+
` from ${modPath} import ${canonicalCls}`,
|
|
105
|
+
` return ${canonicalCls}`,
|
|
106
|
+
' raise AttributeError(f"module {__name__!r} has no attribute {name!r}")',
|
|
107
|
+
`__all__ = ["${aliasName}"]`,
|
|
108
|
+
].join('\n');
|
|
109
|
+
}
|
|
110
|
+
files.push({
|
|
111
|
+
path: `src/${ctx.namespace}/${dirName}/models/${fileName(aliasName)}.py`,
|
|
112
|
+
content: compatContent,
|
|
87
113
|
integrateTarget: true,
|
|
88
114
|
overwriteExisting: true,
|
|
89
115
|
});
|
package/src/python/fixtures.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function generateModelFixture(
|
|
|
117
117
|
// Use the original field name as the wire key (matches from_dict access patterns)
|
|
118
118
|
const wireName = field.name;
|
|
119
119
|
if (field.example !== undefined) {
|
|
120
|
-
fixture[wireName] = field.example;
|
|
120
|
+
fixture[wireName] = normalizeDateTimeExample(field, field.example);
|
|
121
121
|
} else {
|
|
122
122
|
fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
|
|
123
123
|
}
|
|
@@ -129,12 +129,10 @@ export function generateModelFixture(
|
|
|
129
129
|
const variantModel = modelMap.get(variantName);
|
|
130
130
|
if (variantModel) {
|
|
131
131
|
for (const field of variantModel.fields) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
field.example
|
|
135
|
-
|
|
136
|
-
: generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
|
|
137
|
-
}
|
|
132
|
+
fixture[field.name] =
|
|
133
|
+
field.example !== undefined
|
|
134
|
+
? normalizeDateTimeExample(field, field.example)
|
|
135
|
+
: generateFieldValue(field.type, field.name, variantName, modelMap, enumMap);
|
|
138
136
|
}
|
|
139
137
|
}
|
|
140
138
|
}
|
|
@@ -187,6 +185,24 @@ function generateFieldValue(
|
|
|
187
185
|
}
|
|
188
186
|
}
|
|
189
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Normalize date-time example values to millisecond precision so that
|
|
190
|
+
* round-trip tests (from_dict → to_dict) produce identical output.
|
|
191
|
+
* Python's _format_datetime always serializes with milliseconds.
|
|
192
|
+
*/
|
|
193
|
+
function normalizeDateTimeExample(
|
|
194
|
+
field: { type: { kind: string; format?: string; type?: string; inner?: any } },
|
|
195
|
+
value: any,
|
|
196
|
+
): any {
|
|
197
|
+
if (typeof value !== 'string') return value;
|
|
198
|
+
const ref = field.type.kind === 'nullable' ? field.type.inner : field.type;
|
|
199
|
+
if (ref?.kind !== 'primitive' || ref?.type !== 'string' || ref?.format !== 'date-time') return value;
|
|
200
|
+
const match = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/);
|
|
201
|
+
if (!match) return value;
|
|
202
|
+
const [, prefix, , suffix] = match;
|
|
203
|
+
return `${prefix}.000${suffix}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
190
206
|
function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
|
|
191
207
|
switch (type) {
|
|
192
208
|
case 'string':
|
package/src/python/models.ts
CHANGED
|
@@ -51,6 +51,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
51
51
|
// otherwise the wrapper's module imports a class that was never written.
|
|
52
52
|
const listMetadataNeeded = collectReferencedListMetadataModels(models, nonPaginatedRefs);
|
|
53
53
|
|
|
54
|
+
// Discriminator model names — fields referencing these should use the Variant union type
|
|
55
|
+
const discriminatorNames = new Set<string>();
|
|
56
|
+
for (const m of models) {
|
|
57
|
+
if ((m as any).discriminator) discriminatorNames.add(m.name);
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
for (const model of models) {
|
|
55
61
|
// Skip list wrapper models (e.g., OrganizationList) — SyncPage handles envelopes
|
|
56
62
|
if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
|
|
@@ -201,7 +207,6 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
201
207
|
const canonicalClassName = className(canonicalName);
|
|
202
208
|
const lines: string[] = [];
|
|
203
209
|
lines.push('from typing import TypeAlias');
|
|
204
|
-
// Always use direct file import to avoid barrel dependency on the canonical
|
|
205
210
|
if (canonicalDir === dirName) {
|
|
206
211
|
lines.push(`from .${fileName(canonicalName)} import ${canonicalClassName}`);
|
|
207
212
|
} else {
|
|
@@ -274,11 +279,15 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
274
279
|
if (modelName === model.name) continue; // skip self
|
|
275
280
|
const modelService = modelToService.get(modelName);
|
|
276
281
|
const modelDir = resolveDir(modelService);
|
|
282
|
+
// For discriminator models, also import the Variant type alias (lives in same file)
|
|
283
|
+
const importNames = discriminatorNames.has(modelName)
|
|
284
|
+
? `${className(modelName)}, ${className(modelName)}Variant`
|
|
285
|
+
: className(modelName);
|
|
277
286
|
if (modelDir === dirName) {
|
|
278
|
-
lines.push(`from .${fileName(modelName)} import ${
|
|
287
|
+
lines.push(`from .${fileName(modelName)} import ${importNames}`);
|
|
279
288
|
} else {
|
|
280
289
|
lines.push(
|
|
281
|
-
`from ${ctx.namespace}.${dirToModule(modelDir)}.models.${fileName(modelName)} import ${
|
|
290
|
+
`from ${ctx.namespace}.${dirToModule(modelDir)}.models.${fileName(modelName)} import ${importNames}`,
|
|
282
291
|
);
|
|
283
292
|
}
|
|
284
293
|
}
|
|
@@ -321,9 +330,21 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
321
330
|
const requiredFields = deduplicatedFields.filter((f) => !isOptionalField(model.name, f, ctx));
|
|
322
331
|
const optionalFields = deduplicatedFields.filter((f) => isOptionalField(model.name, f, ctx));
|
|
323
332
|
|
|
333
|
+
// Rewrite discriminator model references to their Variant union type.
|
|
334
|
+
// E.g. `"ConnectApplication"` → `"ConnectApplicationVariant"`
|
|
335
|
+
const rewriteDiscriminatorType = (typeStr: string): string => {
|
|
336
|
+
for (const discName of discriminatorNames) {
|
|
337
|
+
const quoted = `"${className(discName)}"`;
|
|
338
|
+
if (typeStr.includes(quoted)) {
|
|
339
|
+
typeStr = typeStr.replace(quoted, `"${className(discName)}Variant"`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return typeStr;
|
|
343
|
+
};
|
|
344
|
+
|
|
324
345
|
for (const field of requiredFields) {
|
|
325
346
|
const pyFieldName = fieldName(field.name);
|
|
326
|
-
const pyType = resolveModelFieldType(field.type);
|
|
347
|
+
const pyType = rewriteDiscriminatorType(resolveModelFieldType(field.type));
|
|
327
348
|
if (field.description || field.deprecated) {
|
|
328
349
|
const parts: string[] = [];
|
|
329
350
|
if (field.description) parts.push(field.description);
|
|
@@ -339,7 +360,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
339
360
|
const pyFieldName = fieldName(field.name);
|
|
340
361
|
const innerType =
|
|
341
362
|
field.type.kind === 'nullable' ? resolveModelFieldType(field.type.inner) : resolveModelFieldType(field.type);
|
|
342
|
-
const pyType = `Optional[${innerType}]`;
|
|
363
|
+
const pyType = `Optional[${rewriteDiscriminatorType(innerType)}]`;
|
|
343
364
|
if (field.description || field.deprecated) {
|
|
344
365
|
const parts: string[] = [];
|
|
345
366
|
if (field.description) parts.push(field.description);
|