@apiquest/plugin-http 1.0.2 → 1.0.5
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/README.md +154 -0
- package/dist/index.js +3167 -2953
- package/dist/index.js.map +1 -1
- package/esbuild.config.js +34 -34
- package/package.json +66 -62
- package/rollup.config.js +31 -31
- package/tsconfig.json +20 -20
- package/tsconfig.test.json +5 -5
- package/vitest.config.ts +12 -12
- package/dist/src/index.d.ts +0 -4
- package/dist/src/index.d.ts.map +0 -1
- package/src/index.ts +0 -420
package/src/index.ts
DELETED
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
import got, { OptionsOfTextResponseBody, Response, RequestError } from 'got';
|
|
2
|
-
import type { IProtocolPlugin, Request, ExecutionContext, ProtocolResponse, ValidationResult, ValidationError, RuntimeOptions, ILogger } from '@apiquest/types';
|
|
3
|
-
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
|
4
|
-
|
|
5
|
-
interface BodyObject {
|
|
6
|
-
mode?: string;
|
|
7
|
-
raw?: string;
|
|
8
|
-
urlencoded?: Array<{ key?: string; value?: unknown; disabled?: boolean }>;
|
|
9
|
-
formdata?: Array<{ key?: string; value?: unknown; disabled?: boolean }>;
|
|
10
|
-
[key: string]: unknown;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Helper functions for string validation
|
|
14
|
-
function isNullOrEmpty(value: string | null | undefined): boolean {
|
|
15
|
-
return value === null || value === undefined || value === '';
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function isNullOrWhitespace(value: string | null | undefined): boolean {
|
|
19
|
-
return value === null || value === undefined || value.trim() === '';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Parse proxy configuration from environment variables
|
|
24
|
-
* Platform-agnostic: checks both uppercase and lowercase variants
|
|
25
|
-
*/
|
|
26
|
-
function getProxyFromEnv(targetUrl: string): { host: string; port: number; auth?: { username: string; password: string } } | null {
|
|
27
|
-
// Check both uppercase and lowercase variants (platform-agnostic)
|
|
28
|
-
const HTTP_PROXY = process.env.HTTP_PROXY ?? process.env.http_proxy;
|
|
29
|
-
const HTTPS_PROXY = process.env.HTTPS_PROXY ?? process.env.https_proxy;
|
|
30
|
-
|
|
31
|
-
// Choose proxy based on target URL protocol
|
|
32
|
-
const proxyUrl = targetUrl.startsWith('https:') ? (HTTPS_PROXY ?? HTTP_PROXY) : HTTP_PROXY;
|
|
33
|
-
|
|
34
|
-
if (proxyUrl === undefined || proxyUrl === '') {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const parsed = new URL(proxyUrl);
|
|
40
|
-
return {
|
|
41
|
-
host: parsed.hostname,
|
|
42
|
-
port: (parsed.port !== '' ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80)),
|
|
43
|
-
auth: parsed.username !== '' ? {
|
|
44
|
-
username: decodeURIComponent(parsed.username),
|
|
45
|
-
password: decodeURIComponent(parsed.password)
|
|
46
|
-
} : undefined
|
|
47
|
-
};
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Check if host should bypass proxy based on NO_PROXY env var
|
|
55
|
-
*/
|
|
56
|
-
function shouldBypassProxy(targetUrl: string): boolean {
|
|
57
|
-
const NO_PROXY = process.env.NO_PROXY ?? process.env.no_proxy;
|
|
58
|
-
|
|
59
|
-
if (NO_PROXY === undefined || NO_PROXY === '') {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const bypassList = NO_PROXY.split(',').map(s => s.trim());
|
|
64
|
-
const parsed = new URL(targetUrl);
|
|
65
|
-
|
|
66
|
-
return bypassList.some(pattern => {
|
|
67
|
-
return parsed.hostname === pattern ||
|
|
68
|
-
(pattern.startsWith('*.') && parsed.hostname.endsWith(pattern.slice(1)));
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const httpPlugin: IProtocolPlugin = {
|
|
73
|
-
name: 'HTTP Client',
|
|
74
|
-
version: '1.0.0',
|
|
75
|
-
description: 'HTTP/HTTPS protocol support for REST APIs',
|
|
76
|
-
|
|
77
|
-
// What protocols this plugin provides
|
|
78
|
-
protocols: ['http'],
|
|
79
|
-
|
|
80
|
-
// Supported authentication types
|
|
81
|
-
supportedAuthTypes: ['bearer', 'basic', 'oauth2', 'apikey', 'digest', 'ntlm'],
|
|
82
|
-
|
|
83
|
-
// Accept additional auth plugins beyond the listed types
|
|
84
|
-
strictAuthList: false,
|
|
85
|
-
|
|
86
|
-
// Data schema for HTTP requests
|
|
87
|
-
dataSchema: {
|
|
88
|
-
type: 'object',
|
|
89
|
-
required: ['method', 'url'],
|
|
90
|
-
properties: {
|
|
91
|
-
method: {
|
|
92
|
-
type: 'string',
|
|
93
|
-
enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
|
|
94
|
-
description: 'HTTP method'
|
|
95
|
-
},
|
|
96
|
-
url: {
|
|
97
|
-
type: 'string',
|
|
98
|
-
description: 'Request URL'
|
|
99
|
-
},
|
|
100
|
-
headers: {
|
|
101
|
-
type: 'object',
|
|
102
|
-
description: 'HTTP headers',
|
|
103
|
-
additionalProperties: { type: 'string' }
|
|
104
|
-
},
|
|
105
|
-
body: {
|
|
106
|
-
description: 'Request body (string or structured object)',
|
|
107
|
-
oneOf: [
|
|
108
|
-
{ type: 'string' },
|
|
109
|
-
{ type: 'object' }
|
|
110
|
-
]
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
// Options schema for runtime configuration
|
|
116
|
-
optionsSchema: {
|
|
117
|
-
keepAlive: {
|
|
118
|
-
type: 'boolean',
|
|
119
|
-
default: true,
|
|
120
|
-
description: 'Keep TCP connections alive between requests'
|
|
121
|
-
},
|
|
122
|
-
timeout: {
|
|
123
|
-
type: 'number',
|
|
124
|
-
default: 30000,
|
|
125
|
-
description: 'Request timeout in milliseconds'
|
|
126
|
-
},
|
|
127
|
-
followRedirects: {
|
|
128
|
-
type: 'boolean',
|
|
129
|
-
default: true,
|
|
130
|
-
description: 'Follow HTTP redirects automatically'
|
|
131
|
-
},
|
|
132
|
-
maxRedirects: {
|
|
133
|
-
type: 'number',
|
|
134
|
-
default: 5,
|
|
135
|
-
description: 'Maximum number of redirects to follow'
|
|
136
|
-
},
|
|
137
|
-
validateCertificates: {
|
|
138
|
-
type: 'boolean',
|
|
139
|
-
default: true,
|
|
140
|
-
description: 'Validate SSL/TLS certificates'
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
|
|
144
|
-
async execute(request: Request, context: ExecutionContext, options: RuntimeOptions, emitEvent?: (eventName: string, eventData: unknown) => Promise<void>, logger?: ILogger): Promise<ProtocolResponse> {
|
|
145
|
-
const startTime = Date.now();
|
|
146
|
-
const url = String(request.data.url ?? '');
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
// Request configuration
|
|
150
|
-
const method = String(request.data.method ?? 'GET');
|
|
151
|
-
const headers: Record<string, string> = typeof request.data.headers === 'object' && request.data.headers !== null
|
|
152
|
-
? Object.fromEntries(
|
|
153
|
-
Object.entries(request.data.headers as Record<string, unknown>).map(([k, v]) => [k, String(v)])
|
|
154
|
-
)
|
|
155
|
-
: {};
|
|
156
|
-
const body: unknown = request.data.body;
|
|
157
|
-
|
|
158
|
-
if (isNullOrWhitespace(url)) {
|
|
159
|
-
logger?.error('HTTP request missing URL');
|
|
160
|
-
throw new Error('URL is required for HTTP requests');
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const httpOptions: Record<string, unknown> = (options.plugins?.http as Record<string, unknown> | null | undefined) ?? {};
|
|
164
|
-
const httpTimeout = typeof httpOptions.timeout === 'number' ? httpOptions.timeout : null;
|
|
165
|
-
const timeout = options.timeout?.request ?? httpTimeout ?? 60000;
|
|
166
|
-
const httpFollowRedirects = typeof httpOptions.followRedirects === 'boolean' ? httpOptions.followRedirects : null;
|
|
167
|
-
const followRedirects = options.followRedirects ?? httpFollowRedirects ?? true;
|
|
168
|
-
const httpMaxRedirects = typeof httpOptions.maxRedirects === 'number' ? httpOptions.maxRedirects : null;
|
|
169
|
-
const maxRedirects = options.maxRedirects ?? httpMaxRedirects ?? 5;
|
|
170
|
-
const httpValidateCerts = typeof httpOptions.validateCertificates === 'boolean' ? httpOptions.validateCertificates : null;
|
|
171
|
-
const validateCerts = options.ssl?.validateCertificates ?? httpValidateCerts ?? true;
|
|
172
|
-
|
|
173
|
-
logger?.debug('HTTP request options resolved', {
|
|
174
|
-
method,
|
|
175
|
-
timeout,
|
|
176
|
-
followRedirects,
|
|
177
|
-
maxRedirects,
|
|
178
|
-
validateCerts
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// Cookie handling
|
|
182
|
-
const cookieHeader = context.cookieJar.getCookieHeader(url);
|
|
183
|
-
if (cookieHeader !== null) {
|
|
184
|
-
headers['Cookie'] = cookieHeader;
|
|
185
|
-
logger?.trace('Cookie header applied', { url });
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const gotOptions: OptionsOfTextResponseBody = {
|
|
189
|
-
method: method.toUpperCase() as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS',
|
|
190
|
-
headers: { ...headers },
|
|
191
|
-
throwHttpErrors: false,
|
|
192
|
-
timeout: { request: timeout },
|
|
193
|
-
followRedirect: followRedirects,
|
|
194
|
-
allowGetBody: true,
|
|
195
|
-
https: {
|
|
196
|
-
rejectUnauthorized: validateCerts,
|
|
197
|
-
certificate: options.ssl?.clientCertificate?.cert,
|
|
198
|
-
key: options.ssl?.clientCertificate?.key,
|
|
199
|
-
passphrase: options.ssl?.clientCertificate?.passphrase,
|
|
200
|
-
certificateAuthority: options.ssl?.ca
|
|
201
|
-
},
|
|
202
|
-
signal: context.abortSignal as AbortSignal | undefined
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
// Body encoding
|
|
206
|
-
if (body !== undefined && body !== null && body !== '') {
|
|
207
|
-
if (typeof body === 'string') {
|
|
208
|
-
gotOptions.body = body;
|
|
209
|
-
} else if (typeof body === 'object') {
|
|
210
|
-
const bodyObj = body as BodyObject;
|
|
211
|
-
|
|
212
|
-
if (bodyObj.mode === 'none') {
|
|
213
|
-
logger?.trace('HTTP body mode set to none; skipping body');
|
|
214
|
-
} else if (bodyObj.mode === 'raw' && typeof bodyObj.raw === 'string') {
|
|
215
|
-
gotOptions.body = bodyObj.raw;
|
|
216
|
-
} else if (bodyObj.mode === 'urlencoded' && Array.isArray(bodyObj.urlencoded)) {
|
|
217
|
-
const params = new URLSearchParams();
|
|
218
|
-
bodyObj.urlencoded.forEach((item: { key?: string; value?: unknown; disabled?: boolean }) => {
|
|
219
|
-
if (typeof item.key === 'string' && item.value !== undefined) {
|
|
220
|
-
params.append(item.key, String(item.value));
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
gotOptions.body = params.toString();
|
|
224
|
-
gotOptions.headers ??= {};
|
|
225
|
-
gotOptions.headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
226
|
-
} else if (bodyObj.mode === 'formdata' && Array.isArray(bodyObj.formdata)) {
|
|
227
|
-
gotOptions.json = bodyObj;
|
|
228
|
-
} else {
|
|
229
|
-
gotOptions.json = bodyObj;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Proxy setup
|
|
235
|
-
let proxyConfig = options.proxy;
|
|
236
|
-
|
|
237
|
-
if ((proxyConfig?.host === undefined) && shouldBypassProxy(url) === false) {
|
|
238
|
-
const envProxy = getProxyFromEnv(url);
|
|
239
|
-
if (envProxy !== null) {
|
|
240
|
-
proxyConfig = {
|
|
241
|
-
enabled: true,
|
|
242
|
-
host: envProxy.host,
|
|
243
|
-
port: envProxy.port,
|
|
244
|
-
auth: envProxy.auth
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (proxyConfig?.enabled !== false && proxyConfig?.host !== undefined && proxyConfig.host !== '') {
|
|
250
|
-
const targetUrl = new URL(url);
|
|
251
|
-
const explicitBypass = proxyConfig.bypass?.some(pattern => {
|
|
252
|
-
return targetUrl.hostname === pattern ||
|
|
253
|
-
(pattern.startsWith('*.') && targetUrl.hostname.endsWith(pattern.slice(1)));
|
|
254
|
-
}) ?? false;
|
|
255
|
-
|
|
256
|
-
const envBypass = shouldBypassProxy(url);
|
|
257
|
-
const shouldBypass = explicitBypass || envBypass;
|
|
258
|
-
|
|
259
|
-
if (shouldBypass === false) {
|
|
260
|
-
const proxyAuth = (proxyConfig.auth !== undefined && proxyConfig.auth !== null)
|
|
261
|
-
? `${encodeURIComponent(proxyConfig.auth.username)}:${encodeURIComponent(proxyConfig.auth.password)}@`
|
|
262
|
-
: '';
|
|
263
|
-
|
|
264
|
-
const fullProxyUrl = `http://${proxyAuth}${proxyConfig.host}:${proxyConfig.port}`;
|
|
265
|
-
|
|
266
|
-
gotOptions.agent = {
|
|
267
|
-
http: new HttpProxyAgent({
|
|
268
|
-
keepAlive: true,
|
|
269
|
-
keepAliveMsecs: 1000,
|
|
270
|
-
maxSockets: 256,
|
|
271
|
-
maxFreeSockets: 256,
|
|
272
|
-
scheduling: 'lifo',
|
|
273
|
-
proxy: fullProxyUrl
|
|
274
|
-
}),
|
|
275
|
-
https: new HttpsProxyAgent({
|
|
276
|
-
keepAlive: true,
|
|
277
|
-
keepAliveMsecs: 1000,
|
|
278
|
-
maxSockets: 256,
|
|
279
|
-
maxFreeSockets: 256,
|
|
280
|
-
scheduling: 'lifo',
|
|
281
|
-
proxy: fullProxyUrl
|
|
282
|
-
})
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Dispatch
|
|
288
|
-
logger?.debug('HTTP request dispatch', { url, method });
|
|
289
|
-
const response: Response = await got(url, gotOptions);
|
|
290
|
-
const duration = Date.now() - startTime;
|
|
291
|
-
|
|
292
|
-
// Response normalization
|
|
293
|
-
const normalizedHeaders: Record<string, string | string[]> = {};
|
|
294
|
-
if (typeof response.headers === 'object' && response.headers !== null) {
|
|
295
|
-
Object.entries(response.headers).forEach(([key, value]) => {
|
|
296
|
-
if (Array.isArray(value)) {
|
|
297
|
-
normalizedHeaders[key.toLowerCase()] = value.map(item => String(item));
|
|
298
|
-
} else if (value !== undefined && value !== null) {
|
|
299
|
-
normalizedHeaders[key.toLowerCase()] = String(value);
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (normalizedHeaders['set-cookie'] !== undefined) {
|
|
305
|
-
context.cookieJar.store(normalizedHeaders['set-cookie'], url);
|
|
306
|
-
logger?.trace('Cookies stored from response', { url });
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
logger?.debug('HTTP response received', { status: response.statusCode, duration });
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
status: response.statusCode,
|
|
313
|
-
statusText: (response.statusMessage !== null && response.statusMessage !== undefined && response.statusMessage.length > 0) ? response.statusMessage : '',
|
|
314
|
-
headers: normalizedHeaders,
|
|
315
|
-
body: String(response.body),
|
|
316
|
-
duration,
|
|
317
|
-
};
|
|
318
|
-
} catch (err) {
|
|
319
|
-
const duration = Date.now() - startTime;
|
|
320
|
-
const error = err as RequestError;
|
|
321
|
-
|
|
322
|
-
if (error instanceof RequestError && error.name === 'AbortError') {
|
|
323
|
-
logger?.warn('HTTP request aborted', { url, duration });
|
|
324
|
-
return {
|
|
325
|
-
status: 0,
|
|
326
|
-
statusText: 'Aborted',
|
|
327
|
-
body: '',
|
|
328
|
-
headers: {},
|
|
329
|
-
duration,
|
|
330
|
-
error: 'Request aborted'
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (error instanceof RequestError) {
|
|
335
|
-
if (error.response !== undefined) {
|
|
336
|
-
const normalizedHeaders: Record<string, string | string[]> = {};
|
|
337
|
-
if (typeof error.response.headers === 'object' && error.response.headers !== null) {
|
|
338
|
-
Object.entries(error.response.headers).forEach(([key, value]) => {
|
|
339
|
-
if (Array.isArray(value)) {
|
|
340
|
-
normalizedHeaders[key.toLowerCase()] = value.map(item => String(item));
|
|
341
|
-
} else if (value !== undefined && value !== null) {
|
|
342
|
-
normalizedHeaders[key.toLowerCase()] = String(value);
|
|
343
|
-
}
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (normalizedHeaders['set-cookie'] !== undefined) {
|
|
348
|
-
context.cookieJar.store(normalizedHeaders['set-cookie'], url);
|
|
349
|
-
logger?.trace('Cookies stored from error response', { url });
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
logger?.debug('HTTP error response received', { status: error.response.statusCode, duration });
|
|
353
|
-
|
|
354
|
-
return {
|
|
355
|
-
status: error.response.statusCode,
|
|
356
|
-
statusText: (error.response.statusMessage !== null && error.response.statusMessage !== undefined && error.response.statusMessage.length > 0) ? error.response.statusMessage : '',
|
|
357
|
-
headers: normalizedHeaders,
|
|
358
|
-
body: String(error.response.body),
|
|
359
|
-
duration,
|
|
360
|
-
};
|
|
361
|
-
} else {
|
|
362
|
-
logger?.warn('HTTP network error', { message: error.message, duration });
|
|
363
|
-
return {
|
|
364
|
-
status: 0,
|
|
365
|
-
statusText: 'Network Error',
|
|
366
|
-
headers: {},
|
|
367
|
-
body: '',
|
|
368
|
-
duration,
|
|
369
|
-
error: !isNullOrEmpty(error.message) ? error.message : 'Network request failed'
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
logger?.error('HTTP unexpected error', { error: err instanceof Error ? err.message : String(err), duration });
|
|
375
|
-
return {
|
|
376
|
-
status: 0,
|
|
377
|
-
statusText: 'Error',
|
|
378
|
-
headers: {},
|
|
379
|
-
body: '',
|
|
380
|
-
duration,
|
|
381
|
-
error: err instanceof Error ? err.message : String(err)
|
|
382
|
-
};
|
|
383
|
-
}
|
|
384
|
-
},
|
|
385
|
-
|
|
386
|
-
validate(request: Request, options: RuntimeOptions): ValidationResult {
|
|
387
|
-
const errors: ValidationError[] = [];
|
|
388
|
-
|
|
389
|
-
// Check URL
|
|
390
|
-
if (typeof request.data.url !== 'string' || isNullOrWhitespace(request.data.url)) {
|
|
391
|
-
errors.push({
|
|
392
|
-
message: 'URL is required',
|
|
393
|
-
location: '',
|
|
394
|
-
source: 'protocol'
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Check method
|
|
399
|
-
const method = (typeof request.data.method === 'string' && !isNullOrEmpty(request.data.method)) ? request.data.method.toUpperCase() : 'GET';
|
|
400
|
-
const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
401
|
-
if (!validMethods.includes(method)) {
|
|
402
|
-
errors.push({
|
|
403
|
-
message: `Invalid HTTP method: ${method}`,
|
|
404
|
-
location: '',
|
|
405
|
-
source: 'protocol'
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (errors.length > 0) {
|
|
410
|
-
return {
|
|
411
|
-
valid: false,
|
|
412
|
-
errors
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return { valid: true };
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
export default httpPlugin;
|