-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathrest-connector.ts
More file actions
174 lines (156 loc) · 6.69 KB
/
Copy pathrest-connector.ts
File metadata and controls
174 lines (156 loc) · 6.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import type { Connector } from '@objectstack/spec/integration';
import { resilientFetch } from '@objectstack/spec/shared';
/**
* Generic REST connector — the reference *concrete* connector (ADR-0018
* §Addendum). It produces a {@link Connector} definition plus the handler for
* its one action, `request`, which the baseline `connector_action` node
* dispatches to.
*
* Open-source scope: **static** auth only (`none` / `api-key` / `basic` /
* `bearer`), with credentials supplied by the caller. OAuth2 token acquisition
* and refresh, credential vaulting, and multi-tenant connection lifecycle are
* the enterprise tier (see `../cloud/docs/design/connector-tiering.md`) and are
* deliberately out of scope here.
*/
/** Auth config understood by the REST connector (the static subset). */
export type RestAuth = Extract<
Connector['authentication'],
{ type: 'none' | 'api-key' | 'basic' | 'bearer' }
>;
export interface RestConnectorOptions {
/** Connector machine name (snake_case). Defaults to `rest`. */
name?: string;
/** Human-readable label. Defaults to a title derived from `name`. */
label?: string;
/** Base URL prepended to each request's `path` (e.g. `https://api.example.com`). */
baseUrl: string;
/** Static authentication. Defaults to `{ type: 'none' }`. */
auth?: RestAuth;
/** Headers merged into every request (request-level headers win). */
defaultHeaders?: Record<string, string>;
/** Injected for tests; defaults to the global `fetch`. */
fetchImpl?: typeof fetch;
}
/** Input accepted by the `request` action. */
export interface RestRequestInput {
method?: string;
path?: string;
headers?: Record<string, string>;
query?: Record<string, string | number | boolean | null | undefined>;
body?: unknown;
}
/** A connector definition paired with its action handlers, ready for registerConnector(). */
export interface RestConnectorBundle {
def: Connector;
handlers: Record<
string,
(input: Record<string, unknown>, ctx: unknown) => Promise<Record<string, unknown>>
>;
}
/** Build the request URL from base + path + query, encoding query params. */
function buildUrl(baseUrl: string, path: string, query?: RestRequestInput['query']): string {
const base = baseUrl.replace(/\/+$/, '');
const suffix = path ? (path.startsWith('/') ? path : `/${path}`) : '';
const url = new URL(base + suffix);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
}
}
return url.toString();
}
/**
* Apply static auth to the outgoing headers / query. Returns possibly-extended
* query so an `api-key` configured with `paramName` can ride the query string.
*/
function applyAuth(
auth: RestAuth,
headers: Record<string, string>,
query: Record<string, string | number | boolean | null | undefined>,
): void {
switch (auth.type) {
case 'none':
return;
case 'bearer':
headers['Authorization'] = `Bearer ${auth.token}`;
return;
case 'basic': {
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
return;
}
case 'api-key':
if (auth.paramName) query[auth.paramName] = auth.key;
else headers[auth.headerName ?? 'X-API-Key'] = auth.key;
return;
}
}
export function createRestConnector(opts: RestConnectorOptions): RestConnectorBundle {
const name = opts.name ?? 'rest';
const auth: RestAuth = opts.auth ?? { type: 'none' };
const def: Connector = {
name,
label: opts.label ?? 'REST Connector',
type: 'api',
description: 'Generic REST/HTTP connector with static authentication.',
icon: 'globe',
authentication: auth,
// Defaulted by ConnectorSchema; set explicitly so the literal satisfies
// the (post-parse) Connector output type.
status: 'active',
enabled: true,
connectionTimeoutMs: 30000,
requestTimeoutMs: 30000,
actions: [
{
key: 'request',
label: 'HTTP Request',
description: 'Send an HTTP request to the connector\'s base URL with static auth applied.',
inputSchema: {
type: 'object',
properties: {
method: { type: 'string', description: 'HTTP method (default GET)' },
path: { type: 'string', description: 'Path appended to the base URL' },
headers: { type: 'object', description: 'Per-request headers' },
query: { type: 'object', description: 'Query parameters' },
body: { description: 'Request body (JSON-encoded for non-GET)' },
},
},
outputSchema: {
type: 'object',
properties: {
status: { type: 'number' },
ok: { type: 'boolean' },
body: {},
},
},
},
],
};
async function request(input: Record<string, unknown>): Promise<Record<string, unknown>> {
const req = input as RestRequestInput;
const method = (req.method ?? 'GET').toUpperCase();
const headers: Record<string, string> = { ...opts.defaultHeaders, ...req.headers };
const query: Record<string, string | number | boolean | null | undefined> = { ...req.query };
applyAuth(auth, headers, query);
const url = buildUrl(opts.baseUrl, req.path ?? '', query);
const hasBody = req.body !== undefined && method !== 'GET' && method !== 'HEAD';
if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await resilientFetch(url, {
method,
headers,
body: hasBody ? JSON.stringify(req.body) : undefined,
}, { fetchImpl: opts.fetchImpl });
// Parse JSON when advertised; fall back to text so non-JSON endpoints
// don't throw.
const contentType = response.headers.get('content-type') ?? '';
const parsed = contentType.includes('application/json')
? await response.json()
: await response.text();
return { status: response.status, ok: response.ok, body: parsed };
}
return { def, handlers: { request } };
}