@mutagent/sdk 0.2.40 → 0.2.43

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.
@@ -1,1116 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * OpenAPI Post-Processing Script for MutagenT SDK Generation
4
- *
5
- * This script fixes known issues in the Elysia-generated OpenAPI spec
6
- * to ensure compatibility with Speakeasy SDK generator.
7
- *
8
- * Usage:
9
- * bun run scripts/fix-openapi.ts [input.json] [output.json]
10
- *
11
- * Default: reads from ./openapi.json, writes back to same file
12
- */
13
-
14
- // =============================================================================
15
- // TYPE DEFINITIONS
16
- // =============================================================================
17
-
18
- type AnyValue = any;
19
-
20
- interface OpenAPISpec {
21
- paths?: Record<string, Record<string, AnyValue>>;
22
- components?: {
23
- securitySchemes?: Record<string, AnyValue>;
24
- schemas?: Record<string, AnyValue>;
25
- };
26
- security?: Array<Record<string, string[]>>;
27
- }
28
-
29
- // =============================================================================
30
- // NULLABLE TYPE FIXES
31
- // =============================================================================
32
-
33
- function fixNullableTypes(obj: AnyValue): AnyValue {
34
- /**
35
- * Fix nullable types for OpenAPI 3.0 compliance.
36
- *
37
- * Problem: Elysia generates { "anyOf": [{ "type": "string" }, { "type": "null" }] }
38
- * This is invalid in OpenAPI 3.0 because "type": "null" is not allowed.
39
- *
40
- * Solution: Convert to { "type": "string", "nullable": true }
41
- */
42
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
43
- // If we have nullable:true and anyOf with type:null, fix it
44
- if (obj.nullable === true && 'anyOf' in obj) {
45
- const anyOf = obj.anyOf;
46
- // Filter out the {type: null} entry
47
- const nonNullTypes = anyOf.filter((t: AnyValue) => t.type !== 'null');
48
- if (nonNullTypes.length === 1) {
49
- // Single type with nullable - simplify
50
- const actualType = nonNullTypes[0];
51
- delete obj.anyOf;
52
- Object.assign(obj, actualType);
53
- } else if (nonNullTypes.length > 1) {
54
- // Multiple types - keep anyOf but without type:null
55
- obj.anyOf = nonNullTypes;
56
- }
57
- }
58
-
59
- // Recurse into all values
60
- for (const key of Object.keys(obj)) {
61
- obj[key] = fixNullableTypes(obj[key]);
62
- }
63
- } else if (Array.isArray(obj)) {
64
- return obj.map(item => fixNullableTypes(item));
65
- }
66
-
67
- return obj;
68
- }
69
-
70
- // =============================================================================
71
- // WEBSOCKET PATH REMOVAL
72
- // =============================================================================
73
-
74
- function removeWebsocketPaths(spec: OpenAPISpec): string[] {
75
- /**
76
- * Remove WebSocket paths from OpenAPI spec.
77
- *
78
- * Problem: OpenAPI doesn't support WebSocket specs. Elysia generates "ws": {...}
79
- * which is invalid and causes Speakeasy to fail.
80
- *
81
- * Solution: Remove all paths that only have "ws" method, or remove "ws" from
82
- * paths that have other methods.
83
- */
84
- const wsPathsRemoved: string[] = [];
85
-
86
- if (!spec.paths) return wsPathsRemoved;
87
-
88
- for (const path of Object.keys(spec.paths)) {
89
- const methods = spec.paths[path];
90
- if ('ws' in methods) {
91
- // If ws is the only method, remove the whole path
92
- if (Object.keys(methods).length === 1) {
93
- wsPathsRemoved.push(path);
94
- delete spec.paths[path];
95
- } else {
96
- // Just remove the ws method
97
- delete methods.ws;
98
- }
99
- }
100
- }
101
-
102
- return wsPathsRemoved;
103
- }
104
-
105
- // =============================================================================
106
- // MISSING RESPONSE HANDLING
107
- // =============================================================================
108
-
109
- function addMissingResponses(spec: OpenAPISpec): { noResponseCount: number; noSuccessCount: number } {
110
- /**
111
- * Add default responses to operations missing them.
112
- *
113
- * Problem 1: Some Elysia routes don't have any response schemas defined,
114
- * which is required by OpenAPI spec.
115
- *
116
- * Problem 2: SSE/streaming endpoints may have error responses from
117
- * detail.responses but no 2xx success response, which Speakeasy flags as
118
- * `style-operation-success-response`.
119
- *
120
- * Solution: Add a generic 200 response where needed.
121
- */
122
- const defaultSuccessResponse = {
123
- "description": "Successful response",
124
- "content": {
125
- "application/json": {
126
- "schema": {
127
- "type": "object"
128
- }
129
- }
130
- }
131
- };
132
-
133
- let noResponseCount = 0;
134
- let noSuccessCount = 0;
135
- if (!spec.paths) return { noResponseCount, noSuccessCount };
136
-
137
- for (const [path, methods] of Object.entries(spec.paths)) {
138
- for (const [method, operation] of Object.entries(methods)) {
139
- if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
140
- if (!operation.responses || Object.keys(operation.responses).length === 0) {
141
- // No responses at all - add default 200
142
- operation.responses = { "200": JSON.parse(JSON.stringify(defaultSuccessResponse)) };
143
- noResponseCount++;
144
- } else {
145
- // Has responses but check if missing any 2xx/3xx
146
- const codes = Object.keys(operation.responses);
147
- const hasSuccess = codes.some(c => {
148
- const num = Number(c);
149
- return num >= 200 && num < 400;
150
- });
151
- if (!hasSuccess) {
152
- // Has only error responses (e.g., SSE/streaming endpoints) - add 200
153
- operation.responses["200"] = JSON.parse(JSON.stringify(defaultSuccessResponse));
154
- noSuccessCount++;
155
- }
156
- }
157
- }
158
- }
159
- }
160
-
161
- return { noResponseCount, noSuccessCount };
162
- }
163
-
164
- // =============================================================================
165
- // INJECT STANDARD ERROR RESPONSES
166
- // =============================================================================
167
-
168
- function injectStandardErrorResponses(spec: OpenAPISpec): { getCount: number; mutationCount: number } {
169
- /**
170
- * Inject standard error responses (4xx/5xx) for operations missing them.
171
- *
172
- * Problem: Elysia's `detail.responses` (standardErrorResponses / standardMutationErrorResponses)
173
- * are NOT merged into the generated OpenAPI spec when the route uses a shorthand
174
- * `response: 'SomeModel'` instead of explicit per-status-code response mapping.
175
- * This means routes that define `detail: { responses: standardErrorResponses }` in
176
- * their route config still only produce a 200 response in the OpenAPI output.
177
- * Speakeasy flags these as `generator-missing-error-response` hints.
178
- *
179
- * Solution: For each operation that only has 2xx responses, inject the standard
180
- * error response schemas based on the HTTP method:
181
- * - GET/HEAD: 400, 401, 403, 404, 500 (standardErrorResponses)
182
- * - POST/PATCH/PUT/DELETE: 400, 401, 403, 404, 409, 500 (standardMutationErrorResponses)
183
- *
184
- * These match the exact schemas defined in mutagent/src/shared/error-responses.ts.
185
- */
186
-
187
- const errorSchema = {
188
- type: 'object',
189
- properties: {
190
- error: { type: 'string' },
191
- message: { type: 'string' },
192
- },
193
- required: ['error', 'message'],
194
- };
195
-
196
- const errorWithStatusSchema = {
197
- type: 'object',
198
- properties: {
199
- error: { type: 'string' },
200
- message: { type: 'string' },
201
- statusCode: { type: 'number' },
202
- },
203
- required: ['error', 'message', 'statusCode'],
204
- };
205
-
206
- const makeErrorResponse = (description: string, schema: AnyValue) => ({
207
- description,
208
- content: { 'application/json': { schema: JSON.parse(JSON.stringify(schema)) } },
209
- });
210
-
211
- // Standard error responses for GET routes
212
- const standardErrors: Record<string, AnyValue> = {
213
- '400': makeErrorResponse('Bad Request', errorSchema),
214
- '401': makeErrorResponse('Unauthorized', errorSchema),
215
- '403': makeErrorResponse('Forbidden', errorSchema),
216
- '404': makeErrorResponse('Not Found', errorSchema),
217
- '500': makeErrorResponse('Internal Server Error', errorWithStatusSchema),
218
- };
219
-
220
- // Mutation error responses add 409 Conflict
221
- const mutationErrors: Record<string, AnyValue> = {
222
- ...standardErrors,
223
- '409': makeErrorResponse('Conflict', errorWithStatusSchema),
224
- };
225
-
226
- let getCount = 0;
227
- let mutationCount = 0;
228
-
229
- if (!spec.paths) return { getCount, mutationCount };
230
-
231
- for (const [_path, methods] of Object.entries(spec.paths)) {
232
- for (const [method, operation] of Object.entries(methods)) {
233
- if (!['get', 'post', 'put', 'patch', 'delete', 'head'].includes(method)) continue;
234
- if (!operation.responses) continue;
235
-
236
- const responseCodes = Object.keys(operation.responses);
237
- const hasErrorResponse = responseCodes.some(code => Number(code) >= 400);
238
-
239
- // Skip if already has error responses
240
- if (hasErrorResponse) continue;
241
-
242
- // Determine which error set to use based on HTTP method
243
- const isMutation = ['post', 'put', 'patch', 'delete'].includes(method);
244
- const errorsToInject = isMutation ? mutationErrors : standardErrors;
245
-
246
- // Inject error responses
247
- for (const [code, response] of Object.entries(errorsToInject)) {
248
- if (!operation.responses[code]) {
249
- operation.responses[code] = JSON.parse(JSON.stringify(response));
250
- }
251
- }
252
-
253
- if (isMutation) {
254
- mutationCount++;
255
- } else {
256
- getCount++;
257
- }
258
- }
259
- }
260
-
261
- return { getCount, mutationCount };
262
- }
263
-
264
- // =============================================================================
265
- // PATTERN PROPERTIES REMOVAL
266
- // =============================================================================
267
-
268
- function removePatternProperties(obj: AnyValue): AnyValue {
269
- /**
270
- * Remove patternProperties from schemas.
271
- *
272
- * Problem: OpenAPI 3.0 doesn't support JSON Schema's patternProperties.
273
- *
274
- * Solution: Remove them.
275
- */
276
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
277
- if ('patternProperties' in obj) {
278
- delete obj.patternProperties;
279
- }
280
- for (const k of Object.keys(obj)) {
281
- obj[k] = removePatternProperties(obj[k]);
282
- }
283
- } else if (Array.isArray(obj)) {
284
- return obj.map(item => removePatternProperties(item));
285
- }
286
- return obj;
287
- }
288
-
289
- // =============================================================================
290
- // DESCRIPTION FIXES
291
- // =============================================================================
292
-
293
- function fixDescriptions(obj: AnyValue): AnyValue {
294
- /**
295
- * Fix problematic text in descriptions.
296
- *
297
- * Problem: Speakeasy flags descriptions containing 'eval(' as security risk.
298
- *
299
- * Solution: Replace with safe alternative.
300
- */
301
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
302
- if ('description' in obj && typeof obj.description === 'string') {
303
- if (obj.description.includes('eval(')) {
304
- obj.description = obj.description.replace(/eval\(/g, 'evaluate(');
305
- }
306
- }
307
- for (const k of Object.keys(obj)) {
308
- obj[k] = fixDescriptions(obj[k]);
309
- }
310
- } else if (Array.isArray(obj)) {
311
- return obj.map(item => fixDescriptions(item));
312
- }
313
- return obj;
314
- }
315
-
316
- // =============================================================================
317
- // $id FIELD REMOVAL
318
- // =============================================================================
319
-
320
- function removeIdFields(obj: AnyValue): { result: AnyValue; count: number } {
321
- /**
322
- * Recursively remove all $id fields from the spec.
323
- *
324
- * Problem: Elysia's OpenAPI plugin adds $id fields like
325
- * "$id": "#/components/schemas/PromptDatasetItem"
326
- * throughout the spec. Speakeasy rejects these with:
327
- * schema.items.$id does not match pattern '[#]*#?$'
328
- *
329
- * Solution: Walk the entire spec and delete any key named "$id".
330
- */
331
- let count = 0;
332
-
333
- function walk(node: AnyValue): AnyValue {
334
- if (node && typeof node === 'object' && !Array.isArray(node)) {
335
- if ('$id' in node) {
336
- delete node.$id;
337
- count++;
338
- }
339
- for (const key of Object.keys(node)) {
340
- node[key] = walk(node[key]);
341
- }
342
- } else if (Array.isArray(node)) {
343
- return node.map(item => walk(item));
344
- }
345
- return node;
346
- }
347
-
348
- const result = walk(obj);
349
- return { result, count };
350
- }
351
-
352
- // =============================================================================
353
- // AUTHENTICATION SETUP
354
- // =============================================================================
355
-
356
- function setupDualAuthSchemes(spec: OpenAPISpec): { apiKey: boolean; bearerAuth: boolean } {
357
- /**
358
- * Configure BOTH authentication methods in OpenAPI spec.
359
- *
360
- * SDK Authentication Methods:
361
- * 1. apiKey - Platform API Keys (mg_live_...) via MUTAGENT_API_KEY env var
362
- * 2. bearerAuth - JWT session tokens via MUTAGENT_BEARER_AUTH env var
363
- *
364
- * Users can authenticate with EITHER method. The SDK will try apiKey first
365
- * (if MUTAGENT_API_KEY is set), then fall back to bearerAuth.
366
- *
367
- * Backend supports both via x-api-key header OR Authorization: Bearer header.
368
- */
369
- if (!spec.components) {
370
- spec.components = {};
371
- }
372
- if (!spec.components.securitySchemes) {
373
- spec.components.securitySchemes = {};
374
- }
375
-
376
- const schemes = spec.components.securitySchemes;
377
-
378
- // Primary: Platform API Key (MUTAGENT_API_KEY)
379
- // Uses x-api-key header for clear distinction from JWT tokens
380
- schemes.apiKey = {
381
- "type": "apiKey",
382
- "in": "header",
383
- "name": "x-api-key",
384
- "description": "Platform API Key (mg_live_...). Get one from Settings > API Keys."
385
- };
386
-
387
- // Secondary: Bearer Auth for JWT session tokens (MUTAGENT_BEARER_AUTH)
388
- // Keep existing bearerAuth for backward compatibility with session tokens
389
- if (!('bearerAuth' in schemes)) {
390
- schemes.bearerAuth = {
391
- "type": "http",
392
- "scheme": "bearer",
393
- "bearerFormat": "JWT",
394
- "description": "JWT session token from login. Use for browser/UI authentication."
395
- };
396
- }
397
-
398
- return {
399
- apiKey: true,
400
- bearerAuth: 'bearerAuth' in schemes
401
- };
402
- }
403
-
404
- function configureSecurityOptions(spec: OpenAPISpec): number {
405
- /**
406
- * Configure security to allow EITHER apiKey OR bearerAuth.
407
- *
408
- * OpenAPI security arrays use OR logic between items, AND logic within items.
409
- * We want: [{ apiKey: [] }, { bearerAuth: [] }] = apiKey OR bearerAuth
410
- *
411
- * This ensures SDK users can use either:
412
- * - MUTAGENT_API_KEY for programmatic/service access
413
- * - MUTAGENT_BEARER_AUTH for session-based access
414
- */
415
- let count = 0;
416
-
417
- // Define security options (either apiKey OR bearerAuth)
418
- const dualSecurity = [
419
- { apiKey: [] },
420
- { bearerAuth: [] }
421
- ];
422
-
423
- // Update global security
424
- spec.security = dualSecurity;
425
- count++;
426
-
427
- // Update path-level security to support both methods
428
- if (spec.paths) {
429
- for (const [path, methods] of Object.entries(spec.paths)) {
430
- for (const [method, operation] of Object.entries(methods)) {
431
- if (operation && typeof operation === 'object' && 'security' in operation) {
432
- // Replace any existing security with dual auth
433
- operation.security = dualSecurity;
434
- count++;
435
- }
436
- }
437
- }
438
- }
439
-
440
- return count;
441
- }
442
-
443
- // =============================================================================
444
- // MODULE FILTERING - Configure which API modules to include in SDK
445
- // =============================================================================
446
-
447
- // Modules to INCLUDE in SDK (paths starting with these prefixes)
448
- const INCLUDED_MODULES = [
449
- // Core APIs
450
- '/api/prompt', // Prompts (includes /api/prompt/ and /api/prompts/)
451
- '/api/prompts', // Prompts datasets, evaluations, etc.
452
- '/api/agents', // Agents
453
- '/api/optimization', // Optimization jobs
454
- '/api/agent-datasets', // Agent datasets
455
- '/api/agent-dataset-items', // Agent dataset items
456
-
457
- // Infrastructure
458
- '/api/conversations', // Conversations
459
- '/api/stream', // Streaming/SSE
460
- '/api/traces', // Traces (ADDED - was missing)
461
-
462
- // Tenancy/Org
463
- '/api/organizations', // Organizations
464
- '/api/workspaces', // Workspaces
465
-
466
- // Auth/User
467
- '/api/users', // User profile
468
- '/api/providers', // OAuth providers
469
- ];
470
-
471
- // Modules to EXCLUDE from SDK (explicitly removed even if matched above)
472
- const EXCLUDED_MODULES = [
473
- '/health', // Health checks
474
- '/hello', // Test/debug endpoints
475
- '/api/stats', // Internal stats
476
- '/api/auth', // Auth validation (internal)
477
- '/api/sessions', // Session management (use users instead)
478
- '/api/api-keys', // API key management
479
- '/api/invitations', // Invitations
480
- '/api/mcp-servers', // MCP servers
481
- ];
482
-
483
- function filterModules(spec: OpenAPISpec): { included: string[]; excluded: string[] } {
484
- /**
485
- * Filter API paths to only include selected modules.
486
- *
487
- * Returns object with included_paths and excluded_paths for logging.
488
- */
489
- const included: string[] = [];
490
- const excluded: string[] = [];
491
-
492
- if (!spec.paths) return { included, excluded };
493
-
494
- for (const path of Object.keys(spec.paths)) {
495
- // Check if path should be excluded
496
- const shouldExclude = EXCLUDED_MODULES.some(excl => path.startsWith(excl));
497
-
498
- // Check if path should be included
499
- const shouldInclude = INCLUDED_MODULES.some(incl => path.startsWith(incl));
500
-
501
- if (shouldExclude || !shouldInclude) {
502
- excluded.push(path);
503
- delete spec.paths[path];
504
- } else {
505
- included.push(path);
506
- }
507
- }
508
-
509
- return { included, excluded };
510
- }
511
-
512
- // =============================================================================
513
- // COLLAPSE t.Numeric() anyOf PATTERNS IN QUERY PARAMETERS
514
- // =============================================================================
515
-
516
- function collapseNumericAnyOfInQueryParams(spec: OpenAPISpec): number {
517
- /**
518
- * Collapse Elysia t.Numeric() / t.BooleanString() anyOf patterns in query parameters.
519
- *
520
- * Problem: Elysia's t.Numeric() generates:
521
- * { "anyOf": [{ "type": "string", "format": "numeric", "default": 0 }, { "type": "number", ... }] }
522
- * The default:0 is a number but the branch says type:"string", causing Speakeasy warnings.
523
- *
524
- * Similarly t.BooleanString() generates:
525
- * { "anyOf": [{ "type": "string", "default": false }, { ... boolean-like ... }] }
526
- *
527
- * Solution: For query parameters only, when anyOf has exactly 2 items where one is
528
- * type:"string" and the other is type:"number", collapse to the number branch.
529
- * Same for string+boolean pairs: collapse to the boolean branch.
530
- */
531
- let collapsedCount = 0;
532
-
533
- if (!spec.paths) return collapsedCount;
534
-
535
- for (const [_path, methods] of Object.entries(spec.paths)) {
536
- for (const [method, operation] of Object.entries(methods)) {
537
- if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue;
538
- if (!operation.parameters || !Array.isArray(operation.parameters)) continue;
539
-
540
- for (const param of operation.parameters) {
541
- if (param.in !== 'query') continue;
542
- if (!param.schema || !Array.isArray(param.schema.anyOf)) continue;
543
-
544
- const anyOf = param.schema.anyOf;
545
- if (anyOf.length !== 2) continue;
546
-
547
- const stringBranch = anyOf.find((b: AnyValue) => b.type === 'string');
548
- const numberBranch = anyOf.find((b: AnyValue) => b.type === 'number');
549
- const booleanBranch = anyOf.find((b: AnyValue) => b.type === 'boolean');
550
-
551
- if (stringBranch && numberBranch) {
552
- // Collapse to number branch, merging default from string branch if needed
553
- const merged = { ...numberBranch };
554
- if (!('default' in merged) && 'default' in stringBranch) {
555
- merged.default = stringBranch.default;
556
- }
557
- // Replace the schema entirely
558
- delete param.schema.anyOf;
559
- Object.assign(param.schema, merged);
560
- collapsedCount++;
561
- } else if (stringBranch && booleanBranch) {
562
- // Collapse to boolean branch, merging default from string branch if needed
563
- const merged = { ...booleanBranch };
564
- if (!('default' in merged) && 'default' in stringBranch) {
565
- merged.default = stringBranch.default;
566
- }
567
- delete param.schema.anyOf;
568
- Object.assign(param.schema, merged);
569
- collapsedCount++;
570
- }
571
- }
572
- }
573
- }
574
-
575
- return collapsedCount;
576
- }
577
-
578
- // =============================================================================
579
- // STRIP default FROM OPTIONAL BOOLEAN QUERY PARAMETERS
580
- // =============================================================================
581
-
582
- function stripDefaultFromOptionalBooleanQueryParams(spec: OpenAPISpec): number {
583
- /**
584
- * Remove `default` values from optional boolean query parameters.
585
- *
586
- * Problem: Elysia's t.BooleanString() generates schemas with "default": false.
587
- * When Speakeasy sees this, it generates SDK code like:
588
- * isLatest: z._default(z.boolean(), false)
589
- * This means even when the user does NOT specify isLatest, the SDK sends
590
- * isLatest=false as a query parameter, causing the backend to filter
591
- * WHERE is_latest = false — silently hiding results.
592
- *
593
- * This bug caused CLI-created prompts to be invisible in the dashboard because:
594
- * 1. CLI creates prompts with isLatest=true (auto-set by service)
595
- * 2. SDK list call sends isLatest=false (Speakeasy default)
596
- * 3. Backend filters to only non-latest prompts → empty results
597
- *
598
- * Solution: Strip `default` from all optional boolean query parameters.
599
- * These parameters are filters — when omitted, the backend should return
600
- * ALL results, not filter by the default value.
601
- *
602
- * Also resolves $ref schemas that point to shared boolean components with
603
- * defaults (e.g., Schemadefaul4: { type: "boolean", default: false }).
604
- * These refs are inlined without the default to prevent Speakeasy from
605
- * generating default values.
606
- */
607
- let strippedCount = 0;
608
-
609
- if (!spec.paths) return strippedCount;
610
-
611
- for (const [_path, methods] of Object.entries(spec.paths)) {
612
- for (const [method, operation] of Object.entries(methods)) {
613
- if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue;
614
- if (!operation.parameters || !Array.isArray(operation.parameters)) continue;
615
-
616
- for (const param of operation.parameters) {
617
- if (param.in !== 'query') continue;
618
- if (param.required === true) continue; // Skip required params
619
- if (!param.schema) continue;
620
-
621
- const schema = param.schema;
622
-
623
- // Case 1: Direct boolean schema with default
624
- if (schema.type === 'boolean' && 'default' in schema) {
625
- delete schema.default;
626
- strippedCount++;
627
- continue;
628
- }
629
-
630
- // Case 2: $ref to a shared boolean schema (e.g., Schemadefaul4)
631
- // Inline the schema without the default
632
- if (schema.$ref && typeof schema.$ref === 'string') {
633
- const refName = schema.$ref.replace('#/components/schemas/', '');
634
- const refSchema = spec.components?.schemas?.[refName];
635
- if (refSchema && refSchema.type === 'boolean' && 'default' in refSchema) {
636
- // Inline the schema without the default
637
- delete param.schema.$ref;
638
- param.schema.type = 'boolean';
639
- // Copy other properties except default
640
- for (const [k, v] of Object.entries(refSchema)) {
641
- if (k !== 'default' && k !== 'type') {
642
- param.schema[k] = v;
643
- }
644
- }
645
- strippedCount++;
646
- continue;
647
- }
648
- }
649
-
650
- // Case 3: allOf with $ref to boolean schema (from deduplication)
651
- if (Array.isArray(schema.allOf) && schema.allOf.length >= 1) {
652
- const refItem = schema.allOf.find((item: AnyValue) => item.$ref);
653
- if (refItem) {
654
- const refName = refItem.$ref.replace('#/components/schemas/', '');
655
- const refSchema = spec.components?.schemas?.[refName];
656
- if (refSchema && refSchema.type === 'boolean' && 'default' in refSchema) {
657
- // Inline the schema without the default, preserve description
658
- const description = schema.description;
659
- delete param.schema.allOf;
660
- param.schema.type = 'boolean';
661
- if (description) {
662
- param.schema.description = description;
663
- }
664
- strippedCount++;
665
- continue;
666
- }
667
- }
668
- }
669
- }
670
- }
671
- }
672
-
673
- return strippedCount;
674
- }
675
-
676
- // =============================================================================
677
- // INJECT x-speakeasy-pagination FOR OFFSET/LIMIT ENDPOINTS
678
- // =============================================================================
679
-
680
- function injectSpeakeasyPagination(spec: OpenAPISpec): number {
681
- /**
682
- * Add x-speakeasy-pagination extension to operations with limit+offset query params.
683
- *
684
- * Problem: 10+ list endpoints use limit/offset but Speakeasy doesn't know how to paginate.
685
- *
686
- * Solution: For each operation that has both "limit" AND "offset" query parameters,
687
- * inject the x-speakeasy-pagination extension for offsetLimit pagination.
688
- */
689
- let paginatedCount = 0;
690
-
691
- if (!spec.paths) return paginatedCount;
692
-
693
- for (const [_path, methods] of Object.entries(spec.paths)) {
694
- for (const [method, operation] of Object.entries(methods)) {
695
- if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue;
696
- if (!operation.parameters || !Array.isArray(operation.parameters)) continue;
697
-
698
- const queryParams = operation.parameters.filter((p: AnyValue) => p.in === 'query');
699
- const hasOffset = queryParams.some((p: AnyValue) => p.name === 'offset');
700
- const hasLimit = queryParams.some((p: AnyValue) => p.name === 'limit');
701
-
702
- if (hasOffset && hasLimit) {
703
- operation['x-speakeasy-pagination'] = {
704
- type: 'offsetLimit',
705
- inputs: [
706
- { name: 'offset', in: 'parameters', type: 'offset' },
707
- { name: 'limit', in: 'parameters', type: 'limit' }
708
- ],
709
- outputs: {
710
- results: '$.data'
711
- }
712
- };
713
- paginatedCount++;
714
- }
715
- }
716
- }
717
-
718
- return paginatedCount;
719
- }
720
-
721
- // =============================================================================
722
- // CONVERT const TO enum
723
- // =============================================================================
724
-
725
- function convertConstToEnum(obj: AnyValue): { result: AnyValue; count: number } {
726
- /**
727
- * Convert JSON Schema `const` keyword to `enum` for Speakeasy compatibility.
728
- *
729
- * Problem: Some schemas use { "const": "value" } which Speakeasy may not handle well.
730
- *
731
- * Solution: Replace { "const": "value" } with { "enum": ["value"] }.
732
- * Also handles { "type": "string", "const": "foo" } -> { "type": "string", "enum": ["foo"] }.
733
- */
734
- let count = 0;
735
-
736
- function walk(node: AnyValue): AnyValue {
737
- if (node && typeof node === 'object' && !Array.isArray(node)) {
738
- if ('const' in node) {
739
- const constValue = node.const;
740
- delete node.const;
741
- node.enum = [constValue];
742
- count++;
743
- }
744
- for (const key of Object.keys(node)) {
745
- node[key] = walk(node[key]);
746
- }
747
- } else if (Array.isArray(node)) {
748
- return node.map(item => walk(item));
749
- }
750
- return node;
751
- }
752
-
753
- const result = walk(obj);
754
- return { result, count };
755
- }
756
-
757
- // =============================================================================
758
- // SCHEMA DEDUPLICATION
759
- // =============================================================================
760
-
761
- /**
762
- * Create a canonical hash key for a schema by deep-stringifying it,
763
- * excluding `description` and `title` so structurally identical schemas
764
- * with different descriptions are still considered duplicates.
765
- */
766
- function canonicalizeSchema(schema: AnyValue): string {
767
- if (schema === null || schema === undefined) return String(schema);
768
- if (typeof schema !== 'object') return JSON.stringify(schema);
769
-
770
- if (Array.isArray(schema)) {
771
- return '[' + schema.map(item => canonicalizeSchema(item)).join(',') + ']';
772
- }
773
-
774
- // Sort keys, exclude description and title for dedup purposes
775
- const keys = Object.keys(schema)
776
- .filter(k => k !== 'description' && k !== 'title')
777
- .sort();
778
-
779
- const parts = keys.map(k => JSON.stringify(k) + ':' + canonicalizeSchema(schema[k]));
780
- return '{' + parts.join(',') + '}';
781
- }
782
-
783
- /**
784
- * Generate a PascalCase component name from a schema's structure.
785
- */
786
- function generateComponentName(schema: AnyValue): string {
787
- // If the schema has a title, use it
788
- if (schema.title && typeof schema.title === 'string') {
789
- return toPascalCase(schema.title);
790
- }
791
-
792
- // For objects with properties, use property names
793
- if (schema.type === 'object' && schema.properties) {
794
- const propNames = Object.keys(schema.properties);
795
- // Use first 3 property names to form a name
796
- const nameParts = propNames.slice(0, 3).map(toPascalCase);
797
- return nameParts.join('');
798
- }
799
-
800
- // For arrays of objects, prefix with ArrayOf
801
- if (schema.type === 'array' && schema.items) {
802
- const itemName = generateComponentName(schema.items);
803
- if (itemName) return `ArrayOf${itemName}`;
804
- }
805
-
806
- // For anyOf/oneOf/allOf, try to derive from first item
807
- for (const combiner of ['anyOf', 'oneOf', 'allOf']) {
808
- if (Array.isArray(schema[combiner]) && schema[combiner].length > 0) {
809
- const firstName = generateComponentName(schema[combiner][0]);
810
- if (firstName) return `${firstName}${toPascalCase(combiner)}`;
811
- }
812
- }
813
-
814
- return '';
815
- }
816
-
817
- /**
818
- * Convert a string to PascalCase.
819
- */
820
- function toPascalCase(str: string): string {
821
- return str
822
- .replace(/[^a-zA-Z0-9]+/g, ' ')
823
- .split(' ')
824
- .filter(Boolean)
825
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
826
- .join('');
827
- }
828
-
829
- /**
830
- * Check if a schema is "trivial" — too simple to be worth deduplicating.
831
- * We skip schemas that are just a type with no properties, or objects with < 2 properties.
832
- */
833
- function isTrivialSchema(schema: AnyValue): boolean {
834
- if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return true;
835
-
836
- // If it's a $ref, it's already deduplicated
837
- if ('$ref' in schema) return true;
838
-
839
- // Simple type-only schemas (e.g., { type: "string" })
840
- const keys = Object.keys(schema).filter(k => k !== 'description' && k !== 'title');
841
- if (keys.length <= 1) return true;
842
-
843
- // Objects with fewer than 2 properties
844
- if (schema.type === 'object' && schema.properties) {
845
- if (Object.keys(schema.properties).length < 2) return true;
846
- }
847
-
848
- return false;
849
- }
850
-
851
- /**
852
- * Walk the entire spec and collect all inline schemas with their locations.
853
- * Each location is represented as a path of keys to reach the schema,
854
- * plus a reference to the parent object and the key to replace.
855
- */
856
- interface SchemaLocation {
857
- parent: AnyValue;
858
- key: string | number;
859
- schema: AnyValue;
860
- }
861
-
862
- function collectInlineSchemas(
863
- obj: AnyValue,
864
- locations: SchemaLocation[],
865
- parent: AnyValue | null,
866
- key: string | number | null,
867
- depth: number = 0,
868
- visited: Set<AnyValue> = new Set()
869
- ): void {
870
- // Guard against circular references and excessive depth
871
- if (depth > 50) return;
872
- if (obj === null || obj === undefined || typeof obj !== 'object') return;
873
-
874
- if (visited.has(obj)) return;
875
- visited.add(obj);
876
-
877
- if (Array.isArray(obj)) {
878
- for (let i = 0; i < obj.length; i++) {
879
- collectInlineSchemas(obj[i], locations, obj, i, depth + 1, visited);
880
- }
881
- return;
882
- }
883
-
884
- // If this looks like a schema object (has 'type' as a string value, or 'properties', or combiners, or 'items')
885
- // IMPORTANT: 'type' in obj is not enough — properties dicts can have a key named "type"
886
- // (e.g., { path: {...}, type: { type: "string", enum: [...] } }). We must verify obj.type
887
- // is a valid OpenAPI type string, not a nested schema object.
888
- const VALID_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
889
- const hasValidType = 'type' in obj && typeof obj.type === 'string' && VALID_TYPES.has(obj.type);
890
- const isSchema = hasValidType || 'properties' in obj ||
891
- 'anyOf' in obj || 'oneOf' in obj || 'allOf' in obj || 'items' in obj;
892
-
893
- if (isSchema && parent !== null && key !== null && !isTrivialSchema(obj)) {
894
- // Don't collect schemas that are already $refs
895
- if (!('$ref' in obj)) {
896
- locations.push({ parent, key, schema: obj });
897
- }
898
- }
899
-
900
- // Recurse into child properties
901
- for (const k of Object.keys(obj)) {
902
- collectInlineSchemas(obj[k], locations, obj, k, depth + 1, visited);
903
- }
904
- }
905
-
906
- /**
907
- * Deduplicate schemas: find structurally identical schemas that appear 3+ times,
908
- * extract them into components/schemas, and replace inline occurrences with $ref.
909
- */
910
- function deduplicateSchemas(spec: OpenAPISpec): { extractedCount: number; replacedCount: number } {
911
- // Ensure components.schemas exists
912
- if (!spec.components) spec.components = {};
913
- if (!spec.components.schemas) spec.components.schemas = {};
914
-
915
- const existingComponentNames = new Set(Object.keys(spec.components.schemas));
916
-
917
- // Step 1: Collect all inline schemas from paths
918
- const locations: SchemaLocation[] = [];
919
- if (spec.paths) {
920
- collectInlineSchemas(spec.paths, locations, null, null);
921
- }
922
-
923
- // Step 2: Group by canonical hash
924
- const hashToLocations = new Map<string, SchemaLocation[]>();
925
- for (const loc of locations) {
926
- const hash = canonicalizeSchema(loc.schema);
927
- if (!hashToLocations.has(hash)) {
928
- hashToLocations.set(hash, []);
929
- }
930
- hashToLocations.get(hash)!.push(loc);
931
- }
932
-
933
- // Step 3: Filter to schemas appearing 3+ times
934
- let extractedCount = 0;
935
- let replacedCount = 0;
936
- const usedNames = new Set(existingComponentNames);
937
-
938
- for (const [hash, locs] of hashToLocations.entries()) {
939
- if (locs.length < 3) continue;
940
-
941
- // Use the first occurrence as the canonical schema
942
- const canonicalSchema = JSON.parse(JSON.stringify(locs[0].schema));
943
-
944
- // Generate a name for this component
945
- let baseName = generateComponentName(canonicalSchema);
946
- if (!baseName) {
947
- // Fallback: use hash-based name
948
- baseName = 'Schema' + hash.slice(1, 8).replace(/[^a-zA-Z0-9]/g, '');
949
- }
950
-
951
- // Ensure uniqueness
952
- let componentName = baseName;
953
- let suffix = 2;
954
- while (usedNames.has(componentName)) {
955
- componentName = `${baseName}${suffix}`;
956
- suffix++;
957
- }
958
- usedNames.add(componentName);
959
-
960
- // Extract the canonical schema (strip description for the shared component)
961
- const componentSchema = JSON.parse(JSON.stringify(canonicalSchema));
962
- delete componentSchema.description;
963
-
964
- // Safety net: if the extracted schema looks like a bare properties dict
965
- // (has nested objects as values but no valid "type" string), wrap it properly.
966
- // This catches cases where a properties dict was mistakenly collected as a schema
967
- // (e.g., it had a key named "properties" or slipped through detection).
968
- const validTypes = ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'];
969
- const hasValidType = typeof componentSchema.type === 'string' && validTypes.includes(componentSchema.type);
970
- const hasCombiner = 'anyOf' in componentSchema || 'oneOf' in componentSchema || 'allOf' in componentSchema;
971
- const hasRef = '$ref' in componentSchema;
972
-
973
- if (!hasValidType && !hasCombiner && !hasRef) {
974
- // This is likely a bare properties dict — wrap it as an object schema
975
- const wrappedSchema: AnyValue = {
976
- type: 'object',
977
- properties: { ...componentSchema },
978
- };
979
- // Remove non-property keys that shouldn't be inside properties
980
- for (const k of Object.keys(componentSchema)) {
981
- delete componentSchema[k];
982
- }
983
- Object.assign(componentSchema, wrappedSchema);
984
- }
985
-
986
- // Add to components/schemas
987
- spec.components!.schemas![componentName] = componentSchema;
988
- extractedCount++;
989
-
990
- // Replace all inline occurrences with $ref
991
- const refPath = `#/components/schemas/${componentName}`;
992
- for (const loc of locs) {
993
- const originalDescription = loc.schema.description;
994
- const needsDescription = originalDescription && typeof originalDescription === 'string';
995
-
996
- if (needsDescription) {
997
- // Wrap in allOf to preserve the inline description
998
- loc.parent[loc.key] = {
999
- allOf: [{ $ref: refPath }],
1000
- description: originalDescription,
1001
- };
1002
- } else {
1003
- loc.parent[loc.key] = { $ref: refPath };
1004
- }
1005
- replacedCount++;
1006
- }
1007
- }
1008
-
1009
- return { extractedCount, replacedCount };
1010
- }
1011
-
1012
- // =============================================================================
1013
- // MAIN EXECUTION
1014
- // =============================================================================
1015
-
1016
- async function main() {
1017
- // Parse arguments
1018
- const inputFile = process.argv[2] || 'openapi.json';
1019
- const outputFile = process.argv[3] || inputFile;
1020
-
1021
- console.log(`Processing: ${inputFile}`);
1022
-
1023
- // Load spec
1024
- const file = Bun.file(inputFile);
1025
- let spec: OpenAPISpec;
1026
-
1027
- try {
1028
- spec = await file.json();
1029
- } catch (error) {
1030
- console.error(`Error: Could not read or parse ${inputFile}`);
1031
- console.error(error);
1032
- process.exit(1);
1033
- }
1034
-
1035
- // Apply fixes
1036
- console.log("1. Fixing nullable types...");
1037
- spec = fixNullableTypes(spec);
1038
-
1039
- console.log("2. Removing WebSocket paths...");
1040
- const wsPaths = removeWebsocketPaths(spec);
1041
- console.log(` Removed ${wsPaths.length} WebSocket paths: ${JSON.stringify(wsPaths)}`);
1042
-
1043
- console.log("3. Adding missing responses...");
1044
- const responseResult = addMissingResponses(spec);
1045
- console.log(` Added default 200 to ${responseResult.noResponseCount} operations with no responses, ${responseResult.noSuccessCount} operations missing 2xx success`);
1046
-
1047
- console.log("3b. Injecting standard error responses...");
1048
- const errorResponseResult = injectStandardErrorResponses(spec);
1049
- console.log(` Injected error responses: ${errorResponseResult.getCount} GET, ${errorResponseResult.mutationCount} mutation operations`);
1050
-
1051
- console.log("4. Removing patternProperties...");
1052
- spec = removePatternProperties(spec);
1053
-
1054
- console.log("5. Fixing descriptions...");
1055
- spec = fixDescriptions(spec);
1056
-
1057
- console.log("6. Removing $id fields...");
1058
- const idResult = removeIdFields(spec);
1059
- spec = idResult.result;
1060
- console.log(` Removed ${idResult.count} $id fields`);
1061
-
1062
- console.log("7. Setting up dual authentication schemes...");
1063
- const authSchemes = setupDualAuthSchemes(spec);
1064
- console.log(` Configured schemes: apiKey=${authSchemes.apiKey}, bearerAuth=${authSchemes.bearerAuth}`);
1065
-
1066
- console.log("8. Configuring security options (apiKey OR bearerAuth)...");
1067
- const securityCount = configureSecurityOptions(spec);
1068
- console.log(` Updated ${securityCount} security configurations`);
1069
-
1070
- console.log("9. Filtering modules...");
1071
- const { included, excluded } = filterModules(spec);
1072
- console.log(` Included ${included.length} paths, excluded ${excluded.length} paths`);
1073
- if (excluded.length > 0) {
1074
- const excludedModules = Array.from(new Set(
1075
- excluded.map(p => {
1076
- const parts = p.split('/');
1077
- return parts.length > 2 ? parts[2] : p;
1078
- })
1079
- )).sort();
1080
- console.log(` Excluded modules: ${JSON.stringify(excludedModules)}`);
1081
- }
1082
-
1083
- console.log("10. Collapsing t.Numeric() anyOf patterns in query parameters...");
1084
- const collapsedCount = collapseNumericAnyOfInQueryParams(spec);
1085
- console.log(` Collapsed ${collapsedCount} anyOf query parameters`);
1086
-
1087
- console.log("11. Stripping default from optional boolean query parameters...");
1088
- const strippedBoolCount = stripDefaultFromOptionalBooleanQueryParams(spec);
1089
- console.log(` Stripped default from ${strippedBoolCount} optional boolean query parameters`);
1090
-
1091
- console.log("12. Injecting x-speakeasy-pagination for offset/limit endpoints...");
1092
- const paginatedCount = injectSpeakeasyPagination(spec);
1093
- console.log(` Added pagination to ${paginatedCount} operations`);
1094
-
1095
- console.log("13. Converting const to enum...");
1096
- const constResult = convertConstToEnum(spec);
1097
- spec = constResult.result;
1098
- console.log(` Converted ${constResult.count} const to enum`);
1099
-
1100
- console.log("14. Deduplicating inline schemas...");
1101
- const dedupResult = deduplicateSchemas(spec);
1102
- console.log(` Deduplicated ${dedupResult.replacedCount} schemas into ${dedupResult.extractedCount} components`);
1103
-
1104
- // Save
1105
- await Bun.write(outputFile, JSON.stringify(spec, null, 2));
1106
-
1107
- console.log(`\nSaved to: ${outputFile}`);
1108
- console.log(`Total endpoints in SDK: ${included.length}`);
1109
- console.log("Ready for SDK generation: speakeasy run");
1110
- }
1111
-
1112
- // Run main
1113
- main().catch(error => {
1114
- console.error("Fatal error:", error);
1115
- process.exit(1);
1116
- });