@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.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11893 -3226
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +78 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +2 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +374 -364
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +32 -12
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +18 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +99 -69
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
package/smoke/sdk-php.ts
CHANGED
|
@@ -184,8 +184,8 @@ function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
|
|
|
184
184
|
// ---------------------------------------------------------------------------
|
|
185
185
|
|
|
186
186
|
interface MethodResolution {
|
|
187
|
-
|
|
188
|
-
method: string;
|
|
187
|
+
service: string; // camelCase accessor on client (e.g., "organizations")
|
|
188
|
+
method: string; // camelCase method name (e.g., "get")
|
|
189
189
|
tier: ExchangeProvenance['resolutionTier'];
|
|
190
190
|
confidence: number;
|
|
191
191
|
}
|
|
@@ -200,14 +200,12 @@ function resolveMethod(
|
|
|
200
200
|
if (manifest) {
|
|
201
201
|
const entry = manifest.get(httpKey);
|
|
202
202
|
if (entry) {
|
|
203
|
-
|
|
204
|
-
return { className, method: entry.sdkMethod, tier: 'manifest', confidence: 1.0 };
|
|
203
|
+
return { service: entry.service, method: entry.sdkMethod, tier: 'manifest', confidence: 1.0 };
|
|
205
204
|
}
|
|
206
205
|
}
|
|
207
206
|
|
|
208
207
|
const sdkProp = SERVICE_PROPERTY_MAP[irService] || toCamelCase(irService);
|
|
209
|
-
|
|
210
|
-
return { className, method: toCamelCase(op.name), tier: 'exact', confidence: 0.8 };
|
|
208
|
+
return { service: sdkProp, method: toCamelCase(op.name), tier: 'exact', confidence: 0.8 };
|
|
211
209
|
}
|
|
212
210
|
|
|
213
211
|
// ---------------------------------------------------------------------------
|
|
@@ -278,9 +276,14 @@ function buildBatchedPhpScript(
|
|
|
278
276
|
}
|
|
279
277
|
lines.push('');
|
|
280
278
|
|
|
281
|
-
// Configure SDK
|
|
282
|
-
lines.push(
|
|
283
|
-
lines.push(
|
|
279
|
+
// Configure SDK — generated SDK uses instance-based client with Guzzle handler
|
|
280
|
+
lines.push(`use GuzzleHttp\\HandlerStack;`);
|
|
281
|
+
lines.push(`use GuzzleHttp\\Handler\\CurlHandler;`);
|
|
282
|
+
lines.push('');
|
|
283
|
+
lines.push(`$client = new ${namespace}\\${namespace}(`);
|
|
284
|
+
lines.push(` apiKey: '${escapePhpString(apiKey)}',`);
|
|
285
|
+
lines.push(` baseUrl: 'http://127.0.0.1:${proxyPort}',`);
|
|
286
|
+
lines.push(');');
|
|
284
287
|
lines.push('');
|
|
285
288
|
|
|
286
289
|
for (const call of calls) {
|
|
@@ -289,7 +292,8 @@ function buildBatchedPhpScript(
|
|
|
289
292
|
// Marker: start
|
|
290
293
|
lines.push(`fwrite(STDERR, "OAGEN_CALL_START:${index}\\n");`);
|
|
291
294
|
|
|
292
|
-
// Build arguments
|
|
295
|
+
// Build arguments — generated PHP SDK takes positional path params,
|
|
296
|
+
// then named keyword args for body fields and query params
|
|
293
297
|
const phpArgs: string[] = [];
|
|
294
298
|
|
|
295
299
|
for (const p of op.pathParams) {
|
|
@@ -299,33 +303,31 @@ function buildBatchedPhpScript(
|
|
|
299
303
|
|
|
300
304
|
if (op.requestBody) {
|
|
301
305
|
const payload = generatePayload(op, spec);
|
|
302
|
-
if (payload &&
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
+
if (payload && typeof payload === 'object') {
|
|
307
|
+
// Pass as named arguments (the generated SDK uses promoted properties)
|
|
308
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
309
|
+
phpArgs.push(`${toCamelCase(key)}: ${phpArrayLiteral(value)}`);
|
|
310
|
+
}
|
|
306
311
|
}
|
|
307
312
|
}
|
|
308
313
|
|
|
309
314
|
if (!op.requestBody && op.queryParams.some((p) => p.required)) {
|
|
310
315
|
const queryOpts = generateQueryParams(op, spec);
|
|
311
|
-
|
|
312
|
-
phpArgs.push(phpArrayLiteral(
|
|
316
|
+
for (const [key, value] of Object.entries(queryOpts)) {
|
|
317
|
+
phpArgs.push(`${toCamelCase(key)}: ${phpArrayLiteral(value)}`);
|
|
313
318
|
}
|
|
314
319
|
}
|
|
315
320
|
|
|
316
|
-
if (op.pagination
|
|
317
|
-
phpArgs.
|
|
318
|
-
|
|
319
|
-
const last = phpArgs[phpArgs.length - 1];
|
|
320
|
-
if (last && last.startsWith('[')) {
|
|
321
|
-
phpArgs[phpArgs.length - 1] = last.replace(/\]$/, ", 'limit' => 1]");
|
|
322
|
-
} else {
|
|
323
|
-
phpArgs.push("['limit' => 1]");
|
|
321
|
+
if (op.pagination) {
|
|
322
|
+
if (!phpArgs.some((a) => a.startsWith('limit:'))) {
|
|
323
|
+
phpArgs.push('limit: 1');
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
// The generated SDK uses $client->resource()->method(...) pattern
|
|
328
|
+
const serviceAccessor = resolution.service;
|
|
327
329
|
lines.push('try {');
|
|
328
|
-
lines.push(` $result = ${
|
|
330
|
+
lines.push(` $result = $client->${serviceAccessor}()->${resolution.method}(${phpArgs.join(', ')});`);
|
|
329
331
|
lines.push(` fwrite(STDERR, "OAGEN_CALL_OK:${index}\\n");`);
|
|
330
332
|
lines.push('} catch (\\Throwable $e) {');
|
|
331
333
|
lines.push(` fwrite(STDERR, "OAGEN_CALL_ERROR:${index}:" . $e->getMessage() . "\\n");`);
|
|
@@ -669,7 +671,7 @@ function buildExchange(
|
|
|
669
671
|
provenance: {
|
|
670
672
|
resolutionTier: resolution.tier,
|
|
671
673
|
resolutionConfidence: resolution.confidence,
|
|
672
|
-
sdkMethodName: `${resolution.
|
|
674
|
+
sdkMethodName: `${resolution.service}->${resolution.method}`,
|
|
673
675
|
captureIndex: 0,
|
|
674
676
|
totalCaptures: 1,
|
|
675
677
|
},
|
package/smoke/sdk-python.ts
CHANGED
|
@@ -284,7 +284,10 @@ function buildBatchedPythonScript(
|
|
|
284
284
|
calls: PlannedCall[],
|
|
285
285
|
spec: any,
|
|
286
286
|
): string {
|
|
287
|
-
|
|
287
|
+
// Use src/ subdirectory if it exists, otherwise use the SDK root directly.
|
|
288
|
+
// Generated SDKs use a flat layout (workos/ at root), while some hand-written
|
|
289
|
+
// SDKs nest under src/.
|
|
290
|
+
const srcPath = existsSync(resolve(sdkPath, 'src')) ? resolve(sdkPath, 'src') : resolve(sdkPath);
|
|
288
291
|
const lines: string[] = [];
|
|
289
292
|
|
|
290
293
|
// Preamble -- loaded once
|
|
@@ -504,7 +507,7 @@ async function main(): Promise<void> {
|
|
|
504
507
|
const child = spawn(python3Path, [scriptPath], {
|
|
505
508
|
env: {
|
|
506
509
|
...process.env,
|
|
507
|
-
PYTHONPATH: resolve(sdkPath, 'src'),
|
|
510
|
+
PYTHONPATH: existsSync(resolve(sdkPath, 'src')) ? resolve(sdkPath, 'src') : resolve(sdkPath),
|
|
508
511
|
PYTHONDONTWRITEBYTECODE: '1',
|
|
509
512
|
},
|
|
510
513
|
stdio: ['pipe', 'pipe', 'pipe'],
|
package/src/go/client.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
+
import { toPascalCase, toSnakeCase } from '@workos/oagen';
|
|
3
|
+
// naming utilities used indirectly via resolveResourceClassName
|
|
4
|
+
import { resolveResourceClassName } from './resources.js';
|
|
5
|
+
import { unexportedName } from './naming.js';
|
|
6
|
+
import { getMountTarget } from '../shared/resolved-ops.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate the Go client file with service accessors.
|
|
10
|
+
* Produces: workos.go (Client struct + constructor + service accessors).
|
|
11
|
+
* Static files (client.go, pagination.go, errors.go, go.mod, options.go)
|
|
12
|
+
* are hand-maintained in the target SDK with @oagen-ignore-file.
|
|
13
|
+
*/
|
|
14
|
+
export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
15
|
+
return [generateWorkOSFile(spec, ctx)];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Deduplicate services by mount target.
|
|
20
|
+
*/
|
|
21
|
+
function deduplicateByMount(services: Service[], ctx: EmitterContext): Service[] {
|
|
22
|
+
const byTarget = new Map<string, Service>();
|
|
23
|
+
for (const s of services) {
|
|
24
|
+
const target = getMountTarget(s, ctx);
|
|
25
|
+
const existing = byTarget.get(target);
|
|
26
|
+
if (!existing || toPascalCase(s.name) === target) {
|
|
27
|
+
byTarget.set(target, s);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...byTarget.values()];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build map of service name -> accessor property name.
|
|
35
|
+
*/
|
|
36
|
+
export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext): Map<string, string> {
|
|
37
|
+
const topLevel = deduplicateByMount(services, ctx);
|
|
38
|
+
const paths = new Map<string, string>();
|
|
39
|
+
|
|
40
|
+
for (const service of topLevel) {
|
|
41
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
42
|
+
const prop = toSnakeCase(resolvedName);
|
|
43
|
+
paths.set(service.name, prop);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Also map mount targets
|
|
47
|
+
for (const service of services) {
|
|
48
|
+
const target = getMountTarget(service, ctx);
|
|
49
|
+
if (!paths.has(target)) {
|
|
50
|
+
const existing = paths.get(service.name);
|
|
51
|
+
if (existing) paths.set(target, existing);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return paths;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
|
|
59
|
+
const topLevel = deduplicateByMount(spec.services, ctx);
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
|
|
62
|
+
lines.push(`package ${ctx.namespace}`);
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push('import "net/http"');
|
|
65
|
+
lines.push('');
|
|
66
|
+
|
|
67
|
+
// Client struct
|
|
68
|
+
lines.push('// Client is the WorkOS API client.');
|
|
69
|
+
lines.push('type Client struct {');
|
|
70
|
+
lines.push('\tapiKey string');
|
|
71
|
+
lines.push('\tclientID string');
|
|
72
|
+
lines.push('\tbaseURL string');
|
|
73
|
+
lines.push('\thttpClient *http.Client');
|
|
74
|
+
lines.push('\tmaxRetries int');
|
|
75
|
+
lines.push('');
|
|
76
|
+
// Service fields
|
|
77
|
+
for (const service of topLevel) {
|
|
78
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
79
|
+
const fieldNameStr = unexportedName(resolvedName);
|
|
80
|
+
const serviceTypeName = serviceType(resolvedName);
|
|
81
|
+
lines.push(`\t${fieldNameStr} *${serviceTypeName}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push('}');
|
|
84
|
+
lines.push('');
|
|
85
|
+
|
|
86
|
+
// NewClient constructor
|
|
87
|
+
lines.push('// NewClient creates a new WorkOS API client.');
|
|
88
|
+
lines.push('func NewClient(apiKey string, opts ...ClientOption) *Client {');
|
|
89
|
+
lines.push('\tc := &Client{');
|
|
90
|
+
lines.push('\t\tapiKey: apiKey,');
|
|
91
|
+
lines.push('\t\tbaseURL: defaultBaseURL,');
|
|
92
|
+
lines.push('\t\thttpClient: &http.Client{Timeout: defaultTimeout},');
|
|
93
|
+
lines.push('\t\tmaxRetries: defaultMaxRetries,');
|
|
94
|
+
lines.push('\t}');
|
|
95
|
+
lines.push('\tfor _, opt := range opts {');
|
|
96
|
+
lines.push('\t\topt(c)');
|
|
97
|
+
lines.push('\t}');
|
|
98
|
+
// Initialize services
|
|
99
|
+
for (const service of topLevel) {
|
|
100
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
101
|
+
const fieldNameStr = unexportedName(resolvedName);
|
|
102
|
+
const serviceTypeName = serviceType(resolvedName);
|
|
103
|
+
lines.push(`\tc.${fieldNameStr} = &${serviceTypeName}{client: c}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('\treturn c');
|
|
106
|
+
lines.push('}');
|
|
107
|
+
lines.push('');
|
|
108
|
+
|
|
109
|
+
// Service accessor methods
|
|
110
|
+
for (const service of topLevel) {
|
|
111
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
112
|
+
const accessorName = resolvedName;
|
|
113
|
+
const fieldNameStr = unexportedName(resolvedName);
|
|
114
|
+
const serviceTypeName = serviceType(resolvedName);
|
|
115
|
+
lines.push(`// ${accessorName} returns the ${resolvedName} service.`);
|
|
116
|
+
lines.push(`func (c *Client) ${accessorName}() *${serviceTypeName} {`);
|
|
117
|
+
lines.push(`\treturn c.${fieldNameStr}`);
|
|
118
|
+
lines.push('}');
|
|
119
|
+
lines.push('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
path: `${ctx.namespace}.go`,
|
|
124
|
+
content: lines.join('\n'),
|
|
125
|
+
overwriteExisting: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function singularizePascal(name: string): string {
|
|
130
|
+
if (name.endsWith('ies')) {
|
|
131
|
+
return `${name.slice(0, -3)}y`;
|
|
132
|
+
}
|
|
133
|
+
if (name.endsWith('s') && !name.endsWith('ss')) {
|
|
134
|
+
return name.slice(0, -1);
|
|
135
|
+
}
|
|
136
|
+
return name;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function serviceType(name: string): string {
|
|
140
|
+
return `${unexportedName(singularizePascal(name))}Service`;
|
|
141
|
+
}
|
package/src/go/enums.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
|
|
2
|
+
import { walkTypeRef } from '@workos/oagen';
|
|
3
|
+
import { className } from './naming.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate Go typed string enum constants from IR Enum definitions.
|
|
7
|
+
*
|
|
8
|
+
* Each enum becomes a named string type + const block:
|
|
9
|
+
* type Status string
|
|
10
|
+
* const (
|
|
11
|
+
* StatusActive Status = "active"
|
|
12
|
+
* StatusInactive Status = "inactive"
|
|
13
|
+
* )
|
|
14
|
+
*/
|
|
15
|
+
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
16
|
+
if (enums.length === 0) return [];
|
|
17
|
+
|
|
18
|
+
const aliasOf = collectEnumAliasOf(enums);
|
|
19
|
+
const files: GeneratedFile[] = [];
|
|
20
|
+
|
|
21
|
+
// Group all enums into a single file per SDK
|
|
22
|
+
const lines: string[] = [];
|
|
23
|
+
lines.push(`package ${ctx.namespace}`);
|
|
24
|
+
lines.push('');
|
|
25
|
+
|
|
26
|
+
for (const enumDef of enums) {
|
|
27
|
+
// If this enum is an alias, emit a simple type alias
|
|
28
|
+
const canonicalName = aliasOf.get(enumDef.name);
|
|
29
|
+
if (canonicalName) {
|
|
30
|
+
const aliasType = className(enumDef.name);
|
|
31
|
+
const canonicalType = className(canonicalName);
|
|
32
|
+
lines.push(`// ${aliasType} is an alias for ${canonicalType}.`);
|
|
33
|
+
lines.push(`type ${aliasType} = ${canonicalType}`);
|
|
34
|
+
lines.push('');
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const typeName = className(enumDef.name);
|
|
39
|
+
|
|
40
|
+
if (enumDef.values.length === 0) {
|
|
41
|
+
const humanized = humanize(enumDef.name);
|
|
42
|
+
lines.push(`// ${typeName} represents ${humanized} values.`);
|
|
43
|
+
lines.push(`type ${typeName} = string`);
|
|
44
|
+
lines.push('');
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Deduplicate values
|
|
49
|
+
const seenValues = new Set<string>();
|
|
50
|
+
const uniqueValues: typeof enumDef.values = [];
|
|
51
|
+
for (const v of enumDef.values) {
|
|
52
|
+
const vs = String(v.value);
|
|
53
|
+
if (!seenValues.has(vs)) {
|
|
54
|
+
seenValues.add(vs);
|
|
55
|
+
uniqueValues.push(v);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const humanized = humanize(enumDef.name);
|
|
60
|
+
lines.push(`// ${typeName} represents ${humanized} values.`);
|
|
61
|
+
lines.push(`type ${typeName} string`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push('const (');
|
|
64
|
+
|
|
65
|
+
const usedNames = new Set<string>();
|
|
66
|
+
for (const v of uniqueValues) {
|
|
67
|
+
let constSuffix = className(String(v.value));
|
|
68
|
+
// Avoid collision with the type itself
|
|
69
|
+
if (usedNames.has(`${typeName}${constSuffix}`)) {
|
|
70
|
+
let suffix = 2;
|
|
71
|
+
while (usedNames.has(`${typeName}${constSuffix}${suffix}`)) suffix++;
|
|
72
|
+
constSuffix = `${constSuffix}${suffix}`;
|
|
73
|
+
}
|
|
74
|
+
const constName = `${typeName}${constSuffix}`;
|
|
75
|
+
usedNames.add(constName);
|
|
76
|
+
const valueStr = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
|
|
77
|
+
if (v.description) {
|
|
78
|
+
lines.push(`\t// ${constName} is ${v.description}.`);
|
|
79
|
+
}
|
|
80
|
+
if (v.deprecated) {
|
|
81
|
+
if (v.description) lines.push(`\t//`);
|
|
82
|
+
lines.push(`\t// Deprecated: this value is deprecated.`);
|
|
83
|
+
}
|
|
84
|
+
lines.push(`\t${constName} ${typeName} = ${valueStr}`);
|
|
85
|
+
}
|
|
86
|
+
lines.push(')');
|
|
87
|
+
lines.push('');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
files.push({
|
|
91
|
+
path: 'enums.go',
|
|
92
|
+
content: lines.join('\n'),
|
|
93
|
+
overwriteExisting: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Known acronyms to preserve as single tokens during humanization. */
|
|
100
|
+
const HUMANIZE_ACRONYMS: [RegExp, string][] = [
|
|
101
|
+
[/OAuth/g, 'OAUTH_ACRN'],
|
|
102
|
+
[/URN/g, 'URN_ACRN'],
|
|
103
|
+
[/IETF/g, 'IETF_ACRN'],
|
|
104
|
+
[/API/g, 'API_ACRN'],
|
|
105
|
+
[/SSO/g, 'SSO_ACRN'],
|
|
106
|
+
[/PKCE/g, 'PKCE_ACRN'],
|
|
107
|
+
[/JWT/g, 'JWT_ACRN'],
|
|
108
|
+
[/MFA/g, 'MFA_ACRN'],
|
|
109
|
+
[/TOTP/g, 'TOTP_ACRN'],
|
|
110
|
+
[/SAML/g, 'SAML_ACRN'],
|
|
111
|
+
[/SCIM/g, 'SCIM_ACRN'],
|
|
112
|
+
[/OIDC/g, 'OIDC_ACRN'],
|
|
113
|
+
[/CORS/g, 'CORS_ACRN'],
|
|
114
|
+
[/RBAC/g, 'RBAC_ACRN'],
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const HUMANIZE_RESTORE: [RegExp, string][] = [
|
|
118
|
+
[/oauth_acrn/g, 'OAuth'],
|
|
119
|
+
[/urn_acrn/g, 'URN'],
|
|
120
|
+
[/ietf_acrn/g, 'IETF'],
|
|
121
|
+
[/api_acrn/g, 'API'],
|
|
122
|
+
[/sso_acrn/g, 'SSO'],
|
|
123
|
+
[/pkce_acrn/g, 'PKCE'],
|
|
124
|
+
[/jwt_acrn/g, 'JWT'],
|
|
125
|
+
[/mfa_acrn/g, 'MFA'],
|
|
126
|
+
[/totp_acrn/g, 'TOTP'],
|
|
127
|
+
[/saml_acrn/g, 'SAML'],
|
|
128
|
+
[/scim_acrn/g, 'SCIM'],
|
|
129
|
+
[/oidc_acrn/g, 'OIDC'],
|
|
130
|
+
[/cors_acrn/g, 'CORS'],
|
|
131
|
+
[/rbac_acrn/g, 'RBAC'],
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function humanize(name: string): string {
|
|
135
|
+
// Replace known acronyms with placeholders before splitting
|
|
136
|
+
let s = name;
|
|
137
|
+
for (const [pattern, replacement] of HUMANIZE_ACRONYMS) {
|
|
138
|
+
s = s.replace(pattern, replacement);
|
|
139
|
+
}
|
|
140
|
+
let result = s.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
141
|
+
result = result.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
|
142
|
+
result = result.toLowerCase();
|
|
143
|
+
for (const [pattern, replacement] of HUMANIZE_RESTORE) {
|
|
144
|
+
result = result.replace(pattern, replacement);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
|
|
150
|
+
const hashGroups = new Map<string, string[]>();
|
|
151
|
+
for (const enumDef of enums) {
|
|
152
|
+
const hash = [...enumDef.values]
|
|
153
|
+
.map((v) => String(v.value))
|
|
154
|
+
.sort()
|
|
155
|
+
.join('|');
|
|
156
|
+
if (!hashGroups.has(hash)) hashGroups.set(hash, []);
|
|
157
|
+
hashGroups.get(hash)!.push(enumDef.name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const aliasOf = new Map<string, string>();
|
|
161
|
+
for (const [, names] of hashGroups) {
|
|
162
|
+
if (names.length <= 1) continue;
|
|
163
|
+
const sorted = [...names].sort();
|
|
164
|
+
const canonical = sorted[0];
|
|
165
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
166
|
+
aliasOf.set(sorted[i], canonical);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return aliasOf;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
|
|
173
|
+
const enumToService = new Map<string, string>();
|
|
174
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
175
|
+
|
|
176
|
+
for (const service of services) {
|
|
177
|
+
for (const op of service.operations) {
|
|
178
|
+
const refs = new Set<string>();
|
|
179
|
+
const collect = (ref: any) => {
|
|
180
|
+
walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
|
|
181
|
+
};
|
|
182
|
+
if (op.requestBody) collect(op.requestBody);
|
|
183
|
+
collect(op.response);
|
|
184
|
+
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
|
|
185
|
+
collect(p.type);
|
|
186
|
+
}
|
|
187
|
+
for (const name of refs) {
|
|
188
|
+
if (enumNames.has(name) && !enumToService.has(name)) {
|
|
189
|
+
enumToService.set(name, service.name);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return enumToService;
|
|
196
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { Model, TypeRef, Enum } from '@workos/oagen';
|
|
2
|
+
import { fileName, fieldName } from './naming.js';
|
|
3
|
+
import { isListMetadataModel, isListWrapperModel } from './models.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prefix mapping for generating realistic ID fixture values.
|
|
7
|
+
*/
|
|
8
|
+
export const ID_PREFIXES: Record<string, string> = {
|
|
9
|
+
Connection: 'conn_',
|
|
10
|
+
Organization: 'org_',
|
|
11
|
+
OrganizationMembership: 'om_',
|
|
12
|
+
User: 'user_',
|
|
13
|
+
Directory: 'directory_',
|
|
14
|
+
DirectoryGroup: 'dir_grp_',
|
|
15
|
+
DirectoryUser: 'dir_usr_',
|
|
16
|
+
Invitation: 'inv_',
|
|
17
|
+
Session: 'session_',
|
|
18
|
+
AuthenticationFactor: 'auth_factor_',
|
|
19
|
+
EmailVerification: 'email_verification_',
|
|
20
|
+
MagicAuth: 'magic_auth_',
|
|
21
|
+
PasswordReset: 'password_reset_',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate JSON fixture files for test data.
|
|
26
|
+
*/
|
|
27
|
+
export function generateFixtures(spec: {
|
|
28
|
+
models: Model[];
|
|
29
|
+
enums: Enum[];
|
|
30
|
+
services: any[];
|
|
31
|
+
}): { path: string; content: string }[] {
|
|
32
|
+
if (spec.models.length === 0) return [];
|
|
33
|
+
|
|
34
|
+
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
35
|
+
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
36
|
+
const files: { path: string; content: string }[] = [];
|
|
37
|
+
|
|
38
|
+
for (const model of spec.models) {
|
|
39
|
+
if (isListMetadataModel(model)) continue;
|
|
40
|
+
if (isListWrapperModel(model)) continue;
|
|
41
|
+
|
|
42
|
+
const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
|
|
43
|
+
|
|
44
|
+
files.push({
|
|
45
|
+
path: `testdata/${fileName(model.name)}.json`,
|
|
46
|
+
content: JSON.stringify(fixture, null, 2),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Generate list fixtures for paginated responses
|
|
51
|
+
for (const service of spec.services) {
|
|
52
|
+
for (const op of service.operations) {
|
|
53
|
+
if (op.pagination) {
|
|
54
|
+
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
55
|
+
if (itemModel) {
|
|
56
|
+
const unwrapped = unwrapListModel(itemModel, modelMap);
|
|
57
|
+
if (unwrapped) itemModel = unwrapped;
|
|
58
|
+
if (itemModel.fields.length === 0) continue;
|
|
59
|
+
const fixture = generateModelFixture(itemModel, modelMap, enumMap);
|
|
60
|
+
const listFixture = {
|
|
61
|
+
data: [fixture],
|
|
62
|
+
list_metadata: {
|
|
63
|
+
before: null,
|
|
64
|
+
after: null,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
files.push({
|
|
68
|
+
path: `testdata/list_${fileName(itemModel.name)}.json`,
|
|
69
|
+
content: JSON.stringify(listFixture, null, 2),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Deduplicate fixtures with identical content.
|
|
77
|
+
// When multiple fixtures have the same content, emit one shared file and
|
|
78
|
+
// rewrite the others as references to the shared path.
|
|
79
|
+
const contentGroups = new Map<string, string[]>();
|
|
80
|
+
for (const f of files) {
|
|
81
|
+
if (!contentGroups.has(f.content)) contentGroups.set(f.content, []);
|
|
82
|
+
contentGroups.get(f.content)!.push(f.path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pathRewrites = new Map<string, string>();
|
|
86
|
+
for (const [_content, paths] of contentGroups) {
|
|
87
|
+
if (paths.length < 3) continue; // only dedup when 3+ files are identical
|
|
88
|
+
// Use the shortest path as the canonical shared fixture
|
|
89
|
+
const sorted = [...paths].sort((a, b) => a.length - b.length);
|
|
90
|
+
const canonical = sorted[0];
|
|
91
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
92
|
+
pathRewrites.set(sorted[i], canonical);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Remove duplicate files (they'll reference the canonical)
|
|
97
|
+
const deduped = files.filter((f) => !pathRewrites.has(f.path));
|
|
98
|
+
|
|
99
|
+
return deduped;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
|
|
103
|
+
const dataField = model.fields.find((f) => f.name === 'data');
|
|
104
|
+
const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
|
|
105
|
+
if (dataField && hasListMetadata && dataField.type.kind === 'array') {
|
|
106
|
+
const itemType = dataField.type.items;
|
|
107
|
+
if (itemType.kind === 'model') {
|
|
108
|
+
return modelMap.get(itemType.name) ?? null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function generateModelFixture(
|
|
115
|
+
model: Model,
|
|
116
|
+
modelMap: Map<string, Model>,
|
|
117
|
+
enumMap: Map<string, Enum>,
|
|
118
|
+
): Record<string, any> {
|
|
119
|
+
const fixture: Record<string, any> = {};
|
|
120
|
+
|
|
121
|
+
const seenFieldNames = new Set<string>();
|
|
122
|
+
const deduplicatedFields = model.fields.filter((f) => {
|
|
123
|
+
const goName = fieldName(f.name);
|
|
124
|
+
if (seenFieldNames.has(goName)) return false;
|
|
125
|
+
seenFieldNames.add(goName);
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
for (const field of deduplicatedFields) {
|
|
130
|
+
const wireName = field.name;
|
|
131
|
+
if (field.example !== undefined) {
|
|
132
|
+
fixture[wireName] = field.example;
|
|
133
|
+
} else {
|
|
134
|
+
fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return fixture;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function generateFieldValue(
|
|
142
|
+
ref: TypeRef,
|
|
143
|
+
fName: string,
|
|
144
|
+
modelName: string,
|
|
145
|
+
modelMap: Map<string, Model>,
|
|
146
|
+
enumMap: Map<string, Enum>,
|
|
147
|
+
): any {
|
|
148
|
+
switch (ref.kind) {
|
|
149
|
+
case 'primitive':
|
|
150
|
+
return generatePrimitiveValue(ref.type, ref.format, fName, modelName);
|
|
151
|
+
case 'literal':
|
|
152
|
+
return ref.value;
|
|
153
|
+
case 'enum': {
|
|
154
|
+
const e = enumMap.get(ref.name);
|
|
155
|
+
return e?.values[0]?.value ?? 'unknown';
|
|
156
|
+
}
|
|
157
|
+
case 'model': {
|
|
158
|
+
const nested = modelMap.get(ref.name);
|
|
159
|
+
if (nested) return generateModelFixture(nested, modelMap, enumMap);
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
case 'array': {
|
|
163
|
+
if (ref.items.kind === 'enum') {
|
|
164
|
+
const e = enumMap.get(ref.items.name);
|
|
165
|
+
if (e && e.values.length > 0) {
|
|
166
|
+
return e.values.map((v) => v.value);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const item = generateFieldValue(ref.items, fName, modelName, modelMap, enumMap);
|
|
170
|
+
return [item];
|
|
171
|
+
}
|
|
172
|
+
case 'nullable':
|
|
173
|
+
return generateFieldValue(ref.inner, fName, modelName, modelMap, enumMap);
|
|
174
|
+
case 'union':
|
|
175
|
+
if (ref.variants.length > 0) {
|
|
176
|
+
return generateFieldValue(ref.variants[0], fName, modelName, modelMap, enumMap);
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
case 'map':
|
|
180
|
+
return {
|
|
181
|
+
key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
|
|
187
|
+
switch (type) {
|
|
188
|
+
case 'string':
|
|
189
|
+
if (format === 'date-time') return '2023-01-01T00:00:00.000Z';
|
|
190
|
+
if (format === 'date') return '2023-01-01';
|
|
191
|
+
if (format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
192
|
+
if (name === 'id') {
|
|
193
|
+
const prefix = ID_PREFIXES[modelName] ?? '';
|
|
194
|
+
return `${prefix}01234`;
|
|
195
|
+
}
|
|
196
|
+
if (name.includes('id')) return `${name}_01234`;
|
|
197
|
+
if (name.includes('email')) return 'test@example.com';
|
|
198
|
+
if (name.includes('url') || name.includes('uri')) return 'https://example.com';
|
|
199
|
+
if (name.includes('name')) return 'Test';
|
|
200
|
+
return `test_${name}`;
|
|
201
|
+
case 'integer':
|
|
202
|
+
return 1;
|
|
203
|
+
case 'number':
|
|
204
|
+
return 1.0;
|
|
205
|
+
case 'boolean':
|
|
206
|
+
return true;
|
|
207
|
+
case 'unknown':
|
|
208
|
+
return {};
|
|
209
|
+
default:
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|