Add an option to allow jsonpath/xpath to return as array (#297)

Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
Gregor Majcen
2025-11-13 14:57:11 +01:00
committed by GitHub
parent a4c4663011
commit 593a7ab7e5
34 changed files with 800 additions and 338 deletions

View File

@@ -1,44 +1,53 @@
import { DOMParser } from '@xmldom/xmldom';
import type { JSONPathResult } from '@yaak/template-function-json';
import { filterJSONPath } from '@yaak/template-function-json';
import type { XPathResult } from '@yaak/template-function-xml';
import { filterXPath } from '@yaak/template-function-xml';
import type {
CallTemplateFunctionArgs,
Context,
DynamicTemplateFunctionArg,
FormInput,
GetHttpAuthenticationConfigRequest,
HttpResponse,
PluginDefinition,
RenderPurpose,
} from '@yaakapp/api';
import type { DynamicTemplateFunctionArg } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin';
import { JSONPath } from 'jsonpath-plus';
import { readFileSync } from 'node:fs';
import xpath from 'xpath';
const BEHAVIOR_TTL = 'ttl';
const BEHAVIOR_ALWAYS = 'always';
const BEHAVIOR_SMART = 'smart';
const behaviorArg: FormInput = {
type: 'select',
name: 'behavior',
label: 'Sending Behavior',
defaultValue: 'smart',
options: [
{ label: 'When no responses', value: BEHAVIOR_SMART },
{ label: 'Always', value: BEHAVIOR_ALWAYS },
{ label: 'When expired', value: BEHAVIOR_TTL },
],
};
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
const ttlArg: DynamicTemplateFunctionArg = {
type: 'text',
name: 'ttl',
label: 'Expiration Time (seconds)',
placeholder: '0',
description: 'Resend the request when the latest response is older than this many seconds, or if there are no responses yet.',
dynamic(_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) {
const show = values.behavior === BEHAVIOR_TTL;
return { hidden: !show };
},
const behaviorArgs: DynamicTemplateFunctionArg = {
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'behavior',
label: 'Sending Behavior',
defaultValue: BEHAVIOR_SMART,
options: [
{ label: 'When no responses', value: BEHAVIOR_SMART },
{ label: 'Always', value: BEHAVIOR_ALWAYS },
{ label: 'When expired', value: BEHAVIOR_TTL },
],
},
{
type: 'text',
name: 'ttl',
label: 'TTL (seconds)',
placeholder: '0',
defaultValue: '0',
description:
'Resend the request when the latest response is older than this many seconds, or if there are no responses yet. "0" means never expires',
dynamic(_ctx, args) {
return { hidden: args.values.behavior !== BEHAVIOR_TTL };
},
},
],
};
const requestArg: FormInput = {
@@ -54,14 +63,13 @@ export const plugin: PluginDefinition = {
description: 'Read the value of a response header, by name',
args: [
requestArg,
behaviorArgs,
{
type: 'text',
name: 'header',
label: 'Header Name',
placeholder: 'Content-Type',
},
behaviorArg,
ttlArg,
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.header) return null;
@@ -86,14 +94,67 @@ export const plugin: PluginDefinition = {
aliases: ['response'],
args: [
requestArg,
behaviorArgs,
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'result',
label: 'Return Format',
defaultValue: RETURN_FIRST,
options: [
{ label: 'First result', value: RETURN_FIRST },
{ label: 'All results', value: RETURN_ALL },
{ label: 'Join with separator', value: RETURN_JOIN },
],
},
{
name: 'join',
type: 'text',
label: 'Separator',
optional: true,
defaultValue: ', ',
dynamic(_ctx, args) {
return { hidden: args.values.result !== RETURN_JOIN };
},
},
],
},
{
type: 'text',
name: 'path',
label: 'JSONPath or XPath',
placeholder: '$.books[0].id or /books[0]/id',
dynamic: async (ctx, args) => {
const resp = await getResponse(ctx, {
requestId: String(args.values.request || ''),
purpose: 'preview',
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
});
if (resp == null) {
return null;
}
const contentType =
resp?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) {
return {
label: 'XPath',
placeholder: '/books[0]/id',
description: 'Enter an XPath expression used to filter the results',
};
} else {
return {
label: 'JSONPath',
placeholder: '$.books[0].id',
description: 'Enter a JSONPath expression used to filter the results',
};
}
},
},
behaviorArg,
ttlArg,
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.path) return null;
@@ -118,13 +179,35 @@ export const plugin: PluginDefinition = {
}
try {
return filterJSONPath(body, String(args.values.path || ''));
const result: JSONPathResult =
args.values.result === RETURN_ALL
? 'all'
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
return filterJSONPath(
body,
String(args.values.path || ''),
result,
args.values.join == null ? null : String(args.values.join),
);
} catch {
// Probably not JSON, try XPath
}
try {
return filterXPath(body, String(args.values.path || ''));
const result: XPathResult =
args.values.result === RETURN_ALL
? 'all'
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
return filterXPath(
body,
String(args.values.path || ''),
result,
args.values.join == null ? null : String(args.values.join),
);
} catch {
// Probably not XML
}
@@ -136,7 +219,7 @@ export const plugin: PluginDefinition = {
name: 'response.body.raw',
description: 'Access the entire response body, as text',
aliases: ['response'],
args: [requestArg, behaviorArg, ttlArg],
args: [requestArg, behaviorArgs],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request) return null;
@@ -165,36 +248,6 @@ export const plugin: PluginDefinition = {
],
};
function filterJSONPath(body: string, path: string): string {
const parsed = JSON.parse(body);
const items = JSONPath({ path, json: parsed })[0];
if (items == null) {
return '';
}
if (
Object.prototype.toString.call(items) === '[object Array]' ||
Object.prototype.toString.call(items) === '[object Object]'
) {
return JSON.stringify(items);
} else {
return String(items);
}
}
function filterXPath(body: string, path: string): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const doc: any = new DOMParser().parseFromString(body, 'text/xml');
const items = xpath.select(path, doc, false);
if (Array.isArray(items)) {
return items[0] != null ? String(items[0].firstChild ?? '') : '';
} else {
// Not sure what cases this happens in (?)
return String(items);
}
}
async function getResponse(
ctx: Context,
{
@@ -244,8 +297,8 @@ async function getResponse(
function shouldSendExpired(response: HttpResponse | null, ttl: string | null): boolean {
if (response == null) return true;
const ttlSeconds = parseInt(ttl || '0');
if (isNaN(ttlSeconds)) throw new Error(`Invalid TTL "${ttl}"`);
const ttlSeconds = parseInt(ttl || '0') || 0;
if (ttlSeconds === 0) return false;
const nowMillis = Date.now();
const respMillis = new Date(response.createdAt + 'Z').getTime();
return respMillis + ttlSeconds * 1000 < nowMillis;