refactor(server): improve URL change detection and context handling
- Renamed `ensureDataObject` to `passContext` and added `mergeData` for better context management. - Updated `detectUrlChanges` to utilize the new context handling functions, improving data merging and response handling. - Enhanced evaluation of JSONata expressions with a new `evalCtx` function to streamline context passing. - Adjusted test assertions to reflect changes in output structure from `data` to `output`. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
import jsonata from "jsonata";
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
function ensureDataObject(ctx) {
|
function passContext(ctx) {
|
||||||
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
ctx.data = {};
|
return { ...ctx.context };
|
||||||
}
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeData(data) {
|
||||||
|
if (data != null && typeof data === "object" && !Array.isArray(data)) {
|
||||||
|
return { ...data };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_METHODS = new Set([
|
const ALLOWED_METHODS = new Set([
|
||||||
@@ -35,6 +43,19 @@ function previewValue(value) {
|
|||||||
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
return { preview: `${json.slice(0, 500)}...`, truncated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eval context for fingerprint/transform JSONata (reads `data.*`).
|
||||||
|
* @param {unknown} ctx
|
||||||
|
* @param {Record<string, unknown>} data
|
||||||
|
*/
|
||||||
|
function evalCtx(ctx, data) {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
context: passContext(ctx),
|
||||||
|
config: ctx?.config ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function detectUrlChanges(ctx) {
|
async function detectUrlChanges(ctx) {
|
||||||
const url = ctx.config?.url;
|
const url = ctx.config?.url;
|
||||||
if (typeof url !== "string" || url.length === 0) {
|
if (typeof url !== "string" || url.length === 0) {
|
||||||
@@ -63,7 +84,7 @@ async function detectUrlChanges(ctx) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDataObject(ctx);
|
const data = mergeData(ctx.data);
|
||||||
|
|
||||||
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
log.info({ url, method, key }, "detect-url-changes: fetching url");
|
||||||
const response = await $axios.request({
|
const response = await $axios.request({
|
||||||
@@ -77,28 +98,31 @@ async function detectUrlChanges(ctx) {
|
|||||||
"detect-url-changes: fetch complete",
|
"detect-url-changes: fetch complete",
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.data.httpResponse = response.data;
|
data.httpResponse = response.data;
|
||||||
|
|
||||||
let fingerprintSource = ctx.data.httpResponse;
|
let fingerprintSource = data.httpResponse;
|
||||||
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
if (typeof fingerprintExpr === "string" && fingerprintExpr.length > 0) {
|
||||||
log.info(
|
log.info(
|
||||||
{ jsonata: fingerprintExpr },
|
{ jsonata: fingerprintExpr },
|
||||||
"detect-url-changes: evaluating fingerprint jsonata",
|
"detect-url-changes: evaluating fingerprint jsonata",
|
||||||
);
|
);
|
||||||
fingerprintSource = await jsonata(fingerprintExpr).evaluate(ctx);
|
fingerprintSource = await jsonata(fingerprintExpr).evaluate(evalCtx(ctx, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await $fingerprint.claim(key, fingerprintSource, {
|
const result = await $fingerprint.claim(key, fingerprintSource, {
|
||||||
maxAge: ctx.config?.maxAge,
|
maxAge: ctx.config?.maxAge,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.hasChanges = result.changed;
|
const extra = {
|
||||||
ctx.data.fingerprint = result.hash;
|
hasChanges: result.changed,
|
||||||
ctx.data.fingerprintChanged = result.changed;
|
fingerprint: result.hash,
|
||||||
ctx.data.fingerprintPrevious = result.previous;
|
fingerprintChanged: result.changed,
|
||||||
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
|
fingerprintPrevious: result.previous,
|
||||||
ctx.data.fingerprintAge = result.ageMs;
|
fingerprintAt: result.changed ? result.at : result.previousAt,
|
||||||
ctx.data.fingerprintExpired = result.expired;
|
fingerprintAge: result.ageMs,
|
||||||
|
fingerprintExpired: result.expired,
|
||||||
|
};
|
||||||
|
Object.assign(data, extra);
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
@@ -116,28 +140,35 @@ async function detectUrlChanges(ctx) {
|
|||||||
{ outputVar, jsonata: transformExpr },
|
{ outputVar, jsonata: transformExpr },
|
||||||
"detect-url-changes: evaluating transform jsonata",
|
"detect-url-changes: evaluating transform jsonata",
|
||||||
);
|
);
|
||||||
const transformed = await jsonata(transformExpr).evaluate(ctx);
|
const transformed = await jsonata(transformExpr).evaluate(evalCtx(ctx, data));
|
||||||
ctx.data[outputVar] = transformed;
|
data[outputVar] = transformed;
|
||||||
log.info(
|
log.info(
|
||||||
{ outputVar, value: previewValue(transformed) },
|
{ outputVar, value: previewValue(transformed) },
|
||||||
"detect-url-changes: saved transform result",
|
"detect-url-changes: saved transform result",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.data[outputVar] = ctx.data.httpResponse;
|
data[outputVar] = data.httpResponse;
|
||||||
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
log.info({ outputVar }, "detect-url-changes: saved raw response to outputVar");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {{ output: Record<string, unknown>, context: Record<string, unknown>, skipRemaining?: true }} */
|
||||||
|
const envelope = {
|
||||||
|
output: data,
|
||||||
|
context: { ...passContext(ctx), ...extra },
|
||||||
|
};
|
||||||
if (skipRemainingWhenUnchanged && !result.changed) {
|
if (skipRemainingWhenUnchanged && !result.changed) {
|
||||||
ctx.skipRemaining = true;
|
envelope.skipRemaining = true;
|
||||||
}
|
}
|
||||||
|
return envelope;
|
||||||
return ctx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
detectUrlChanges.meta = {
|
detectUrlChanges.meta = {
|
||||||
description:
|
description:
|
||||||
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
"Fetch a URL, fingerprint the response (or a JSONata-derived value), and report whether it changed since the last run",
|
||||||
|
previewConfigKey: "url",
|
||||||
|
tags: ["HTTP"],
|
||||||
|
reads: "ctx",
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "URL to fetch" },
|
url: { type: "string", required: true, description: "URL to fetch" },
|
||||||
method: {
|
method: {
|
||||||
@@ -203,6 +234,15 @@ detectUrlChanges.meta = {
|
|||||||
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
|
||||||
fingerprintExpired: { type: "boolean" },
|
fingerprintExpired: { type: "boolean" },
|
||||||
},
|
},
|
||||||
|
context: {
|
||||||
|
hasChanges: { type: "boolean" },
|
||||||
|
fingerprint: { type: "string" },
|
||||||
|
fingerprintChanged: { type: "boolean" },
|
||||||
|
fingerprintPrevious: { type: "string", required: false },
|
||||||
|
fingerprintAt: { type: "string", required: false },
|
||||||
|
fingerprintAge: { type: "number", required: false },
|
||||||
|
fingerprintExpired: { type: "boolean" },
|
||||||
|
},
|
||||||
example: {
|
example: {
|
||||||
data: {},
|
data: {},
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -51,21 +51,21 @@ async function freshUrl(pathname) {
|
|||||||
state.body = "<html><body>v1</body></html>";
|
state.body = "<html><body>v1</body></html>";
|
||||||
|
|
||||||
const first = await run({ url });
|
const first = await run({ url });
|
||||||
assert(first.data.hasChanges === true, "first run should report hasChanges=true");
|
assert(first.output.hasChanges === true, "first run should report hasChanges=true");
|
||||||
assert(
|
assert(
|
||||||
first.data.httpResponse === "<html><body>v1</body></html>",
|
first.output.httpResponse === "<html><body>v1</body></html>",
|
||||||
"httpResponse should hold the raw body",
|
"httpResponse should hold the raw body",
|
||||||
);
|
);
|
||||||
assert(typeof first.data.fingerprint === "string", "fingerprint hash should be set");
|
assert(typeof first.output.fingerprint === "string", "fingerprint hash should be set");
|
||||||
|
|
||||||
const second = await run({ url });
|
const second = await run({ url });
|
||||||
assert(second.data.hasChanges === false, "unchanged body should report hasChanges=false");
|
assert(second.output.hasChanges === false, "unchanged body should report hasChanges=false");
|
||||||
|
|
||||||
state.body = "<html><body>v2 CHANGED</body></html>";
|
state.body = "<html><body>v2 CHANGED</body></html>";
|
||||||
const third = await run({ url });
|
const third = await run({ url });
|
||||||
assert(third.data.hasChanges === true, "changed body should report hasChanges=true");
|
assert(third.output.hasChanges === true, "changed body should report hasChanges=true");
|
||||||
assert(
|
assert(
|
||||||
third.data.fingerprintPrevious === second.data.fingerprint,
|
third.output.fingerprintPrevious === second.output.fingerprint,
|
||||||
"fingerprintPrevious should equal the prior hash",
|
"fingerprintPrevious should equal the prior hash",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -77,20 +77,20 @@ async function freshUrl(pathname) {
|
|||||||
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
state.body = { version: "1.0.0", servedAt: "2020-01-01T00:00:00Z" };
|
||||||
|
|
||||||
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
const first = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||||
assert(first.data.hasChanges === true, "json first run should report a change");
|
assert(first.output.hasChanges === true, "json first run should report a change");
|
||||||
|
|
||||||
// Change only an unwatched field -> no change.
|
// Change only an unwatched field -> no change.
|
||||||
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
state.body = { version: "1.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||||
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
const second = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||||
assert(
|
assert(
|
||||||
second.data.hasChanges === false,
|
second.output.hasChanges === false,
|
||||||
"changing an unwatched field should not report a change",
|
"changing an unwatched field should not report a change",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Change the watched field -> change.
|
// Change the watched field -> change.
|
||||||
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
state.body = { version: "2.0.0", servedAt: "2020-06-01T00:00:00Z" };
|
||||||
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
const third = await run({ url, fingerprint: "data.httpResponse.version" });
|
||||||
assert(third.data.hasChanges === true, "changing the watched field should report a change");
|
assert(third.output.hasChanges === true, "changing the watched field should report a change");
|
||||||
|
|
||||||
state.contentType = "text/html; charset=utf-8";
|
state.contentType = "text/html; charset=utf-8";
|
||||||
}
|
}
|
||||||
@@ -106,13 +106,13 @@ async function freshUrl(pathname) {
|
|||||||
transform: '"changed=" & $string(data.hasChanges)',
|
transform: '"changed=" & $string(data.hasChanges)',
|
||||||
});
|
});
|
||||||
assert(
|
assert(
|
||||||
withTransform.data.message === "changed=true",
|
withTransform.output.message === "changed=true",
|
||||||
`transform should populate outputVar, got ${JSON.stringify(withTransform.data.message)}`,
|
`transform should populate outputVar, got ${JSON.stringify(withTransform.output.message)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
const rawOutput = await run({ url: await freshUrl("/output-raw"), outputVar: "payload" });
|
||||||
assert(
|
assert(
|
||||||
rawOutput.data.payload === rawOutput.data.httpResponse,
|
rawOutput.output.payload === rawOutput.output.httpResponse,
|
||||||
"outputVar without transform should store the raw response",
|
"outputVar without transform should store the raw response",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -131,11 +131,11 @@ async function freshUrl(pathname) {
|
|||||||
state.body = "<html><body>stable</body></html>";
|
state.body = "<html><body>stable</body></html>";
|
||||||
|
|
||||||
const first = await run({ url, skipRemaining: true });
|
const first = await run({ url, skipRemaining: true });
|
||||||
assert(first.data.hasChanges === true, "skip test first run should change");
|
assert(first.output.hasChanges === true, "skip test first run should change");
|
||||||
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
assert(first.skipRemaining !== true, "changed run must not set skipRemaining");
|
||||||
|
|
||||||
const second = await run({ url, skipRemaining: true });
|
const second = await run({ url, skipRemaining: true });
|
||||||
assert(second.data.hasChanges === false, "skip test second run should be unchanged");
|
assert(second.output.hasChanges === false, "skip test second run should be unchanged");
|
||||||
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
assert(second.skipRemaining === true, "unchanged run with skipRemaining should halt");
|
||||||
|
|
||||||
// Default (skipRemaining off) never halts, so downstream can still notify.
|
// Default (skipRemaining off) never halts, so downstream can still notify.
|
||||||
|
|||||||
@@ -136,7 +136,11 @@ export function NewWorkflowPresetDialog({ onChoose, onCancel }) {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-sm"
|
className="checkbox checkbox-sm"
|
||||||
checked={dontAsk}
|
checked={dontAsk}
|
||||||
onChange={(e) => setDontAsk(e.target.checked)}
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setDontAsk(checked);
|
||||||
|
if (checked) setMode("empty");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="label-text">Don't ask this. Always create empty workflow.</span>
|
<span className="label-text">Don't ask this. Always create empty workflow.</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user