@codemation/core-nodes 1.0.2 → 1.1.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/CHANGELOG.md +108 -0
- package/dist/index.cjs +2851 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -684
- package/dist/index.d.ts +1556 -684
- package/dist/index.js +2796 -49
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/authoring/defineRestNode.types.ts +204 -0
- package/src/credentials/ApiKeyCredentialType.ts +60 -0
- package/src/credentials/BasicAuthCredentialType.ts +51 -0
- package/src/credentials/BearerTokenCredentialType.ts +40 -0
- package/src/credentials/OAuth2ClientCredentialsTypeFactory.ts +117 -0
- package/src/credentials/OAuth2TokenExchangeFactory.ts +52 -0
- package/src/credentials/index.ts +4 -0
- package/src/http/HttpBodyBuilder.ts +90 -0
- package/src/http/HttpRequestExecutor.ts +150 -0
- package/src/http/HttpUrlBuilder.ts +22 -0
- package/src/http/httpRequest.types.ts +69 -0
- package/src/index.ts +9 -0
- package/src/nodes/AssertionNode.ts +42 -0
- package/src/nodes/CronTriggerFactory.ts +45 -0
- package/src/nodes/CronTriggerNode.ts +40 -0
- package/src/nodes/HttpRequestNodeFactory.ts +99 -23
- package/src/nodes/IsTestRunNode.ts +25 -0
- package/src/nodes/TestTriggerNode.ts +33 -0
- package/src/nodes/assertion.ts +42 -0
- package/src/nodes/collections/collectionDeleteNode.types.ts +23 -0
- package/src/nodes/collections/collectionFindOneNode.types.ts +26 -0
- package/src/nodes/collections/collectionGetNode.types.ts +26 -0
- package/src/nodes/collections/collectionInsertNode.types.ts +22 -0
- package/src/nodes/collections/collectionListNode.types.ts +30 -0
- package/src/nodes/collections/collectionUpdateNode.types.ts +23 -0
- package/src/nodes/collections/index.ts +6 -0
- package/src/nodes/httpRequest.ts +61 -1
- package/src/nodes/isTestRun.ts +24 -0
- package/src/nodes/testTrigger.ts +72 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { Item } from "@codemation/core";
|
|
2
|
+
import type { HttpRequestResult, HttpRequestSpec } from "./httpRequest.types";
|
|
3
|
+
import type { HttpBodyBuilder } from "./HttpBodyBuilder";
|
|
4
|
+
import type { HttpUrlBuilder } from "./HttpUrlBuilder";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Executes a single HTTP request described by {@link HttpRequestSpec}.
|
|
8
|
+
*
|
|
9
|
+
* - Credential sessions provide header/query deltas via `applyToRequest`.
|
|
10
|
+
* - Body encoding is delegated to {@link HttpBodyBuilder}.
|
|
11
|
+
* - URL query merging is delegated to {@link HttpUrlBuilder}.
|
|
12
|
+
* - Binary response bodies: when `download.mode` triggers binary attach, the
|
|
13
|
+
* `bodyBinaryName` field is set in the result but the body is NOT read here.
|
|
14
|
+
* Callers that need binary attachment should use `buildRequest` to get the
|
|
15
|
+
* resolved URL + init and make the fetch + binary attach themselves.
|
|
16
|
+
*
|
|
17
|
+
* Collaborators (`fetch`, body builder, url builder) are injected so callers
|
|
18
|
+
* own construction at composition roots and tests can supply deterministic stubs.
|
|
19
|
+
*/
|
|
20
|
+
export class HttpRequestExecutor {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly fetchFn: typeof globalThis.fetch,
|
|
23
|
+
private readonly bodyBuilder: HttpBodyBuilder,
|
|
24
|
+
private readonly urlBuilder: HttpUrlBuilder,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds the fetch init (headers, query, body) from the spec + credential delta,
|
|
29
|
+
* returning both the resolved URL and the RequestInit so callers can make the
|
|
30
|
+
* actual fetch call themselves (useful for streaming / binary attach).
|
|
31
|
+
*/
|
|
32
|
+
async buildRequest(spec: HttpRequestSpec, item: Item): Promise<Readonly<{ url: string; init: RequestInit }>> {
|
|
33
|
+
const credentialDelta = spec.credential?.applyToRequest(spec) ?? {};
|
|
34
|
+
|
|
35
|
+
const mergedHeaders: Record<string, string> = {
|
|
36
|
+
...(spec.headers ?? {}),
|
|
37
|
+
...(credentialDelta.headers ?? {}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mergedQuery: Record<string, string | string[]> = {
|
|
41
|
+
...(spec.query ?? {}),
|
|
42
|
+
...(credentialDelta.query ?? {}),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const encodedBody = await this.bodyBuilder.build(spec.body, item, spec.ctx);
|
|
46
|
+
|
|
47
|
+
// Only set Content-Type from the encoded body when it is non-empty
|
|
48
|
+
// (empty string = FormData will set it automatically).
|
|
49
|
+
if (encodedBody && encodedBody.contentType) {
|
|
50
|
+
mergedHeaders["content-type"] = encodedBody.contentType;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resolvedUrl = this.urlBuilder.build(spec.url, mergedQuery);
|
|
54
|
+
|
|
55
|
+
const init: RequestInit = {
|
|
56
|
+
method: spec.method,
|
|
57
|
+
headers: mergedHeaders,
|
|
58
|
+
...(encodedBody ? { body: encodedBody.body } : {}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return { url: resolvedUrl, init };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Executes an HTTP request and returns parsed result.
|
|
66
|
+
* For binary downloads (when `shouldAttachBody` is true), the body is NOT consumed
|
|
67
|
+
* and callers must call `ctx.binary.attach` directly using the resolved URL + init
|
|
68
|
+
* (available via `buildRequest`).
|
|
69
|
+
*/
|
|
70
|
+
async execute(spec: HttpRequestSpec, item: Item): Promise<HttpRequestResult> {
|
|
71
|
+
const { url: resolvedUrl, init } = await this.buildRequest(spec, item);
|
|
72
|
+
|
|
73
|
+
const response = await this.fetchFn(resolvedUrl, init);
|
|
74
|
+
|
|
75
|
+
const responseHeaders = this.readHeaders(response.headers);
|
|
76
|
+
const mimeType = this.resolveMimeType(responseHeaders);
|
|
77
|
+
|
|
78
|
+
const downloadMode = spec.download?.mode ?? "auto";
|
|
79
|
+
const binaryName = spec.download?.binaryName ?? "body";
|
|
80
|
+
const shouldDownload = this.shouldAttachBody(downloadMode, mimeType);
|
|
81
|
+
|
|
82
|
+
const isJson = this.isJsonMimeType(mimeType);
|
|
83
|
+
|
|
84
|
+
let json: unknown | undefined;
|
|
85
|
+
let text: string | undefined;
|
|
86
|
+
let bodyBinaryName: string | undefined;
|
|
87
|
+
|
|
88
|
+
if (shouldDownload) {
|
|
89
|
+
// Signal to caller that binary attachment is needed.
|
|
90
|
+
bodyBinaryName = binaryName;
|
|
91
|
+
// Do NOT read the body here — the caller must handle binary attach separately.
|
|
92
|
+
} else if (isJson) {
|
|
93
|
+
try {
|
|
94
|
+
json = await response.json();
|
|
95
|
+
} catch {
|
|
96
|
+
text = await response.text();
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
text = await response.text();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
url: resolvedUrl,
|
|
104
|
+
method: spec.method.toUpperCase(),
|
|
105
|
+
status: response.status,
|
|
106
|
+
ok: response.ok,
|
|
107
|
+
statusText: response.statusText,
|
|
108
|
+
mimeType,
|
|
109
|
+
headers: responseHeaders,
|
|
110
|
+
...(json !== undefined ? { json } : {}),
|
|
111
|
+
...(text !== undefined ? { text } : {}),
|
|
112
|
+
...(bodyBinaryName !== undefined ? { bodyBinaryName } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private readHeaders(headers: Headers): Readonly<Record<string, string>> {
|
|
117
|
+
const values: Record<string, string> = {};
|
|
118
|
+
headers.forEach((value, key) => {
|
|
119
|
+
values[key] = value;
|
|
120
|
+
});
|
|
121
|
+
return values;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private resolveMimeType(headers: Readonly<Record<string, string>>): string {
|
|
125
|
+
const contentType = headers["content-type"];
|
|
126
|
+
if (!contentType) {
|
|
127
|
+
return "application/octet-stream";
|
|
128
|
+
}
|
|
129
|
+
return contentType.split(";")[0]?.trim() || "application/octet-stream";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private isJsonMimeType(mimeType: string): boolean {
|
|
133
|
+
return mimeType === "application/json" || mimeType.endsWith("+json");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private shouldAttachBody(mode: "auto" | "always" | "never", mimeType: string): boolean {
|
|
137
|
+
if (mode === "always") {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (mode === "never") {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return (
|
|
144
|
+
mimeType.startsWith("image/") ||
|
|
145
|
+
mimeType.startsWith("audio/") ||
|
|
146
|
+
mimeType.startsWith("video/") ||
|
|
147
|
+
mimeType === "application/pdf"
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges query parameters into a base URL.
|
|
3
|
+
* Handles both scalar and array values, and preserves any existing params.
|
|
4
|
+
*/
|
|
5
|
+
export class HttpUrlBuilder {
|
|
6
|
+
build(baseUrl: string, query?: Readonly<Record<string, string | string[]>>): string {
|
|
7
|
+
if (!query || Object.keys(query).length === 0) {
|
|
8
|
+
return baseUrl;
|
|
9
|
+
}
|
|
10
|
+
const parsed = new URL(baseUrl);
|
|
11
|
+
for (const [key, value] of Object.entries(query)) {
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
for (const entry of value) {
|
|
14
|
+
parsed.searchParams.append(key, entry);
|
|
15
|
+
}
|
|
16
|
+
} else {
|
|
17
|
+
parsed.searchParams.append(key, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return parsed.toString();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { NodeExecutionContext } from "@codemation/core";
|
|
2
|
+
import type { RunnableNodeConfig } from "@codemation/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Binary reference key into `item.binary`.
|
|
6
|
+
*/
|
|
7
|
+
export type BinaryRef = string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discriminated union for the HTTP request body.
|
|
11
|
+
*/
|
|
12
|
+
export type HttpBodySpec =
|
|
13
|
+
| Readonly<{ kind: "none" }>
|
|
14
|
+
| Readonly<{ kind: "json"; data: unknown }>
|
|
15
|
+
| Readonly<{ kind: "form"; data: Readonly<Record<string, string>> }>
|
|
16
|
+
| Readonly<{
|
|
17
|
+
kind: "multipart";
|
|
18
|
+
fields: Readonly<Record<string, string>>;
|
|
19
|
+
binaries?: Readonly<Record<string, BinaryRef>>;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Session interface that credential types implement.
|
|
24
|
+
* Returns header/query deltas so the executor can merge them without
|
|
25
|
+
* mutating the immutable HttpRequestSpec.
|
|
26
|
+
*/
|
|
27
|
+
export interface CredentialSession {
|
|
28
|
+
applyToRequest(spec: HttpRequestSpec): HttpCredentialDelta;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mutations the credential session wants to apply to the outgoing request.
|
|
33
|
+
*/
|
|
34
|
+
export type HttpCredentialDelta = Readonly<{
|
|
35
|
+
headers?: Readonly<Record<string, string>>;
|
|
36
|
+
query?: Readonly<Record<string, string>>;
|
|
37
|
+
}>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Full specification of one HTTP request. All URLs are fully resolved before
|
|
41
|
+
* being passed here (template substitution already applied by the caller).
|
|
42
|
+
*/
|
|
43
|
+
export type HttpRequestSpec = Readonly<{
|
|
44
|
+
url: string;
|
|
45
|
+
method: string;
|
|
46
|
+
headers?: Readonly<Record<string, string>>;
|
|
47
|
+
query?: Readonly<Record<string, string | string[]>>;
|
|
48
|
+
body?: HttpBodySpec;
|
|
49
|
+
credential?: CredentialSession;
|
|
50
|
+
download?: Readonly<{ mode: "auto" | "always" | "never"; binaryName: string }>;
|
|
51
|
+
/** Execution context — needed for binary attach. */
|
|
52
|
+
ctx: NodeExecutionContext<RunnableNodeConfig<unknown, unknown>>;
|
|
53
|
+
}>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Result of executing an HTTP request.
|
|
57
|
+
*/
|
|
58
|
+
export type HttpRequestResult = Readonly<{
|
|
59
|
+
url: string;
|
|
60
|
+
method: string;
|
|
61
|
+
status: number;
|
|
62
|
+
ok: boolean;
|
|
63
|
+
statusText: string;
|
|
64
|
+
mimeType: string;
|
|
65
|
+
headers: Readonly<Record<string, string>>;
|
|
66
|
+
json?: unknown;
|
|
67
|
+
text?: string;
|
|
68
|
+
bodyBinaryName?: string;
|
|
69
|
+
}>;
|
package/src/index.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
export * from "./canvasIconName";
|
|
2
|
+
export * from "./credentials/index";
|
|
3
|
+
export * from "./http/httpRequest.types";
|
|
4
|
+
export * from "./authoring/defineRestNode.types";
|
|
2
5
|
export * from "./chatModels/OpenAIChatModelFactory";
|
|
3
6
|
export * from "./chatModels/OpenAiStrictJsonSchemaFactory";
|
|
4
7
|
export * from "./chatModels/OpenAiCredentialSession";
|
|
5
8
|
export * from "./chatModels/openAiChatModelConfig";
|
|
6
9
|
export * from "./chatModels/OpenAiChatModelPresetsFactory";
|
|
7
10
|
export * from "./nodes/aiAgent";
|
|
11
|
+
export * from "./nodes/assertion";
|
|
8
12
|
export * from "./nodes/CallbackNodeFactory";
|
|
9
13
|
export * from "./nodes/httpRequest";
|
|
10
14
|
export * from "./nodes/aggregate";
|
|
11
15
|
export * from "./nodes/filter";
|
|
12
16
|
export * from "./nodes/if";
|
|
17
|
+
export * from "./nodes/isTestRun";
|
|
13
18
|
export * from "./nodes/switch";
|
|
14
19
|
export * from "./nodes/split";
|
|
20
|
+
export * from "./nodes/CronTriggerFactory";
|
|
21
|
+
export * from "./nodes/CronTriggerNode";
|
|
15
22
|
export * from "./nodes/ManualTriggerFactory";
|
|
16
23
|
export * from "./nodes/mapData";
|
|
17
24
|
export * from "./nodes/merge";
|
|
18
25
|
export * from "./nodes/noOp";
|
|
19
26
|
export * from "./nodes/subWorkflow";
|
|
27
|
+
export * from "./nodes/testTrigger";
|
|
20
28
|
export * from "./nodes/wait";
|
|
21
29
|
export * from "./nodes/webhookRespondNowAndContinueError";
|
|
22
30
|
export * from "./nodes/webhookRespondNowError";
|
|
@@ -30,3 +38,4 @@ export * from "./nodes/ConnectionCredentialNode";
|
|
|
30
38
|
export * from "./nodes/ConnectionCredentialNodeConfig";
|
|
31
39
|
export * from "./nodes/ConnectionCredentialNodeConfigFactory";
|
|
32
40
|
export * from "./nodes/ConnectionCredentialExecutionContextFactory";
|
|
41
|
+
export * from "./nodes/collections";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AssertionResult, RunnableNode, RunnableNodeExecuteArgs } from "@codemation/core";
|
|
2
|
+
import { node } from "@codemation/core";
|
|
3
|
+
|
|
4
|
+
import { Assertion } from "./assertion";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runs the author's `assertions` callback for each input item and emits one workflow `Item` per
|
|
8
|
+
* returned {@link AssertionResult} on `main`. Persistence is handled by a host-side subscriber
|
|
9
|
+
* to `nodeCompleted` events that filters on `config.emitsAssertions === true`; this node does
|
|
10
|
+
* not write to any store on its own.
|
|
11
|
+
*
|
|
12
|
+
* If the author callback throws, we emit a single synthetic AssertionResult with `errored: true`
|
|
13
|
+
* and `score: 0`. Without this catch the whole node would fail and no assertion row would be
|
|
14
|
+
* persisted — making the rollup blind to "the assertion code itself is broken." The synthetic
|
|
15
|
+
* row keeps `failedAssertionsByRunId` consistent and gives the UI something to surface.
|
|
16
|
+
*/
|
|
17
|
+
@node({ packageName: "@codemation/core-nodes" })
|
|
18
|
+
export class AssertionNode implements RunnableNode<Assertion<any>> {
|
|
19
|
+
kind = "node" as const;
|
|
20
|
+
outputPorts = ["main"] as const;
|
|
21
|
+
|
|
22
|
+
async execute(args: RunnableNodeExecuteArgs<Assertion<any>>): Promise<unknown> {
|
|
23
|
+
const ctx = args.ctx;
|
|
24
|
+
const config = ctx.config;
|
|
25
|
+
try {
|
|
26
|
+
const results: ReadonlyArray<AssertionResult> = await config.assertions(args.item, ctx);
|
|
27
|
+
// Engine "array → fan-out on main, each element is item.json" — returning the plain results
|
|
28
|
+
// makes downstream `item.json` exactly an AssertionResult. Wrapping in `{ json: result }`
|
|
29
|
+
// would double-wrap (engine would see `Item`-shaped values but treat them as JSON values).
|
|
30
|
+
return [...results];
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
33
|
+
const erroredResult: AssertionResult = {
|
|
34
|
+
name: config.name ?? "assertion",
|
|
35
|
+
score: 0,
|
|
36
|
+
errored: true,
|
|
37
|
+
message,
|
|
38
|
+
};
|
|
39
|
+
return [erroredResult];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { TriggerNodeConfig, TypeToken } from "@codemation/core";
|
|
2
|
+
|
|
3
|
+
import { Cron } from "croner";
|
|
4
|
+
import type { CronCallback } from "croner";
|
|
5
|
+
|
|
6
|
+
import { CronTriggerNode } from "./CronTriggerNode";
|
|
7
|
+
|
|
8
|
+
export type CronTickJson = { firedAt: string; scheduledFor: string };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schedules a workflow on a standard cron expression.
|
|
12
|
+
*
|
|
13
|
+
* Each tick emits one item: `{ firedAt: string, scheduledFor: string }` — both ISO-8601 timestamps.
|
|
14
|
+
* `firedAt` is the wall-clock moment the callback ran; `scheduledFor` is the cron-computed
|
|
15
|
+
* firing instant (these differ when the job was delayed).
|
|
16
|
+
*
|
|
17
|
+
* Timezone defaults to UTC when omitted — cron without an explicit TZ is a DST footgun.
|
|
18
|
+
*/
|
|
19
|
+
export class CronTrigger implements TriggerNodeConfig<CronTickJson> {
|
|
20
|
+
readonly kind = "trigger" as const;
|
|
21
|
+
readonly type: TypeToken<unknown> = CronTriggerNode;
|
|
22
|
+
readonly icon = "lucide:clock" as const;
|
|
23
|
+
readonly id?: string;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
public readonly name: string,
|
|
27
|
+
private readonly args: Readonly<{ schedule: string; timezone?: string }>,
|
|
28
|
+
id?: string,
|
|
29
|
+
) {
|
|
30
|
+
new Cron(args.schedule, { paused: true, timezone: args.timezone });
|
|
31
|
+
this.id = id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get schedule(): string {
|
|
35
|
+
return this.args.schedule;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get timezone(): string | undefined {
|
|
39
|
+
return this.args.timezone;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
createJob(callback: CronCallback): Cron {
|
|
43
|
+
return new Cron(this.args.schedule, { timezone: this.args.timezone, protect: true }, callback);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Items,
|
|
3
|
+
NodeExecutionContext,
|
|
4
|
+
NodeOutputs,
|
|
5
|
+
TestableTriggerNode,
|
|
6
|
+
TriggerSetupContext,
|
|
7
|
+
TriggerTestItemsContext,
|
|
8
|
+
} from "@codemation/core";
|
|
9
|
+
|
|
10
|
+
import { node } from "@codemation/core";
|
|
11
|
+
|
|
12
|
+
import { CronTrigger } from "./CronTriggerFactory";
|
|
13
|
+
|
|
14
|
+
@node({ packageName: "@codemation/core-nodes" })
|
|
15
|
+
export class CronTriggerNode implements TestableTriggerNode<CronTrigger> {
|
|
16
|
+
readonly kind = "trigger" as const;
|
|
17
|
+
readonly outputPorts = ["main"] as const;
|
|
18
|
+
|
|
19
|
+
async setup(ctx: TriggerSetupContext<CronTrigger>): Promise<undefined> {
|
|
20
|
+
const job = ctx.config.createJob(async (self) => {
|
|
21
|
+
const scheduledFor = self.currentRun()?.toISOString() ?? ctx.now().toISOString();
|
|
22
|
+
await ctx.emit([{ json: { firedAt: ctx.now().toISOString(), scheduledFor } }]);
|
|
23
|
+
});
|
|
24
|
+
ctx.registerCleanup({
|
|
25
|
+
stop: () => {
|
|
26
|
+
job.stop();
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async execute(items: Items, _ctx: NodeExecutionContext<CronTrigger>): Promise<NodeOutputs> {
|
|
33
|
+
return { main: items };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getTestItems(ctx: TriggerTestItemsContext<CronTrigger>): Promise<Items> {
|
|
37
|
+
const nowIso = ctx.now().toISOString();
|
|
38
|
+
return [{ json: { firedAt: nowIso, scheduledFor: nowIso } }];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Item, NodeExecutionContext, RunnableNode, RunnableNodeExecuteArgs } from "@codemation/core";
|
|
2
2
|
|
|
3
3
|
import { node } from "@codemation/core";
|
|
4
|
-
|
|
4
|
+
import type { CredentialSession, HttpRequestSpec } from "../http/httpRequest.types";
|
|
5
|
+
import { HttpRequestExecutor } from "../http/HttpRequestExecutor";
|
|
6
|
+
import { HttpBodyBuilder } from "../http/HttpBodyBuilder";
|
|
7
|
+
import { HttpUrlBuilder } from "../http/HttpUrlBuilder";
|
|
5
8
|
import type { HttpRequestDownloadMode } from "./httpRequest";
|
|
6
9
|
import { HttpRequest } from "./httpRequest";
|
|
7
10
|
|
|
@@ -16,44 +19,113 @@ export class HttpRequestNode implements RunnableNode<HttpRequest<any, any>> {
|
|
|
16
19
|
|
|
17
20
|
private async executeItem(item: Item, ctx: NodeExecutionContext<HttpRequest<any, any>>): Promise<Item> {
|
|
18
21
|
const url = this.resolveUrl(item, ctx);
|
|
19
|
-
const
|
|
22
|
+
const credential = await this.resolveCredential(ctx);
|
|
23
|
+
|
|
24
|
+
const spec: HttpRequestSpec = {
|
|
25
|
+
url,
|
|
20
26
|
method: ctx.config.method,
|
|
21
|
-
|
|
27
|
+
headers: ctx.config.args.headers,
|
|
28
|
+
query: ctx.config.args.query,
|
|
29
|
+
body: ctx.config.args.body,
|
|
30
|
+
credential,
|
|
31
|
+
download: {
|
|
32
|
+
mode: ctx.config.downloadMode,
|
|
33
|
+
binaryName: ctx.config.binaryName,
|
|
34
|
+
},
|
|
35
|
+
ctx: ctx as unknown as HttpRequestSpec["ctx"],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Build the request (headers, body encoding, URL query merge) once,
|
|
39
|
+
// then make a SINGLE fetch call and decide what to do with the response.
|
|
40
|
+
// This avoids a double-fetch regression for auto-mode binary responses.
|
|
41
|
+
const executor = new HttpRequestExecutor(globalThis.fetch, new HttpBodyBuilder(), new HttpUrlBuilder());
|
|
42
|
+
const { url: resolvedUrl, init } = await executor.buildRequest(spec, item);
|
|
43
|
+
|
|
44
|
+
const response = await globalThis.fetch(resolvedUrl, init);
|
|
45
|
+
|
|
22
46
|
const headers = this.readHeaders(response.headers);
|
|
23
47
|
const mimeType = this.resolveMimeType(headers);
|
|
24
|
-
const
|
|
25
|
-
const
|
|
48
|
+
const binaryName = ctx.config.binaryName;
|
|
49
|
+
const shouldAttach = this.shouldAttachBody(ctx.config.downloadMode, mimeType);
|
|
50
|
+
|
|
51
|
+
if (shouldAttach) {
|
|
52
|
+
const outputJson: Readonly<Record<string, unknown>> = {
|
|
53
|
+
url: resolvedUrl,
|
|
54
|
+
method: ctx.config.method,
|
|
55
|
+
ok: response.ok,
|
|
56
|
+
status: response.status,
|
|
57
|
+
statusText: response.statusText,
|
|
58
|
+
mimeType,
|
|
59
|
+
headers,
|
|
60
|
+
bodyBinaryName: binaryName,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const attachment = await ctx.binary.attach({
|
|
64
|
+
name: binaryName,
|
|
65
|
+
body: response.body
|
|
66
|
+
? (response.body as unknown as Parameters<typeof ctx.binary.attach>[0]["body"])
|
|
67
|
+
: new Uint8Array(await response.arrayBuffer()),
|
|
68
|
+
mimeType,
|
|
69
|
+
filename: this.resolveFilename(resolvedUrl, headers),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let outputItem: Item = { json: outputJson };
|
|
73
|
+
outputItem = ctx.binary.withAttachment(outputItem, binaryName, attachment);
|
|
74
|
+
return outputItem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Non-binary path: parse JSON or read text.
|
|
78
|
+
const isJson = this.isJsonMimeType(mimeType);
|
|
79
|
+
let json: unknown | undefined;
|
|
80
|
+
let text: string | undefined;
|
|
81
|
+
|
|
82
|
+
if (isJson) {
|
|
83
|
+
try {
|
|
84
|
+
json = await response.json();
|
|
85
|
+
} catch {
|
|
86
|
+
text = await response.text();
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
text = await response.text();
|
|
90
|
+
}
|
|
91
|
+
|
|
26
92
|
const outputJson: Readonly<Record<string, unknown>> = {
|
|
27
|
-
url,
|
|
93
|
+
url: resolvedUrl,
|
|
28
94
|
method: ctx.config.method,
|
|
29
95
|
ok: response.ok,
|
|
30
96
|
status: response.status,
|
|
31
97
|
statusText: response.statusText,
|
|
32
98
|
mimeType,
|
|
33
99
|
headers,
|
|
34
|
-
...(
|
|
100
|
+
...(json !== undefined ? { json } : {}),
|
|
101
|
+
...(text !== undefined ? { text } : {}),
|
|
35
102
|
};
|
|
36
103
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
};
|
|
40
|
-
if (!shouldAttachBody) {
|
|
41
|
-
return outputItem;
|
|
42
|
-
}
|
|
104
|
+
return { json: outputJson };
|
|
105
|
+
}
|
|
43
106
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
107
|
+
private async resolveCredential(
|
|
108
|
+
ctx: NodeExecutionContext<HttpRequest<any, any>>,
|
|
109
|
+
): Promise<CredentialSession | undefined> {
|
|
110
|
+
const slotKey = ctx.config.args.credentialSlot;
|
|
111
|
+
if (!slotKey) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return await ctx.getCredential<CredentialSession>(slotKey);
|
|
116
|
+
} catch {
|
|
117
|
+
// Credential slot configured but not bound — treat as no credential.
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
54
120
|
}
|
|
55
121
|
|
|
56
122
|
private resolveUrl(item: Item, ctx: NodeExecutionContext<HttpRequest<any, any>>): string {
|
|
123
|
+
// Literal URL in args takes precedence over the legacy urlField approach.
|
|
124
|
+
const literalUrl = ctx.config.args.url;
|
|
125
|
+
if (literalUrl && literalUrl.trim().length > 0) {
|
|
126
|
+
return literalUrl.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
57
129
|
const json = this.asRecord(item.json);
|
|
58
130
|
const candidate = json[ctx.config.urlField];
|
|
59
131
|
if (typeof candidate !== "string" || candidate.trim() === "") {
|
|
@@ -85,6 +157,10 @@ export class HttpRequestNode implements RunnableNode<HttpRequest<any, any>> {
|
|
|
85
157
|
return contentType.split(";")[0]?.trim() || "application/octet-stream";
|
|
86
158
|
}
|
|
87
159
|
|
|
160
|
+
private isJsonMimeType(mimeType: string): boolean {
|
|
161
|
+
return mimeType === "application/json" || mimeType.endsWith("+json");
|
|
162
|
+
}
|
|
163
|
+
|
|
88
164
|
private shouldAttachBody(mode: HttpRequestDownloadMode, mimeType: string): boolean {
|
|
89
165
|
if (mode === "always") {
|
|
90
166
|
return true;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RunnableNode, RunnableNodeExecuteArgs } from "@codemation/core";
|
|
2
|
+
import { emitPorts, node } from "@codemation/core";
|
|
3
|
+
|
|
4
|
+
import { IsTestRun } from "./isTestRun";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Routes each item to the `true` port if `ctx.testContext` is set (the run was started by the
|
|
8
|
+
* TestSuiteOrchestrator), else to `false`. Lets workflow authors guard real side-effects:
|
|
9
|
+
*
|
|
10
|
+
* GmailTrigger / TestTrigger → ClassifyAgent → IsTestRun
|
|
11
|
+
* ├── true → AssertionNode
|
|
12
|
+
* └── false → SendReply
|
|
13
|
+
*/
|
|
14
|
+
@node({ packageName: "@codemation/core-nodes" })
|
|
15
|
+
export class IsTestRunNode implements RunnableNode<IsTestRun<unknown>> {
|
|
16
|
+
kind = "node" as const;
|
|
17
|
+
|
|
18
|
+
execute(args: RunnableNodeExecuteArgs<IsTestRun<unknown>>): unknown {
|
|
19
|
+
const isTest = args.ctx.testContext !== undefined;
|
|
20
|
+
return emitPorts({
|
|
21
|
+
true: isTest ? [args.item] : [],
|
|
22
|
+
false: isTest ? [] : [args.item],
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Items,
|
|
3
|
+
NodeExecutionContext,
|
|
4
|
+
NodeOutputs,
|
|
5
|
+
TestTriggerNodeConfig,
|
|
6
|
+
TriggerNode,
|
|
7
|
+
TriggerSetupContext,
|
|
8
|
+
} from "@codemation/core";
|
|
9
|
+
|
|
10
|
+
import { node } from "@codemation/core";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Author-defined test-fixture trigger. Live activation skips this trigger (filtered by
|
|
14
|
+
* `triggerKind === "test"` in `TriggerRuntimeService`); the `TestSuiteOrchestrator` drives its
|
|
15
|
+
* `generateItems` callback during a TestSuiteRun and dispatches one workflow run per yielded item.
|
|
16
|
+
*
|
|
17
|
+
* `setup` is intentionally a no-op for symmetry with other trigger nodes — the real work happens
|
|
18
|
+
* in the orchestrator. `execute` is a passthrough so items provided to `engine.runWorkflow(...)`
|
|
19
|
+
* (one per case) flow downstream unchanged on `main`.
|
|
20
|
+
*/
|
|
21
|
+
@node({ packageName: "@codemation/core-nodes" })
|
|
22
|
+
export class TestTriggerNode implements TriggerNode<TestTriggerNodeConfig<any>> {
|
|
23
|
+
kind = "trigger" as const;
|
|
24
|
+
outputPorts = ["main"] as const;
|
|
25
|
+
|
|
26
|
+
async setup(_ctx: TriggerSetupContext<TestTriggerNodeConfig<any>>): Promise<undefined> {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async execute(items: Items, _ctx: NodeExecutionContext<TestTriggerNodeConfig<any>>): Promise<NodeOutputs> {
|
|
31
|
+
return { main: items };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AssertionResult, Item, NodeExecutionContext, RunnableNodeConfig, TypeToken } from "@codemation/core";
|
|
2
|
+
|
|
3
|
+
import { AssertionNode } from "./AssertionNode";
|
|
4
|
+
|
|
5
|
+
export interface AssertionOptions<TInputJson> {
|
|
6
|
+
readonly name?: string;
|
|
7
|
+
readonly id?: string;
|
|
8
|
+
readonly icon?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Author callback. Returns one or more {@link AssertionResult}s per input item. Each becomes
|
|
11
|
+
* one emitted output item — useful for per-row reporting in the Tests tab. Return `[]` to
|
|
12
|
+
* emit nothing for this case (rare; usually you want at least a "no-op" pass).
|
|
13
|
+
*/
|
|
14
|
+
assertions(
|
|
15
|
+
item: Item<TInputJson>,
|
|
16
|
+
ctx: NodeExecutionContext<Assertion<TInputJson>>,
|
|
17
|
+
): Promise<ReadonlyArray<AssertionResult>> | ReadonlyArray<AssertionResult>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generic assertion node — the "callback" form. For declarative shorthands (StringEquals,
|
|
22
|
+
* JudgeByAgent) compose this with helpers added in later phases. Sets `emitsAssertions: true`
|
|
23
|
+
* so host-side persisters know to record its outputs as `TestAssertion` rows.
|
|
24
|
+
*/
|
|
25
|
+
export class Assertion<TInputJson = unknown> implements RunnableNodeConfig<TInputJson, AssertionResult> {
|
|
26
|
+
readonly kind = "node" as const;
|
|
27
|
+
readonly type: TypeToken<unknown> = AssertionNode;
|
|
28
|
+
readonly icon: string;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly id?: string;
|
|
31
|
+
readonly emitsAssertions = true as const;
|
|
32
|
+
readonly assertions: AssertionOptions<TInputJson>["assertions"];
|
|
33
|
+
|
|
34
|
+
constructor(options: AssertionOptions<TInputJson>) {
|
|
35
|
+
this.name = options.name ?? "Assertion";
|
|
36
|
+
this.id = options.id;
|
|
37
|
+
this.icon = options.icon ?? "lucide:check-circle";
|
|
38
|
+
this.assertions = options.assertions;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { AssertionNode } from "./AssertionNode";
|