@qaick/http-executor 0.1.0-alpha.1
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 +38 -0
- package/dist/http-action-executor.d.ts +36 -0
- package/dist/http-action-executor.js +146 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node-http-transport.d.ts +10 -0
- package/dist/node-http-transport.js +84 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# `@qaick/http-executor`
|
|
2
|
+
|
|
3
|
+
Reusable QAick HTTP Action execution for browser, Desktop, Server and CLI/CI hosts.
|
|
4
|
+
|
|
5
|
+
The package keeps HTTP Action semantics behind QAick Executor's generic Action Executor boundary. Choose the transport at the host boundary:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createExecutor } from '@qaick/executor';
|
|
9
|
+
import { HttpActionExecutor } from '@qaick/http-executor';
|
|
10
|
+
import { NodeHttpTransport } from '@qaick/http-executor/node';
|
|
11
|
+
|
|
12
|
+
const executor = createExecutor({
|
|
13
|
+
actionExecutors: [new HttpActionExecutor(new NodeHttpTransport())],
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`NodeHttpTransport` uses Node's standard `http` and `https` modules. It is the unrestricted host transport and does not apply browser CORS or preflight rules. `FetchHttpTransport` is available for browser environments and reports browser network and CORS failures through the HTTP error boundary.
|
|
18
|
+
|
|
19
|
+
The transport is injected into `HttpActionExecutor`, so host code can provide a deterministic fake transport for tests or another transport implementation without changing HTTP Action request construction, response parsing, extraction, timing, or failure classification.
|
|
20
|
+
|
|
21
|
+
The package requires Node 20 or newer for native Node transport support. `@qaick/executor` is a peer dependency and must be registered by the host application.
|
|
22
|
+
|
|
23
|
+
## Public exports
|
|
24
|
+
|
|
25
|
+
- `HttpActionExecutor`
|
|
26
|
+
- `HttpActionError` and `HttpActionErrorCode`
|
|
27
|
+
- `HttpTransport` and `HttpResponse`
|
|
28
|
+
- `FetchHttpTransport`
|
|
29
|
+
- `NodeHttpTransport`
|
|
30
|
+
|
|
31
|
+
## Development
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm run typecheck
|
|
35
|
+
npm test
|
|
36
|
+
npm run build
|
|
37
|
+
npm pack --dry-run
|
|
38
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ActionExecutionContext, ActionExecutionResult, ActionExecutor } from '@qaick/executor';
|
|
2
|
+
export interface HttpResponse {
|
|
3
|
+
status: number;
|
|
4
|
+
body: unknown;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
export interface HttpTransport {
|
|
8
|
+
request(url: string, init: {
|
|
9
|
+
method: string;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): Promise<HttpResponse>;
|
|
15
|
+
}
|
|
16
|
+
export type HttpActionErrorCode = 'HTTP_INVALID_URL' | 'HTTP_REQUEST_CONSTRUCTION_ERROR' | 'HTTP_ABORTED' | 'HTTP_TIMEOUT' | 'HTTP_NETWORK_ERROR' | 'HTTP_RESPONSE_PARSE_ERROR' | 'HTTP_STATUS_ERROR';
|
|
17
|
+
export declare class HttpActionError extends Error {
|
|
18
|
+
readonly code: HttpActionErrorCode;
|
|
19
|
+
readonly details?: Record<string, unknown> | undefined;
|
|
20
|
+
constructor(code: HttpActionErrorCode, message: string, details?: Record<string, unknown> | undefined);
|
|
21
|
+
}
|
|
22
|
+
export declare class FetchHttpTransport implements HttpTransport {
|
|
23
|
+
request(url: string, init: {
|
|
24
|
+
method: string;
|
|
25
|
+
body?: unknown;
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
signal: AbortSignal;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}): Promise<HttpResponse>;
|
|
30
|
+
}
|
|
31
|
+
export declare class HttpActionExecutor implements ActionExecutor {
|
|
32
|
+
private readonly transport;
|
|
33
|
+
constructor(transport: HttpTransport);
|
|
34
|
+
supports(action: string): boolean;
|
|
35
|
+
execute(context: ActionExecutionContext): Promise<ActionExecutionResult>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export class HttpActionError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
details;
|
|
4
|
+
constructor(code, message, details) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.details = details;
|
|
8
|
+
this.name = 'HttpActionError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class FetchHttpTransport {
|
|
12
|
+
async request(url, init) {
|
|
13
|
+
const headers = { ...init.headers };
|
|
14
|
+
let body;
|
|
15
|
+
if (init.body !== undefined && init.body !== null) {
|
|
16
|
+
body = typeof init.body === 'string' ? init.body : JSON.stringify(init.body);
|
|
17
|
+
if (!Object.keys(headers).some(name => name.toLowerCase() === 'content-type'))
|
|
18
|
+
headers['content-type'] = 'application/json';
|
|
19
|
+
}
|
|
20
|
+
if (!/^https?:\/\//i.test(url))
|
|
21
|
+
throw new HttpActionError('HTTP_INVALID_URL', `HTTP Action URL is invalid: ${url || '(empty URL)'}.`);
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const abortFromCaller = () => controller.abort();
|
|
24
|
+
init.signal.addEventListener('abort', abortFromCaller, { once: true });
|
|
25
|
+
const timeout = init.timeoutMs === undefined ? undefined : setTimeout(() => controller.abort(), init.timeoutMs);
|
|
26
|
+
let response;
|
|
27
|
+
try {
|
|
28
|
+
response = await fetch(url, { method: init.method, headers, body, signal: controller.signal });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (init.signal.aborted)
|
|
32
|
+
throw new HttpActionError('HTTP_ABORTED', 'HTTP Action was cancelled before a response was received.');
|
|
33
|
+
if (controller.signal.aborted && init.timeoutMs !== undefined)
|
|
34
|
+
throw new HttpActionError('HTTP_TIMEOUT', `HTTP Action exceeded its ${init.timeoutMs}ms timeout.`);
|
|
35
|
+
const detail = error instanceof Error ? error.message : 'The browser could not reach the target.';
|
|
36
|
+
throw new HttpActionError('HTTP_NETWORK_ERROR', `HTTP Action could not reach ${url}. This may be a network or browser CORS failure. ${detail}`);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
if (timeout !== undefined)
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
init.signal.removeEventListener('abort', abortFromCaller);
|
|
42
|
+
}
|
|
43
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
44
|
+
let responseBody;
|
|
45
|
+
try {
|
|
46
|
+
responseBody = contentType.includes('application/json') ? await response.json() : await response.text();
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const detail = error instanceof Error ? error.message : 'The response body could not be read.';
|
|
50
|
+
throw new HttpActionError('HTTP_RESPONSE_PARSE_ERROR', `HTTP Action received status ${response.status}, but the response body could not be parsed. ${detail}`);
|
|
51
|
+
}
|
|
52
|
+
return { status: response.status, body: responseBody, headers: Object.fromEntries(response.headers.entries()) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class HttpActionExecutor {
|
|
56
|
+
transport;
|
|
57
|
+
constructor(transport) {
|
|
58
|
+
this.transport = transport;
|
|
59
|
+
}
|
|
60
|
+
supports(action) { return action.startsWith('http.'); }
|
|
61
|
+
async execute(context) {
|
|
62
|
+
const startedAt = new Date().toISOString();
|
|
63
|
+
const startedMs = Date.now();
|
|
64
|
+
let request;
|
|
65
|
+
try {
|
|
66
|
+
const url = substituteTemplate(String(context.inputs.url ?? ''), context.inputs);
|
|
67
|
+
request = {
|
|
68
|
+
url: appendQuery(url, substituteTemplateValue(context.inputs.query, context.inputs)),
|
|
69
|
+
method: substituteTemplate(String(context.inputs.method ?? 'GET'), context.inputs),
|
|
70
|
+
...(context.inputs.body !== undefined ? { body: substituteTemplateValue(context.inputs.body, context.inputs) } : {}),
|
|
71
|
+
...(isRecord(context.inputs.headers) ? { headers: Object.fromEntries(Object.entries(substituteTemplateValue(context.inputs.headers, context.inputs)).map(([name, value]) => [name, String(value)])) } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const detail = error instanceof Error ? error.message : 'The request template could not be resolved.';
|
|
76
|
+
throw new HttpActionError('HTTP_REQUEST_CONSTRUCTION_ERROR', `HTTP Action request could not be constructed. ${detail}`);
|
|
77
|
+
}
|
|
78
|
+
const timeoutMs = typeof context.step.adapterConfig?.timeoutMs === 'number' ? context.step.adapterConfig.timeoutMs : undefined;
|
|
79
|
+
const response = await this.transport.request(request.url, { ...request, signal: context.signal, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
|
|
80
|
+
if (response.status < 200 || response.status >= 300) {
|
|
81
|
+
const serializedBody = typeof response.body === 'string' ? response.body : JSON.stringify(response.body) ?? '';
|
|
82
|
+
throw new HttpActionError('HTTP_STATUS_ERROR', `HTTP Action returned status ${response.status}.${serializedBody ? ` Response: ${serializedBody.slice(0, 500)}` : ''}`, { status: response.status });
|
|
83
|
+
}
|
|
84
|
+
const outputs = { status: response.status, body: response.body, ...(response.headers ? { headers: response.headers } : {}) };
|
|
85
|
+
const responseFields = isRecord(context.step.adapterConfig?.responseFields) ? context.step.adapterConfig.responseFields : undefined;
|
|
86
|
+
if (responseFields)
|
|
87
|
+
for (const [name, path] of Object.entries(responseFields))
|
|
88
|
+
if (typeof path === 'string')
|
|
89
|
+
outputs[name] = readJsonPath(response.body, path);
|
|
90
|
+
if (typeof context.step.adapterConfig?.responseBodyName === 'string')
|
|
91
|
+
outputs[context.step.adapterConfig.responseBodyName] = response.body;
|
|
92
|
+
const finishedAt = new Date().toISOString();
|
|
93
|
+
return { outputs, evidence: { request: { url: request.url, method: request.method, headers: request.headers }, response: { status: response.status, headers: response.headers, body: response.body } }, timing: { startedAt, finishedAt, durationMs: Date.now() - startedMs } };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function appendQuery(url, query) {
|
|
97
|
+
if (query === undefined || query === null)
|
|
98
|
+
return url;
|
|
99
|
+
if (!isRecord(query))
|
|
100
|
+
throw new Error('Query parameters must be an object.');
|
|
101
|
+
const parsed = new URL(url);
|
|
102
|
+
for (const [name, value] of Object.entries(query))
|
|
103
|
+
for (const item of (Array.isArray(value) ? value : [value])) {
|
|
104
|
+
if (item !== undefined && item !== null)
|
|
105
|
+
parsed.searchParams.append(name, typeof item === 'object' ? JSON.stringify(item) : String(item));
|
|
106
|
+
}
|
|
107
|
+
return parsed.toString();
|
|
108
|
+
}
|
|
109
|
+
function substituteTemplateValue(value, inputs) {
|
|
110
|
+
if (typeof value === 'string')
|
|
111
|
+
return substituteTemplate(value, inputs);
|
|
112
|
+
if (Array.isArray(value))
|
|
113
|
+
return value.map(item => substituteTemplateValue(item, inputs));
|
|
114
|
+
if (isRecord(value))
|
|
115
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteTemplateValue(item, inputs)]));
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function substituteTemplate(value, inputs) {
|
|
119
|
+
const exact = /^\s*\{\{\s*(?:\$?(?:inputs\.)?)?([^{}]+?)\s*\}\}\s*$/.exec(value);
|
|
120
|
+
if (exact)
|
|
121
|
+
return stringifyTemplateValue(resolveTemplateValue(exact[1], inputs));
|
|
122
|
+
return value.replace(/\{\{\s*(?:\$?(?:inputs\.)?)?([^{}]+?)\s*\}\}/g, (_match, path) => stringifyTemplateValue(resolveTemplateValue(path, inputs)));
|
|
123
|
+
}
|
|
124
|
+
function resolveTemplateValue(path, inputs) {
|
|
125
|
+
const normalized = path.trim().replace(/^\$?(?:inputs\.)?/, '');
|
|
126
|
+
const value = normalized.split('.').reduce((current, segment) => isRecord(current) ? current[segment] : undefined, inputs);
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
throw new Error(`Template value '${path.trim()}' is missing.`);
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function stringifyTemplateValue(value) { return typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value) ?? String(value); }
|
|
132
|
+
function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
133
|
+
function readJsonPath(value, path) {
|
|
134
|
+
if (path === '$' || path === '')
|
|
135
|
+
return value;
|
|
136
|
+
if (!path.startsWith('$.'))
|
|
137
|
+
return undefined;
|
|
138
|
+
return path.slice(2).split('.').reduce((current, segment) => {
|
|
139
|
+
if (current === null || current === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
const match = /^(.*)\[(\d+)\]$/.exec(segment);
|
|
142
|
+
if (match)
|
|
143
|
+
return Array.isArray(isRecord(current) ? current[match[1]] : current) ? (isRecord(current) ? current[match[1]] : current)[Number(match[2])] : undefined;
|
|
144
|
+
return isRecord(current) ? current[segment] : undefined;
|
|
145
|
+
}, value);
|
|
146
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './http-action-executor.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './http-action-executor.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HttpResponse, HttpTransport } from './http-action-executor.js';
|
|
2
|
+
export declare class NodeHttpTransport implements HttpTransport {
|
|
3
|
+
request(url: string, init: {
|
|
4
|
+
method: string;
|
|
5
|
+
body?: unknown;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
}): Promise<HttpResponse>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import * as https from 'node:https';
|
|
3
|
+
import { FetchHttpTransport, HttpActionError } from './http-action-executor.js';
|
|
4
|
+
export class NodeHttpTransport {
|
|
5
|
+
request(url, init) {
|
|
6
|
+
if (!/^https?:\/\//i.test(url))
|
|
7
|
+
throw new HttpActionError('HTTP_INVALID_URL', `HTTP Action URL is invalid: ${url || '(empty URL)'}.`);
|
|
8
|
+
if (init.signal.aborted)
|
|
9
|
+
return Promise.reject(new HttpActionError('HTTP_ABORTED', 'HTTP Action was cancelled before a response was received.'));
|
|
10
|
+
const parsed = new URL(url);
|
|
11
|
+
const headers = { ...init.headers };
|
|
12
|
+
let body;
|
|
13
|
+
if (init.body !== undefined && init.body !== null) {
|
|
14
|
+
body = typeof init.body === 'string' ? init.body : JSON.stringify(init.body);
|
|
15
|
+
if (!Object.keys(headers).some(name => name.toLowerCase() === 'content-type'))
|
|
16
|
+
headers['content-type'] = 'application/json';
|
|
17
|
+
if (!Object.keys(headers).some(name => name.toLowerCase() === 'content-length'))
|
|
18
|
+
headers['content-length'] = String(Buffer.byteLength(body));
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
let settled = false;
|
|
22
|
+
let timedOut = false;
|
|
23
|
+
let timeout;
|
|
24
|
+
let request;
|
|
25
|
+
const finish = (error, response) => {
|
|
26
|
+
if (settled)
|
|
27
|
+
return;
|
|
28
|
+
settled = true;
|
|
29
|
+
if (timeout !== undefined)
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
init.signal.removeEventListener('abort', abort);
|
|
32
|
+
if (error)
|
|
33
|
+
reject(error);
|
|
34
|
+
else
|
|
35
|
+
resolve(response);
|
|
36
|
+
};
|
|
37
|
+
const abort = () => {
|
|
38
|
+
request.destroy();
|
|
39
|
+
finish(new HttpActionError('HTTP_ABORTED', 'HTTP Action was cancelled before a response was received.'));
|
|
40
|
+
};
|
|
41
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
42
|
+
request = transport.request({ protocol: parsed.protocol, hostname: parsed.hostname, port: parsed.port || undefined, path: `${parsed.pathname}${parsed.search}`, method: init.method, headers }, response => {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
response.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
45
|
+
response.on('end', () => {
|
|
46
|
+
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
47
|
+
const contentType = headerValue(response.headers, 'content-type');
|
|
48
|
+
let responseBody = rawBody;
|
|
49
|
+
try {
|
|
50
|
+
if (contentType.includes('application/json'))
|
|
51
|
+
responseBody = JSON.parse(rawBody);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const detail = error instanceof Error ? error.message : 'The response body could not be parsed.';
|
|
55
|
+
finish(new HttpActionError('HTTP_RESPONSE_PARSE_ERROR', `HTTP Action received status ${response.statusCode ?? 0}, but the response body could not be parsed. ${detail}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
finish(undefined, { status: response.statusCode ?? 0, body: responseBody, headers: normalizeHeaders(response.headers) });
|
|
59
|
+
});
|
|
60
|
+
response.on('error', error => finish(new HttpActionError('HTTP_NETWORK_ERROR', `HTTP Action could not read the response from ${url}. ${error.message}`)));
|
|
61
|
+
});
|
|
62
|
+
request.on('error', error => {
|
|
63
|
+
if (timedOut)
|
|
64
|
+
finish(new HttpActionError('HTTP_TIMEOUT', `HTTP Action exceeded its ${init.timeoutMs}ms timeout.`));
|
|
65
|
+
else if (!settled && !init.signal.aborted)
|
|
66
|
+
finish(new HttpActionError('HTTP_NETWORK_ERROR', `HTTP Action could not reach ${url}. ${error.message}`));
|
|
67
|
+
});
|
|
68
|
+
init.signal.addEventListener('abort', abort, { once: true });
|
|
69
|
+
if (init.timeoutMs !== undefined) {
|
|
70
|
+
timeout = setTimeout(() => { timedOut = true; request.destroy(); finish(new HttpActionError('HTTP_TIMEOUT', `HTTP Action exceeded its ${init.timeoutMs}ms timeout.`)); }, init.timeoutMs);
|
|
71
|
+
}
|
|
72
|
+
if (body !== undefined)
|
|
73
|
+
request.write(body);
|
|
74
|
+
request.end();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function normalizeHeaders(headers) {
|
|
79
|
+
return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, Array.isArray(value) ? value.join(', ') : value ?? '']));
|
|
80
|
+
}
|
|
81
|
+
function headerValue(headers, name) {
|
|
82
|
+
const value = headers[name];
|
|
83
|
+
return Array.isArray(value) ? value.join(', ') : value ?? '';
|
|
84
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qaick/http-executor",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Reusable QAick HTTP Action execution with browser and native Node transports.",
|
|
5
|
+
"keywords": ["qaick", "http", "executor", "automation"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": ["dist", "README.md"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"publishConfig": { "access": "public", "tag": "alpha" },
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
14
|
+
"./node": { "types": "./dist/node-http-transport.d.ts", "import": "./dist/node-http-transport.js" }
|
|
15
|
+
},
|
|
16
|
+
"scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "prepublishOnly": "npm run build" },
|
|
17
|
+
"peerDependencies": { "@qaick/executor": ">=0.1.0-alpha.1 <0.2.0" },
|
|
18
|
+
"devDependencies": { "@qaick/executor": "file:../QAICK_Executor", "@types/node": "^24.13.3", "typescript": "~6.0.2", "vitest": "^5.0.1" }
|
|
19
|
+
}
|