@apiquest/plugin-http 1.0.2

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.
@@ -0,0 +1,4 @@
1
+ import type { IProtocolPlugin } from '@apiquest/types';
2
+ export declare const httpPlugin: IProtocolPlugin;
3
+ export default httpPlugin;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAA2G,MAAM,iBAAiB,CAAC;AAmBhK,eAAO,MAAM,UAAU,EAAE,eAmRxB,CAAC;AAEF,eAAe,UAAU,CAAC"}
@@ -0,0 +1,34 @@
1
+ import esbuild from 'esbuild';
2
+
3
+ await esbuild.build({
4
+ entryPoints: ['src/index.ts'],
5
+ bundle: true,
6
+ outfile: 'dist/index.js',
7
+ format: 'esm',
8
+ platform: 'node',
9
+ target: 'node18',
10
+ // Externalize peerDependencies and Node.js built-ins
11
+ external: [
12
+ '@apiquest/fracture',
13
+ // Node.js built-in modules (got depends on these)
14
+ 'http',
15
+ 'https',
16
+ 'http2',
17
+ 'net',
18
+ 'tls',
19
+ 'stream',
20
+ 'util',
21
+ 'url',
22
+ 'zlib',
23
+ 'events',
24
+ 'buffer',
25
+ 'querystring',
26
+ 'dns',
27
+ 'fs',
28
+ 'path',
29
+ ],
30
+ minify: false,
31
+ sourcemap: true,
32
+ });
33
+
34
+ console.log('✓ Built plugin-http');
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@apiquest/plugin-http",
3
+ "version": "1.0.2",
4
+ "description": "HTTP protocol plugin for Quest",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "build": "rollup -c && tsc --emitDeclarationOnly",
10
+ "dev": "rollup -c --watch",
11
+ "test": "vitest"
12
+ },
13
+ "keywords": [
14
+ "api",
15
+ "http",
16
+ "quest",
17
+ "plugin"
18
+ ],
19
+ "author": "ApiQuest",
20
+ "license": "AGPL-3.0-or-later",
21
+ "dependencies": {
22
+ "got": "^14.6.6",
23
+ "hpagent": "^1.2.0"
24
+ },
25
+ "devDependencies": {
26
+ "@apiquest/types": "workspace:*",
27
+ "@rollup/plugin-commonjs": "^25.0.0",
28
+ "@rollup/plugin-node-resolve": "^15.0.0",
29
+ "@rollup/plugin-typescript": "^11.0.0",
30
+ "@types/node": "^20.10.6",
31
+ "rollup": "^4.0.0",
32
+ "typescript": "^5.3.3",
33
+ "vitest": "^4.0.18"
34
+ },
35
+ "peerDependencies": {
36
+ "@apiquest/types": "^1.0.0"
37
+ },
38
+ "apiquest": {
39
+ "type": "protocol",
40
+ "runtime": [
41
+ "fracture"
42
+ ],
43
+ "capabilities": {
44
+ "provides": {
45
+ "protocols": [
46
+ "http"
47
+ ]
48
+ },
49
+ "supports": {
50
+ "authTypes": [
51
+ "bearer",
52
+ "basic",
53
+ "oauth2",
54
+ "apikey",
55
+ "digest",
56
+ "ntlm"
57
+ ],
58
+ "strictAuthList": false
59
+ }
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,31 @@
1
+ import typescript from '@rollup/plugin-typescript';
2
+ import resolve from '@rollup/plugin-node-resolve';
3
+ import commonjs from '@rollup/plugin-commonjs';
4
+
5
+ export default {
6
+ input: 'src/index.ts',
7
+ output: {
8
+ file: 'dist/index.js',
9
+ format: 'esm',
10
+ sourcemap: true,
11
+ },
12
+ external: [
13
+ // Externalize peer dependencies
14
+ '@apiquest/fracture',
15
+ ],
16
+ plugins: [
17
+ // Resolve node modules
18
+ resolve({
19
+ preferBuiltins: true, // Prefer Node.js built-in modules
20
+ exportConditions: ['node', 'import', 'default'],
21
+ }),
22
+ // Convert CommonJS to ESM (for any CJS dependencies)
23
+ commonjs(),
24
+ // Compile TypeScript
25
+ typescript({
26
+ tsconfig: './tsconfig.json',
27
+ sourceMap: true,
28
+ declaration: false, // We'll use tsc for declarations
29
+ }),
30
+ ],
31
+ };
package/src/index.ts ADDED
@@ -0,0 +1,420 @@
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;
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "lib": ["ES2022"],
6
+ "moduleResolution": "bundler",
7
+ "outDir": "./dist",
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "allowSyntheticDefaultImports": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist", "tests"]
20
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["tests/**/*"],
4
+ "exclude": []
5
+ }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'json', 'html'],
10
+ },
11
+ },
12
+ });