@workos/oagen-emitters 0.2.1 → 0.4.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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,90 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className, enumClassName } from './naming.js';
4
+
5
+ /**
6
+ * Map an IR TypeRef to a PHP type hint string.
7
+ */
8
+ export function mapTypeRef(ref: TypeRef, opts?: { qualified?: boolean }): string {
9
+ const qualify = opts?.qualified ?? false;
10
+ const prefix = qualify ? '\\WorkOS\\Resource\\' : '';
11
+ return irMapTypeRef<string>(ref, {
12
+ primitive: mapPrimitive,
13
+ array: (_ref, _items) => 'array',
14
+ model: (r) => `${prefix}${className(r.name)}`,
15
+ enum: (r) => `${prefix}${enumClassName(r.name)}`,
16
+ union: (r, variants) => joinUnionVariants(r, variants),
17
+ nullable: (_ref, inner) => `?${inner}`,
18
+ literal: (r) => (typeof r.value === 'number' ? (Number.isInteger(r.value) ? 'int' : 'float') : 'string'),
19
+ map: (_ref, _value) => 'array',
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Map an IR TypeRef to a PHPDoc type string for richer documentation.
25
+ * Uses fully-qualified names (leading \) so types resolve correctly
26
+ * regardless of the namespace the docblock appears in.
27
+ */
28
+ export function mapTypeRefForPHPDoc(ref: TypeRef, opts?: { prefix?: string }): string {
29
+ const prefix = opts?.prefix ?? '\\WorkOS\\Resource\\';
30
+ return irMapTypeRef<string>(ref, {
31
+ primitive: mapPrimitiveDoc,
32
+ array: (_ref, items) => `array<${items}>`,
33
+ model: (r) => `${prefix}${className(r.name)}`,
34
+ enum: (r) => `${prefix}${enumClassName(r.name)}`,
35
+ union: (r, variants) => joinDocUnionVariants(r, variants),
36
+ nullable: (_ref, inner) => `${inner}|null`,
37
+ literal: (r) => (typeof r.value === 'string' ? 'string' : typeof r.value === 'number' ? 'int' : 'string'),
38
+ map: (_ref, value) => `array<string, ${value}>`,
39
+ });
40
+ }
41
+
42
+ function mapPrimitive(ref: PrimitiveType): string {
43
+ if (ref.format === 'date-time') return '\\DateTimeImmutable';
44
+ switch (ref.type) {
45
+ case 'string':
46
+ return 'string';
47
+ case 'integer':
48
+ return 'int';
49
+ case 'number':
50
+ return 'float';
51
+ case 'boolean':
52
+ return 'bool';
53
+ case 'unknown':
54
+ return 'mixed';
55
+ }
56
+ }
57
+
58
+ function mapPrimitiveDoc(ref: PrimitiveType): string {
59
+ if (ref.format === 'date-time') return '\\DateTimeImmutable';
60
+ switch (ref.type) {
61
+ case 'string':
62
+ return 'string';
63
+ case 'integer':
64
+ return 'int';
65
+ case 'number':
66
+ return 'float';
67
+ case 'boolean':
68
+ return 'bool';
69
+ case 'unknown':
70
+ return 'mixed';
71
+ }
72
+ }
73
+
74
+ function joinUnionVariants(ref: UnionType, variants: string[]): string {
75
+ if (ref.compositionKind === 'allOf') {
76
+ return variants[0] ?? 'mixed';
77
+ }
78
+ const unique = [...new Set(variants)];
79
+ if (unique.length === 1) return unique[0];
80
+ return unique.join('|');
81
+ }
82
+
83
+ function joinDocUnionVariants(ref: UnionType, variants: string[]): string {
84
+ if (ref.compositionKind === 'allOf') {
85
+ return variants[0] ?? 'mixed';
86
+ }
87
+ const unique = [...new Set(variants)];
88
+ if (unique.length === 1) return unique[0];
89
+ return unique.join('|');
90
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Render a PHPDoc comment block from a description string.
3
+ * Handles multiline descriptions by prefixing each line with ` * `.
4
+ * Returns the lines with the given indent (default 0 spaces).
5
+ */
6
+ export function phpDocComment(description: string, indent = 0): string[] {
7
+ const pad = ' '.repeat(indent);
8
+ const descLines = description.split('\n');
9
+ if (descLines.length === 1) {
10
+ return [`${pad}/** ${descLines[0]} */`];
11
+ }
12
+ const lines: string[] = [`${pad}/**`];
13
+ for (const line of descLines) {
14
+ lines.push(line === '' ? `${pad} *` : `${pad} * ${line}`);
15
+ }
16
+ lines.push(`${pad} */`);
17
+ return lines;
18
+ }
@@ -0,0 +1,152 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper, TypeRef } from '@workos/oagen';
2
+ import { toCamelCase } from '@workos/oagen';
3
+ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
4
+ import { className, fieldName } from './naming.js';
5
+ import { phpDocComment } from './utils.js';
6
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
7
+
8
+ /**
9
+ * Generate PHP wrapper methods for split union operations.
10
+ */
11
+ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
12
+ const lines: string[] = [];
13
+ for (const wrapper of resolvedOp.wrappers ?? []) {
14
+ emitWrapperMethod(lines, resolvedOp, wrapper, ctx);
15
+ }
16
+ return lines;
17
+ }
18
+
19
+ function emitWrapperMethod(
20
+ lines: string[],
21
+ resolvedOp: ResolvedOperation,
22
+ wrapper: ResolvedWrapper,
23
+ ctx: EmitterContext,
24
+ ): void {
25
+ const method = toCamelCase(wrapper.name);
26
+ const ns = ctx.namespacePascal;
27
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
28
+
29
+ lines.push('');
30
+
31
+ // PHPDoc block
32
+ const docParts: string[] = [];
33
+ for (const { paramName, field, isOptional } of wrapperParams) {
34
+ const docType = field ? mapTypeRefForPHPDoc(field.type) : 'mixed';
35
+ const nullSuffix = isOptional && !docType.endsWith('|null') ? '|null' : '';
36
+ docParts.push(`@param ${docType}${nullSuffix} $${fieldName(paramName)}`);
37
+ }
38
+ const op2 = resolvedOp.operation;
39
+ const returnDocType = op2.response.kind === 'model' ? `\\${ns}\\Resource\\${className(op2.response.name)}` : 'mixed';
40
+ docParts.push(`@return ${returnDocType}`);
41
+ docParts.push(`@throws \\${ns}\\Exception\\WorkOSException`);
42
+ lines.push(...phpDocComment(docParts.join('\n'), 4));
43
+
44
+ lines.push(` public function ${method}(`);
45
+
46
+ // Build params: required first, then optional, to avoid PHP deprecation
47
+ const requiredParams: string[] = [];
48
+ const optionalParamLines: string[] = [];
49
+ for (const { paramName, field, isOptional } of wrapperParams) {
50
+ const phpName = fieldName(paramName);
51
+ if (field) {
52
+ const phpType = mapTypeRef(field.type);
53
+ if (isOptional) {
54
+ const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
55
+ optionalParamLines.push(` ${nullableType} $${phpName} = null,`);
56
+ } else {
57
+ requiredParams.push(` ${phpType} $${phpName},`);
58
+ }
59
+ } else {
60
+ optionalParamLines.push(` mixed $${phpName} = null,`);
61
+ }
62
+ }
63
+ optionalParamLines.push(` ?\\${ns}\\RequestOptions $options = null,`);
64
+ for (const p of [...requiredParams, ...optionalParamLines]) {
65
+ lines.push(p);
66
+ }
67
+
68
+ // Return type
69
+ const op = resolvedOp.operation;
70
+ const responseType = op.response.kind === 'model' ? `\\${ns}\\Resource\\${className(op.response.name)}` : 'mixed';
71
+ lines.push(` ): ${responseType} {`);
72
+
73
+ // Build body using array_filter for consistency
74
+ const bodyEntries: string[] = [];
75
+
76
+ // Defaults (always included)
77
+ if (wrapper.defaults) {
78
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
79
+ bodyEntries.push(`'${key}' => ${phpLiteral(value)}`);
80
+ }
81
+ }
82
+
83
+ // Exposed params (extract enum values)
84
+ for (const { paramName, field } of wrapperParams) {
85
+ const phpName = fieldName(paramName);
86
+ if (field && isEnumType(field.type)) {
87
+ bodyEntries.push(`'${paramName}' => $${phpName}?->value`);
88
+ } else {
89
+ bodyEntries.push(`'${paramName}' => $${phpName}`);
90
+ }
91
+ }
92
+
93
+ lines.push(' $body = array_filter([');
94
+ for (const entry of bodyEntries) {
95
+ lines.push(` ${entry},`);
96
+ }
97
+ lines.push(' ], fn ($v) => $v !== null);');
98
+
99
+ // inferFromClient fields need special handling (conditional injection)
100
+ for (const clientField of wrapper.inferFromClient ?? []) {
101
+ const clientExpr = clientFieldExpression(clientField);
102
+ lines.push(` $body['${clientField}'] = ${clientExpr};`);
103
+ }
104
+
105
+ // Delegate to HTTP client
106
+ const httpMethod = op.httpMethod.toUpperCase();
107
+ let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
108
+ const hasInterpolation = /\{[^}]+\}/.test(path);
109
+ path = path.replace(/\{([^}]+)\}/g, (_match, param) => `{$${fieldName(param)}}`);
110
+ const pathQuote = hasInterpolation ? '"' : "'";
111
+
112
+ lines.push('');
113
+ lines.push(' $response = $this->client->request(');
114
+ lines.push(` method: '${httpMethod}',`);
115
+ lines.push(` path: ${pathQuote}${path}${pathQuote},`);
116
+ lines.push(' body: $body,');
117
+ lines.push(' options: $options,');
118
+ lines.push(' );');
119
+
120
+ if (op.response.kind === 'model') {
121
+ lines.push(` return ${className(op.response.name)}::fromArray($response);`);
122
+ } else {
123
+ lines.push(' return $response;');
124
+ }
125
+
126
+ lines.push(' }');
127
+ }
128
+
129
+ function isEnumType(ref: TypeRef): boolean {
130
+ if (ref.kind === 'enum') return true;
131
+ if (ref.kind === 'nullable') return isEnumType(ref.inner);
132
+ return false;
133
+ }
134
+
135
+ function phpLiteral(value: unknown): string {
136
+ if (typeof value === 'string') return `'${value}'`;
137
+ if (typeof value === 'number') return String(value);
138
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
139
+ return 'null';
140
+ }
141
+
142
+ function clientFieldExpression(field: string): string {
143
+ // Map inferFromClient fields to the actual client/config accessors
144
+ switch (field) {
145
+ case 'client_id':
146
+ return '$this->client->requireClientId()';
147
+ case 'client_secret':
148
+ return '$this->client->requireApiKey()';
149
+ default:
150
+ return `$this->client->${toCamelCase(field)}`;
151
+ }
152
+ }
@@ -0,0 +1,345 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { toPascalCase } from '@workos/oagen';
3
+ import { className, resolveServiceDir, servicePropertyName, buildMountDirMap, dirToModule } from './naming.js';
4
+ import { resolveResourceClassName } from './resources.js';
5
+ import { getMountTarget } from '../shared/resolved-ops.js';
6
+ import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
7
+
8
+ /** Python-specific wiring for each non-spec service. */
9
+ interface PythonNonSpecWiring {
10
+ importLine: string;
11
+ prop: string;
12
+ syncClass: string;
13
+ asyncClass: string | null;
14
+ ctorArg: 'self' | '';
15
+ docstring?: string;
16
+ }
17
+
18
+ const PYTHON_NON_SPEC_WIRING: Record<string, PythonNonSpecWiring> = {
19
+ passwordless: {
20
+ importLine: 'from .passwordless import AsyncPasswordless, Passwordless',
21
+ prop: 'passwordless',
22
+ syncClass: 'Passwordless',
23
+ asyncClass: 'AsyncPasswordless',
24
+ ctorArg: 'self',
25
+ docstring: 'Passwordless authentication sessions.',
26
+ },
27
+ vault: {
28
+ importLine: 'from .vault import AsyncVault, Vault',
29
+ prop: 'vault',
30
+ syncClass: 'Vault',
31
+ asyncClass: 'AsyncVault',
32
+ ctorArg: 'self',
33
+ docstring: 'Vault encryption, key management, and secret storage.',
34
+ },
35
+ actions: {
36
+ importLine: 'from .actions import Actions, AsyncActions',
37
+ prop: 'actions',
38
+ syncClass: 'Actions',
39
+ asyncClass: 'AsyncActions',
40
+ ctorArg: '',
41
+ docstring: 'Actions logging and audit trail.',
42
+ },
43
+ pkce: {
44
+ importLine: 'from .pkce import PKCE',
45
+ prop: 'pkce',
46
+ syncClass: 'PKCE',
47
+ asyncClass: null,
48
+ ctorArg: '',
49
+ docstring: 'PKCE (Proof Key for Code Exchange) utilities.',
50
+ },
51
+ };
52
+
53
+ /**
54
+ * Generate the slim Python client class (service-wiring only),
55
+ * service __init__.py files, and types barrels.
56
+ *
57
+ * Static HTTP infrastructure lives in _base_client.py (hand-maintained
58
+ * in the target SDK, marked @oagen-ignore-file).
59
+ */
60
+ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
61
+ assertPublicClientReachability(spec, ctx);
62
+
63
+ const files: GeneratedFile[] = [];
64
+
65
+ files.push(...generateWorkOSClient(spec, ctx));
66
+ files.push(...generateServiceInits(spec, ctx));
67
+ files.push(...generateTypesBarrels(spec, ctx));
68
+
69
+ return files;
70
+ }
71
+
72
+ /**
73
+ * Deduplicate services by mount target. Multiple IR services may mount to the
74
+ * same target (e.g., Applications + ApplicationClientSecrets -> Connect).
75
+ * Returns one representative service per unique mount target, using the service
76
+ * whose PascalCase name matches the target (if any), or the first one found.
77
+ */
78
+ function deduplicateByMount(services: Service[], ctx: EmitterContext): Service[] {
79
+ const byTarget = new Map<string, Service>();
80
+ for (const s of services) {
81
+ const target = getMountTarget(s, ctx);
82
+ const existing = byTarget.get(target);
83
+ if (!existing || toPascalCase(s.name) === target) {
84
+ byTarget.set(target, s);
85
+ }
86
+ }
87
+ return [...byTarget.values()];
88
+ }
89
+
90
+ export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext): Map<string, string> {
91
+ const topLevel = deduplicateByMount(services, ctx);
92
+ const paths = new Map<string, string>();
93
+
94
+ for (const service of topLevel) {
95
+ const resolvedName = resolveResourceClassName(service, ctx);
96
+ const prop = servicePropertyName(resolvedName);
97
+ paths.set(service.name, prop);
98
+ }
99
+
100
+ // Build reverse map: mount target name -> access path
101
+ const targetPaths = new Map<string, string>();
102
+ for (const service of topLevel) {
103
+ const target = getMountTarget(service, ctx);
104
+ if (!targetPaths.has(target) && paths.has(service.name)) {
105
+ targetPaths.set(target, paths.get(service.name)!);
106
+ }
107
+ }
108
+
109
+ // Map mounted services to their mount target's access path
110
+ for (const service of services) {
111
+ if (paths.has(service.name)) continue;
112
+ const mountTarget = getMountTarget(service, ctx);
113
+ const targetPath = targetPaths.get(mountTarget) ?? paths.get(mountTarget);
114
+ if (targetPath) paths.set(service.name, targetPath);
115
+ }
116
+
117
+ return paths;
118
+ }
119
+
120
+ function assertPublicClientReachability(spec: ApiSpec, ctx: EmitterContext): void {
121
+ const topLevelServices = deduplicateByMount(spec.services, ctx);
122
+ const accessPaths = buildServiceAccessPaths(spec.services, ctx);
123
+ const unreachableServices = topLevelServices
124
+ .filter((service) => service.operations.length > 0 && !accessPaths.has(service.name))
125
+ .map((service) => service.name);
126
+
127
+ if (unreachableServices.length > 0) {
128
+ throw new Error(`Python emitter reachability audit failed for services: ${unreachableServices.join(', ')}`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Generate the slim _client.py that subclasses the static _base_client.py
134
+ * and wires spec-driven service accessors.
135
+ */
136
+ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
137
+ const lines: string[] = [];
138
+ const topLevelServices = deduplicateByMount(spec.services, ctx);
139
+
140
+ // --- Imports ---
141
+ lines.push('from __future__ import annotations');
142
+ lines.push('');
143
+ lines.push('import functools');
144
+ lines.push('');
145
+ lines.push('from ._base_client import (');
146
+ lines.push(' WorkOSClient as _SyncBase,');
147
+ lines.push(' AsyncWorkOSClient as _AsyncBase,');
148
+ lines.push(')');
149
+
150
+ // Import resource classes (both sync and async)
151
+ const serviceDirMap = buildMountDirMap(ctx);
152
+ for (const service of topLevelServices) {
153
+ const resolvedName = resolveResourceClassName(service, ctx);
154
+ const clsName = className(resolvedName);
155
+ const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
156
+ const importLine = `from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`;
157
+ if (importLine.length > 88) {
158
+ lines.push(`from .${dirToModule(dirName)}._resource import (`);
159
+ lines.push(` ${clsName},`);
160
+ lines.push(` Async${clsName},`);
161
+ lines.push(')');
162
+ } else {
163
+ lines.push(importLine);
164
+ }
165
+ }
166
+ // Non-spec service imports — wrapped in ignore markers so the merger
167
+ // matches them positionally and doesn't displace them.
168
+ lines.push('');
169
+ lines.push('# @oagen-ignore-start — non-spec service imports (hand-maintained)');
170
+ for (const s of NON_SPEC_SERVICES) {
171
+ const w = PYTHON_NON_SPEC_WIRING[s.id];
172
+ if (w) lines.push(w.importLine);
173
+ }
174
+ lines.push('# @oagen-ignore-end');
175
+ lines.push('');
176
+ lines.push('');
177
+
178
+ // --- Sync client ---
179
+ lines.push('class WorkOSClient(_SyncBase):');
180
+ lines.push(' """Synchronous WorkOS API client with service accessors."""');
181
+
182
+ // Collect all generated property names
183
+ const generatedProps = new Set<string>();
184
+ for (const service of topLevelServices) {
185
+ const resolvedName = resolveResourceClassName(service, ctx);
186
+ const clsName = className(resolvedName);
187
+ const prop = servicePropertyName(resolvedName);
188
+ const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
189
+ lines.push('');
190
+ lines.push(' @functools.cached_property');
191
+ lines.push(` def ${prop}(self) -> ${clsName}:`);
192
+ lines.push(` """${readable} API resources."""`);
193
+ lines.push(` return ${clsName}(self)`);
194
+ generatedProps.add(prop);
195
+ }
196
+ emitCompatClientPropertyAliases(lines, generatedProps, false);
197
+ emitNonSpecClientAccessors(lines, false);
198
+
199
+ lines.push('');
200
+ lines.push('');
201
+
202
+ // --- Async client ---
203
+ lines.push('class AsyncWorkOSClient(_AsyncBase):');
204
+ lines.push(' """Asynchronous WorkOS API client with service accessors."""');
205
+
206
+ const asyncGeneratedProps = new Set<string>();
207
+ for (const service of topLevelServices) {
208
+ const resolvedName = resolveResourceClassName(service, ctx);
209
+ const clsName = className(resolvedName);
210
+ const prop = servicePropertyName(resolvedName);
211
+ const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
212
+ lines.push('');
213
+ lines.push(' @functools.cached_property');
214
+ lines.push(` def ${prop}(self) -> Async${clsName}:`);
215
+ lines.push(` """${readable} API resources."""`);
216
+ lines.push(` return Async${clsName}(self)`);
217
+ asyncGeneratedProps.add(prop);
218
+ }
219
+ emitCompatClientPropertyAliases(lines, asyncGeneratedProps, true);
220
+ emitNonSpecClientAccessors(lines, true);
221
+
222
+ return [
223
+ {
224
+ path: `src/${ctx.namespace}/_client.py`,
225
+ content: lines.join('\n'),
226
+ overwriteExisting: true,
227
+ },
228
+ ];
229
+ }
230
+
231
+ function emitNonSpecClientAccessors(lines: string[], isAsync: boolean): void {
232
+ lines.push('');
233
+ lines.push(' # @oagen-ignore-start — non-spec service accessors (hand-maintained)');
234
+ for (const s of NON_SPEC_SERVICES) {
235
+ const w = PYTHON_NON_SPEC_WIRING[s.id];
236
+ if (!w) continue;
237
+ const typeName = isAsync ? (w.asyncClass ?? w.syncClass) : w.syncClass;
238
+ const arg = w.ctorArg === 'self' ? 'self' : '';
239
+
240
+ lines.push('');
241
+ lines.push(' @functools.cached_property');
242
+ lines.push(` def ${w.prop}(self) -> ${typeName}:`);
243
+ if (w.docstring) {
244
+ lines.push(` """${w.docstring}"""`);
245
+ }
246
+ lines.push(` return ${typeName}(${arg})`);
247
+ }
248
+ lines.push('');
249
+ lines.push(' # @oagen-ignore-end');
250
+ }
251
+
252
+ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
253
+ const files: GeneratedFile[] = [];
254
+ const topLevel = deduplicateByMount(spec.services, ctx);
255
+ const serviceDirMap = buildMountDirMap(ctx);
256
+
257
+ for (const service of topLevel) {
258
+ const resolvedName = resolveResourceClassName(service, ctx);
259
+ const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
260
+ const lines: string[] = [];
261
+
262
+ lines.push(`from ._resource import ${resolvedName}, Async${resolvedName}`);
263
+ lines.push('from .models import *');
264
+
265
+ files.push({
266
+ path: `src/${ctx.namespace}/${dirName}/__init__.py`,
267
+ content: lines.join('\n'),
268
+ integrateTarget: true,
269
+ overwriteExisting: true,
270
+ });
271
+
272
+ // Ensure models/__init__.py exists even if no models are assigned to this service
273
+ files.push({
274
+ path: `src/${ctx.namespace}/${dirName}/models/__init__.py`,
275
+ content: '',
276
+ skipIfExists: true,
277
+ });
278
+ }
279
+
280
+ return files;
281
+ }
282
+
283
+ function emitCompatClientPropertyAliases(lines: string[], generatedProps: Set<string>, isAsync: boolean): void {
284
+ const aliases: Array<{ alias: string; typeName: string; returnExpr: string; docstring: string }> = [];
285
+ if (generatedProps.has('multi_factor_auth') && !generatedProps.has('mfa')) {
286
+ const mfaType = isAsync ? 'AsyncMultiFactorAuth' : 'MultiFactorAuth';
287
+ aliases.push({
288
+ alias: 'mfa',
289
+ typeName: mfaType,
290
+ returnExpr: 'self.multi_factor_auth',
291
+ docstring: '"""Alias for multi_factor_auth."""',
292
+ });
293
+ }
294
+ for (const alias of aliases) {
295
+ lines.push('');
296
+ lines.push(' @functools.cached_property');
297
+ lines.push(` def ${alias.alias}(self) -> ${alias.typeName}:`);
298
+ lines.push(` ${alias.docstring}`);
299
+ lines.push(` return ${alias.returnExpr}`);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Generate types/<service>/__init__.py re-export barrels so that
305
+ * `from workos.types.<service> import Model` continues to work.
306
+ */
307
+ function generateTypesBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
308
+ const files: GeneratedFile[] = [];
309
+ const serviceDirMap = buildMountDirMap(ctx);
310
+
311
+ // Collect (types dir name -> set of service dirs whose models should be re-exported)
312
+ const typesEntries = new Map<string, Set<string>>();
313
+
314
+ for (const service of spec.services) {
315
+ const resolvedName = resolveResourceClassName(service, ctx);
316
+ const prop = servicePropertyName(resolvedName);
317
+ const dir = serviceDirMap.get(service.name) ?? prop;
318
+ const dirs = typesEntries.get(prop) ?? new Set();
319
+ dirs.add(dir);
320
+ typesEntries.set(prop, dirs);
321
+ }
322
+
323
+ for (const [typesDir, serviceDirs] of typesEntries) {
324
+ const imports = [...serviceDirs]
325
+ .sort()
326
+ .map((dir) => `from ${ctx.namespace}.${dirToModule(dir)}.models import * # noqa: F401,F403`);
327
+
328
+ files.push({
329
+ path: `src/${ctx.namespace}/types/${typesDir}/__init__.py`,
330
+ content: imports.join('\n'),
331
+ integrateTarget: true,
332
+ overwriteExisting: true,
333
+ });
334
+ }
335
+
336
+ // Root types/__init__.py
337
+ files.push({
338
+ path: `src/${ctx.namespace}/types/__init__.py`,
339
+ content: '',
340
+ integrateTarget: true,
341
+ overwriteExisting: true,
342
+ });
343
+
344
+ return files;
345
+ }