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

7
package-lock.json generated
View File

@@ -19146,12 +19146,7 @@
"name": "@yaak/template-function-response", "name": "@yaak/template-function-response",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@xmldom/xmldom": "^0.9.8", "@yaak/template-function-xml": "*"
"jsonpath-plus": "^10.3.0",
"xpath": "^0.0.34"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
} }
}, },
"plugins/template-function-timestamp": { "plugins/template-function-timestamp": {

View File

@@ -30,7 +30,7 @@ export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, }; export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonValue }, }; export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonPrimitive }, };
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, }; export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
@@ -74,7 +74,7 @@ export type FindHttpResponsesRequest = { requestId: string, limit?: number, };
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, }; export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown; export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "h_stack" } & FormInputHStack | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown;
export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, }; export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, };
@@ -224,6 +224,8 @@ defaultValue?: string, disabled?: boolean,
*/ */
description?: string, }; description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, };
export type FormInputHttpRequest = { export type FormInputHttpRequest = {
/** /**
* The name of the input. The value will be stored at this object attribute in the resulting data * The name of the input. The value will be stored at this object attribute in the resulting data

View File

@@ -3,22 +3,37 @@ import {
CallHttpAuthenticationRequest, CallHttpAuthenticationRequest,
CallHttpAuthenticationResponse, CallHttpAuthenticationResponse,
FormInput, FormInput,
GetHttpAuthenticationConfigRequest,
GetHttpAuthenticationSummaryResponse, GetHttpAuthenticationSummaryResponse,
HttpAuthenticationAction, HttpAuthenticationAction,
} from '../bindings/gen_events'; } from '../bindings/gen_events';
import { MaybePromise } from '../helpers'; import { MaybePromise } from '../helpers';
import { Context } from './Context'; import { Context } from './Context';
type DynamicFormInput = FormInput & { type AddDynamicMethod<T> = {
dynamic( dynamic?: (
ctx: Context, ctx: Context,
args: GetHttpAuthenticationConfigRequest, args: CallHttpAuthenticationActionArgs,
): MaybePromise<Partial<FormInput> | undefined | null>; ) => MaybePromise<Partial<T> | null | undefined>;
}; };
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallHttpAuthenticationActionArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicAuthenticationArg = AddDynamic<FormInput>;
export type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & { export type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & {
args: (FormInput | DynamicFormInput)[]; args: DynamicAuthenticationArg[];
onApply( onApply(
ctx: Context, ctx: Context,
args: CallHttpAuthenticationRequest, args: CallHttpAuthenticationRequest,

View File

@@ -1,21 +1,31 @@
import { import { CallTemplateFunctionArgs, FormInput, TemplateFunction } from '../bindings/gen_events';
CallTemplateFunctionArgs,
FormInput,
GetHttpAuthenticationConfigRequest,
TemplateFunction,
TemplateFunctionArg,
} from '../bindings/gen_events';
import { MaybePromise } from '../helpers'; import { MaybePromise } from '../helpers';
import { Context } from './Context'; import { Context } from './Context';
export type DynamicTemplateFunctionArg = FormInput & { type AddDynamicMethod<T> = {
dynamic( dynamic?: (
ctx: Context, ctx: Context,
args: GetHttpAuthenticationConfigRequest, args: CallTemplateFunctionArgs,
): MaybePromise<Partial<FormInput> | undefined | null>; ) => MaybePromise<Partial<T> | null | undefined>;
}; };
export type TemplateFunctionPlugin = TemplateFunction & { type AddDynamic<T> = T extends any
args: (TemplateFunctionArg | DynamicTemplateFunctionArg)[]; ? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallTemplateFunctionArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicTemplateFunctionArg = AddDynamic<FormInput>;
export type TemplateFunctionPlugin = Omit<TemplateFunction, 'args'> & {
args: DynamicTemplateFunctionArg[];
onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null>; onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null>;
}; };

View File

@@ -1,4 +1,6 @@
import { AuthenticationPlugin } from './AuthenticationPlugin'; import { AuthenticationPlugin } from './AuthenticationPlugin';
import type { Context } from './Context';
import type { FilterPlugin } from './FilterPlugin'; import type { FilterPlugin } from './FilterPlugin';
import { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin'; import { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin'; import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
@@ -6,9 +8,10 @@ import type { ImporterPlugin } from './ImporterPlugin';
import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin'; import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin';
import type { ThemePlugin } from './ThemePlugin'; import type { ThemePlugin } from './ThemePlugin';
import type { Context } from './Context';
export type { Context }; export type { Context };
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { DynamicAuthenticationArg } from './AuthenticationPlugin';
export type { TemplateFunctionPlugin };
/** /**
* The global structure of a Yaak plugin * The global structure of a Yaak plugin

View File

@@ -2,7 +2,6 @@ import {
BootRequest, BootRequest,
DeleteKeyValueResponse, DeleteKeyValueResponse,
FindHttpResponsesResponse, FindHttpResponsesResponse,
FormInput,
GetCookieValueRequest, GetCookieValueRequest,
GetCookieValueResponse, GetCookieValueResponse,
GetHttpRequestByIdResponse, GetHttpRequestByIdResponse,
@@ -19,14 +18,13 @@ import {
RenderHttpRequestResponse, RenderHttpRequestResponse,
SendHttpRequestResponse, SendHttpRequestResponse,
TemplateFunction, TemplateFunction,
TemplateFunctionArg,
TemplateRenderResponse, TemplateRenderResponse,
} from '@yaakapp-internal/plugins'; } from '@yaakapp-internal/plugins';
import { Context, PluginDefinition } from '@yaakapp/api'; import { Context, PluginDefinition } from '@yaakapp/api';
import { JsonValue } from '@yaakapp/api/lib/bindings/serde_json/JsonValue';
import console from 'node:console'; import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs'; import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { applyDynamicFormInput, applyFormInputDefaults } from './common';
import { EventChannel } from './EventChannel'; import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations'; import { migrateTemplateFunctionSelectOptions } from './migrations';
@@ -213,24 +211,19 @@ export class PluginInstance {
return; return;
} }
templateFunction = migrateTemplateFunctionSelectOptions(templateFunction); const fn = {
// @ts-ignore ...migrateTemplateFunctionSelectOptions(templateFunction),
delete templateFunction.onRender; onRender: undefined,
const resolvedArgs: TemplateFunctionArg[] = []; };
for (const arg of templateFunction.args) {
if (arg && 'dynamic' in arg) { payload.values = applyFormInputDefaults(fn.args, payload.values);
const dynamicAttrs = await arg.dynamic(ctx, payload); const p = { ...payload, purpose: 'preview' } as const;
const { dynamic, ...other } = arg; const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, p);
resolvedArgs.push({ ...other, ...dynamicAttrs } as TemplateFunctionArg);
} else if (arg) {
resolvedArgs.push(arg);
}
templateFunction.args = resolvedArgs;
}
const replyPayload: InternalEventPayload = { const replyPayload: InternalEventPayload = {
type: 'get_template_function_config_response', type: 'get_template_function_config_response',
pluginRefId: this.#workerData.pluginRefId, pluginRefId: this.#workerData.pluginRefId,
function: templateFunction, function: { ...fn, args: resolvedArgs },
}; };
this.#sendPayload(context, replyPayload, replyId); this.#sendPayload(context, replyPayload, replyId);
return; return;
@@ -248,16 +241,8 @@ export class PluginInstance {
if (payload.type === 'get_http_authentication_config_request' && this.#mod?.authentication) { if (payload.type === 'get_http_authentication_config_request' && this.#mod?.authentication) {
const { args, actions } = this.#mod.authentication; const { args, actions } = this.#mod.authentication;
const resolvedArgs: FormInput[] = []; payload.values = applyFormInputDefaults(args, payload.values);
for (const v of args) { const resolvedArgs = await applyDynamicFormInput(ctx, args, payload);
if (v && 'dynamic' in v) {
const dynamicAttrs = await v.dynamic(ctx, payload);
const { dynamic, ...other } = v;
resolvedArgs.push({ ...other, ...dynamicAttrs } as FormInput);
} else if (v) {
resolvedArgs.push(v);
}
}
const resolvedActions: HttpAuthenticationAction[] = []; const resolvedActions: HttpAuthenticationAction[] = [];
for (const { onSelect, ...action } of actions ?? []) { for (const { onSelect, ...action } of actions ?? []) {
resolvedActions.push(action); resolvedActions.push(action);
@@ -277,7 +262,8 @@ export class PluginInstance {
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) { if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
const auth = this.#mod.authentication; const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') { if (typeof auth?.onApply === 'function') {
applyFormInputDefaults(auth.args, payload.values); auth.args = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(auth.args, payload.values);
this.#sendPayload( this.#sendPayload(
context, context,
{ {
@@ -332,7 +318,8 @@ export class PluginInstance {
) { ) {
const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name); const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name);
if (typeof fn?.onRender === 'function') { if (typeof fn?.onRender === 'function') {
applyFormInputDefaults(fn.args, payload.args.values); const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, payload.args);
payload.args.values = applyFormInputDefaults(resolvedArgs, payload.args.values);
try { try {
const result = await fn.onRender(ctx, payload.args); const result = await fn.onRender(ctx, payload.args);
this.#sendPayload( this.#sendPayload(
@@ -652,20 +639,6 @@ function genId(len = 5): string {
return id; return id;
} }
/** Recursively apply form input defaults to a set of values */
function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonValue | undefined },
) {
for (const input of inputs) {
if ('inputs' in input) {
applyFormInputDefaults(input.inputs ?? [], values);
} else if ('defaultValue' in input && values[input.name] === undefined) {
values[input.name] = input.defaultValue;
}
}
}
const watchedFiles: Record<string, Stats | null> = {}; const watchedFiles: Record<string, Stats | null> = {};
/** /**

View File

@@ -0,0 +1,56 @@
import {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
JsonPrimitive,
TemplateFunctionArg,
} from '@yaakapp-internal/plugins';
import { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
/** Recursively apply form input defaults to a set of values */
export function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonPrimitive | undefined },
) {
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
for (const input of inputs) {
if ('defaultValue' in input && values[input.name] === undefined) {
newValues[input.name] = input.defaultValue;
}
// Recurse down to all child inputs
if ('inputs' in input) {
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
}
}
return newValues;
}
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicTemplateFunctionArg[],
callArgs: CallTemplateFunctionArgs,
): Promise<DynamicTemplateFunctionArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicAuthenticationArg[],
callArgs: CallHttpAuthenticationActionArgs,
): Promise<DynamicAuthenticationArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[],
callArgs: CallTemplateFunctionArgs | CallHttpAuthenticationActionArgs,
): Promise<(DynamicTemplateFunctionArg | DynamicAuthenticationArg)[]> {
const resolvedArgs: any[] = [];
for (const { dynamic, ...arg } of args) {
const newArg: any = {
...arg,
...(typeof dynamic === 'function' ? await dynamic(ctx, callArgs as any) : undefined),
};
if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
newArg.inputs = await applyDynamicFormInput(ctx, newArg.inputs, callArgs as any);
}
resolvedArgs.push(newArg);
}
return resolvedArgs;
}

View File

@@ -1,4 +1,4 @@
import { TemplateFunctionPlugin } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin'; import type { TemplateFunctionPlugin } from '@yaakapp/api';
export function migrateTemplateFunctionSelectOptions( export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin, f: TemplateFunctionPlugin,
@@ -13,8 +13,5 @@ export function migrateTemplateFunctionSelectOptions(
return a; return a;
}); });
return { return { ...f, args: migratedArgs };
...f,
args: migratedArgs,
};
} }

View File

@@ -0,0 +1,150 @@
import { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import { Context, DynamicTemplateFunctionArg } from '@yaakapp/api';
import { describe, expect, test } from 'vitest';
import { applyDynamicFormInput, applyFormInputDefaults } from '../src/common';
describe('applyFormInputDefaults', () => {
test('Works with top-level select', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
});
test('Works with existing value', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
];
expect(applyFormInputDefaults(args, { test: 'explicit' })).toEqual({
test: 'explicit',
});
});
test('Works with recursive select', () => {
const args: DynamicTemplateFunctionArg[] = [
{ type: 'text', name: 'dummy', defaultValue: 'top' },
{
type: 'accordion',
label: 'Test',
inputs: [
{ type: 'text', name: 'name', defaultValue: 'hello' },
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
],
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
dummy: 'top',
test: 'one',
name: 'hello',
});
});
test('Works with dynamic options', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
defaultValue: 'one',
options: [],
dynamic() {
return { options: [{ label: 'Option 1', value: 'one' }] };
},
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
});
});
describe('applyDynamicFormInput', () => {
test('Works with plain input', async () => {
const ctx = {} as Context;
const args: DynamicTemplateFunctionArg[] = [
{ type: 'text', name: 'name' },
{ type: 'checkbox', name: 'checked' },
];
const callArgs: CallTemplateFunctionArgs = {
values: {},
purpose: 'preview',
};
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{ type: 'text', name: 'name' },
{ type: 'checkbox', name: 'checked' },
]);
});
test('Works with dynamic input', async () => {
const ctx = {} as Context;
const args: DynamicTemplateFunctionArg[] = [
{
type: 'text',
name: 'name',
async dynamic(_ctx, _args) {
return { hidden: true };
},
},
];
const callArgs: CallTemplateFunctionArgs = {
values: {},
purpose: 'preview',
};
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{ type: 'text', name: 'name', hidden: true },
]);
});
test('Works with recursive dynamic input', async () => {
const ctx = {} as Context;
const callArgs: CallTemplateFunctionArgs = {
values: { hello: 'world' },
purpose: 'preview',
};
const args: DynamicTemplateFunctionArg[] = [
{
type: 'banner',
inputs: [
{
type: 'text',
name: 'name',
async dynamic(_ctx, args) {
return { hidden: args.values.hello === 'world' };
},
},
],
},
];
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{
type: 'banner',
inputs: [
{
type: 'text',
name: 'name',
hidden: true,
},
],
},
]);
});
});

View File

@@ -57,10 +57,6 @@ export const plugin: PluginDefinition = {
} }
} }
if (args.method !== 'GET') {
headers['x-amz-content-sha256'] = 'UNSIGNED-PAYLOAD';
}
const signature = aws4.sign( const signature = aws4.sign(
{ {
host: url.host, host: url.host,
@@ -68,6 +64,7 @@ export const plugin: PluginDefinition = {
path: url.pathname + (url.search || ''), path: url.pathname + (url.search || ''),
service: String(values.service || 'sts'), service: String(values.service || 'sts'),
region: values.region ? String(values.region) : undefined, region: values.region ? String(values.region) : undefined,
body: values.body ? String(values.body) : undefined,
headers, headers,
}, },
{ {
@@ -77,11 +74,6 @@ export const plugin: PluginDefinition = {
}, },
); );
// After signing, aws4 will set:
// - opts.headers["Authorization"]
// - opts.headers["X-Amz-Date"]
// - optionally content sha256 header etc
if (signature.headers == null) { if (signature.headers == null) {
return {}; return {};
} }

View File

@@ -288,6 +288,7 @@ export const plugin: PluginDefinition = {
{ {
type: 'accordion', type: 'accordion',
label: 'Access Token Response', label: 'Access Token Response',
inputs: [],
async dynamic(ctx, { contextId, values }) { async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = { const tokenArgs: TokenStoreArgs = {
contextId, contextId,
@@ -304,6 +305,7 @@ export const plugin: PluginDefinition = {
inputs: [ inputs: [
{ {
type: 'editor', type: 'editor',
name: 'response',
defaultValue: JSON.stringify(token.response, null, 2), defaultValue: JSON.stringify(token.response, null, 2),
hideLabel: true, hideLabel: true,
readOnly: true, readOnly: true,

View File

@@ -1,9 +1,10 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api'; import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import fs from 'node:fs'; import fs from 'node:fs';
const UTF8 = 'utf8';
const options = [ const options = [
{ label: 'ASCII', value: 'ascii' }, { label: 'ASCII', value: 'ascii' },
{ label: 'UTF-8', value: 'utf8' }, { label: 'UTF-8', value: UTF8 },
{ label: 'UTF-16 LE', value: 'utf16le' }, { label: 'UTF-16 LE', value: 'utf16le' },
{ label: 'Base64', value: 'base64' }, { label: 'Base64', value: 'base64' },
{ label: 'Base64 URL-safe', value: 'base64url' }, { label: 'Base64 URL-safe', value: 'base64url' },
@@ -18,12 +19,11 @@ export const plugin: PluginDefinition = {
args: [ args: [
{ title: 'Select File', type: 'file', name: 'path', label: 'File' }, { title: 'Select File', type: 'file', name: 'path', label: 'File' },
{ {
title: 'Select encoding',
type: 'select', type: 'select',
name: 'encoding', name: 'encoding',
label: 'Encoding', label: 'Encoding',
defaultValue: 'utf8', defaultValue: UTF8,
description: 'Specifies how the files bytes are decoded into text when read', description: "Specifies how the file's bytes are decoded into text when read",
options, options,
}, },
], ],

View File

@@ -4,6 +4,7 @@
"description": "Template functions for working with JSON data", "description": "Template functions for working with JSON data",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"main": "src/index.ts",
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",

View File

@@ -1,6 +1,11 @@
import type { XPathResult } from '@yaak/template-function-xml';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api'; import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import { JSONPath } from 'jsonpath-plus'; import { JSONPath } from 'jsonpath-plus';
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
export const plugin: PluginDefinition = { export const plugin: PluginDefinition = {
templateFunctions: [ templateFunctions: [
{ {
@@ -8,32 +13,59 @@ export const plugin: PluginDefinition = {
description: 'Filter JSON-formatted text using JSONPath syntax', description: 'Filter JSON-formatted text using JSONPath syntax',
args: [ args: [
{ {
type: 'text', type: 'editor',
name: 'input', name: 'input',
label: 'Input', label: 'Input',
multiLine: true, language: 'json',
placeholder: '{ "foo": "bar" }', placeholder: '{ "foo": "bar" }',
}, },
{
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: 'checkbox',
name: 'formatted',
label: 'Pretty Print',
description: 'Format the output as JSON',
dynamic(_ctx, args) {
return { hidden: args.values.result === RETURN_JOIN };
},
},
{ type: 'text', name: 'query', label: 'Query', placeholder: '$..foo' }, { type: 'text', name: 'query', label: 'Query', placeholder: '$..foo' },
{ type: 'checkbox', name: 'formatted', label: 'Format Output' },
], ],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
try { try {
const parsed = JSON.parse(String(args.values.input)); console.log('formatted', args.values.formatted);
const query = String(args.values.query ?? '$').trim(); return filterJSONPath(
let filtered = JSONPath({ path: query, json: parsed }); String(args.values.input),
if (Array.isArray(filtered)) { String(args.values.query),
filtered = filtered[0]; (args.values.result || RETURN_FIRST) as XPathResult,
} args.values.join == null ? null : String(args.values.join),
if (typeof filtered === 'string') { Boolean(args.values.formatted),
return filtered; );
}
if (args.values.formatted) {
return JSON.stringify(filtered, null, 2);
} else {
return JSON.stringify(filtered);
}
} catch { } catch {
return null; return null;
} }
@@ -79,3 +111,41 @@ export const plugin: PluginDefinition = {
}, },
], ],
}; };
export type JSONPathResult = 'first' | 'join' | 'all';
export function filterJSONPath(
body: string,
path: string,
result: JSONPathResult,
join: string | null,
formatted: boolean = false,
): string {
const parsed = JSON.parse(body);
let items = JSONPath({ path, json: parsed });
if (items == null) {
return '';
}
if (!Array.isArray(items)) {
// Already good
} else if (result === 'first') {
items = items[0] ?? '';
} else if (result === 'join') {
items = items.map((i) => objToStr(i, false)).join(join ?? '');
}
return objToStr(items, formatted);
}
function objToStr(o: unknown, formatted: boolean = false): string {
if (
Object.prototype.toString.call(o) === '[object Array]' ||
Object.prototype.toString.call(o) === '[object Object]'
) {
return formatted ? JSON.stringify(o, null, 2) : JSON.stringify(o);
} else {
return String(o);
}
}

View File

@@ -10,11 +10,6 @@
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx" "lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}, },
"dependencies": { "dependencies": {
"jsonpath-plus": "^10.3.0", "@yaak/template-function-xml": "*"
"xpath": "^0.0.34",
"@xmldom/xmldom": "^0.9.8"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
} }
} }

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 { import type {
CallTemplateFunctionArgs, CallTemplateFunctionArgs,
Context, Context,
DynamicTemplateFunctionArg,
FormInput, FormInput,
GetHttpAuthenticationConfigRequest,
HttpResponse, HttpResponse,
PluginDefinition, PluginDefinition,
RenderPurpose, RenderPurpose,
} from '@yaakapp/api'; } from '@yaakapp/api';
import type { DynamicTemplateFunctionArg } from '@yaakapp/api/lib/plugins/TemplateFunctionPlugin';
import { JSONPath } from 'jsonpath-plus';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import xpath from 'xpath';
const BEHAVIOR_TTL = 'ttl'; const BEHAVIOR_TTL = 'ttl';
const BEHAVIOR_ALWAYS = 'always'; const BEHAVIOR_ALWAYS = 'always';
const BEHAVIOR_SMART = 'smart'; const BEHAVIOR_SMART = 'smart';
const behaviorArg: FormInput = { const RETURN_FIRST = 'first';
type: 'select', const RETURN_ALL = 'all';
name: 'behavior', const RETURN_JOIN = 'join';
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 ttlArg: DynamicTemplateFunctionArg = { const behaviorArgs: DynamicTemplateFunctionArg = {
type: 'text', type: 'h_stack',
name: 'ttl', inputs: [
label: 'Expiration Time (seconds)', {
placeholder: '0', type: 'select',
description: 'Resend the request when the latest response is older than this many seconds, or if there are no responses yet.', name: 'behavior',
dynamic(_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) { label: 'Sending Behavior',
const show = values.behavior === BEHAVIOR_TTL; defaultValue: BEHAVIOR_SMART,
return { hidden: !show }; 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 = { const requestArg: FormInput = {
@@ -54,14 +63,13 @@ export const plugin: PluginDefinition = {
description: 'Read the value of a response header, by name', description: 'Read the value of a response header, by name',
args: [ args: [
requestArg, requestArg,
behaviorArgs,
{ {
type: 'text', type: 'text',
name: 'header', name: 'header',
label: 'Header Name', label: 'Header Name',
placeholder: 'Content-Type', placeholder: 'Content-Type',
}, },
behaviorArg,
ttlArg,
], ],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.header) return null; if (!args.values.request || !args.values.header) return null;
@@ -86,14 +94,67 @@ export const plugin: PluginDefinition = {
aliases: ['response'], aliases: ['response'],
args: [ args: [
requestArg, 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', type: 'text',
name: 'path', name: 'path',
label: 'JSONPath or XPath', label: 'JSONPath or XPath',
placeholder: '$.books[0].id or /books[0]/id', 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> { async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request || !args.values.path) return null; if (!args.values.request || !args.values.path) return null;
@@ -118,13 +179,35 @@ export const plugin: PluginDefinition = {
} }
try { 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 { } catch {
// Probably not JSON, try XPath // Probably not JSON, try XPath
} }
try { 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 { } catch {
// Probably not XML // Probably not XML
} }
@@ -136,7 +219,7 @@ export const plugin: PluginDefinition = {
name: 'response.body.raw', name: 'response.body.raw',
description: 'Access the entire response body, as text', description: 'Access the entire response body, as text',
aliases: ['response'], aliases: ['response'],
args: [requestArg, behaviorArg, ttlArg], args: [requestArg, behaviorArgs],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request) return 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( async function getResponse(
ctx: Context, ctx: Context,
{ {
@@ -244,8 +297,8 @@ async function getResponse(
function shouldSendExpired(response: HttpResponse | null, ttl: string | null): boolean { function shouldSendExpired(response: HttpResponse | null, ttl: string | null): boolean {
if (response == null) return true; if (response == null) return true;
const ttlSeconds = parseInt(ttl || '0'); const ttlSeconds = parseInt(ttl || '0') || 0;
if (isNaN(ttlSeconds)) throw new Error(`Invalid TTL "${ttl}"`); if (ttlSeconds === 0) return false;
const nowMillis = Date.now(); const nowMillis = Date.now();
const respMillis = new Date(response.createdAt + 'Z').getTime(); const respMillis = new Date(response.createdAt + 'Z').getTime();
return respMillis + ttlSeconds * 1000 < nowMillis; return respMillis + ttlSeconds * 1000 < nowMillis;

View File

@@ -4,6 +4,7 @@
"description": "Template functions for working with XML data", "description": "Template functions for working with XML data",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"main": "src/index.ts",
"scripts": { "scripts": {
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",

View File

@@ -2,6 +2,10 @@ import { DOMParser } from '@xmldom/xmldom';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api'; import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import xpath from 'xpath'; import xpath from 'xpath';
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
export const plugin: PluginDefinition = { export const plugin: PluginDefinition = {
templateFunctions: [ templateFunctions: [
{ {
@@ -15,20 +19,39 @@ export const plugin: PluginDefinition = {
multiLine: true, multiLine: true,
placeholder: '<foo></foo>', placeholder: '<foo></foo>',
}, },
{
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: 'query', label: 'Query', placeholder: '//foo' }, { type: 'text', name: 'query', label: 'Query', placeholder: '//foo' },
], ],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const result = (args.values.result || RETURN_FIRST) as XPathResult;
const doc: any = new DOMParser().parseFromString(String(args.values.input), 'text/xml'); const join = args.values.join == null ? null : String(args.values.join);
const result = xpath.select(String(args.values.query), doc, false); return filterXPath(String(args.values.input), String(args.values.query), result, join);
if (Array.isArray(result)) {
return String(result.map((c) => String(c.firstChild))[0] ?? '');
} else if (result instanceof Node) {
return String(result.firstChild);
} else {
return String(result);
}
} catch { } catch {
return null; return null;
} }
@@ -36,3 +59,26 @@ export const plugin: PluginDefinition = {
}, },
], ],
}; };
export type XPathResult = 'first' | 'join' | 'all';
export function filterXPath(
body: string,
path: string,
result: XPathResult,
join: string | null,
): 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 String(items);
} else if (!Array.isArray(items) || result === 'first') {
return items[0] != null ? String(items[0].firstChild ?? '') : '';
} else if (result === 'join') {
return items.map((item) => String(item.firstChild ?? '')).join(join ?? '');
} else {
// Not sure what cases this happens in (?)
return String(items);
}
}

View File

@@ -32,7 +32,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
event: &InternalEvent, event: &InternalEvent,
plugin_handle: &PluginHandle, plugin_handle: &PluginHandle,
) -> Result<Option<InternalEventPayload>> { ) -> Result<Option<InternalEventPayload>> {
// debug!("Got event to app {event:?}"); // log::debug!("Got event to app {event:?}");
let plugin_context = event.context.to_owned(); let plugin_context = event.context.to_owned();
match event.clone().payload { match event.clone().payload {
InternalEventPayload::CopyTextRequest(req) => { InternalEventPayload::CopyTextRequest(req) => {

View File

@@ -3,11 +3,11 @@ use crate::error::Error::ModelNotFound;
use crate::error::Result; use crate::error::Result;
use crate::models::{AnyModel, UpsertModelInfo}; use crate::models::{AnyModel, UpsertModelInfo};
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource}; use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
use log::{error, warn}; use log::error;
use rusqlite::OptionalExtension; use rusqlite::OptionalExtension;
use sea_query::{ use sea_query::{
Alias, Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, SimpleExpr,
ReturningClause, SimpleExpr, SqliteQueryBuilder, SqliteQueryBuilder,
}; };
use sea_query_rusqlite::RusqliteBinder; use sea_query_rusqlite::RusqliteBinder;
use std::fmt::Debug; use std::fmt::Debug;

View File

@@ -1,7 +1,6 @@
use crate::commands::*; use crate::commands::*;
use crate::migrate::migrate_db; use crate::migrate::migrate_db;
use crate::query_manager::QueryManager; use crate::query_manager::QueryManager;
use crate::util::ModelChangeEvent;
use log::error; use log::error;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;

View File

@@ -30,7 +30,7 @@ export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, }; export type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };
export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonValue }, }; export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonPrimitive }, };
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, }; export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
@@ -74,7 +74,7 @@ export type FindHttpResponsesRequest = { requestId: string, limit?: number, };
export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, }; export type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };
export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown; export type FormInput = { "type": "text" } & FormInputText | { "type": "editor" } & FormInputEditor | { "type": "select" } & FormInputSelect | { "type": "checkbox" } & FormInputCheckbox | { "type": "file" } & FormInputFile | { "type": "http_request" } & FormInputHttpRequest | { "type": "accordion" } & FormInputAccordion | { "type": "h_stack" } & FormInputHStack | { "type": "banner" } & FormInputBanner | { "type": "markdown" } & FormInputMarkdown;
export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, }; export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, };
@@ -224,6 +224,8 @@ defaultValue?: string, disabled?: boolean,
*/ */
description?: string, }; description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, };
export type FormInputHttpRequest = { export type FormInputHttpRequest = {
/** /**
* The name of the input. The value will be stored at this object attribute in the resulting data * The name of the input. The value will be stored at this object attribute in the resulting data

View File

@@ -658,6 +658,18 @@ pub enum JsonPrimitive {
Null, Null,
} }
impl From<serde_json::Value> for JsonPrimitive {
fn from(value: serde_json::Value) -> Self {
match value {
serde_json::Value::Null => JsonPrimitive::Null,
serde_json::Value::Bool(b) => JsonPrimitive::Boolean(b),
serde_json::Value::Number(n) => JsonPrimitive::Number(n.as_f64().unwrap()),
serde_json::Value::String(s) => JsonPrimitive::String(s),
v => panic!("Unsupported JSON primitive type {:?}", v),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")] #[ts(export, export_to = "gen_events.ts")]
@@ -733,6 +745,7 @@ pub enum FormInput {
File(FormInputFile), File(FormInputFile),
HttpRequest(FormInputHttpRequest), HttpRequest(FormInputHttpRequest),
Accordion(FormInputAccordion), Accordion(FormInputAccordion),
HStack(FormInputHStack),
Banner(FormInputBanner), Banner(FormInputBanner),
Markdown(FormInputMarkdown), Markdown(FormInputMarkdown),
} }
@@ -895,7 +908,7 @@ pub struct FormInputFile {
#[ts(optional)] #[ts(optional)]
pub directory: Option<bool>, pub directory: Option<bool>,
// Default file path for selection dialog // Default file path for the selection dialog
#[ts(optional)] #[ts(optional)]
pub default_path: Option<String>, pub default_path: Option<String>,
@@ -953,6 +966,14 @@ pub struct FormInputAccordion {
pub hidden: Option<bool>, pub hidden: Option<bool>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct FormInputHStack {
#[ts(optional)]
pub inputs: Option<Vec<FormInput>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")] #[ts(export, export_to = "gen_events.ts")]
@@ -1015,7 +1036,7 @@ pub struct CallTemplateFunctionResponse {
#[ts(export, export_to = "gen_events.ts")] #[ts(export, export_to = "gen_events.ts")]
pub struct CallTemplateFunctionArgs { pub struct CallTemplateFunctionArgs {
pub purpose: RenderPurpose, pub purpose: RenderPurpose,
pub values: HashMap<String, serde_json::Value>, pub values: HashMap<String, JsonPrimitive>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]

View File

@@ -786,7 +786,7 @@ impl PluginManager {
&self, &self,
plugin_context: &PluginContext, plugin_context: &PluginContext,
fn_name: &str, fn_name: &str,
values: HashMap<String, serde_json::Value>, values: HashMap<String, JsonPrimitive>,
purpose: RenderPurpose, purpose: RenderPurpose,
) -> TemplateResult<String> { ) -> TemplateResult<String> {
let req = CallTemplateFunctionRequest { let req = CallTemplateFunctionRequest {

View File

@@ -1,4 +1,4 @@
use crate::events::{PluginContext, RenderPurpose}; use crate::events::{JsonPrimitive, PluginContext, RenderPurpose};
use crate::manager::PluginManager; use crate::manager::PluginManager;
use crate::native_template_functions::{ use crate::native_template_functions::{
template_function_keychain_run, template_function_secure_run, template_function_keychain_run, template_function_secure_run,
@@ -42,12 +42,17 @@ impl<R: Runtime> TemplateCallback for PluginTemplateCallback<R> {
return template_function_keychain_run(args); return template_function_keychain_run(args);
} }
let mut primitive_args = HashMap::new();
for (key, value) in args {
primitive_args.insert(key, JsonPrimitive::from(value));
}
let plugin_manager = &*self.app_handle.state::<PluginManager>(); let plugin_manager = &*self.app_handle.state::<PluginManager>();
let resp = plugin_manager let resp = plugin_manager
.call_template_function( .call_template_function(
&self.plugin_context, &self.plugin_context,
fn_name, fn_name,
args, primitive_args,
self.render_purpose.to_owned(), self.render_purpose.to_owned(),
) )
.await?; .await?;

View File

@@ -26,7 +26,7 @@ import { IconButton } from './core/IconButton';
import { Input } from './core/Input'; import { Input } from './core/Input';
import { Label } from './core/Label'; import { Label } from './core/Label';
import { Select } from './core/Select'; import { Select } from './core/Select';
import { VStack } from './core/Stacks'; import { HStack, VStack } from './core/Stacks';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { SelectFile } from './SelectFile'; import { SelectFile } from './SelectFile';
@@ -41,6 +41,7 @@ interface Props<T> {
autocompleteFunctions?: boolean; autocompleteFunctions?: boolean;
autocompleteVariables?: boolean; autocompleteVariables?: boolean;
stateKey: string; stateKey: string;
className?: string;
disabled?: boolean; disabled?: boolean;
} }
@@ -51,6 +52,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteVariables, autocompleteVariables,
autocompleteFunctions, autocompleteFunctions,
stateKey, stateKey,
className,
disabled, disabled,
}: Props<T>) { }: Props<T>) {
const setDataAttr = useCallback( const setDataAttr = useCallback(
@@ -61,7 +63,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
); );
return ( return (
<FormInputs <FormInputsStack
disabled={disabled} disabled={disabled}
inputs={inputs} inputs={inputs}
setDataAttr={setDataAttr} setDataAttr={setDataAttr}
@@ -69,28 +71,15 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
autocompleteFunctions={autocompleteFunctions} autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables} autocompleteVariables={autocompleteVariables}
data={data} data={data}
className="pb-4" // Pad the bottom to look nice className={classNames(className, 'pb-4')} // Pad the bottom to look nice
/> />
); );
} }
function FormInputs<T extends Record<string, JsonPrimitive>>({ function FormInputsStack<T extends Record<string, JsonPrimitive>>({
inputs,
autocompleteFunctions,
autocompleteVariables,
stateKey,
setDataAttr,
data,
disabled,
className, className,
}: Pick< ...props
Props<T>, }: FormInputsProps<T> & { className?: string }) {
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
className?: string;
}) {
return ( return (
<VStack <VStack
space={3} space={3}
@@ -100,6 +89,30 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
'pr-1', // A bit of space between inputs and scrollbar 'pr-1', // A bit of space between inputs and scrollbar
)} )}
> >
<FormInputs {...props} />
</VStack>
);
}
type FormInputsProps<T> = Pick<
Props<T>,
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
};
function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs,
autocompleteFunctions,
autocompleteVariables,
stateKey,
setDataAttr,
data,
disabled,
}: FormInputsProps<T>) {
return (
<>
{inputs?.map((input, i) => { {inputs?.map((input, i) => {
if ('hidden' in input && input.hidden) { if ('hidden' in input && input.hidden) {
return null; return null;
@@ -113,7 +126,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
case 'select': case 'select':
return ( return (
<SelectArg <SelectArg
key={i + stateKey} key={i}
arg={input} arg={input}
onChange={(v) => setDataAttr(input.name, v)} onChange={(v) => setDataAttr(input.name, v)}
value={ value={
@@ -126,7 +139,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
case 'text': case 'text':
return ( return (
<TextArg <TextArg
key={i} key={i + stateKey}
stateKey={stateKey} stateKey={stateKey}
arg={input} arg={input}
autocompleteFunctions={autocompleteFunctions || false} autocompleteFunctions={autocompleteFunctions || false}
@@ -140,7 +153,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
case 'editor': case 'editor':
return ( return (
<EditorArg <EditorArg
key={i} key={i + stateKey}
stateKey={stateKey} stateKey={stateKey}
arg={input} arg={input}
autocompleteFunctions={autocompleteFunctions || false} autocompleteFunctions={autocompleteFunctions || false}
@@ -182,13 +195,13 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
); );
case 'accordion': case 'accordion':
return ( return (
<div key={i}> <div key={i + stateKey}>
<DetailsBanner <DetailsBanner
summary={input.label} summary={input.label}
className={classNames('!mb-auto', disabled && 'opacity-disabled')} className={classNames('!mb-auto', disabled && 'opacity-disabled')}
> >
<div className="my-3"> <div className="my-3">
<FormInputs <FormInputsStack
data={data} data={data}
disabled={disabled} disabled={disabled}
inputs={input.inputs} inputs={input.inputs}
@@ -201,14 +214,28 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</DetailsBanner> </DetailsBanner>
</div> </div>
); );
case 'h_stack':
return (
<HStack key={i + stateKey} alignItems="end" space={3}>
<FormInputs
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</HStack>
);
case 'banner': case 'banner':
return ( return (
<Banner <Banner
key={i} key={i + stateKey}
color={input.color} color={input.color}
className={classNames(disabled && 'opacity-disabled')} className={classNames(disabled && 'opacity-disabled')}
> >
<FormInputs <FormInputsStack
data={data} data={data}
disabled={disabled} disabled={disabled}
inputs={input.inputs} inputs={input.inputs}
@@ -220,10 +247,10 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</Banner> </Banner>
); );
case 'markdown': case 'markdown':
return <Markdown>{input.content}</Markdown>; return <Markdown key={i + stateKey}>{input.content}</Markdown>;
} }
})} })}
</VStack> </>
); );
} }
@@ -255,7 +282,7 @@ function TextArg({
type={arg.password ? 'password' : 'text'} type={arg.password ? 'password' : 'text'}
label={arg.label ?? arg.name} label={arg.label ?? arg.name}
size={INPUT_SIZE} size={INPUT_SIZE}
hideLabel={arg.label == null} hideLabel={arg.hideLabel ?? arg.label == null}
placeholder={arg.placeholder ?? undefined} placeholder={arg.placeholder ?? undefined}
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined} autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
autocompleteFunctions={autocompleteFunctions} autocompleteFunctions={autocompleteFunctions}
@@ -313,7 +340,9 @@ function EditorArg({
language={arg.language} language={arg.language}
readOnly={arg.readOnly} readOnly={arg.readOnly}
onChange={onChange} onChange={onChange}
hideGutter
heightMode="auto" heightMode="auto"
className="min-h-[3rem]"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value} defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined} placeholder={arg.placeholder ?? undefined}
autocompleteFunctions={autocompleteFunctions} autocompleteFunctions={autocompleteFunctions}
@@ -374,7 +403,6 @@ function EditorArg({
/> />
</div> </div>
} }
hideGutter
/> />
</div> </div>
</div> </div>
@@ -396,6 +424,7 @@ function SelectArg({
name={arg.name} name={arg.name}
help={arg.description} help={arg.description}
onChange={onChange} onChange={onChange}
defaultValue={arg.defaultValue}
hideLabel={arg.hideLabel} hideLabel={arg.hideLabel}
value={value} value={value}
size={INPUT_SIZE} size={INPUT_SIZE}

View File

@@ -5,7 +5,7 @@ import type {
WebsocketRequest, WebsocketRequest,
Workspace, Workspace,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import type { TemplateFunction } from '@yaakapp-internal/plugins'; import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
import type { FnArg, Tokens } from '@yaakapp-internal/templates'; import type { FnArg, Tokens } from '@yaakapp-internal/templates';
import classNames from 'classnames'; import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@@ -45,24 +45,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
} }
(async function () { (async function () {
const initial: Record<string, string> = {}; const initial = collectArgumentValues(initialTokens, templateFunction);
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
for (const arg of templateFunction.args) {
if (!('name' in arg)) {
// Skip visual-only args
continue;
}
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
? initialArg?.value.text
: // TODO: Implement variable-based args
undefined;
initial[arg.name] = initialArgValue ?? arg.defaultValue ?? DYNAMIC_FORM_NULL_ARG;
}
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so // HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input. // we can display it in the editor input.
@@ -71,12 +54,14 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
initial.value = await convertTemplateToInsecure(template); initial.value = await convertTemplateToInsecure(template);
} }
console.log('INITIAL', initial);
setInitialArgValues(initial); setInitialArgValues(initial);
})().catch(console.error); })().catch(console.error);
}, [ }, [
initialArgValues, initialArgValues,
initialTokens, initialTokens,
initialTokens.tokens, initialTokens.tokens,
templateFunction,
templateFunction.args, templateFunction.args,
templateFunction.name, templateFunction.name,
]); ]);
@@ -159,84 +144,117 @@ function InitializedTemplateFunctionDialog({
if (templateFunction == null) return null; if (templateFunction == null) return null;
return ( return (
<VStack <form
as="form" className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
className="pb-3"
space={4}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleDone(); handleDone();
}} }}
> >
{name === 'secure' ? ( <div className="overflow-y-auto h-full px-6">
<PlainInput {name === 'secure' ? (
required <PlainInput
label="Value" required
name="value" label="Value"
type="password" name="value"
placeholder="••••••••••••" type="password"
defaultValue={String(argValues['value'] ?? '')} placeholder="••••••••••••"
onChange={(value) => setArgValues({ ...argValues, value })} defaultValue={String(argValues['value'] ?? '')}
/> onChange={(value) => setArgValues({ ...argValues, value })}
) : ( />
<DynamicForm ) : (
autocompleteVariables <DynamicForm
autocompleteFunctions autocompleteVariables
inputs={templateFunction.args} autocompleteFunctions
data={argValues} inputs={templateFunction.args}
onChange={setArgValues} data={argValues}
stateKey={`template_function.${templateFunction.name}`} onChange={setArgValues}
/> stateKey={`template_function.${templateFunction.name}`}
)} />
{enablePreview && (
<VStack className="w-full" space={1}>
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isPending && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? 'lock' : 'lock_open'}
title={showSecretsInPreview ? 'Show preview' : 'Hide preview'}
onClick={toggleShowSecretsInPreview}
className={classNames(
'ml-auto text-text-subtlest',
!dataContainsSecrets && 'invisible',
)}
/>
</HStack>
<InlineCode
className={classNames(
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, '')}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">------ sensitive values hidden ------</span>
) : tooLarge ? (
'too large to preview'
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
</VStack>
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.name === 'secure' && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)} )}
<Button type="submit" color="primary">
Save
</Button>
</div> </div>
</VStack> <div className="px-6 border-t border-t-border py-3 bg-surface-highlight w-full flex flex-col gap-4">
{enablePreview ? (
<VStack className="w-full">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isPending && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? 'lock' : 'lock_open'}
title={showSecretsInPreview ? 'Show preview' : 'Hide preview'}
onClick={toggleShowSecretsInPreview}
className={classNames(
'ml-auto text-text-subtlest',
!dataContainsSecrets && 'invisible',
)}
/>
</HStack>
<InlineCode
className={classNames(
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars !border-text-subtlest',
tooLarge && 'italic text-danger',
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, '')}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
'too large to preview'
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
</VStack>
) : (
<span />
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.name === 'secure' && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
</form>
); );
} }
/**
* Process the initial tokens from the template and merge those with the default values pulled from
* the template function definition.
*/
function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
const initial: Record<string, string | boolean> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
const processArg = (arg: FormInput) => {
if ('inputs' in arg && arg.inputs) {
arg.inputs.forEach(processArg);
}
if (!('name' in arg)) return;
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue = initialArg?.value.type === 'str' ? initialArg?.value.text : undefined;
initial[arg.name] = initialArgValue ?? arg.defaultValue ?? DYNAMIC_FORM_NULL_ARG;
};
templateFunction.args.forEach(processArg);
return initial;
}

View File

@@ -655,7 +655,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
className, className,
'h-xs', // More compact 'h-xs', // More compact
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap', 'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-surface-highlight focus:text rounded', 'focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1',
item.color === 'danger' && '!text-danger', item.color === 'danger' && '!text-danger',
item.color === 'primary' && '!text-primary', item.color === 'primary' && '!text-primary',
item.color === 'success' && '!text-success', item.color === 'success' && '!text-success',

View File

@@ -78,9 +78,19 @@
@apply cursor-default; @apply cursor-default;
} }
}
.cm-gutter-lint {
@apply w-auto !important;
.cm-gutterElement {
@apply px-0;
}
.cm-lint-marker { .cm-lint-marker {
@apply cursor-default opacity-80 hover:opacity-100 transition-opacity; @apply cursor-default opacity-80 hover:opacity-100 transition-opacity;
@apply rounded-full w-[0.9em] h-[0.9em]; @apply rounded-full w-[0.9em] h-[0.9em];
content: ''; content: '';
&.cm-lint-marker-error { &.cm-lint-marker-error {

View File

@@ -290,6 +290,8 @@ export function Editor({
showDialog({ showDialog({
id: 'template-function-' + Math.random(), // Allow multiple at once id: 'template-function-' + Math.random(), // Allow multiple at once
size: 'md', size: 'md',
className: 'h-[90vh]',
noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>, title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description, description: fn.description,
render: ({ hide }) => { render: ({ hide }) => {
@@ -354,6 +356,7 @@ export function Editor({
const ext = getLanguageExtension({ const ext = getLanguageExtension({
useTemplating, useTemplating,
language, language,
hideGutter,
environmentVariables, environmentVariables,
autocomplete, autocomplete,
completionOptions, completionOptions,
@@ -374,6 +377,7 @@ export function Editor({
completionOptions, completionOptions,
useTemplating, useTemplating,
graphQLSchema, graphQLSchema,
hideGutter,
]); ]);
// Initialize the editor when ref mounts // Initialize the editor when ref mounts

View File

@@ -105,6 +105,7 @@ export function getLanguageExtension({
language = 'text', language = 'text',
environmentVariables, environmentVariables,
autocomplete, autocomplete,
hideGutter,
onClickVariable, onClickVariable,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, onClickPathParameter,
@@ -118,7 +119,7 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void; onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[]; completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null; graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, 'language' | 'autocomplete'>) { } & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter'>) {
const extraExtensions: Extension[] = []; const extraExtensions: Extension[] = [];
if (language === 'url') { if (language === 'url') {
@@ -155,7 +156,10 @@ export function getLanguageExtension({
} }
if (language === 'json') { if (language === 'json') {
extraExtensions.push(linter(jsonParseLinter()), lintGutter()); extraExtensions.push(linter(jsonParseLinter()));
if (!hideGutter) {
extraExtensions.push(lintGutter());
}
} }
const maybeBase = language ? syntaxExtensions[language] : null; const maybeBase = language ? syntaxExtensions[language] : null;

View File

@@ -47,7 +47,7 @@ export function useHttpAuthenticationConfig(
], ],
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => { queryFn: async () => {
if (authName == null) return null; if (authName == null || authName === 'inherit') return null;
const config = await invokeCmd<GetHttpAuthenticationConfigResponse>( const config = await invokeCmd<GetHttpAuthenticationConfigResponse>(
'cmd_get_http_authentication_config', 'cmd_get_http_authentication_config',
{ {

View File

@@ -45,16 +45,25 @@ export function useTemplateFunctionConfig(
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => { queryFn: async () => {
if (functionName == null) return null; if (functionName == null) return null;
const config = await invokeCmd<GetTemplateFunctionConfigResponse>( return getTemplateFunctionConfig(functionName, values, model, environmentId);
'cmd_template_function_config',
{
functionName: functionName,
values,
model,
environmentId,
},
);
return config.function;
}, },
}); });
} }
export async function getTemplateFunctionConfig(
functionName: string,
values: Record<string, JsonPrimitive>,
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
environmentId: string | undefined,
) {
const config = await invokeCmd<GetTemplateFunctionConfigResponse>(
'cmd_template_function_config',
{
functionName,
values,
model,
environmentId,
},
);
return config.function;
}

View File

@@ -45,7 +45,7 @@ export async function editEnvironment(
id: 'environment-editor', id: 'environment-editor',
noPadding: true, noPadding: true,
size: 'lg', size: 'lg',
className: 'h-[80vh]', className: 'h-[90vh]',
render: () => ( render: () => (
<EnvironmentEditDialog <EnvironmentEditDialog
initialEnvironmentId={environment?.id ?? null} initialEnvironmentId={environment?.id ?? null}