@workos/oagen-emitters 0.2.1 → 0.3.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.
Files changed (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,109 @@
1
+ import type { Operation, EmitterContext, Service, ResolvedOperation } from '@workos/oagen';
2
+ import { toPascalCase } from '@workos/oagen';
3
+
4
+ /**
5
+ * Build a lookup map from "METHOD /path" to ResolvedOperation.
6
+ * Used by emitters to find the resolved method name for any IR operation.
7
+ */
8
+ export function buildResolvedLookup(ctx: EmitterContext): Map<string, ResolvedOperation> {
9
+ const map = new Map<string, ResolvedOperation>();
10
+ for (const r of ctx.resolvedOperations ?? []) {
11
+ const key = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}`;
12
+ map.set(key, r);
13
+ }
14
+ return map;
15
+ }
16
+
17
+ /**
18
+ * Look up the resolved method name for an operation.
19
+ * Returns the snake_case resolved name, or undefined if not found.
20
+ */
21
+ export function lookupMethodName(op: Operation, lookup: Map<string, ResolvedOperation>): string | undefined {
22
+ const key = `${op.httpMethod.toUpperCase()} ${op.path}`;
23
+ return lookup.get(key)?.methodName;
24
+ }
25
+
26
+ /**
27
+ * Look up the full ResolvedOperation for an IR operation.
28
+ */
29
+ export function lookupResolved(op: Operation, lookup: Map<string, ResolvedOperation>): ResolvedOperation | undefined {
30
+ const key = `${op.httpMethod.toUpperCase()} ${op.path}`;
31
+ return lookup.get(key);
32
+ }
33
+
34
+ /**
35
+ * A mount group: a set of resolved operations that all mount on the same target.
36
+ * Serves the same role as a Service in the old architecture, but operations may
37
+ * come from multiple IR services.
38
+ */
39
+ export interface MountGroup {
40
+ /** PascalCase mount target name (e.g., "SSO", "UserManagement"). */
41
+ name: string;
42
+ /** All resolved operations in this group. */
43
+ resolvedOps: ResolvedOperation[];
44
+ /** The raw IR operations (convenience — same as resolvedOps[*].operation). */
45
+ operations: Operation[];
46
+ }
47
+
48
+ /**
49
+ * Group resolved operations by their mountOn target.
50
+ * Returns a map from PascalCase mount target to MountGroup.
51
+ */
52
+ export function groupByMount(ctx: EmitterContext): Map<string, MountGroup> {
53
+ const groups = new Map<string, MountGroup>();
54
+ for (const r of ctx.resolvedOperations ?? []) {
55
+ let group = groups.get(r.mountOn);
56
+ if (!group) {
57
+ group = { name: r.mountOn, resolvedOps: [], operations: [] };
58
+ groups.set(r.mountOn, group);
59
+ }
60
+ group.resolvedOps.push(r);
61
+ group.operations.push(r.operation);
62
+ }
63
+ return groups;
64
+ }
65
+
66
+ /**
67
+ * Get the mount target for an IR service.
68
+ * Checks the first resolved operation that belongs to this service.
69
+ * Falls back to PascalCase of the service name if no resolved ops exist.
70
+ */
71
+ export function getMountTarget(service: Service, ctx: EmitterContext): string {
72
+ for (const r of ctx.resolvedOperations ?? []) {
73
+ if (r.service.name === service.name) return r.mountOn;
74
+ }
75
+ return toPascalCase(service.name);
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Defaults / inferFromClient helpers
80
+ //
81
+ // For non-split operations with operationHints that specify `defaults` or
82
+ // `inferFromClient`, oagen attaches these properties at runtime but they
83
+ // are not part of the ResolvedOperation TypeScript interface (they live on
84
+ // ResolvedWrapper for split operations). These helpers provide type-safe
85
+ // access so individual emitters don't need `as any` casts.
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /** Extract constant defaults from a resolved operation (if any). */
89
+ export function getOpDefaults(resolvedOp?: ResolvedOperation): Record<string, string | number | boolean> {
90
+ return ((resolvedOp as any)?.defaults as Record<string, string | number | boolean> | undefined) ?? {};
91
+ }
92
+
93
+ /** Extract inferFromClient fields from a resolved operation (if any). */
94
+ export function getOpInferFromClient(resolvedOp?: ResolvedOperation): string[] {
95
+ return ((resolvedOp as any)?.inferFromClient as string[] | undefined) ?? [];
96
+ }
97
+
98
+ /** Build the set of param names hidden from the public API (injected as defaults or inferred from client config). */
99
+ export function buildHiddenParams(resolvedOp?: ResolvedOperation): Set<string> {
100
+ const hidden = new Set<string>();
101
+ for (const key of Object.keys(getOpDefaults(resolvedOp))) hidden.add(key);
102
+ for (const key of getOpInferFromClient(resolvedOp)) hidden.add(key);
103
+ return hidden;
104
+ }
105
+
106
+ /** Check whether a resolved operation has any hidden params. */
107
+ export function hasHiddenParams(resolvedOp?: ResolvedOperation): boolean {
108
+ return Object.keys(getOpDefaults(resolvedOp)).length > 0 || getOpInferFromClient(resolvedOp).length > 0;
109
+ }
@@ -0,0 +1,59 @@
1
+ import type { EmitterContext, Field, ResolvedWrapper } from '@workos/oagen';
2
+ import { toSnakeCase } from '@workos/oagen';
3
+ import { enrichModelsFromSpec } from './model-utils.js';
4
+
5
+ /**
6
+ * A resolved wrapper parameter with its variant model field and optional status.
7
+ * Pre-computed once per wrapper so emitters don't repeat the lookup.
8
+ */
9
+ export interface ResolvedWrapperParam {
10
+ /** Wire name of the param (e.g., "email", "grant_type"). */
11
+ paramName: string;
12
+ /** The field from the variant model, or null if not found in the spec. */
13
+ field: Field | null;
14
+ /** Whether this param should be optional in the generated SDK. */
15
+ isOptional: boolean;
16
+ }
17
+
18
+ /**
19
+ * Resolve the variant model's fields for a wrapper's exposed params.
20
+ *
21
+ * Encapsulates the three-step lookup every emitter needs:
22
+ * 1. Find the variant model via wrapper.targetVariant in ctx.spec.models
23
+ * 2. Match each exposed param to its field in the variant
24
+ * 3. Classify as required or optional using wrapper.optionalParams + field.required
25
+ *
26
+ * Field matching uses exact name first, then falls back to snake_case normalization
27
+ * to handle cases where wire names and model field names differ in casing.
28
+ */
29
+ export function resolveWrapperParams(wrapper: ResolvedWrapper, ctx: EmitterContext): ResolvedWrapperParam[] {
30
+ let variantModel = ctx.spec.models.find((m) => m.name === wrapper.targetVariant);
31
+
32
+ // If the variant model has no fields, try enriching from the raw spec.
33
+ // Some oneOf variants have 0 IR fields until enrichModelsFromSpec backfills them.
34
+ if (!variantModel || variantModel.fields.length === 0) {
35
+ const enriched = enrichModelsFromSpec(ctx.spec.models);
36
+ variantModel = enriched.find((m) => m.name === wrapper.targetVariant) ?? variantModel;
37
+ }
38
+
39
+ const variantFields = variantModel?.fields ?? [];
40
+ const optionalSet = new Set(wrapper.optionalParams);
41
+
42
+ return wrapper.exposedParams.map((paramName) => {
43
+ const field =
44
+ variantFields.find((f) => f.name === paramName || toSnakeCase(f.name) === toSnakeCase(paramName)) ?? null;
45
+ const isOptional = optionalSet.has(paramName) || !field?.required;
46
+ return { paramName, field, isOptional };
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Format a snake_case wrapper name into a human-readable description.
52
+ * "authenticate_with_password" → "Authenticate with password"
53
+ */
54
+ export function formatWrapperDescription(name: string): string {
55
+ return name
56
+ .split('_')
57
+ .map((w, i) => (i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w))
58
+ .join(' ');
59
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateClient } from '../../src/go/client.js';
5
+
6
+ function makeSpec(services: Service[]): ApiSpec {
7
+ return {
8
+ name: 'Test',
9
+ version: '1.0.0',
10
+ baseUrl: 'https://api.workos.com',
11
+ services,
12
+ models: [],
13
+ enums: [],
14
+ sdk: defaultSdkBehavior(),
15
+ };
16
+ }
17
+
18
+ function makeCtx(spec: ApiSpec): EmitterContext {
19
+ return {
20
+ namespace: 'workos',
21
+ namespacePascal: 'WorkOS',
22
+ spec,
23
+ };
24
+ }
25
+
26
+ describe('go/client', () => {
27
+ it('generates only workos.go', () => {
28
+ const spec = makeSpec([]);
29
+ const files = generateClient(spec, makeCtx(spec));
30
+ expect(files.length).toBe(1);
31
+ expect(files[0].path).toBe('workos.go');
32
+ });
33
+
34
+ it('generates Client struct with service fields', () => {
35
+ const spec = makeSpec([
36
+ {
37
+ name: 'Organizations',
38
+ operations: [
39
+ {
40
+ name: 'listOrganizations',
41
+ httpMethod: 'get',
42
+ path: '/organizations',
43
+ pathParams: [],
44
+ queryParams: [],
45
+ headerParams: [],
46
+ response: { kind: 'model', name: 'Organization' },
47
+ errors: [],
48
+ injectIdempotencyKey: false,
49
+ },
50
+ ],
51
+ },
52
+ ]);
53
+ const files = generateClient(spec, makeCtx(spec));
54
+ const workosFile = files.find((f) => f.path === 'workos.go')!;
55
+ const content = workosFile.content;
56
+
57
+ expect(content).toContain('package workos');
58
+ expect(content).toContain('organizations *organizationService');
59
+ expect(content).toContain('func NewClient(apiKey string, opts ...ClientOption) *Client {');
60
+ expect(content).toContain('func (c *Client) Organizations() *organizationService {');
61
+ });
62
+
63
+ it('does not emit static options or HTTP infrastructure', () => {
64
+ const spec = makeSpec([]);
65
+ const files = generateClient(spec, makeCtx(spec));
66
+ const workosFile = files.find((f) => f.path === 'workos.go')!;
67
+ const content = workosFile.content;
68
+
69
+ // These definitions are now in hand-maintained options.go
70
+ expect(content).not.toContain('type ClientOption func(*Client)');
71
+ expect(content).not.toContain('func WithBaseURL');
72
+ expect(content).not.toContain('type RequestOption');
73
+ expect(content).not.toContain('type requestConfig struct');
74
+ // Constants are defined in options.go, but referenced in NewClient
75
+ expect(content).not.toContain('defaultBaseURL =');
76
+ });
77
+
78
+ it('uses acronym-aware service accessors and fields', () => {
79
+ const spec = makeSpec([
80
+ { name: 'ApiKeys', operations: [] },
81
+ { name: 'SSO', operations: [] },
82
+ ]);
83
+ const files = generateClient(spec, makeCtx(spec));
84
+ const workosFile = files.find((f) => f.path === 'workos.go')!;
85
+ const content = workosFile.content;
86
+
87
+ expect(content).toContain('apiKeys *apiKeyService');
88
+ expect(content).toContain('sso *ssoService');
89
+ expect(content).toContain('func (c *Client) APIKeys() *apiKeyService {');
90
+ expect(content).toContain('func (c *Client) SSO() *ssoService {');
91
+ });
92
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateEnums } from '../../src/go/enums.js';
5
+
6
+ const emptySpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [],
11
+ models: [],
12
+ enums: [],
13
+ sdk: defaultSdkBehavior(),
14
+ };
15
+
16
+ const ctx: EmitterContext = {
17
+ namespace: 'workos',
18
+ namespacePascal: 'WorkOS',
19
+ spec: emptySpec,
20
+ };
21
+
22
+ describe('go/enums', () => {
23
+ it('returns empty for no enums', () => {
24
+ expect(generateEnums([], ctx)).toEqual([]);
25
+ });
26
+
27
+ it('generates typed string constants', () => {
28
+ const enums: Enum[] = [
29
+ {
30
+ name: 'ConnectionStatus',
31
+ values: [
32
+ { name: 'ACTIVE', value: 'active' },
33
+ { name: 'INACTIVE', value: 'inactive' },
34
+ ],
35
+ },
36
+ ];
37
+ const files = generateEnums(enums, ctx);
38
+ expect(files).toHaveLength(1);
39
+ expect(files[0].path).toBe('enums.go');
40
+ const content = files[0].content;
41
+ expect(content).toContain('package workos');
42
+ expect(content).toContain('type ConnectionStatus string');
43
+ expect(content).toContain('ConnectionStatusActive ConnectionStatus = "active"');
44
+ expect(content).toContain('ConnectionStatusInactive ConnectionStatus = "inactive"');
45
+ });
46
+
47
+ it('deduplicates identical enums as type aliases', () => {
48
+ const enums: Enum[] = [
49
+ {
50
+ name: 'Alpha',
51
+ values: [{ name: 'A', value: 'a' }],
52
+ },
53
+ {
54
+ name: 'Beta',
55
+ values: [{ name: 'A', value: 'a' }],
56
+ },
57
+ ];
58
+ const files = generateEnums(enums, ctx);
59
+ const content = files[0].content;
60
+ expect(content).toContain('type Alpha string');
61
+ expect(content).toContain('type Beta = Alpha');
62
+ });
63
+
64
+ it('handles empty enums as type aliases to string', () => {
65
+ const enums: Enum[] = [{ name: 'UnknownType', values: [] }];
66
+ const files = generateEnums(enums, ctx);
67
+ const content = files[0].content;
68
+ expect(content).toContain('type UnknownType = string');
69
+ });
70
+
71
+ it('snapshot: ConnectionStatus enum', () => {
72
+ const enums: Enum[] = [
73
+ {
74
+ name: 'ConnectionStatus',
75
+ values: [
76
+ { name: 'ACTIVE', value: 'active' },
77
+ { name: 'INACTIVE', value: 'inactive' },
78
+ { name: 'PENDING', value: 'pending' },
79
+ ],
80
+ },
81
+ ];
82
+ const files = generateEnums(enums, ctx);
83
+ expect(files[0].content).toMatchInlineSnapshot(`
84
+ "package workos
85
+
86
+ // ConnectionStatus represents connection status values.
87
+ type ConnectionStatus string
88
+
89
+ const (
90
+ ConnectionStatusActive ConnectionStatus = "active"
91
+ ConnectionStatusInactive ConnectionStatus = "inactive"
92
+ ConnectionStatusPending ConnectionStatus = "pending"
93
+ )
94
+ "
95
+ `);
96
+ });
97
+
98
+ it('emits Deprecated comments for deprecated enum values', () => {
99
+ const enums: Enum[] = [
100
+ {
101
+ name: 'WidgetStatus',
102
+ values: [
103
+ { name: 'ACTIVE', value: 'active', description: 'Currently active', deprecated: true },
104
+ { name: 'LEGACY', value: 'legacy', deprecated: true },
105
+ { name: 'CURRENT', value: 'current' },
106
+ ],
107
+ },
108
+ ];
109
+ const files = generateEnums(enums, ctx);
110
+ const content = files[0].content;
111
+ // deprecated value WITH description gets separator + Deprecated
112
+ expect(content).toContain(
113
+ '\t// WidgetStatusActive is Currently active.\n\t//\n\t// Deprecated: this value is deprecated.',
114
+ );
115
+ // deprecated value WITHOUT description gets Deprecated only
116
+ expect(content).toContain('\t// Deprecated: this value is deprecated.\n\tWidgetStatusLegacy');
117
+ // non-deprecated value does NOT get Deprecated
118
+ expect(content).not.toMatch(/Deprecated.*\n\tWidgetStatusCurrent/);
119
+ });
120
+
121
+ it('uses Go acronym conventions for enum type names', () => {
122
+ const enums: Enum[] = [
123
+ {
124
+ name: 'SsoConnectionType',
125
+ values: [{ name: 'SAML', value: 'saml' }],
126
+ },
127
+ ];
128
+ const files = generateEnums(enums, ctx);
129
+ const content = files[0].content;
130
+ expect(content).toContain('type SSOConnectionType string');
131
+ });
132
+ });
@@ -0,0 +1,9 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { goEmitter } from '../../src/go/index.js';
3
+
4
+ describe('go/errors', () => {
5
+ it('returns empty array (errors are hand-maintained in the target SDK)', () => {
6
+ const files = goEmitter.generateErrors({} as any);
7
+ expect(files).toHaveLength(0);
8
+ });
9
+ });
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateModels } from '../../src/go/models.js';
5
+
6
+ const emptySpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [],
11
+ models: [],
12
+ enums: [],
13
+ sdk: defaultSdkBehavior(),
14
+ };
15
+
16
+ const ctx: EmitterContext = {
17
+ namespace: 'workos',
18
+ namespacePascal: 'WorkOS',
19
+ spec: emptySpec,
20
+ };
21
+
22
+ describe('go/models', () => {
23
+ it('returns empty for no models', () => {
24
+ expect(generateModels([], ctx)).toEqual([]);
25
+ });
26
+
27
+ it('generates a struct with required and optional fields', () => {
28
+ const models: Model[] = [
29
+ {
30
+ name: 'Organization',
31
+ fields: [
32
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
33
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
34
+ {
35
+ name: 'metadata',
36
+ type: { kind: 'map', valueType: { kind: 'primitive', type: 'string' } },
37
+ required: false,
38
+ },
39
+ ],
40
+ },
41
+ ];
42
+ const files = generateModels(models, ctx);
43
+ expect(files).toHaveLength(1);
44
+ expect(files[0].path).toBe('models.go');
45
+ const content = files[0].content;
46
+ expect(content).toContain('package workos');
47
+ expect(content).toContain('type Organization struct {');
48
+ expect(content).toContain('ID string `json:"id"`');
49
+ expect(content).toContain('Name string `json:"name"`');
50
+ expect(content).toContain('Metadata map[string]string `json:"metadata,omitempty"`');
51
+ });
52
+
53
+ it('handles model refs as pointer types', () => {
54
+ const models: Model[] = [
55
+ {
56
+ name: 'User',
57
+ fields: [
58
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
59
+ { name: 'profile', type: { kind: 'model', name: 'Profile' }, required: true },
60
+ ],
61
+ },
62
+ {
63
+ name: 'Profile',
64
+ fields: [{ name: 'bio', type: { kind: 'primitive', type: 'string' }, required: true }],
65
+ },
66
+ ];
67
+ const files = generateModels(models, ctx);
68
+ const content = files[0].content;
69
+ expect(content).toContain('Profile *Profile `json:"profile"`');
70
+ });
71
+
72
+ it('handles nullable fields as pointers', () => {
73
+ const models: Model[] = [
74
+ {
75
+ name: 'Item',
76
+ fields: [
77
+ {
78
+ name: 'description',
79
+ type: { kind: 'nullable', inner: { kind: 'primitive', type: 'string' } },
80
+ required: false,
81
+ },
82
+ ],
83
+ },
84
+ ];
85
+ const files = generateModels(models, ctx);
86
+ const content = files[0].content;
87
+ expect(content).toContain('Description *string');
88
+ });
89
+
90
+ it('skips list wrapper models', () => {
91
+ const models: Model[] = [
92
+ {
93
+ name: 'OrganizationList',
94
+ fields: [
95
+ {
96
+ name: 'data',
97
+ type: {
98
+ kind: 'array',
99
+ items: { kind: 'model', name: 'Organization' },
100
+ },
101
+ required: true,
102
+ },
103
+ {
104
+ name: 'list_metadata',
105
+ type: { kind: 'model', name: 'ListMetadata' },
106
+ required: true,
107
+ },
108
+ ],
109
+ },
110
+ ];
111
+ const files = generateModels(models, ctx);
112
+ const content = files[0].content;
113
+ expect(content).not.toContain('OrganizationList');
114
+ });
115
+
116
+ it('deduplicates structurally identical models', () => {
117
+ const models: Model[] = [
118
+ {
119
+ name: 'Alpha',
120
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
121
+ },
122
+ {
123
+ name: 'Beta',
124
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
125
+ },
126
+ ];
127
+ const files = generateModels(models, ctx);
128
+ const content = files[0].content;
129
+ expect(content).toContain('type Alpha struct {');
130
+ expect(content).toContain('type Beta = Alpha');
131
+ });
132
+
133
+ it('uses Go acronym conventions for field names', () => {
134
+ const models: Model[] = [
135
+ {
136
+ name: 'Connection',
137
+ fields: [
138
+ { name: 'connection_id', type: { kind: 'primitive', type: 'string' }, required: true },
139
+ { name: 'sso_url', type: { kind: 'primitive', type: 'string' }, required: false },
140
+ ],
141
+ },
142
+ ];
143
+ const files = generateModels(models, ctx);
144
+ const content = files[0].content;
145
+ expect(content).toContain('ConnectionID string');
146
+ expect(content).toContain('SSOURL *string');
147
+ });
148
+
149
+ it('generates array fields', () => {
150
+ const models: Model[] = [
151
+ {
152
+ name: 'Org',
153
+ fields: [
154
+ {
155
+ name: 'domains',
156
+ type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
157
+ required: true,
158
+ },
159
+ ],
160
+ },
161
+ ];
162
+ const files = generateModels(models, ctx);
163
+ const content = files[0].content;
164
+ expect(content).toContain('Domains []string `json:"domains"`');
165
+ });
166
+
167
+ it('generates enum field references', () => {
168
+ const models: Model[] = [
169
+ {
170
+ name: 'Connection',
171
+ fields: [{ name: 'status', type: { kind: 'enum', name: 'ConnectionStatus' }, required: true }],
172
+ },
173
+ ];
174
+ const files = generateModels(models, ctx);
175
+ const content = files[0].content;
176
+ expect(content).toContain('Status ConnectionStatus `json:"status"`');
177
+ });
178
+
179
+ it('preserves DTO model names when emitting distinct types', () => {
180
+ const models: Model[] = [
181
+ {
182
+ name: 'RedirectUriDto',
183
+ fields: [{ name: 'uri', type: { kind: 'primitive', type: 'string' }, required: true }],
184
+ },
185
+ ];
186
+ const files = generateModels(models, ctx);
187
+ const content = files[0].content;
188
+ expect(content).toContain('type RedirectURIDto struct {');
189
+ expect(content).toContain('URI string `json:"uri"`');
190
+ });
191
+
192
+ it('emits Deprecated comments for deprecated fields', () => {
193
+ const models: Model[] = [
194
+ {
195
+ name: 'Widget',
196
+ fields: [
197
+ {
198
+ name: 'old_name',
199
+ type: { kind: 'primitive', type: 'string' },
200
+ required: false,
201
+ description: 'The original name.',
202
+ deprecated: true,
203
+ },
204
+ {
205
+ name: 'legacy_id',
206
+ type: { kind: 'primitive', type: 'string' },
207
+ required: false,
208
+ deprecated: true,
209
+ },
210
+ {
211
+ name: 'current_name',
212
+ type: { kind: 'primitive', type: 'string' },
213
+ required: true,
214
+ },
215
+ ],
216
+ },
217
+ ];
218
+ const files = generateModels(models, ctx);
219
+ const content = files[0].content;
220
+ // deprecated field WITH description gets separator + Deprecated
221
+ expect(content).toContain('\t// OldName is the original name.\n\t//\n\t// Deprecated: this field is deprecated.');
222
+ // deprecated field WITHOUT description gets Deprecated only (no separator)
223
+ expect(content).toContain('\t// Deprecated: this field is deprecated.\n\tLegacyID');
224
+ // non-deprecated field does NOT get Deprecated
225
+ expect(content).not.toMatch(/Deprecated.*\n\tCurrentName/);
226
+ });
227
+
228
+ it('snapshot: Organization struct', () => {
229
+ const models: Model[] = [
230
+ {
231
+ name: 'Organization',
232
+ description: 'Represents an organization.',
233
+ fields: [
234
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
235
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
236
+ { name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
237
+ ],
238
+ },
239
+ ];
240
+ const files = generateModels(models, ctx);
241
+ expect(files[0].content).toMatchInlineSnapshot(`
242
+ "package workos
243
+
244
+ // Organization represents an organization.
245
+ type Organization struct {
246
+ ID string \`json:"id"\`
247
+ Name string \`json:"name"\`
248
+ CreatedAt string \`json:"created_at"\`
249
+ }
250
+
251
+ // PaginationParams contains common pagination parameters for list operations.
252
+ type PaginationParams struct {
253
+ // Before is a cursor for reverse pagination.
254
+ Before *string \`url:"before,omitempty" json:"-"\`
255
+ // After is a cursor for forward pagination.
256
+ After *string \`url:"after,omitempty" json:"-"\`
257
+ // Limit is the maximum number of items to return per page.
258
+ Limit *int \`url:"limit,omitempty" json:"-"\`
259
+ // Order is the sort order for results (asc or desc).
260
+ Order *string \`url:"order,omitempty" json:"-"\`
261
+ }
262
+ "
263
+ `);
264
+ });
265
+ });