@objectstack/connector-openapi 7.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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +23 -0
- package/LICENSE +93 -0
- package/dist/index.d.mts +147 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.js +252 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +224 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/connector-openapi-plugin.test.ts +28 -0
- package/src/connector-openapi-plugin.ts +32 -0
- package/src/index.ts +36 -0
- package/src/openapi-connector.test.ts +141 -0
- package/src/openapi-connector.ts +414 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Connector } from '@objectstack/spec/integration';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OpenAPI connector generator — turns a declarative OpenAPI 3.x document into a
|
|
7
|
+
* {@link Connector} definition + handler map (ADR-0023).
|
|
8
|
+
*
|
|
9
|
+
* Each OpenAPI operation maps to one connector action; a single generic handler
|
|
10
|
+
* (closing over the operation's method + path template) drives one shared HTTP
|
|
11
|
+
* request implementation. That transport mirrors `@objectstack/connector-rest`
|
|
12
|
+
* (build URL from base+path+query, apply static auth, JSON-encode the body,
|
|
13
|
+
* normalise the response to `{ status, ok, body }`) — kept inline so this package
|
|
14
|
+
* stays self-contained, depending only on `@objectstack/core` + `@objectstack/spec`
|
|
15
|
+
* like its sibling connectors. The output is an ordinary `type: 'api'` connector,
|
|
16
|
+
* registered via `engine.registerConnector(def, handlers)` exactly like a
|
|
17
|
+
* hand-written one — the registry, the `connector_action` node, the discovery
|
|
18
|
+
* route, and the Studio palette never know it came from OpenAPI.
|
|
19
|
+
*
|
|
20
|
+
* Open-source scope: **static** auth only (`none` / `api-key` / `basic` /
|
|
21
|
+
* `bearer`), with credentials supplied by the caller. Managed OAuth2, credential
|
|
22
|
+
* vaulting, and per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Static auth understood by the generated connector (the open-source subset). */
|
|
26
|
+
export type RestAuth = Extract<Connector['authentication'], { type: 'none' | 'api-key' | 'basic' | 'bearer' }>;
|
|
27
|
+
|
|
28
|
+
/** An action on a Connector definition (derived to avoid guessing export names). */
|
|
29
|
+
type ConnectorAction = NonNullable<Connector['actions']>[number];
|
|
30
|
+
|
|
31
|
+
/** Handler signature accepted by the connector registry (ADR-0018 §Addendum). */
|
|
32
|
+
type ConnectorHandler = (input: Record<string, unknown>, ctx: unknown) => Promise<Record<string, unknown>>;
|
|
33
|
+
|
|
34
|
+
/** A connector definition paired with its action handlers, ready for registerConnector(). */
|
|
35
|
+
export interface OpenApiConnectorBundle {
|
|
36
|
+
def: Connector;
|
|
37
|
+
handlers: Record<string, ConnectorHandler>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A free-form JSON Schema fragment (matches ConnectorAction input/outputSchema). */
|
|
41
|
+
export type JsonSchema = Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
/** Minimal subset of an OpenAPI 3.x document consumed by the generator.
|
|
44
|
+
* The caller is responsible for loading and de-referencing ($ref) the doc. */
|
|
45
|
+
export interface OpenApiDocument {
|
|
46
|
+
openapi?: string;
|
|
47
|
+
info?: { title?: string; description?: string; version?: string };
|
|
48
|
+
servers?: { url: string }[];
|
|
49
|
+
paths?: Record<string, OpenApiPathItem>;
|
|
50
|
+
components?: { securitySchemes?: Record<string, OpenApiSecurityScheme> };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OpenApiPathItem {
|
|
54
|
+
[method: string]: OpenApiOperation | unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface OpenApiOperation {
|
|
58
|
+
operationId?: string;
|
|
59
|
+
summary?: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
tags?: string[];
|
|
62
|
+
parameters?: OpenApiParameter[];
|
|
63
|
+
requestBody?: OpenApiRequestBody;
|
|
64
|
+
responses?: Record<string, OpenApiResponse>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface OpenApiParameter {
|
|
68
|
+
name: string;
|
|
69
|
+
in: 'path' | 'query' | 'header' | 'cookie';
|
|
70
|
+
required?: boolean;
|
|
71
|
+
description?: string;
|
|
72
|
+
schema?: JsonSchema;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface OpenApiRequestBody {
|
|
76
|
+
required?: boolean;
|
|
77
|
+
description?: string;
|
|
78
|
+
content?: Record<string, { schema?: JsonSchema }>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface OpenApiResponse {
|
|
82
|
+
description?: string;
|
|
83
|
+
content?: Record<string, { schema?: JsonSchema }>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OpenApiSecurityScheme {
|
|
87
|
+
type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
|
|
88
|
+
name?: string;
|
|
89
|
+
in?: 'header' | 'query' | 'cookie';
|
|
90
|
+
scheme?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flattened view of a single operation, passed to the `include` predicate. */
|
|
94
|
+
export interface OperationInfo {
|
|
95
|
+
operationId?: string;
|
|
96
|
+
method: string;
|
|
97
|
+
path: string;
|
|
98
|
+
tags?: string[];
|
|
99
|
+
summary?: string;
|
|
100
|
+
description?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Configuration for {@link createOpenApiConnector}. */
|
|
104
|
+
export interface OpenApiConnectorConfig {
|
|
105
|
+
/** Connector machine name (snake_case). Defaults to a slug of info.title. */
|
|
106
|
+
name?: string;
|
|
107
|
+
/** Human-friendly label. Defaults to info.title (then name). */
|
|
108
|
+
label?: string;
|
|
109
|
+
/** Description. Defaults to info.description. */
|
|
110
|
+
description?: string;
|
|
111
|
+
/** Icon identifier for the Studio palette. Defaults to `globe`. */
|
|
112
|
+
icon?: string;
|
|
113
|
+
/** The parsed OpenAPI 3.x document (caller loads/derefs it). */
|
|
114
|
+
document: OpenApiDocument;
|
|
115
|
+
/** Override the base URL (else servers[0].url). */
|
|
116
|
+
baseUrl?: string;
|
|
117
|
+
/** Static auth with credentials. Defaults to `{ type: 'none' }`. */
|
|
118
|
+
auth?: RestAuth;
|
|
119
|
+
/** Headers merged into every request (request-level headers win). */
|
|
120
|
+
defaultHeaders?: Record<string, string>;
|
|
121
|
+
/** Only include operations for which this predicate returns true (allowlist). */
|
|
122
|
+
include?: (op: OperationInfo) => boolean;
|
|
123
|
+
/** Injected fetch implementation (defaults to global `fetch`). */
|
|
124
|
+
fetchImpl?: typeof fetch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** OpenAPI HTTP method keys, in a deterministic order. */
|
|
128
|
+
const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'] as const;
|
|
129
|
+
|
|
130
|
+
/** Input passed to the shared request transport. */
|
|
131
|
+
interface RequestInput {
|
|
132
|
+
method: string;
|
|
133
|
+
path: string;
|
|
134
|
+
headers?: Record<string, string>;
|
|
135
|
+
query?: Record<string, string>;
|
|
136
|
+
body?: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build an OpenAPI connector definition and its handler map.
|
|
141
|
+
*
|
|
142
|
+
* @returns the `Connector` definition (`def`) and a `handlers` record keyed by
|
|
143
|
+
* action key, suitable for `engine.registerConnector(def, handlers)`.
|
|
144
|
+
*/
|
|
145
|
+
export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiConnectorBundle {
|
|
146
|
+
const { document, include } = config;
|
|
147
|
+
const auth: RestAuth = config.auth ?? { type: 'none' };
|
|
148
|
+
const doFetch = config.fetchImpl ?? fetch;
|
|
149
|
+
const name = config.name ?? slug(document.info?.title ?? 'openapi_connector');
|
|
150
|
+
const label = config.label ?? document.info?.title ?? titleize(name);
|
|
151
|
+
const description = config.description ?? document.info?.description;
|
|
152
|
+
const baseUrl = config.baseUrl ?? document.servers?.[0]?.url;
|
|
153
|
+
if (!baseUrl) {
|
|
154
|
+
throw new Error('createOpenApiConnector: no base URL — provide config.baseUrl or document.servers[0].url');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// One shared transport (mirrors connector-rest) reused by every action handler.
|
|
158
|
+
async function request(input: RequestInput): Promise<Record<string, unknown>> {
|
|
159
|
+
const method = input.method.toUpperCase();
|
|
160
|
+
const headers: Record<string, string> = { ...config.defaultHeaders, ...input.headers };
|
|
161
|
+
const query: Record<string, string> = { ...input.query };
|
|
162
|
+
applyAuth(auth, headers, query);
|
|
163
|
+
|
|
164
|
+
const url = buildUrl(baseUrl as string, input.path, query);
|
|
165
|
+
const hasBody = input.body !== undefined && method !== 'GET' && method !== 'HEAD';
|
|
166
|
+
if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) {
|
|
167
|
+
headers['Content-Type'] = 'application/json';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const response = await doFetch(url, {
|
|
171
|
+
method,
|
|
172
|
+
headers,
|
|
173
|
+
body: hasBody ? JSON.stringify(input.body) : undefined,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
177
|
+
const parsed = contentType.includes('application/json') ? await response.json() : await response.text();
|
|
178
|
+
return { status: response.status, ok: response.ok, body: parsed };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const actions: ConnectorAction[] = [];
|
|
182
|
+
const handlers: Record<string, ConnectorHandler> = {};
|
|
183
|
+
const seenKeys = new Set<string>();
|
|
184
|
+
|
|
185
|
+
for (const op of collectOperations(document)) {
|
|
186
|
+
if (include && !include(toInfo(op))) continue;
|
|
187
|
+
const key = uniqueKey(op.operationId ?? slug(`${op.method}_${op.path}`), seenKeys);
|
|
188
|
+
|
|
189
|
+
actions.push({
|
|
190
|
+
key,
|
|
191
|
+
label: op.summary ?? titleize(key),
|
|
192
|
+
description: op.description,
|
|
193
|
+
inputSchema: buildInputSchema(op),
|
|
194
|
+
outputSchema: buildOutputSchema(op),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
handlers[key] = async (input: Record<string, unknown>) => {
|
|
198
|
+
const req = input as { path?: unknown; query?: unknown; header?: unknown; body?: unknown };
|
|
199
|
+
return request({
|
|
200
|
+
method: op.method,
|
|
201
|
+
path: interpolatePath(op.path, asRecord(req.path)),
|
|
202
|
+
query: stringifyValues(asRecord(req.query)),
|
|
203
|
+
headers: stringifyValues(asRecord(req.header)),
|
|
204
|
+
body: req.body,
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const def: Connector = {
|
|
210
|
+
name,
|
|
211
|
+
label,
|
|
212
|
+
type: 'api',
|
|
213
|
+
description,
|
|
214
|
+
icon: config.icon ?? 'globe',
|
|
215
|
+
authentication: auth,
|
|
216
|
+
// Defaulted by ConnectorSchema; set explicitly so the literal satisfies
|
|
217
|
+
// the (post-parse) Connector output type (mirrors connector-rest/mcp).
|
|
218
|
+
status: 'active',
|
|
219
|
+
enabled: true,
|
|
220
|
+
connectionTimeoutMs: 30000,
|
|
221
|
+
requestTimeoutMs: 30000,
|
|
222
|
+
actions,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return { def, handlers };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface Op extends OpenApiOperation {
|
|
229
|
+
method: string;
|
|
230
|
+
path: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Flatten paths × methods into a deterministic list of operations. */
|
|
234
|
+
function collectOperations(doc: OpenApiDocument): Op[] {
|
|
235
|
+
const ops: Op[] = [];
|
|
236
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
237
|
+
if (!item || typeof item !== 'object') continue;
|
|
238
|
+
const record = item as Record<string, unknown>;
|
|
239
|
+
for (const method of HTTP_METHODS) {
|
|
240
|
+
const operation = record[method] as OpenApiOperation | undefined;
|
|
241
|
+
if (!operation || typeof operation !== 'object') continue;
|
|
242
|
+
ops.push({ ...operation, method, path });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return ops;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function toInfo(op: Op): OperationInfo {
|
|
249
|
+
return {
|
|
250
|
+
operationId: op.operationId,
|
|
251
|
+
method: op.method,
|
|
252
|
+
path: op.path,
|
|
253
|
+
tags: op.tags,
|
|
254
|
+
summary: op.summary,
|
|
255
|
+
description: op.description,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Assemble the action inputSchema from an operation's parameters + requestBody.
|
|
261
|
+
* Produces { type: 'object', properties: { path, query, header, body }, required }
|
|
262
|
+
* where only non-empty sections are emitted.
|
|
263
|
+
*/
|
|
264
|
+
function buildInputSchema(op: OpenApiOperation): JsonSchema | undefined {
|
|
265
|
+
const sections: Record<'path' | 'query' | 'header', { props: Record<string, JsonSchema>; required: string[] }> = {
|
|
266
|
+
path: { props: {}, required: [] },
|
|
267
|
+
query: { props: {}, required: [] },
|
|
268
|
+
header: { props: {}, required: [] },
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
for (const p of op.parameters ?? []) {
|
|
272
|
+
if (!p || typeof p !== 'object' || '$ref' in p) continue;
|
|
273
|
+
if (p.in !== 'path' && p.in !== 'query' && p.in !== 'header') continue;
|
|
274
|
+
const sec = sections[p.in];
|
|
275
|
+
sec.props[p.name] = p.schema ?? (p.description ? { type: 'string', description: p.description } : { type: 'string' });
|
|
276
|
+
if (p.required) sec.required.push(p.name);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const properties: Record<string, JsonSchema> = {};
|
|
280
|
+
const required: string[] = [];
|
|
281
|
+
for (const where of ['path', 'query', 'header'] as const) {
|
|
282
|
+
const sec = sections[where];
|
|
283
|
+
if (Object.keys(sec.props).length === 0) continue;
|
|
284
|
+
const schema: JsonSchema = { type: 'object', properties: sec.props };
|
|
285
|
+
if (sec.required.length) schema.required = sec.required;
|
|
286
|
+
properties[where] = schema;
|
|
287
|
+
// Path params are always required when present; others only if any are.
|
|
288
|
+
if (where === 'path' || sec.required.length) required.push(where);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const bodySchema = extractRequestBodySchema(op.requestBody);
|
|
292
|
+
if (bodySchema) {
|
|
293
|
+
properties.body = bodySchema;
|
|
294
|
+
if (op.requestBody && !('$ref' in op.requestBody) && op.requestBody.required) required.push('body');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (Object.keys(properties).length === 0) return undefined;
|
|
298
|
+
const schema: JsonSchema = { type: 'object', properties };
|
|
299
|
+
if (required.length) schema.required = required;
|
|
300
|
+
return schema;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Pick the success response's JSON schema (200 → first 2xx → default). */
|
|
304
|
+
function buildOutputSchema(op: OpenApiOperation): JsonSchema | undefined {
|
|
305
|
+
const responses = op.responses;
|
|
306
|
+
if (!responses) return undefined;
|
|
307
|
+
let code: string | undefined;
|
|
308
|
+
if (responses['200']) code = '200';
|
|
309
|
+
else code = Object.keys(responses).find((c) => /^2\d\d$/.test(c));
|
|
310
|
+
if (!code && responses['default']) code = 'default';
|
|
311
|
+
if (!code) return undefined;
|
|
312
|
+
const resp = responses[code];
|
|
313
|
+
if (!resp || typeof resp !== 'object' || '$ref' in resp) return undefined;
|
|
314
|
+
return pickJsonSchema(resp.content);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Extract the requestBody JSON schema (prefers application/json). */
|
|
318
|
+
function extractRequestBodySchema(rb: OpenApiRequestBody | undefined): JsonSchema | undefined {
|
|
319
|
+
if (!rb || typeof rb !== 'object' || '$ref' in rb) return undefined;
|
|
320
|
+
return pickJsonSchema(rb.content);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Choose the application/json schema, falling back to the first content type. */
|
|
324
|
+
function pickJsonSchema(content: Record<string, { schema?: JsonSchema }> | undefined): JsonSchema | undefined {
|
|
325
|
+
if (!content) return undefined;
|
|
326
|
+
const chosen = content['application/json'] ?? Object.values(content)[0];
|
|
327
|
+
return chosen?.schema;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Build the request URL from base + path + query, encoding query params. */
|
|
331
|
+
function buildUrl(baseUrl: string, path: string, query: Record<string, string>): string {
|
|
332
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
333
|
+
const suffix = path ? (path.startsWith('/') ? path : `/${path}`) : '';
|
|
334
|
+
const url = new URL(base + suffix);
|
|
335
|
+
for (const [key, value] of Object.entries(query)) {
|
|
336
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
337
|
+
}
|
|
338
|
+
return url.toString();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Apply static auth to the outgoing headers / query (mirrors connector-rest). */
|
|
342
|
+
function applyAuth(auth: RestAuth, headers: Record<string, string>, query: Record<string, string>): void {
|
|
343
|
+
switch (auth.type) {
|
|
344
|
+
case 'none':
|
|
345
|
+
return;
|
|
346
|
+
case 'bearer':
|
|
347
|
+
headers['Authorization'] = `Bearer ${auth.token}`;
|
|
348
|
+
return;
|
|
349
|
+
case 'basic': {
|
|
350
|
+
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
|
|
351
|
+
headers['Authorization'] = `Basic ${encoded}`;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
case 'api-key':
|
|
355
|
+
if (auth.paramName) query[auth.paramName] = auth.key;
|
|
356
|
+
else headers[auth.headerName ?? 'X-API-Key'] = auth.key;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Interpolate {name} path templates with encoded values from the input. */
|
|
362
|
+
function interpolatePath(template: string, pathParams: Record<string, unknown>): string {
|
|
363
|
+
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => {
|
|
364
|
+
const value = pathParams[key];
|
|
365
|
+
return value === undefined || value === null ? `{${key}}` : encodeURIComponent(String(value));
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Coerce a record of mixed values into string values, dropping null/undefined. */
|
|
370
|
+
function stringifyValues(rec: Record<string, unknown>): Record<string, string> {
|
|
371
|
+
const out: Record<string, string> = {};
|
|
372
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
373
|
+
if (v === undefined || v === null) continue;
|
|
374
|
+
out[k] = String(v);
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Return v if it is a plain object, else an empty record. */
|
|
380
|
+
function asRecord(v: unknown): Record<string, unknown> {
|
|
381
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Ensure a deterministically unique action key within the connector. */
|
|
385
|
+
function uniqueKey(base: string, seen: Set<string>): string {
|
|
386
|
+
let candidate = base;
|
|
387
|
+
if (seen.has(candidate)) {
|
|
388
|
+
let i = 2;
|
|
389
|
+
while (seen.has(`${base}_${i}`)) i++;
|
|
390
|
+
candidate = `${base}_${i}`;
|
|
391
|
+
}
|
|
392
|
+
seen.add(candidate);
|
|
393
|
+
return candidate;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Slugify a string into a snake_case machine name (`/^[a-z_][a-z0-9_]*$/`). */
|
|
397
|
+
function slug(s: string): string {
|
|
398
|
+
const out = s
|
|
399
|
+
.normalize('NFKD')
|
|
400
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
401
|
+
.replace(/^_+|_+$/g, '')
|
|
402
|
+
.toLowerCase();
|
|
403
|
+
if (!out) return 'connector';
|
|
404
|
+
return /^[a-z_]/.test(out) ? out : `op_${out}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Title-case a snake_case key for a default label (`get_pets` → `Get Pets`). */
|
|
408
|
+
function titleize(name: string): string {
|
|
409
|
+
return name
|
|
410
|
+
.split('_')
|
|
411
|
+
.filter(Boolean)
|
|
412
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
413
|
+
.join(' ');
|
|
414
|
+
}
|