Run oxfmt across repo, add format script and docs

Add .oxfmtignore to skip generated bindings and wasm-pack output.
Add npm format script, update DEVELOPMENT.md for Vite+ toolchain,
and format all non-generated files with oxfmt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-13 10:15:49 -07:00
parent 45262edfbd
commit b4a1c418bb
664 changed files with 13638 additions and 13492 deletions

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/action-copy-curl",
"displayName": "Copy as Curl",
"version": "0.1.0",
"private": true,
"description": "Copy request as a curl command",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-copy-curl"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,23 +1,23 @@
import type { HttpRequest, PluginDefinition } from '@yaakapp/api';
import type { HttpRequest, PluginDefinition } from "@yaakapp/api";
const NEWLINE = '\\\n ';
const NEWLINE = "\\\n ";
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: 'Copy as Curl',
icon: 'copy',
label: "Copy as Curl",
icon: "copy",
async onSelect(ctx, args) {
const rendered_request = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'send',
purpose: "send",
});
const data = await convertToCurl(rendered_request);
await ctx.clipboard.copyText(data);
await ctx.toast.show({
message: 'Command copied to clipboard',
icon: 'copy',
color: 'success',
message: "Command copied to clipboard",
icon: "copy",
color: "success",
});
},
},
@@ -25,40 +25,40 @@ export const plugin: PluginDefinition = {
};
export async function convertToCurl(request: Partial<HttpRequest>) {
const xs = ['curl'];
const xs = ["curl"];
// Add method and URL all on first line
if (request.method) xs.push('-X', request.method);
if (request.method) xs.push("-X", request.method);
// Build final URL with parameters (compatible with old curl)
let finalUrl = request.url || '';
let finalUrl = request.url || "";
const urlParams = (request.urlParameters ?? []).filter(onlyEnabled);
if (urlParams.length > 0) {
// Build url
const [base, hash] = finalUrl.split('#');
const separator = base?.includes('?') ? '&' : '?';
const [base, hash] = finalUrl.split("#");
const separator = base?.includes("?") ? "&" : "?";
const queryString = urlParams
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join('&');
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
.join("&");
finalUrl = base + separator + queryString + (hash ? `#${hash}` : "");
}
// Add API key authentication
if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'query') {
const sep = finalUrl.includes('?') ? '&' : '?';
if (request.authenticationType === "apikey") {
if (request.authentication?.location === "query") {
const sep = finalUrl.includes("?") ? "&" : "?";
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
encodeURIComponent(request.authentication?.key ?? "token"),
"=",
encodeURIComponent(request.authentication?.value ?? ""),
].join("");
} else {
request.headers = request.headers ?? [];
request.headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
name: request.authentication?.key ?? "X-Api-Key",
value: request.authentication?.value ?? "",
});
}
}
@@ -68,80 +68,80 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
// Add headers
for (const h of (request.headers ?? []).filter(onlyEnabled)) {
xs.push('--header', quote(`${h.name}: ${h.value}`));
xs.push("--header", quote(`${h.name}: ${h.value}`));
xs.push(NEWLINE);
}
// Add form params
const type = request.bodyType ?? 'none';
const type = request.bodyType ?? "none";
if (
(type === 'multipart/form-data' || type === 'application/x-www-form-urlencoded') &&
(type === "multipart/form-data" || type === "application/x-www-form-urlencoded") &&
Array.isArray(request.body?.form)
) {
const flag = request.bodyType === 'multipart/form-data' ? '--form' : '--data';
const flag = request.bodyType === "multipart/form-data" ? "--form" : "--data";
for (const p of (request.body?.form ?? []).filter(onlyEnabled)) {
if (p.file) {
let v = `${p.name}=@${p.file}`;
v += p.contentType ? `;type=${p.contentType}` : '';
v += p.contentType ? `;type=${p.contentType}` : "";
xs.push(flag, v);
} else {
xs.push(flag, quote(`${p.name}=${p.value}`));
}
xs.push(NEWLINE);
}
} else if (type === 'graphql' && typeof request.body?.query === 'string') {
} else if (type === "graphql" && typeof request.body?.query === "string") {
const body = {
query: request.body.query || '',
query: request.body.query || "",
variables: maybeParseJSON(request.body.variables, undefined),
};
xs.push('--data', quote(JSON.stringify(body)));
xs.push("--data", quote(JSON.stringify(body)));
xs.push(NEWLINE);
} else if (type !== 'none' && typeof request.body?.text === 'string') {
xs.push('--data', quote(request.body.text));
} else if (type !== "none" && typeof request.body?.text === "string") {
xs.push("--data", quote(request.body.text));
xs.push(NEWLINE);
}
// Add basic/digest authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic' || request.authenticationType === 'digest') {
if (request.authenticationType === 'digest') xs.push('--digest');
if (request.authenticationType === "basic" || request.authenticationType === "digest") {
if (request.authenticationType === "digest") xs.push("--digest");
xs.push(
'--user',
"--user",
quote(
`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`,
`${request.authentication?.username ?? ""}:${request.authentication?.password ?? ""}`,
),
);
xs.push(NEWLINE);
}
// Add bearer authentication
if (request.authenticationType === 'bearer') {
if (request.authenticationType === "bearer") {
const value =
`${request.authentication?.prefix ?? 'Bearer'} ${request.authentication?.token ?? ''}`.trim();
xs.push('--header', quote(`Authorization: ${value}`));
`${request.authentication?.prefix ?? "Bearer"} ${request.authentication?.token ?? ""}`.trim();
xs.push("--header", quote(`Authorization: ${value}`));
xs.push(NEWLINE);
}
if (request.authenticationType === 'auth-aws-sig-v4') {
if (request.authenticationType === "auth-aws-sig-v4") {
xs.push(
'--aws-sigv4',
"--aws-sigv4",
[
'aws',
'amz',
request.authentication?.region ?? '',
request.authentication?.service ?? '',
].join(':'),
"aws",
"amz",
request.authentication?.region ?? "",
request.authentication?.service ?? "",
].join(":"),
);
xs.push(NEWLINE);
xs.push(
'--user',
"--user",
quote(
`${request.authentication?.accessKeyId ?? ''}:${request.authentication?.secretAccessKey ?? ''}`,
`${request.authentication?.accessKeyId ?? ""}:${request.authentication?.secretAccessKey ?? ""}`,
),
);
if (request.authentication?.sessionToken) {
xs.push(NEWLINE);
xs.push('--header', quote(`X-Amz-Security-Token: ${request.authentication.sessionToken}`));
xs.push("--header", quote(`X-Amz-Security-Token: ${request.authentication.sessionToken}`));
}
xs.push(NEWLINE);
}
@@ -152,7 +152,7 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
xs.splice(xs.length - 1, 1);
}
return xs.join(' ');
return xs.join(" ");
}
function quote(arg: string): string {

View File

@@ -1,60 +1,60 @@
import { describe, expect, test } from 'vite-plus/test';
import { convertToCurl } from '../src';
import { describe, expect, test } from "vite-plus/test";
import { convertToCurl } from "../src";
describe('exporter-curl', () => {
test('Exports GET with params', async () => {
describe("exporter-curl", () => {
test("Exports GET with params", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
url: "https://yaak.app",
urlParameters: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
}),
).toEqual([`curl 'https://yaak.app?a=aaa&b=bbb'`].join(' \\n '));
).toEqual([`curl 'https://yaak.app?a=aaa&b=bbb'`].join(" \\n "));
});
test('Exports GET with params and hash', async () => {
test("Exports GET with params and hash", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app/path#section',
url: "https://yaak.app/path#section",
urlParameters: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
}),
).toEqual([`curl 'https://yaak.app/path?a=aaa&b=bbb#section'`].join(' \\n '));
).toEqual([`curl 'https://yaak.app/path?a=aaa&b=bbb#section'`].join(" \\n "));
});
test('Exports POST with url form data', async () => {
test("Exports POST with url form data", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data 'a=aaa'`, `--data 'b=bbb'`].join(' \\\n '),
[`curl -X POST 'https://yaak.app'`, `--data 'a=aaa'`, `--data 'b=bbb'`].join(" \\\n "),
);
});
test('Exports POST with GraphQL data', async () => {
test("Exports POST with GraphQL data", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'graphql',
url: "https://yaak.app",
method: "POST",
bodyType: "graphql",
body: {
query: '{foo,bar}',
query: "{foo,bar}",
variables: '{"a": "aaa", "b": "bbb"}',
},
}),
@@ -62,37 +62,37 @@ describe('exporter-curl', () => {
[
`curl -X POST 'https://yaak.app'`,
`--data '{"query":"{foo,bar}","variables":{"a":"aaa","b":"bbb"}}'`,
].join(' \\\n '),
].join(" \\\n "),
);
});
test('Exports POST with GraphQL data no variables', async () => {
test("Exports POST with GraphQL data no variables", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'graphql',
url: "https://yaak.app",
method: "POST",
bodyType: "graphql",
body: {
query: '{foo,bar}',
query: "{foo,bar}",
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data '{"query":"{foo,bar}"}'`].join(' \\\n '),
[`curl -X POST 'https://yaak.app'`, `--data '{"query":"{foo,bar}"}'`].join(" \\\n "),
);
});
test('Exports PUT with multipart form', async () => {
test("Exports PUT with multipart form", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'PUT',
bodyType: 'multipart/form-data',
url: "https://yaak.app",
method: "PUT",
bodyType: "multipart/form-data",
body: {
form: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
{ name: 'f', file: '/foo/bar.png', contentType: 'image/png' },
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
{ name: "f", file: "/foo/bar.png", contentType: "image/png" },
],
},
}),
@@ -101,314 +101,314 @@ describe('exporter-curl', () => {
`curl -X PUT 'https://yaak.app'`,
`--form 'a=aaa'`,
`--form 'b=bbb'`,
'--form f=@/foo/bar.png;type=image/png',
].join(' \\\n '),
"--form f=@/foo/bar.png;type=image/png",
].join(" \\\n "),
);
});
test('Exports JSON body', async () => {
test("Exports JSON body", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
url: "https://yaak.app",
method: "POST",
bodyType: "application/json",
body: {
text: `{"foo":"bar's"}`,
},
headers: [{ name: 'Content-Type', value: 'application/json' }],
headers: [{ name: "Content-Type", value: "application/json" }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data '{"foo":"bar\\'s"}'`,
].join(' \\\n '),
].join(" \\\n "),
);
});
test('Exports multi-line JSON body', async () => {
test("Exports multi-line JSON body", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
url: "https://yaak.app",
method: "POST",
bodyType: "application/json",
body: {
text: `{"foo":"bar",\n"baz":"qux"}`,
},
headers: [{ name: 'Content-Type', value: 'application/json' }],
headers: [{ name: "Content-Type", value: "application/json" }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data '{"foo":"bar",\n"baz":"qux"}'`,
].join(' \\\n '),
].join(" \\\n "),
);
});
test('Exports headers', async () => {
test("Exports headers", async () => {
expect(
await convertToCurl({
headers: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
}),
).toEqual([`curl ''`, `--header 'a: aaa'`, `--header 'b: bbb'`].join(' \\\n '));
).toEqual([`curl ''`, `--header 'a: aaa'`, `--header 'b: bbb'`].join(" \\\n "));
});
test('Basic auth', async () => {
test("Basic auth", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--user 'user:pass'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--user 'user:pass'`].join(" \\\n "));
});
test('Basic auth disabled', async () => {
test("Basic auth disabled", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
disabled: true,
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`].join(" \\\n "));
});
test('Broken basic auth', async () => {
test("Broken basic auth", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
url: "https://yaak.app",
authenticationType: "basic",
authentication: {},
}),
).toEqual([`curl 'https://yaak.app'`, `--user ':'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--user ':'`].join(" \\\n "));
});
test('Digest auth', async () => {
test("Digest auth", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'digest',
url: "https://yaak.app",
authenticationType: "digest",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--digest --user 'user:pass'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--digest --user 'user:pass'`].join(" \\\n "));
});
test('Bearer auth', async () => {
test("Bearer auth", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
token: 'tok',
token: "tok",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer tok'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer tok'`].join(" \\\n "));
});
test('Bearer auth with custom prefix', async () => {
test("Bearer auth with custom prefix", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
token: 'abc123',
prefix: 'Token',
token: "abc123",
prefix: "Token",
},
}),
).toEqual(
[`curl 'https://yaak.app'`, `--header 'Authorization: Token abc123'`].join(' \\\n '),
[`curl 'https://yaak.app'`, `--header 'Authorization: Token abc123'`].join(" \\\n "),
);
});
test('Bearer auth with empty prefix', async () => {
test("Bearer auth with empty prefix", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
token: 'xyz789',
prefix: '',
token: "xyz789",
prefix: "",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: xyz789'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: xyz789'`].join(" \\\n "));
});
test('Broken bearer auth', async () => {
test("Broken bearer auth", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer'`].join(" \\\n "));
});
test('AWS v4 auth', async () => {
test("AWS v4 auth", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'auth-aws-sig-v4',
url: "https://yaak.app",
authenticationType: "auth-aws-sig-v4",
authentication: {
accessKeyId: 'ak',
secretAccessKey: 'sk',
sessionToken: '',
region: 'us-east-1',
service: 's3',
accessKeyId: "ak",
secretAccessKey: "sk",
sessionToken: "",
region: "us-east-1",
service: "s3",
},
}),
).toEqual(
[`curl 'https://yaak.app'`, '--aws-sigv4 aws:amz:us-east-1:s3', `--user 'ak:sk'`].join(
' \\\n ',
[`curl 'https://yaak.app'`, "--aws-sigv4 aws:amz:us-east-1:s3", `--user 'ak:sk'`].join(
" \\\n ",
),
);
});
test('AWS v4 auth with session', async () => {
test("AWS v4 auth with session", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'auth-aws-sig-v4',
url: "https://yaak.app",
authenticationType: "auth-aws-sig-v4",
authentication: {
accessKeyId: 'ak',
secretAccessKey: 'sk',
sessionToken: 'st',
region: 'us-east-1',
service: 's3',
accessKeyId: "ak",
secretAccessKey: "sk",
sessionToken: "st",
region: "us-east-1",
service: "s3",
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
'--aws-sigv4 aws:amz:us-east-1:s3',
"--aws-sigv4 aws:amz:us-east-1:s3",
`--user 'ak:sk'`,
`--header 'X-Amz-Security-Token: st'`,
].join(' \\\n '),
].join(" \\\n "),
);
});
test('API key auth header', async () => {
test("API key auth header", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: 'header',
key: 'X-Header',
value: 'my-token',
location: "header",
key: "X-Header",
value: "my-token",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Header: my-token'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Header: my-token'`].join(" \\\n "));
});
test('API key auth header query', async () => {
test("API key auth header query", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?hi=there',
urlParameters: [{ name: 'param', value: 'hi' }],
authenticationType: 'apikey',
url: "https://yaak.app?hi=there",
urlParameters: [{ name: "param", value: "hi" }],
authenticationType: "apikey",
authentication: {
location: 'query',
key: 'foo',
value: 'bar',
location: "query",
key: "foo",
value: "bar",
},
}),
).toEqual([`curl 'https://yaak.app?hi=there&param=hi&foo=bar'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app?hi=there&param=hi&foo=bar'`].join(" \\\n "));
});
test('API key auth header query with params', async () => {
test("API key auth header query with params", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
urlParameters: [{ name: 'param', value: 'hi' }],
authenticationType: 'apikey',
url: "https://yaak.app",
urlParameters: [{ name: "param", value: "hi" }],
authenticationType: "apikey",
authentication: {
location: 'query',
key: 'foo',
value: 'bar',
location: "query",
key: "foo",
value: "bar",
},
}),
).toEqual([`curl 'https://yaak.app?param=hi&foo=bar'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app?param=hi&foo=bar'`].join(" \\\n "));
});
test('API key auth header default', async () => {
test("API key auth header default", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: 'header',
location: "header",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Api-Key: '`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Api-Key: '`].join(" \\\n "));
});
test('API key auth query', async () => {
test("API key auth query", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: 'query',
key: 'foo',
value: 'bar-baz',
location: "query",
key: "foo",
value: "bar-baz",
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar-baz'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app?foo=bar-baz'`].join(" \\\n "));
});
test('API key auth query with existing', async () => {
test("API key auth query with existing", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
url: "https://yaak.app?foo=bar&baz=qux",
authenticationType: "apikey",
authentication: {
location: 'query',
key: 'hi',
value: 'there',
location: "query",
key: "hi",
value: "there",
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&hi=there'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&hi=there'`].join(" \\\n "));
});
test('API key auth query default', async () => {
test("API key auth query default", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
url: "https://yaak.app?foo=bar&baz=qux",
authenticationType: "apikey",
authentication: {
location: 'query',
location: "query",
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&token='`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&token='`].join(" \\\n "));
});
test('Stale body data', async () => {
test("Stale body data", async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
bodyType: 'none',
url: "https://yaak.app",
bodyType: "none",
body: {
text: 'ignore me',
text: "ignore me",
},
}),
).toEqual([`curl 'https://yaak.app'`].join(' \\\n '));
).toEqual([`curl 'https://yaak.app'`].join(" \\\n "));
});
});

View File

@@ -37,7 +37,6 @@ The plugin analyzes your gRPC request configuration and generates a properly for
### Simple Unary Call
```bash
grpcurl -plaintext \
-d '{"name": "John Doe"}' \

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/action-copy-grpcurl",
"displayName": "Copy as gRPCurl",
"version": "0.1.0",
"private": true,
"description": "Copy gRPC request as a grpcurl command",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-copy-grpcurl"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,24 +1,24 @@
import path from 'node:path';
import type { GrpcRequest, PluginDefinition } from '@yaakapp/api';
import path from "node:path";
import type { GrpcRequest, PluginDefinition } from "@yaakapp/api";
const NEWLINE = '\\\n ';
const NEWLINE = "\\\n ";
export const plugin: PluginDefinition = {
grpcRequestActions: [
{
label: 'Copy as gRPCurl',
icon: 'copy',
label: "Copy as gRPCurl",
icon: "copy",
async onSelect(ctx, args) {
const rendered_request = await ctx.grpcRequest.render({
grpcRequest: args.grpcRequest,
purpose: 'send',
purpose: "send",
});
const data = await convert(rendered_request, args.protoFiles);
await ctx.clipboard.copyText(data);
await ctx.toast.show({
message: 'Command copied to clipboard',
icon: 'copy',
color: 'success',
message: "Command copied to clipboard",
icon: "copy",
color: "success",
});
},
},
@@ -26,14 +26,14 @@ export const plugin: PluginDefinition = {
};
export async function convert(request: Partial<GrpcRequest>, allProtoFiles: string[]) {
const xs = ['grpcurl'];
const xs = ["grpcurl"];
if (request.url?.startsWith('http://')) {
xs.push('-plaintext');
if (request.url?.startsWith("http://")) {
xs.push("-plaintext");
}
const protoIncludes = allProtoFiles.filter((f) => !f.endsWith('.proto'));
const protoFiles = allProtoFiles.filter((f) => f.endsWith('.proto'));
const protoIncludes = allProtoFiles.filter((f) => !f.endsWith(".proto"));
const protoFiles = allProtoFiles.filter((f) => f.endsWith(".proto"));
const inferredIncludes = new Set<string>();
for (const f of protoFiles) {
@@ -41,59 +41,59 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
if (protoDir) {
inferredIncludes.add(protoDir);
} else {
inferredIncludes.add(path.posix.join(f, '..'));
inferredIncludes.add(path.posix.join(f, '..', '..'));
inferredIncludes.add(path.posix.join(f, ".."));
inferredIncludes.add(path.posix.join(f, "..", ".."));
}
}
for (const f of protoIncludes) {
xs.push('-import-path', quote(f));
xs.push("-import-path", quote(f));
xs.push(NEWLINE);
}
for (const f of inferredIncludes.values()) {
xs.push('-import-path', quote(f));
xs.push("-import-path", quote(f));
xs.push(NEWLINE);
}
for (const f of protoFiles) {
xs.push('-proto', quote(f));
xs.push("-proto", quote(f));
xs.push(NEWLINE);
}
// Add headers
for (const h of (request.metadata ?? []).filter(onlyEnabled)) {
xs.push('-H', quote(`${h.name}: ${h.value}`));
xs.push("-H", quote(`${h.name}: ${h.value}`));
xs.push(NEWLINE);
}
// Add basic authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic') {
const user = request.authentication?.username ?? '';
const pass = request.authentication?.password ?? '';
if (request.authenticationType === "basic") {
const user = request.authentication?.username ?? "";
const pass = request.authentication?.password ?? "";
const encoded = btoa(`${user}:${pass}`);
xs.push('-H', quote(`Authorization: Basic ${encoded}`));
xs.push("-H", quote(`Authorization: Basic ${encoded}`));
xs.push(NEWLINE);
} else if (request.authenticationType === 'bearer') {
} else if (request.authenticationType === "bearer") {
// Add bearer authentication
xs.push('-H', quote(`Authorization: Bearer ${request.authentication?.token ?? ''}`));
xs.push("-H", quote(`Authorization: Bearer ${request.authentication?.token ?? ""}`));
xs.push(NEWLINE);
} else if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'query') {
const sep = request.url?.includes('?') ? '&' : '?';
} else if (request.authenticationType === "apikey") {
if (request.authentication?.location === "query") {
const sep = request.url?.includes("?") ? "&" : "?";
request.url = [
request.url,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
encodeURIComponent(request.authentication?.key ?? "token"),
"=",
encodeURIComponent(request.authentication?.value ?? ""),
].join("");
} else {
xs.push(
'-H',
"-H",
quote(
`${request.authentication?.key ?? 'X-Api-Key'}: ${request.authentication?.value ?? ''}`,
`${request.authentication?.key ?? "X-Api-Key"}: ${request.authentication?.value ?? ""}`,
),
);
}
@@ -103,13 +103,13 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
// Add form params
if (request.message) {
xs.push('-d', quote(request.message));
xs.push("-d", quote(request.message));
xs.push(NEWLINE);
}
// Add the server address
if (request.url) {
const server = request.url.replace(/^https?:\/\//, ''); // remove protocol
const server = request.url.replace(/^https?:\/\//, ""); // remove protocol
xs.push(server);
xs.push(NEWLINE);
}
@@ -125,7 +125,7 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
xs.splice(xs.length - 1, 1);
}
return xs.join(' ');
return xs.join(" ");
}
function quote(arg: string): string {
@@ -141,7 +141,7 @@ function findParentProtoDir(startPath: string): string | null {
let dir = path.resolve(startPath);
while (true) {
if (path.basename(dir) === 'proto') {
if (path.basename(dir) === "proto") {
return dir;
}

View File

@@ -1,107 +1,107 @@
import { describe, expect, test } from 'vite-plus/test';
import { convert } from '../src';
import { describe, expect, test } from "vite-plus/test";
import { convert } from "../src";
describe('exporter-curl', () => {
test('Simple example', async () => {
describe("exporter-curl", () => {
test("Simple example", async () => {
expect(
await convert(
{
url: 'https://yaak.app',
url: "https://yaak.app",
},
[],
),
).toEqual(['grpcurl yaak.app'].join(' \\\n '));
).toEqual(["grpcurl yaak.app"].join(" \\\n "));
});
test('Basic metadata', async () => {
test("Basic metadata", async () => {
expect(
await convert(
{
url: 'https://yaak.app',
url: "https://yaak.app",
metadata: [
{ name: 'aaa', value: 'AAA' },
{ enabled: true, name: 'bbb', value: 'BBB' },
{ enabled: false, name: 'disabled', value: 'ddd' },
{ name: "aaa", value: "AAA" },
{ enabled: true, name: "bbb", value: "BBB" },
{ enabled: false, name: "disabled", value: "ddd" },
],
},
[],
),
).toEqual([`grpcurl -H 'aaa: AAA'`, `-H 'bbb: BBB'`, 'yaak.app'].join(' \\\n '));
).toEqual([`grpcurl -H 'aaa: AAA'`, `-H 'bbb: BBB'`, "yaak.app"].join(" \\\n "));
});
test('Basic auth', async () => {
test("Basic auth", async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'basic',
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
},
[],
),
).toEqual([`grpcurl -H 'Authorization: Basic dXNlcjpwYXNz'`, 'yaak.app'].join(' \\\n '));
).toEqual([`grpcurl -H 'Authorization: Basic dXNlcjpwYXNz'`, "yaak.app"].join(" \\\n "));
});
test('API key auth', async () => {
test("API key auth", async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'apikey',
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
key: 'X-Token',
value: 'tok',
key: "X-Token",
value: "tok",
},
},
[],
),
).toEqual([`grpcurl -H 'X-Token: tok'`, 'yaak.app'].join(' \\\n '));
).toEqual([`grpcurl -H 'X-Token: tok'`, "yaak.app"].join(" \\\n "));
});
test('API key auth', async () => {
test("API key auth", async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'apikey',
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: 'query',
key: 'token',
value: 'tok 1',
location: "query",
key: "token",
value: "tok 1",
},
},
[],
),
).toEqual(['grpcurl', 'yaak.app?token=tok%201'].join(' \\\n '));
).toEqual(["grpcurl", "yaak.app?token=tok%201"].join(" \\\n "));
});
test('Single proto file', async () => {
expect(await convert({ url: 'https://yaak.app' }, ['/foo/bar/baz.proto'])).toEqual(
test("Single proto file", async () => {
expect(await convert({ url: "https://yaak.app" }, ["/foo/bar/baz.proto"])).toEqual(
[
`grpcurl -import-path '/foo/bar'`,
`-import-path '/foo'`,
`-proto '/foo/bar/baz.proto'`,
'yaak.app',
].join(' \\\n '),
"yaak.app",
].join(" \\\n "),
);
});
test('Multiple proto files, same dir', async () => {
test("Multiple proto files, same dir", async () => {
expect(
await convert({ url: 'https://yaak.app' }, ['/foo/bar/aaa.proto', '/foo/bar/bbb.proto']),
await convert({ url: "https://yaak.app" }, ["/foo/bar/aaa.proto", "/foo/bar/bbb.proto"]),
).toEqual(
[
`grpcurl -import-path '/foo/bar'`,
`-import-path '/foo'`,
`-proto '/foo/bar/aaa.proto'`,
`-proto '/foo/bar/bbb.proto'`,
'yaak.app',
].join(' \\\n '),
"yaak.app",
].join(" \\\n "),
);
});
test('Multiple proto files, different dir', async () => {
test("Multiple proto files, different dir", async () => {
expect(
await convert({ url: 'https://yaak.app' }, ['/aaa/bbb/ccc.proto', '/xxx/yyy/zzz.proto']),
await convert({ url: "https://yaak.app" }, ["/aaa/bbb/ccc.proto", "/xxx/yyy/zzz.proto"]),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
@@ -110,23 +110,23 @@ describe('exporter-curl', () => {
`-import-path '/xxx'`,
`-proto '/aaa/bbb/ccc.proto'`,
`-proto '/xxx/yyy/zzz.proto'`,
'yaak.app',
].join(' \\\n '),
"yaak.app",
].join(" \\\n "),
);
});
test('Single include dir', async () => {
expect(await convert({ url: 'https://yaak.app' }, ['/aaa/bbb'])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, 'yaak.app'].join(' \\\n '),
test("Single include dir", async () => {
expect(await convert({ url: "https://yaak.app" }, ["/aaa/bbb"])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, "yaak.app"].join(" \\\n "),
);
});
test('Multiple include dir', async () => {
expect(await convert({ url: 'https://yaak.app' }, ['/aaa/bbb', '/xxx/yyy'])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, `-import-path '/xxx/yyy'`, 'yaak.app'].join(' \\\n '),
test("Multiple include dir", async () => {
expect(await convert({ url: "https://yaak.app" }, ["/aaa/bbb", "/xxx/yyy"])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, `-import-path '/xxx/yyy'`, "yaak.app"].join(" \\\n "),
);
});
test('Mixed proto and dirs', async () => {
test("Mixed proto and dirs", async () => {
expect(
await convert({ url: 'https://yaak.app' }, ['/aaa/bbb', '/xxx/yyy', '/foo/bar.proto']),
await convert({ url: "https://yaak.app" }, ["/aaa/bbb", "/xxx/yyy", "/foo/bar.proto"]),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
@@ -134,45 +134,45 @@ describe('exporter-curl', () => {
`-import-path '/foo'`,
`-import-path '/'`,
`-proto '/foo/bar.proto'`,
'yaak.app',
].join(' \\\n '),
"yaak.app",
].join(" \\\n "),
);
});
test('Sends data', async () => {
test("Sends data", async () => {
expect(
await convert(
{
url: 'https://yaak.app',
message: JSON.stringify({ foo: 'bar', baz: 1.0 }, null, 2),
url: "https://yaak.app",
message: JSON.stringify({ foo: "bar", baz: 1.0 }, null, 2),
},
['/foo.proto'],
["/foo.proto"],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{\n "foo": "bar",\n "baz": 1\n}'`,
'yaak.app',
].join(' \\\n '),
"yaak.app",
].join(" \\\n "),
);
});
test('Sends data with unresolved template tags', async () => {
test("Sends data with unresolved template tags", async () => {
expect(
await convert(
{
url: 'https://yaak.app',
url: "https://yaak.app",
message: '{"timestamp": ${[ faker "timestamp" ]}, "foo": "bar"}',
},
['/foo.proto'],
["/foo.proto"],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{"timestamp": \${[ faker "timestamp" ]}, "foo": "bar"}'`,
'yaak.app',
].join(' \\\n '),
"yaak.app",
].join(" \\\n "),
);
});
});

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/action-send-folder",
"displayName": "Send All",
"version": "0.1.0",
"private": true,
"description": "Send all HTTP requests in a folder sequentially in tree order",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-send-folder"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,10 +1,10 @@
import type { PluginDefinition } from '@yaakapp/api';
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
folderActions: [
{
label: 'Send All',
icon: 'send_horizontal',
label: "Send All",
icon: "send_horizontal",
async onSelect(ctx, args) {
const targetFolder = args.folder;
@@ -17,8 +17,8 @@ export const plugin: PluginDefinition = {
// Build the send order to match tree ordering:
// sort siblings by sortPriority then updatedAt, and traverse folders depth-first.
const compareByOrder = (
a: Pick<typeof allFolders[number], 'sortPriority' | 'updatedAt'>,
b: Pick<typeof allFolders[number], 'sortPriority' | 'updatedAt'>,
a: Pick<(typeof allFolders)[number], "sortPriority" | "updatedAt">,
b: Pick<(typeof allFolders)[number], "sortPriority" | "updatedAt">,
) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
@@ -26,7 +26,10 @@ export const plugin: PluginDefinition = {
return a.sortPriority - b.sortPriority;
};
const childrenByFolderId = new Map<string, Array<typeof allFolders[number] | typeof allRequests[number]>>();
const childrenByFolderId = new Map<
string,
Array<(typeof allFolders)[number] | (typeof allRequests)[number]>
>();
for (const folder of allFolders) {
if (folder.folderId == null) continue;
const children = childrenByFolderId.get(folder.folderId) ?? [];
@@ -44,9 +47,9 @@ export const plugin: PluginDefinition = {
const collectRequests = (folderId: string) => {
const children = (childrenByFolderId.get(folderId) ?? []).slice().sort(compareByOrder);
for (const child of children) {
if (child.model === 'folder') {
if (child.model === "folder") {
collectRequests(child.id);
} else if (child.model === 'http_request') {
} else if (child.model === "http_request") {
requestsToSend.push(child);
}
}
@@ -55,9 +58,9 @@ export const plugin: PluginDefinition = {
if (requestsToSend.length === 0) {
await ctx.toast.show({
message: 'No requests in folder',
icon: 'info',
color: 'info',
message: "No requests in folder",
icon: "info",
color: "info",
});
return;
}
@@ -79,15 +82,15 @@ export const plugin: PluginDefinition = {
// Show summary toast
if (errorCount === 0) {
await ctx.toast.show({
message: `Sent ${successCount} request${successCount !== 1 ? 's' : ''}`,
icon: 'send_horizontal',
color: 'success',
message: `Sent ${successCount} request${successCount !== 1 ? "s" : ""}`,
icon: "send_horizontal",
color: "success",
});
} else {
await ctx.toast.show({
message: `Sent ${successCount}, failed ${errorCount}`,
icon: 'alert_triangle',
color: 'warning',
icon: "alert_triangle",
color: "warning",
});
}
},

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-apikey",
"displayName": "API Key Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using an API key",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-apikey"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,51 +1,51 @@
import type { PluginDefinition } from '@yaakapp/api';
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
authentication: {
name: 'apikey',
label: 'API Key',
shortLabel: 'API Key',
name: "apikey",
label: "API Key",
shortLabel: "API Key",
args: [
{
type: 'select',
name: 'location',
label: 'Behavior',
defaultValue: 'header',
type: "select",
name: "location",
label: "Behavior",
defaultValue: "header",
options: [
{ label: 'Insert Header', value: 'header' },
{ label: 'Append Query Parameter', value: 'query' },
{ label: "Insert Header", value: "header" },
{ label: "Append Query Parameter", value: "query" },
],
},
{
type: 'text',
name: 'key',
label: 'Key',
type: "text",
name: "key",
label: "Key",
dynamic: (_ctx, { values }) => {
return values.location === 'query'
return values.location === "query"
? {
label: 'Parameter Name',
description: 'The name of the query parameter to add to the request',
label: "Parameter Name",
description: "The name of the query parameter to add to the request",
}
: {
label: 'Header Name',
description: 'The name of the header to add to the request',
label: "Header Name",
description: "The name of the header to add to the request",
};
},
},
{
type: 'text',
name: 'value',
label: 'API Key',
type: "text",
name: "value",
label: "API Key",
optional: true,
password: true,
},
],
async onApply(_ctx, { values }) {
const key = String(values.key ?? '');
const value = String(values.value ?? '');
const key = String(values.key ?? "");
const value = String(values.value ?? "");
const location = String(values.location);
if (location === 'query') {
if (location === "query") {
return { setQueryParameters: [{ name: key, value }] };
}
return { setHeaders: [{ name: key, value }] };

View File

@@ -38,7 +38,7 @@ The plugin presents the following fields:
- **Access Key ID** Your AWS access key identifier
- **Secret Access Key** The secret associated with the access key
- **Session Token** *(optional)* Used for temporary or assumed-role credentials (treated as secret)
- **Session Token** _(optional)_ Used for temporary or assumed-role credentials (treated as secret)
- **Region** AWS region (e.g., `us-east-1`)
- **Service** AWS service identifier (e.g., `sts`, `s3`, `execute-api`)

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-aws",
"displayName": "AWS SigV4",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using AWS SigV4 signing",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-aws"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,56 +1,56 @@
import { URL } from 'node:url';
import type { PluginDefinition } from '@yaakapp/api';
import type { CallHttpAuthenticationResponse } from '@yaakapp-internal/plugins';
import type { Request } from 'aws4';
import aws4 from 'aws4';
import { URL } from "node:url";
import type { PluginDefinition } from "@yaakapp/api";
import type { CallHttpAuthenticationResponse } from "@yaakapp-internal/plugins";
import type { Request } from "aws4";
import aws4 from "aws4";
export const plugin: PluginDefinition = {
authentication: {
name: 'awsv4',
label: 'AWS Signature',
shortLabel: 'AWS v4',
name: "awsv4",
label: "AWS Signature",
shortLabel: "AWS v4",
args: [
{ name: 'accessKeyId', label: 'Access Key ID', type: 'text', password: true },
{ name: "accessKeyId", label: "Access Key ID", type: "text", password: true },
{
name: 'secretAccessKey',
label: 'Secret Access Key',
type: 'text',
name: "secretAccessKey",
label: "Secret Access Key",
type: "text",
password: true,
},
{
name: 'service',
label: 'Service Name',
type: 'text',
defaultValue: 'sts',
placeholder: 'sts',
description: 'The service that is receiving the request (sts, s3, sqs, ...)',
name: "service",
label: "Service Name",
type: "text",
defaultValue: "sts",
placeholder: "sts",
description: "The service that is receiving the request (sts, s3, sqs, ...)",
},
{
name: 'region',
label: 'Region',
type: 'text',
placeholder: 'us-east-1',
description: 'The region that is receiving the request (defaults to us-east-1)',
name: "region",
label: "Region",
type: "text",
placeholder: "us-east-1",
description: "The region that is receiving the request (defaults to us-east-1)",
optional: true,
},
{
name: 'sessionToken',
label: 'Session Token',
type: 'text',
name: "sessionToken",
label: "Session Token",
type: "text",
password: true,
optional: true,
description: 'Only required if you are using temporary credentials',
description: "Only required if you are using temporary credentials",
},
],
onApply(_ctx, { values, ...args }): CallHttpAuthenticationResponse {
const accessKeyId = String(values.accessKeyId || '');
const secretAccessKey = String(values.secretAccessKey || '');
const sessionToken = String(values.sessionToken || '') || undefined;
const accessKeyId = String(values.accessKeyId || "");
const secretAccessKey = String(values.secretAccessKey || "");
const sessionToken = String(values.sessionToken || "") || undefined;
const url = new URL(args.url);
const headers: NonNullable<Request['headers']> = {};
for (const headerName of ['content-type', 'host', 'x-amz-date', 'x-amz-security-token']) {
const headers: NonNullable<Request["headers"]> = {};
for (const headerName of ["content-type", "host", "x-amz-date", "x-amz-security-token"]) {
const v = args.headers.find((h) => h.name.toLowerCase() === headerName);
if (v != null) {
headers[headerName] = v.value;
@@ -61,8 +61,8 @@ export const plugin: PluginDefinition = {
{
host: url.host,
method: args.method,
path: url.pathname + (url.search || ''),
service: String(values.service || 'sts'),
path: url.pathname + (url.search || ""),
service: String(values.service || "sts"),
region: values.region ? String(values.region) : undefined,
headers,
doNotEncodePath: true,
@@ -80,8 +80,8 @@ export const plugin: PluginDefinition = {
return {
setHeaders: Object.entries(signature.headers)
.filter(([name]) => name !== 'content-type') // Don't add this because we already have it
.map(([name, value]) => ({ name, value: String(value || '') })),
.filter(([name]) => name !== "content-type") // Don't add this because we already have it
.map(([name, value]) => ({ name, value: String(value || "") })),
};
},
},

View File

@@ -1,4 +1,4 @@
# Basic Authentication
# Basic Authentication
A simple Basic Authentication plugin that implements HTTP Basic Auth according
to [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617), enabling secure

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-basic",
"displayName": "Basic Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using Basic Auth",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-basic"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,30 +1,30 @@
import type { PluginDefinition } from '@yaakapp/api';
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
authentication: {
name: 'basic',
label: 'Basic Auth',
shortLabel: 'Basic',
name: "basic",
label: "Basic Auth",
shortLabel: "Basic",
args: [
{
type: 'text',
name: 'username',
label: 'Username',
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: 'text',
name: 'password',
label: 'Password',
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
],
async onApply(_ctx, { values }) {
const username = values.username ?? '';
const password = values.password ?? '';
const value = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
return { setHeaders: [{ name: 'Authorization', value }] };
const username = values.username ?? "";
const password = values.password ?? "";
const value = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
return { setHeaders: [{ name: "Authorization", value }] };
},
},
};

View File

@@ -1,77 +1,87 @@
import type { Context } from '@yaakapp/api';
import { describe, expect, test } from 'vite-plus/test';
import { plugin } from '../src';
import type { Context } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { plugin } from "../src";
const ctx = {} as Context;
describe('auth-basic', () => {
test('Both username and password', async () => {
describe("auth-basic", () => {
test("Both username and password", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: 'user', password: 'pass' },
values: { username: "user", password: "pass" },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('user:pass').toString('base64')}` }],
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from("user:pass").toString("base64")}` },
],
});
});
test('Empty password', async () => {
test("Empty password", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: 'apikey', password: '' },
values: { username: "apikey", password: "" },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('apikey:').toString('base64')}` }],
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from("apikey:").toString("base64")}` },
],
});
});
test('Missing password (undefined)', async () => {
test("Missing password (undefined)", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: 'apikey' },
values: { username: "apikey" },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('apikey:').toString('base64')}` }],
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from("apikey:").toString("base64")}` },
],
});
});
test('Missing username (undefined)', async () => {
test("Missing username (undefined)", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { password: 'secret' },
values: { password: "secret" },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from(':secret').toString('base64')}` }],
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from(":secret").toString("base64")}` },
],
});
});
test('No values (both undefined)', async () => {
test("No values (both undefined)", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from(':').toString('base64')}` }],
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from(":").toString("base64")}` },
],
});
});
});

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-bearer",
"displayName": "Bearer Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using bearer authentication",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-bearer"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,26 +1,26 @@
import type { PluginDefinition } from '@yaakapp/api';
import type { CallHttpAuthenticationRequest } from '@yaakapp-internal/plugins';
import type { PluginDefinition } from "@yaakapp/api";
import type { CallHttpAuthenticationRequest } from "@yaakapp-internal/plugins";
export const plugin: PluginDefinition = {
authentication: {
name: 'bearer',
label: 'Bearer Token',
shortLabel: 'Bearer',
name: "bearer",
label: "Bearer Token",
shortLabel: "Bearer",
args: [
{
type: 'text',
name: 'token',
label: 'Token',
type: "text",
name: "token",
label: "Token",
optional: true,
password: true,
},
{
type: 'text',
name: 'prefix',
label: 'Prefix',
type: "text",
name: "prefix",
label: "Prefix",
optional: true,
placeholder: '',
defaultValue: 'Bearer',
placeholder: "",
defaultValue: "Bearer",
description:
'The prefix to use for the Authorization header, which will be of the format "<PREFIX> <TOKEN>".',
},
@@ -31,9 +31,9 @@ export const plugin: PluginDefinition = {
},
};
function generateAuthorizationHeader(values: CallHttpAuthenticationRequest['values']) {
const token = String(values.token || '').trim();
const prefix = String(values.prefix || '').trim();
function generateAuthorizationHeader(values: CallHttpAuthenticationRequest["values"]) {
const token = String(values.token || "").trim();
const prefix = String(values.prefix || "").trim();
const value = `${prefix} ${token}`.trim();
return { name: 'Authorization', value };
return { name: "Authorization", value };
}

View File

@@ -1,67 +1,67 @@
import type { Context } from '@yaakapp/api';
import { describe, expect, test } from 'vite-plus/test';
import { plugin } from '../src';
import type { Context } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { plugin } from "../src";
const ctx = {} as Context;
describe('auth-bearer', () => {
test('No values', async () => {
describe("auth-bearer", () => {
test("No values", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: 'Authorization', value: '' }] });
).toEqual({ setHeaders: [{ name: "Authorization", value: "" }] });
});
test('Only token', async () => {
test("Only token", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { token: 'my-token' },
values: { token: "my-token" },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: 'Authorization', value: 'my-token' }] });
).toEqual({ setHeaders: [{ name: "Authorization", value: "my-token" }] });
});
test('Only prefix', async () => {
test("Only prefix", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { prefix: 'Hello' },
values: { prefix: "Hello" },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: 'Authorization', value: 'Hello' }] });
).toEqual({ setHeaders: [{ name: "Authorization", value: "Hello" }] });
});
test('Prefix and token', async () => {
test("Prefix and token", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { prefix: 'Hello', token: 'my-token' },
values: { prefix: "Hello", token: "my-token" },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: 'Authorization', value: 'Hello my-token' }] });
).toEqual({ setHeaders: [{ name: "Authorization", value: "Hello my-token" }] });
});
test('Extra spaces', async () => {
test("Extra spaces", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { prefix: '\t Hello ', token: ' \nmy-token ' },
values: { prefix: "\t Hello ", token: " \nmy-token " },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: 'Authorization', value: 'Hello my-token' }] });
).toEqual({ setHeaders: [{ name: "Authorization", value: "Hello my-token" }] });
});
});

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-jwt",
"displayName": "JSON Web Tokens",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using JSON web tokens (JWT)",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-jwt"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,104 +1,104 @@
import type { PluginDefinition } from '@yaakapp/api';
import jwt from 'jsonwebtoken';
import type { PluginDefinition } from "@yaakapp/api";
import jwt from "jsonwebtoken";
const algorithms = [
'HS256',
'HS384',
'HS512',
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES384',
'ES512',
'none',
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"none",
] as const;
const defaultAlgorithm = algorithms[0];
export const plugin: PluginDefinition = {
authentication: {
name: 'jwt',
label: 'JWT Bearer',
shortLabel: 'JWT',
name: "jwt",
label: "JWT Bearer",
shortLabel: "JWT",
args: [
{
type: 'select',
name: 'algorithm',
label: 'Algorithm',
type: "select",
name: "algorithm",
label: "Algorithm",
hideLabel: true,
defaultValue: defaultAlgorithm,
options: algorithms.map((value) => ({ label: value === 'none' ? 'None' : value, value })),
options: algorithms.map((value) => ({ label: value === "none" ? "None" : value, value })),
},
{
type: 'text',
name: 'secret',
label: 'Secret or Private Key',
type: "text",
name: "secret",
label: "Secret or Private Key",
password: true,
optional: true,
multiLine: true,
},
{
type: 'checkbox',
name: 'secretBase64',
label: 'Secret is base64 encoded',
type: "checkbox",
name: "secretBase64",
label: "Secret is base64 encoded",
},
{
type: 'editor',
name: 'payload',
label: 'JWT Payload',
language: 'json',
type: "editor",
name: "payload",
label: "JWT Payload",
language: "json",
defaultValue: '{\n "foo": "bar"\n}',
placeholder: '{ }',
placeholder: "{ }",
},
{
type: 'accordion',
label: 'Advanced',
type: "accordion",
label: "Advanced",
inputs: [
{
type: 'editor',
name: 'headers',
label: 'JWT Header',
description: 'Merged with auto-generated header fields like alg (e.g., kid)',
language: 'json',
defaultValue: '{}',
placeholder: '{ }',
type: "editor",
name: "headers",
label: "JWT Header",
description: "Merged with auto-generated header fields like alg (e.g., kid)",
language: "json",
defaultValue: "{}",
placeholder: "{ }",
optional: true,
},
{
type: 'select',
name: 'location',
label: 'Behavior',
defaultValue: 'header',
type: "select",
name: "location",
label: "Behavior",
defaultValue: "header",
options: [
{ label: 'Insert Header', value: 'header' },
{ label: 'Append Query Parameter', value: 'query' },
{ label: "Insert Header", value: "header" },
{ label: "Append Query Parameter", value: "query" },
],
},
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'text',
name: 'name',
label: 'Header Name',
defaultValue: 'Authorization',
type: "text",
name: "name",
label: "Header Name",
defaultValue: "Authorization",
optional: true,
description: 'The name of the header to add to the request',
description: "The name of the header to add to the request",
},
{
type: 'text',
name: 'headerPrefix',
label: 'Header Prefix',
type: "text",
name: "headerPrefix",
label: "Header Prefix",
optional: true,
defaultValue: 'Bearer',
defaultValue: "Bearer",
},
],
dynamic(_ctx, args) {
if (args.values.location === 'query') {
if (args.values.location === "query") {
return {
hidden: true,
};
@@ -106,14 +106,14 @@ export const plugin: PluginDefinition = {
},
},
{
type: 'text',
name: 'name',
label: 'Parameter Name',
description: 'The name of the query parameter to add to the request',
defaultValue: 'token',
type: "text",
name: "name",
label: "Parameter Name",
description: "The name of the query parameter to add to the request",
defaultValue: "token",
optional: true,
dynamic(_ctx, args) {
if (args.values.location !== 'query') {
if (args.values.location !== "query") {
return {
hidden: true,
};
@@ -125,7 +125,7 @@ export const plugin: PluginDefinition = {
],
async onApply(_ctx, { values }) {
const { algorithm, secret: _secret, secretBase64, payload, headers } = values;
const secret = secretBase64 ? Buffer.from(`${_secret}`, 'base64') : `${_secret}`;
const secret = secretBase64 ? Buffer.from(`${_secret}`, "base64") : `${_secret}`;
const parsedHeaders = headers ? JSON.parse(`${headers}`) : undefined;
@@ -135,12 +135,12 @@ export const plugin: PluginDefinition = {
header: parsedHeaders as jwt.JwtHeader | undefined,
});
if (values.location === 'query') {
const paramName = String(values.name || 'token');
if (values.location === "query") {
const paramName = String(values.name || "token");
return { setQueryParameters: [{ name: paramName, value: token }] };
}
const headerPrefix = values.headerPrefix != null ? values.headerPrefix : 'Bearer';
const headerName = String(values.name || 'Authorization');
const headerPrefix = values.headerPrefix != null ? values.headerPrefix : "Bearer";
const headerName = String(values.name || "Authorization");
const headerValue = `${headerPrefix} ${token}`.trim();
return { setHeaders: [{ name: headerName, value: headerValue }] };
},

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-ntlm",
"displayName": "NTLM Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using NTLM authentication",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-ntlm"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,11 +1,11 @@
import type { PluginDefinition } from '@yaakapp/api';
import type { PluginDefinition } from "@yaakapp/api";
import { ntlm } from 'httpntlm';
import { ntlm } from "httpntlm";
function extractNtlmChallenge(headers: Array<{ name: string; value: string }>): string | null {
const authValues = headers
.filter((h) => h.name.toLowerCase() === 'www-authenticate')
.flatMap((h) => h.value.split(','))
.filter((h) => h.name.toLowerCase() === "www-authenticate")
.flatMap((h) => h.value.split(","))
.map((v) => v.trim())
.filter(Boolean);
@@ -14,40 +14,40 @@ function extractNtlmChallenge(headers: Array<{ name: string; value: string }>):
export const plugin: PluginDefinition = {
authentication: {
name: 'windows',
label: 'NTLM Auth',
shortLabel: 'NTLM',
name: "windows",
label: "NTLM Auth",
shortLabel: "NTLM",
args: [
{
type: 'banner',
color: 'info',
type: "banner",
color: "info",
inputs: [
{
type: 'markdown',
type: "markdown",
content:
'NTLM is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).',
"NTLM is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).",
},
],
},
{
type: 'text',
name: 'username',
label: 'Username',
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: 'text',
name: 'password',
label: 'Password',
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
{
type: 'accordion',
label: 'Advanced',
type: "accordion",
label: "Advanced",
inputs: [
{ name: 'domain', label: 'Domain', type: 'text', optional: true },
{ name: 'workstation', label: 'Workstation', type: 'text', optional: true },
{ name: "domain", label: "Domain", type: "text", optional: true },
{ name: "workstation", label: "Workstation", type: "text", optional: true },
],
},
],
@@ -72,15 +72,15 @@ export const plugin: PluginDefinition = {
method,
url,
headers: [
{ name: 'Authorization', value: type1 },
{ name: 'Connection', value: 'keep-alive' },
{ name: "Authorization", value: type1 },
{ name: "Connection", value: "keep-alive" },
],
},
});
const ntlmChallenge = extractNtlmChallenge(negotiateResponse.headers);
if (ntlmChallenge == null) {
throw new Error('Unable to find NTLM challenge in WWW-Authenticate response headers');
throw new Error("Unable to find NTLM challenge in WWW-Authenticate response headers");
}
const type2 = ntlm.parseType2Message(ntlmChallenge, (err: Error | null) => {
@@ -88,7 +88,7 @@ export const plugin: PluginDefinition = {
});
const type3 = ntlm.createType3Message(type2, options);
return { setHeaders: [{ name: 'Authorization', value: type3 }] };
return { setHeaders: [{ name: "Authorization", value: type3 }] };
},
},
};

View File

@@ -1 +1 @@
declare module 'httpntlm';
declare module "httpntlm";

View File

@@ -1,5 +1,5 @@
import type { Context } from '@yaakapp/api';
import { beforeEach, describe, expect, test, vi } from 'vite-plus/test';
import type { Context } from "@yaakapp/api";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
const ntlmMock = vi.hoisted(() => ({
createType1Message: vi.fn(),
@@ -7,26 +7,26 @@ const ntlmMock = vi.hoisted(() => ({
createType3Message: vi.fn(),
}));
vi.mock('httpntlm', () => ({ ntlm: ntlmMock }));
vi.mock("httpntlm", () => ({ ntlm: ntlmMock }));
import { plugin } from '../src';
import { plugin } from "../src";
describe('auth-ntlm', () => {
describe("auth-ntlm", () => {
beforeEach(() => {
ntlmMock.createType1Message.mockReset();
ntlmMock.parseType2Message.mockReset();
ntlmMock.createType3Message.mockReset();
ntlmMock.createType1Message.mockReturnValue('NTLM TYPE1');
ntlmMock.createType1Message.mockReturnValue("NTLM TYPE1");
// oxlint-disable-next-line no-explicit-any
ntlmMock.parseType2Message.mockReturnValue({} as any);
ntlmMock.createType3Message.mockReturnValue('NTLM TYPE3');
ntlmMock.createType3Message.mockReturnValue("NTLM TYPE3");
});
test('uses NTLM challenge when Negotiate and NTLM headers are separate', async () => {
test("uses NTLM challenge when Negotiate and NTLM headers are separate", async () => {
const send = vi.fn().mockResolvedValue({
headers: [
{ name: 'WWW-Authenticate', value: 'Negotiate' },
{ name: 'WWW-Authenticate', value: 'NTLM TlRMTVNTUAACAAAAAA==' },
{ name: "WWW-Authenticate", value: "Negotiate" },
{ name: "WWW-Authenticate", value: "NTLM TlRMTVNTUAACAAAAAA==" },
],
});
const ctx = { httpRequest: { send } } as unknown as Context;
@@ -34,41 +34,41 @@ describe('auth-ntlm', () => {
const result = await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://example.local/resource',
method: 'GET',
contextId: 'ctx',
url: "https://example.local/resource",
method: "GET",
contextId: "ctx",
});
expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(
'NTLM TlRMTVNTUAACAAAAAA==',
"NTLM TlRMTVNTUAACAAAAAA==",
expect.any(Function),
);
expect(result).toEqual({ setHeaders: [{ name: 'Authorization', value: 'NTLM TYPE3' }] });
expect(result).toEqual({ setHeaders: [{ name: "Authorization", value: "NTLM TYPE3" }] });
});
test('uses NTLM challenge when auth schemes are comma-separated in one header', async () => {
test("uses NTLM challenge when auth schemes are comma-separated in one header", async () => {
const send = vi.fn().mockResolvedValue({
headers: [{ name: 'www-authenticate', value: 'Negotiate, NTLM TlRMTVNTUAACAAAAAA==' }],
headers: [{ name: "www-authenticate", value: "Negotiate, NTLM TlRMTVNTUAACAAAAAA==" }],
});
const ctx = { httpRequest: { send } } as unknown as Context;
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://example.local/resource',
method: 'GET',
contextId: 'ctx',
url: "https://example.local/resource",
method: "GET",
contextId: "ctx",
});
expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(
'NTLM TlRMTVNTUAACAAAAAA==',
"NTLM TlRMTVNTUAACAAAAAA==",
expect.any(Function),
);
});
test('throws a clear error when NTLM challenge is missing', async () => {
test("throws a clear error when NTLM challenge is missing", async () => {
const send = vi.fn().mockResolvedValue({
headers: [{ name: 'WWW-Authenticate', value: 'Negotiate' }],
headers: [{ name: "WWW-Authenticate", value: "Negotiate" }],
});
const ctx = { httpRequest: { send } } as unknown as Context;
@@ -76,10 +76,10 @@ describe('auth-ntlm', () => {
plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://example.local/resource',
method: 'GET',
contextId: 'ctx',
url: "https://example.local/resource",
method: "GET",
contextId: "ctx",
}),
).rejects.toThrow('Unable to find NTLM challenge in WWW-Authenticate response headers');
).rejects.toThrow("Unable to find NTLM challenge in WWW-Authenticate response headers");
});
});

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-oauth1",
"displayName": "OAuth 1.0",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using OAuth 1.0a",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-oauth1"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,26 +1,26 @@
import crypto from 'node:crypto';
import type { Context, GetHttpAuthenticationConfigRequest, PluginDefinition } from '@yaakapp/api';
import OAuth from 'oauth-1.0a';
import crypto from "node:crypto";
import type { Context, GetHttpAuthenticationConfigRequest, PluginDefinition } from "@yaakapp/api";
import OAuth from "oauth-1.0a";
const signatures = {
HMAC_SHA1: 'HMAC-SHA1',
HMAC_SHA256: 'HMAC-SHA256',
HMAC_SHA512: 'HMAC-SHA512',
RSA_SHA1: 'RSA-SHA1',
RSA_SHA256: 'RSA-SHA256',
RSA_SHA512: 'RSA-SHA512',
PLAINTEXT: 'PLAINTEXT',
HMAC_SHA1: "HMAC-SHA1",
HMAC_SHA256: "HMAC-SHA256",
HMAC_SHA512: "HMAC-SHA512",
RSA_SHA1: "RSA-SHA1",
RSA_SHA256: "RSA-SHA256",
RSA_SHA512: "RSA-SHA512",
PLAINTEXT: "PLAINTEXT",
} as const;
const defaultSig = signatures.HMAC_SHA1;
const pkSigs = Object.values(signatures).filter((k) => k.startsWith('RSA-'));
const pkSigs = Object.values(signatures).filter((k) => k.startsWith("RSA-"));
const nonPkSigs = Object.values(signatures).filter((k) => !pkSigs.includes(k));
type SigMethod = (typeof signatures)[keyof typeof signatures];
function hiddenIfNot(
sigMethod: SigMethod[],
...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[]
...other: ((values: GetHttpAuthenticationConfigRequest["values"]) => boolean)[]
) {
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
const hasGrantType = sigMethod.find((t) => t === String(values.signatureMethod ?? defaultSig));
@@ -32,78 +32,78 @@ function hiddenIfNot(
export const plugin: PluginDefinition = {
authentication: {
name: 'oauth1',
label: 'OAuth 1.0',
shortLabel: 'OAuth 1',
name: "oauth1",
label: "OAuth 1.0",
shortLabel: "OAuth 1",
args: [
{
type: 'banner',
color: 'info',
type: "banner",
color: "info",
inputs: [
{
type: 'markdown',
type: "markdown",
content:
'OAuth 1.0 is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).',
"OAuth 1.0 is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).",
},
],
},
{
name: 'signatureMethod',
label: 'Signature Method',
type: 'select',
name: "signatureMethod",
label: "Signature Method",
type: "select",
defaultValue: defaultSig,
options: Object.values(signatures).map((v) => ({ label: v, value: v })),
},
{ name: 'consumerKey', label: 'Consumer Key', type: 'text', password: true, optional: true },
{ name: "consumerKey", label: "Consumer Key", type: "text", password: true, optional: true },
{
name: 'consumerSecret',
label: 'Consumer Secret',
type: 'text',
name: "consumerSecret",
label: "Consumer Secret",
type: "text",
password: true,
optional: true,
},
{
name: 'tokenKey',
label: 'Access Token',
type: 'text',
name: "tokenKey",
label: "Access Token",
type: "text",
password: true,
optional: true,
},
{
name: 'tokenSecret',
label: 'Token Secret',
type: 'text',
name: "tokenSecret",
label: "Token Secret",
type: "text",
password: true,
optional: true,
dynamic: hiddenIfNot(nonPkSigs),
},
{
name: 'privateKey',
label: 'Private Key (RSA-SHA1)',
type: 'text',
name: "privateKey",
label: "Private Key (RSA-SHA1)",
type: "text",
multiLine: true,
optional: true,
password: true,
placeholder:
'-----BEGIN RSA PRIVATE KEY-----\nPrivate key in PEM format\n-----END RSA PRIVATE KEY-----',
"-----BEGIN RSA PRIVATE KEY-----\nPrivate key in PEM format\n-----END RSA PRIVATE KEY-----",
dynamic: hiddenIfNot(pkSigs),
},
{
type: 'accordion',
label: 'Advanced',
type: "accordion",
label: "Advanced",
inputs: [
{ name: 'callback', label: 'Callback Url', type: 'text', optional: true },
{ name: 'verifier', label: 'Verifier', type: 'text', optional: true, password: true },
{ name: 'timestamp', label: 'Timestamp', type: 'text', optional: true },
{ name: 'nonce', label: 'Nonce', type: 'text', optional: true },
{ name: "callback", label: "Callback Url", type: "text", optional: true },
{ name: "verifier", label: "Verifier", type: "text", optional: true, password: true },
{ name: "timestamp", label: "Timestamp", type: "text", optional: true },
{ name: "nonce", label: "Nonce", type: "text", optional: true },
{
name: 'version',
label: 'OAuth Version',
type: 'text',
name: "version",
label: "OAuth Version",
type: "text",
optional: true,
defaultValue: '1.0',
defaultValue: "1.0",
},
{ name: 'realm', label: 'Realm', type: 'text', optional: true },
{ name: "realm", label: "Realm", type: "text", optional: true },
],
},
],
@@ -115,12 +115,12 @@ export const plugin: PluginDefinition = {
setHeaders?: { name: string; value: string }[];
setQueryParameters?: { name: string; value: string }[];
} {
const consumerKey = String(values.consumerKey || '');
const consumerSecret = String(values.consumerSecret || '');
const consumerKey = String(values.consumerKey || "");
const consumerSecret = String(values.consumerSecret || "");
const signatureMethod = String(values.signatureMethod || signatures.HMAC_SHA1) as SigMethod;
const version = String(values.version || '1.0');
const realm = String(values.realm || '') || undefined;
const version = String(values.version || "1.0");
const realm = String(values.realm || "") || undefined;
const oauth = new OAuth({
consumer: { key: consumerKey, secret: consumerSecret },
@@ -131,13 +131,13 @@ export const plugin: PluginDefinition = {
});
if (pkSigs.includes(signatureMethod)) {
oauth.getSigningKey = (tokenSecret?: string) => tokenSecret || '';
oauth.getSigningKey = (tokenSecret?: string) => tokenSecret || "";
}
const requestUrl = new URL(url);
// Base request options passed to oauth-1.0a
const requestData: Omit<OAuth.RequestOptions, 'data'> & {
const requestData: Omit<OAuth.RequestOptions, "data"> & {
data: Record<string, string | string[]>;
} = {
method,
@@ -148,7 +148,7 @@ export const plugin: PluginDefinition = {
// (1) Include existing query params in signature base string
for (const key of requestUrl.searchParams.keys()) {
if (key.startsWith('oauth_')) continue;
if (key.startsWith("oauth_")) continue;
const all = requestUrl.searchParams.getAll(key);
const first = all[0];
if (first == null) continue;
@@ -165,8 +165,8 @@ export const plugin: PluginDefinition = {
if (pkSigs.includes(signatureMethod)) {
token = {
key: String(values.tokenKey || ''),
secret: String(values.privateKey || ''),
key: String(values.tokenKey || ""),
secret: String(values.privateKey || ""),
};
} else if (values.tokenKey && values.tokenSecret) {
token = { key: String(values.tokenKey), secret: String(values.tokenSecret) };
@@ -176,7 +176,7 @@ export const plugin: PluginDefinition = {
const authParams = oauth.authorize(requestData, token as OAuth.Token | undefined);
const { Authorization } = oauth.toHeader(authParams);
return { setHeaders: [{ name: 'Authorization', value: Authorization }] };
return { setHeaders: [{ name: "Authorization", value: Authorization }] };
},
},
};
@@ -185,26 +185,26 @@ function hashFunction(signatureMethod: SigMethod) {
switch (signatureMethod) {
case signatures.HMAC_SHA1:
return (base: string, key: string) =>
crypto.createHmac('sha1', key).update(base).digest('base64');
crypto.createHmac("sha1", key).update(base).digest("base64");
case signatures.HMAC_SHA256:
return (base: string, key: string) =>
crypto.createHmac('sha256', key).update(base).digest('base64');
crypto.createHmac("sha256", key).update(base).digest("base64");
case signatures.HMAC_SHA512:
return (base: string, key: string) =>
crypto.createHmac('sha512', key).update(base).digest('base64');
crypto.createHmac("sha512", key).update(base).digest("base64");
case signatures.RSA_SHA1:
return (base: string, privateKey: string) =>
crypto.createSign('RSA-SHA1').update(base).sign(privateKey, 'base64');
crypto.createSign("RSA-SHA1").update(base).sign(privateKey, "base64");
case signatures.RSA_SHA256:
return (base: string, privateKey: string) =>
crypto.createSign('RSA-SHA256').update(base).sign(privateKey, 'base64');
crypto.createSign("RSA-SHA256").update(base).sign(privateKey, "base64");
case signatures.RSA_SHA512:
return (base: string, privateKey: string) =>
crypto.createSign('RSA-SHA512').update(base).sign(privateKey, 'base64');
crypto.createSign("RSA-SHA512").update(base).sign(privateKey, "base64");
case signatures.PLAINTEXT:
return (base: string) => base;
default:
return (base: string, key: string) =>
crypto.createHmac('sha1', key).update(base).digest('base64');
crypto.createHmac("sha1", key).update(base).digest("base64");
}
}

View File

@@ -25,7 +25,7 @@ The most secure and commonly used OAuth 2.0 flow for web applications.
### Client Credentials Flow
Ideal for server-to-server authentication where no user interaction is required.
Ideal for server-to-server authentication where no user interaction is required.
### Implicit Flow

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/auth-oauth2",
"displayName": "OAuth 2.0",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using OAuth 2.0",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-oauth2"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,8 +1,8 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import http from 'node:http';
import type { Context } from '@yaakapp/api';
import type { IncomingMessage, ServerResponse } from "node:http";
import http from "node:http";
import type { Context } from "@yaakapp/api";
export const HOSTED_CALLBACK_URL_BASE = 'https://oauth.yaak.app/redirect';
export const HOSTED_CALLBACK_URL_BASE = "https://oauth.yaak.app/redirect";
export const DEFAULT_LOCALHOST_PORT = 8765;
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -36,12 +36,12 @@ export function startCallbackServer(options: {
}): Promise<CallbackServerResult> {
// Stop any previously active server before starting a new one
if (activeServer) {
console.log('[oauth2] Stopping previous callback server before starting new one');
console.log("[oauth2] Stopping previous callback server before starting new one");
activeServer.stop();
activeServer = null;
}
const { port = 0, path = '/callback', timeoutMs = CALLBACK_TIMEOUT_MS } = options;
const { port = 0, path = "/callback", timeoutMs = CALLBACK_TIMEOUT_MS } = options;
return new Promise((resolve, reject) => {
let callbackResolve: ((url: string) => void) | null = null;
@@ -50,33 +50,33 @@ export function startCallbackServer(options: {
let stopped = false;
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
const reqUrl = new URL(req.url ?? "/", `http://${req.headers.host}`);
// Only handle the callback path
if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
return;
}
if (req.method === 'POST') {
if (req.method === "POST") {
// POST: read JSON body with the final callback URL and resolve
let body = '';
req.on('data', (chunk: Buffer) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on('end', () => {
req.on("end", () => {
try {
const { url: callbackUrl } = JSON.parse(body);
if (!callbackUrl || typeof callbackUrl !== 'string') {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Missing url in request body');
if (!callbackUrl || typeof callbackUrl !== "string") {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Missing url in request body");
return;
}
// Send success response
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK");
// Resolve the callback promise
if (callbackResolve) {
@@ -88,19 +88,19 @@ export function startCallbackServer(options: {
// Stop the server after a short delay to ensure response is sent
setTimeout(() => stopServer(), 100);
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Invalid JSON');
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Invalid JSON");
}
});
return;
}
// GET: serve intermediate page that reads the fragment and POSTs back
res.writeHead(200, { 'Content-Type': 'text/html' });
res.writeHead(200, { "Content-Type": "text/html" });
res.end(getFragmentForwardingHtml());
});
server.on('error', (err: Error) => {
server.on("error", (err: Error) => {
if (!stopped) {
reject(err);
}
@@ -123,16 +123,16 @@ export function startCallbackServer(options: {
server.close();
if (callbackReject) {
callbackReject(new Error('Callback server stopped'));
callbackReject(new Error("Callback server stopped"));
callbackResolve = null;
callbackReject = null;
}
};
server.listen(port, '127.0.0.1', () => {
server.listen(port, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
if (!address || typeof address === "string") {
reject(new Error("Failed to get server address"));
return;
}
@@ -147,7 +147,7 @@ export function startCallbackServer(options: {
waitForCallback: () => {
return new Promise<string>((res, rej) => {
if (stopped) {
rej(new Error('Callback server already stopped'));
rej(new Error("Callback server already stopped"));
return;
}
@@ -157,7 +157,7 @@ export function startCallbackServer(options: {
// Set timeout
timeoutHandle = setTimeout(() => {
if (callbackReject) {
callbackReject(new Error('Authorization timed out'));
callbackReject(new Error("Authorization timed out"));
callbackResolve = null;
callbackReject = null;
}
@@ -193,7 +193,7 @@ export function buildHostedCallbackRedirectUri(localPort: number): string {
*/
export function stopActiveServer(): void {
if (activeServer) {
console.log('[oauth2] Stopping active callback server during dispose');
console.log("[oauth2] Stopping active callback server during dispose");
activeServer.stop();
activeServer = null;
}
@@ -210,7 +210,7 @@ export async function getRedirectUrlViaExternalBrowser(
ctx: Context,
authorizationUrl: URL,
options: {
callbackType: 'localhost' | 'hosted';
callbackType: "localhost" | "hosted";
callbackPort?: number;
},
): Promise<{ callbackUrl: string; redirectUri: string }> {
@@ -222,31 +222,31 @@ export async function getRedirectUrlViaExternalBrowser(
const server = await startCallbackServer({
port,
path: '/callback',
path: "/callback",
});
try {
// Determine the redirect URI to send to the OAuth provider
let oauthRedirectUri: string;
if (callbackType === 'hosted') {
if (callbackType === "hosted") {
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
console.log("[oauth2] Using hosted callback redirect:", oauthRedirectUri);
} else {
oauthRedirectUri = server.redirectUri;
console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri);
console.log("[oauth2] Using localhost callback redirect:", oauthRedirectUri);
}
// Set the redirect URI on the authorization URL
authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri);
authorizationUrl.searchParams.set("redirect_uri", oauthRedirectUri);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Opening external browser:', authorizationUrlStr);
console.log("[oauth2] Opening external browser:", authorizationUrlStr);
// Show toast to inform user
await ctx.toast.show({
message: 'Opening browser for authorization...',
icon: 'info',
message: "Opening browser for authorization...",
icon: "info",
timeout: 3000,
});
@@ -254,10 +254,10 @@ export async function getRedirectUrlViaExternalBrowser(
await ctx.window.openExternalUrl(authorizationUrlStr);
// Wait for the callback
console.log('[oauth2] Waiting for callback on', server.redirectUri);
console.log("[oauth2] Waiting for callback on", server.redirectUri);
const callbackUrl = await server.waitForCallback();
console.log('[oauth2] Received callback:', callbackUrl);
console.log("[oauth2] Received callback:", callbackUrl);
return { callbackUrl, redirectUri: oauthRedirectUri };
} finally {

View File

@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';
import type { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
import type { AccessTokenRawResponse } from './store';
import { readFileSync } from "node:fs";
import type { Context, HttpRequest, HttpUrlParameter } from "@yaakapp/api";
import type { AccessTokenRawResponse } from "./store";
export async function fetchAccessToken(
ctx: Context,
@@ -14,58 +14,58 @@ export async function fetchAccessToken(
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
): Promise<AccessTokenRawResponse> {
const { clientId, grantType, accessTokenUrl, scope, audience, params } = args;
console.log('[oauth2] Getting access token', accessTokenUrl);
console.log("[oauth2] Getting access token", accessTokenUrl);
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
method: "POST",
url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded',
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'grant_type', value: grantType }, ...params],
form: [{ name: "grant_type", value: grantType }, ...params],
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: "User-Agent", value: "yaak" },
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
name: "Accept",
value: "application/x-www-form-urlencoded, application/json",
},
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
],
};
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience });
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
if (audience) httpRequest.body?.form.push({ name: "audience", value: audience });
if ('clientAssertion' in args) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
if ("clientAssertion" in args) {
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({
name: 'client_assertion_type',
value: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
name: "client_assertion_type",
value: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
});
httpRequest.body?.form.push({
name: 'client_assertion',
name: "client_assertion",
value: args.clientAssertion,
});
} else if (args.credentialsInBody) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({
name: 'client_secret',
name: "client_secret",
value: args.clientSecret,
});
} else {
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString('base64')}`;
httpRequest.headers?.push({ name: 'Authorization', value });
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString("base64")}`;
httpRequest.headers?.push({ name: "Authorization", value });
}
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
console.log('[oauth2] Got access token response', resp.status);
console.log("[oauth2] Got access token response", resp.status);
if (resp.error) {
throw new Error(`Failed to fetch access token: ${resp.error}`);
}
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Failed to fetch access token with status=${resp.status} and body=${body}`);

View File

@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import type { Context, HttpRequest } from '@yaakapp/api';
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from './store';
import { deleteToken, getToken, storeToken } from './store';
import { isTokenExpired } from './util';
import { readFileSync } from "node:fs";
import type { Context, HttpRequest } from "@yaakapp/api";
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from "./store";
import { deleteToken, getToken, storeToken } from "./store";
import { isTokenExpired } from "./util";
export async function getOrRefreshAccessToken(
ctx: Context,
@@ -42,33 +42,33 @@ export async function getOrRefreshAccessToken(
// Access token is expired, so get a new one
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
method: "POST",
url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded',
bodyType: "application/x-www-form-urlencoded",
body: {
form: [
{ name: 'grant_type', value: 'refresh_token' },
{ name: 'refresh_token', value: token.response.refresh_token },
{ name: "grant_type", value: "refresh_token" },
{ name: "refresh_token", value: token.response.refresh_token },
],
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
{ name: "User-Agent", value: "yaak" },
{ name: "Accept", value: "application/x-www-form-urlencoded, application/json" },
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
],
};
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
if (credentialsInBody) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
httpRequest.body?.form.push({ name: 'client_secret', value: clientSecret });
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({ name: "client_secret", value: clientSecret });
} else {
const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
httpRequest.headers?.push({ name: 'Authorization', value });
const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
httpRequest.headers?.push({ name: "Authorization", value });
}
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
if (resp.error) {
@@ -78,14 +78,14 @@ export async function getOrRefreshAccessToken(
if (resp.status >= 400 && resp.status < 500) {
// Client errors (4xx) indicate the refresh token is invalid, expired, or revoked
// Delete the token and return null to trigger a fresh authorization flow
console.log('[oauth2] Refresh token request failed with client error, deleting token');
console.log("[oauth2] Refresh token request failed with client error, deleting token");
await deleteToken(ctx, tokenArgs);
return null;
}
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
console.log('[oauth2] Got refresh token response', resp.status);
console.log("[oauth2] Got refresh token response", resp.status);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Failed to refresh access token with status=${resp.status} and body=${body}`);

View File

@@ -1,17 +1,17 @@
import { createHash, randomBytes } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken, TokenStoreArgs } from '../store';
import { getDataDirKey, storeToken } from '../store';
import { extractCode } from '../util';
import { createHash, randomBytes } from "node:crypto";
import type { Context } from "@yaakapp/api";
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
import { fetchAccessToken } from "../fetchAccessToken";
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
import type { AccessToken, TokenStoreArgs } from "../store";
import { getDataDirKey, storeToken } from "../store";
import { extractCode } from "../util";
export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain';
export const PKCE_SHA256 = "S256";
export const PKCE_PLAIN = "plain";
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
export type CallbackType = 'localhost' | 'hosted';
export type CallbackType = "localhost" | "hosted";
export interface ExternalBrowserOptions {
useExternalBrowser: boolean;
@@ -50,7 +50,7 @@ export async function getAuthorizationCode(
challengeMethod: string;
codeVerifier: string;
} | null;
tokenName: 'access_token' | 'id_token';
tokenName: "access_token" | "id_token";
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
@@ -74,21 +74,21 @@ export async function getAuthorizationCode(
let authorizationUrl: URL;
try {
authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', clientId);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("client_id", clientId);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (pkce) {
authorizationUrl.searchParams.set(
'code_challenge',
"code_challenge",
pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),
);
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
authorizationUrl.searchParams.set("code_challenge_method", pkce.challengeMethod);
}
let code: string;
@@ -103,21 +103,21 @@ export async function getAuthorizationCode(
// Pass null to skip redirect URI matching — the callback came from our own local server
const extractedCode = extractCode(result.callbackUrl, null);
if (!extractedCode) {
throw new Error('No authorization code found in callback URL');
throw new Error("No authorization code found in callback URL");
}
code = extractedCode;
actualRedirectUri = result.redirectUri;
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
}
code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);
}
console.log('[oauth2] Code found');
console.log("[oauth2] Code found");
const response = await fetchAccessToken(ctx, {
grantType: 'authorization_code',
grantType: "authorization_code",
accessTokenUrl,
clientId,
clientSecret,
@@ -125,9 +125,9 @@ export async function getAuthorizationCode(
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
...(actualRedirectUri ? [{ name: 'redirect_uri', value: actualRedirectUri }] : []),
{ name: "code", value: code },
...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []),
...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []),
],
});
@@ -146,7 +146,7 @@ async function getCodeViaEmbeddedBrowser(
): Promise<string> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr);
console.log("[oauth2] Authorizing via embedded browser", authorizationUrlStr);
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
return new Promise<string>(async (resolve, reject) => {
@@ -154,10 +154,10 @@ async function getCodeViaEmbeddedBrowser(
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: 'oauth-authorization-url',
label: "oauth-authorization-url",
async onClose() {
if (!foundCode) {
reject(new Error('Authorization window closed'));
reject(new Error("Authorization window closed"));
}
},
async onNavigate({ url: urlStr }) {
@@ -187,21 +187,21 @@ export function genPkceCodeVerifier() {
}
function pkceCodeChallenge(verifier: string, method: string) {
if (method === 'plain') {
if (method === "plain") {
return verifier;
}
const hash = encodeForPkce(createHash('sha256').update(verifier).digest());
const hash = encodeForPkce(createHash("sha256").update(verifier).digest());
return hash
.replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_'
.replace(/=/g, "") // Remove padding '='
.replace(/\+/g, "-") // Replace '+' with '-'
.replace(/\//g, "_"); // Replace '/' with '_'
}
function encodeForPkce(bytes: Buffer) {
return bytes
.toString('base64')
.replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_'
.toString("base64")
.replace(/=/g, "") // Remove padding '='
.replace(/\+/g, "-") // Replace '+' with '-'
.replace(/\//g, "_"); // Replace '/' with '_'
}

View File

@@ -1,25 +1,25 @@
import { createPrivateKey, randomUUID } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import jwt, { type Algorithm } from 'jsonwebtoken';
import { fetchAccessToken } from '../fetchAccessToken';
import type { TokenStoreArgs } from '../store';
import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
import { createPrivateKey, randomUUID } from "node:crypto";
import type { Context } from "@yaakapp/api";
import jwt, { type Algorithm } from "jsonwebtoken";
import { fetchAccessToken } from "../fetchAccessToken";
import type { TokenStoreArgs } from "../store";
import { getToken, storeToken } from "../store";
import { isTokenExpired } from "../util";
export const jwtAlgorithms = [
'HS256',
'HS384',
'HS512',
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES384',
'ES512',
'none',
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"none",
] as const;
export const defaultJwtAlgorithm = jwtAlgorithms[0];
@@ -40,7 +40,7 @@ function buildClientAssertionJwt(params: {
}): string {
const { clientId, accessTokenUrl, secret, algorithm } = params;
const isHmac = algorithm.startsWith('HS') || algorithm === 'none';
const isHmac = algorithm.startsWith("HS") || algorithm === "none";
// Resolve the signing key depending on format
let signingKey: jwt.Secret;
@@ -51,25 +51,25 @@ function buildClientAssertionJwt(params: {
if (isHmac) {
// HMAC algorithms use the raw secret (string or Buffer)
signingKey = secret;
} else if (trimmed.startsWith('{')) {
} else if (trimmed.startsWith("{")) {
// Looks like JSON - treat as JWK. There is surely a better way to detect JWK vs a raw secret, but this should work in most cases.
// oxlint-disable-next-line no-explicit-any
let jwk: any;
try {
jwk = JSON.parse(trimmed);
} catch {
throw new Error('Client Assertion secret looks like JSON but is not valid');
throw new Error("Client Assertion secret looks like JSON but is not valid");
}
kid = jwk?.kid;
signingKey = createPrivateKey({ key: jwk, format: 'jwk' });
} else if (trimmed.startsWith('-----')) {
signingKey = createPrivateKey({ key: jwk, format: "jwk" });
} else if (trimmed.startsWith("-----")) {
// PEM-encoded key
signingKey = createPrivateKey({ key: trimmed, format: 'pem' });
signingKey = createPrivateKey({ key: trimmed, format: "pem" });
} else {
throw new Error(
'Client Assertion secret must be a JWK JSON object, a PEM-encoded key ' +
'(starting with -----), or a raw secret for HMAC algorithms.',
"Client Assertion secret must be a JWK JSON object, a PEM-encoded key " +
"(starting with -----), or a raw secret for HMAC algorithms.",
);
}
@@ -84,7 +84,7 @@ function buildClientAssertionJwt(params: {
};
// Build the JWT header; include "kid" when available
const header: jwt.JwtHeader = { alg: algorithm, typ: 'JWT' };
const header: jwt.JwtHeader = { alg: algorithm, typ: "JWT" };
if (kid) {
header.kid = kid;
}
@@ -135,9 +135,9 @@ export async function getClientCredentials(
const common: Omit<
Parameters<typeof fetchAccessToken>[1],
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
"clientAssertion" | "clientSecret" | "credentialsInBody"
> = {
grantType: 'client_credentials',
grantType: "client_credentials",
accessTokenUrl,
audience,
clientId,
@@ -146,7 +146,7 @@ export async function getClientCredentials(
};
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
clientCredentialsMethod === 'client_assertion'
clientCredentialsMethod === "client_assertion"
? {
...common,
clientAssertion: buildClientAssertionJwt({
@@ -154,7 +154,7 @@ export async function getClientCredentials(
algorithm: clientAssertionAlgorithm as Algorithm,
accessTokenUrl,
secret: clientAssertionSecretBase64
? Buffer.from(clientAssertionSecret, 'base64').toString('utf-8')
? Buffer.from(clientAssertionSecret, "base64").toString("utf-8")
: clientAssertionSecret,
}),
}

View File

@@ -1,9 +1,9 @@
import type { Context } from '@yaakapp/api';
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
import type { AccessToken, AccessTokenRawResponse } from '../store';
import { getDataDirKey, getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
import type { ExternalBrowserOptions } from './authorizationCode';
import type { Context } from "@yaakapp/api";
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
import type { AccessToken, AccessTokenRawResponse } from "../store";
import { getDataDirKey, getToken, storeToken } from "../store";
import { isTokenExpired } from "../util";
import type { ExternalBrowserOptions } from "./authorizationCode";
export async function getImplicit(
ctx: Context,
@@ -26,7 +26,7 @@ export async function getImplicit(
scope: string | null;
state: string | null;
audience: string | null;
tokenName: 'access_token' | 'id_token';
tokenName: "access_token" | "id_token";
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
@@ -43,18 +43,18 @@ export async function getImplicit(
let authorizationUrl: URL;
try {
authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set('response_type', responseType);
authorizationUrl.searchParams.set('client_id', clientId);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
if (responseType.includes('id_token')) {
authorizationUrl.searchParams.set("response_type", responseType);
authorizationUrl.searchParams.set("client_id", clientId);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (responseType.includes("id_token")) {
authorizationUrl.searchParams.set(
'nonce',
"nonce",
String(Math.floor(Math.random() * 9999999999999) + 1),
);
}
@@ -71,7 +71,7 @@ export async function getImplicit(
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
}
newToken = await getTokenViaEmbeddedBrowser(
ctx,
@@ -99,11 +99,11 @@ async function getTokenViaEmbeddedBrowser(
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: 'access_token' | 'id_token',
tokenName: "access_token" | "id_token",
): Promise<AccessToken> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing via embedded browser (implicit)', authorizationUrlStr);
console.log("[oauth2] Authorizing via embedded browser (implicit)", authorizationUrlStr);
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
return new Promise<AccessToken>(async (resolve, reject) => {
@@ -111,16 +111,16 @@ async function getTokenViaEmbeddedBrowser(
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: 'oauth-authorization-url',
label: "oauth-authorization-url",
async onClose() {
if (!foundAccessToken) {
reject(new Error('Authorization window closed'));
reject(new Error("Authorization window closed"));
}
},
async onNavigate({ url: urlStr }) {
const url = new URL(urlStr);
if (url.searchParams.has('error')) {
return reject(Error(`Failed to authorize: ${url.searchParams.get('error')}`));
if (url.searchParams.has("error")) {
return reject(Error(`Failed to authorize: ${url.searchParams.get("error")}`));
}
const hash = url.hash.slice(1);
@@ -158,13 +158,13 @@ async function extractImplicitToken(
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: 'access_token' | 'id_token',
tokenName: "access_token" | "id_token",
): Promise<AccessToken> {
const url = new URL(callbackUrl);
// Check for errors
if (url.searchParams.has('error')) {
throw new Error(`Failed to authorize: ${url.searchParams.get('error')}`);
if (url.searchParams.has("error")) {
throw new Error(`Failed to authorize: ${url.searchParams.get("error")}`);
}
// Extract token from fragment
@@ -179,18 +179,18 @@ async function extractImplicitToken(
// Build response from params (prefer fragment, fall back to query)
const response: AccessTokenRawResponse = {
access_token: params.get('access_token') ?? url.searchParams.get('access_token') ?? '',
token_type: params.get('token_type') ?? url.searchParams.get('token_type') ?? undefined,
expires_in: params.has('expires_in')
? parseInt(params.get('expires_in') ?? '0', 10)
: url.searchParams.has('expires_in')
? parseInt(url.searchParams.get('expires_in') ?? '0', 10)
access_token: params.get("access_token") ?? url.searchParams.get("access_token") ?? "",
token_type: params.get("token_type") ?? url.searchParams.get("token_type") ?? undefined,
expires_in: params.has("expires_in")
? parseInt(params.get("expires_in") ?? "0", 10)
: url.searchParams.has("expires_in")
? parseInt(url.searchParams.get("expires_in") ?? "0", 10)
: undefined,
scope: params.get('scope') ?? url.searchParams.get('scope') ?? undefined,
scope: params.get("scope") ?? url.searchParams.get("scope") ?? undefined,
};
// Include id_token if present
const idToken = params.get('id_token') ?? url.searchParams.get('id_token');
const idToken = params.get("id_token") ?? url.searchParams.get("id_token");
if (idToken) {
response.id_token = idToken;
}

View File

@@ -1,8 +1,8 @@
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken, TokenStoreArgs } from '../store';
import { storeToken } from '../store';
import type { Context } from "@yaakapp/api";
import { fetchAccessToken } from "../fetchAccessToken";
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
import type { AccessToken, TokenStoreArgs } from "../store";
import { storeToken } from "../store";
export async function getPassword(
ctx: Context,
@@ -50,11 +50,11 @@ export async function getPassword(
clientSecret,
scope,
audience,
grantType: 'password',
grantType: "password",
credentialsInBody,
params: [
{ name: 'username', value: username },
{ name: 'password', value: password },
{ name: "username", value: username },
{ name: "password", value: password },
],
});

View File

@@ -4,13 +4,13 @@ import type {
GetHttpAuthenticationConfigRequest,
JsonPrimitive,
PluginDefinition,
} from '@yaakapp/api';
import type { Algorithm } from 'jsonwebtoken';
} from "@yaakapp/api";
import type { Algorithm } from "jsonwebtoken";
import {
buildHostedCallbackRedirectUri,
DEFAULT_LOCALHOST_PORT,
stopActiveServer,
} from './callbackServer';
} from "./callbackServer";
import {
type CallbackType,
DEFAULT_PKCE_METHOD,
@@ -18,31 +18,31 @@ import {
getAuthorizationCode,
PKCE_PLAIN,
PKCE_SHA256,
} from './grants/authorizationCode';
} from "./grants/authorizationCode";
import {
defaultJwtAlgorithm,
getClientCredentials,
jwtAlgorithms,
} from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import type { AccessToken, TokenStoreArgs } from './store';
import { deleteToken, getToken, resetDataDirKey } from './store';
} from "./grants/clientCredentials";
import { getImplicit } from "./grants/implicit";
import { getPassword } from "./grants/password";
import type { AccessToken, TokenStoreArgs } from "./store";
import { deleteToken, getToken, resetDataDirKey } from "./store";
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
type GrantType = "authorization_code" | "implicit" | "password" | "client_credentials";
const grantTypes: FormInputSelectOption[] = [
{ label: 'Authorization Code', value: 'authorization_code' },
{ label: 'Implicit', value: 'implicit' },
{ label: 'Resource Owner Password Credential', value: 'password' },
{ label: 'Client Credentials', value: 'client_credentials' },
{ label: "Authorization Code", value: "authorization_code" },
{ label: "Implicit", value: "implicit" },
{ label: "Resource Owner Password Credential", value: "password" },
{ label: "Client Credentials", value: "client_credentials" },
];
const defaultGrantType = grantTypes[0]?.value;
function hiddenIfNot(
grantTypes: GrantType[],
...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[]
...other: ((values: GetHttpAuthenticationConfigRequest["values"]) => boolean)[]
) {
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType));
@@ -53,37 +53,37 @@ function hiddenIfNot(
}
const authorizationUrls = [
'https://github.com/login/oauth/authorize',
'https://account.box.com/api/oauth2/authorize',
'https://accounts.google.com/o/oauth2/v2/auth',
'https://api.imgur.com/oauth2/authorize',
'https://bitly.com/oauth/authorize',
'https://gitlab.example.com/oauth/authorize',
'https://medium.com/m/oauth/authorize',
'https://public-api.wordpress.com/oauth2/authorize',
'https://slack.com/oauth/authorize',
'https://todoist.com/oauth/authorize',
'https://www.dropbox.com/oauth2/authorize',
'https://www.linkedin.com/oauth/v2/authorization',
'https://MY_SHOP.myshopify.com/admin/oauth/access_token',
'https://appcenter.intuit.com/app/connect/oauth2/authorize',
"https://github.com/login/oauth/authorize",
"https://account.box.com/api/oauth2/authorize",
"https://accounts.google.com/o/oauth2/v2/auth",
"https://api.imgur.com/oauth2/authorize",
"https://bitly.com/oauth/authorize",
"https://gitlab.example.com/oauth/authorize",
"https://medium.com/m/oauth/authorize",
"https://public-api.wordpress.com/oauth2/authorize",
"https://slack.com/oauth/authorize",
"https://todoist.com/oauth/authorize",
"https://www.dropbox.com/oauth2/authorize",
"https://www.linkedin.com/oauth/v2/authorization",
"https://MY_SHOP.myshopify.com/admin/oauth/access_token",
"https://appcenter.intuit.com/app/connect/oauth2/authorize",
];
const accessTokenUrls = [
'https://github.com/login/oauth/access_token',
'https://api-ssl.bitly.com/oauth/access_token',
'https://api.box.com/oauth2/token',
'https://api.dropboxapi.com/oauth2/token',
'https://api.imgur.com/oauth2/token',
'https://api.medium.com/v1/tokens',
'https://gitlab.example.com/oauth/token',
'https://public-api.wordpress.com/oauth2/token',
'https://slack.com/api/oauth.access',
'https://todoist.com/oauth/access_token',
'https://www.googleapis.com/oauth2/v4/token',
'https://www.linkedin.com/oauth/v2/accessToken',
'https://MY_SHOP.myshopify.com/admin/oauth/authorize',
'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
"https://github.com/login/oauth/access_token",
"https://api-ssl.bitly.com/oauth/access_token",
"https://api.box.com/oauth2/token",
"https://api.dropboxapi.com/oauth2/token",
"https://api.imgur.com/oauth2/token",
"https://api.medium.com/v1/tokens",
"https://gitlab.example.com/oauth/token",
"https://public-api.wordpress.com/oauth2/token",
"https://slack.com/api/oauth.access",
"https://todoist.com/oauth/access_token",
"https://www.googleapis.com/oauth2/v4/token",
"https://www.linkedin.com/oauth/v2/accessToken",
"https://MY_SHOP.myshopify.com/admin/oauth/authorize",
"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
];
export const plugin: PluginDefinition = {
@@ -91,59 +91,59 @@ export const plugin: PluginDefinition = {
stopActiveServer();
},
authentication: {
name: 'oauth2',
label: 'OAuth 2.0',
shortLabel: 'OAuth 2',
name: "oauth2",
label: "OAuth 2.0",
shortLabel: "OAuth 2",
actions: [
{
label: 'Copy Current Token',
label: "Copy Current Token",
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
await ctx.toast.show({
message: 'No token to copy',
color: 'warning',
message: "No token to copy",
color: "warning",
});
} else {
await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({
message: 'Token copied to clipboard',
icon: 'copy',
color: 'success',
message: "Token copied to clipboard",
icon: "copy",
color: "success",
});
}
},
},
{
label: 'Delete Token',
label: "Delete Token",
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
};
if (await deleteToken(ctx, tokenArgs)) {
await ctx.toast.show({
message: 'Token deleted',
color: 'success',
message: "Token deleted",
color: "success",
});
} else {
await ctx.toast.show({
message: 'No token to delete',
color: 'warning',
message: "No token to delete",
color: "warning",
});
}
},
},
{
label: 'Clear Window Session',
label: "Clear Window Session",
async onSelect(ctx, { contextId }) {
await resetDataDirKey(ctx, contextId);
},
@@ -151,85 +151,85 @@ export const plugin: PluginDefinition = {
],
args: [
{
type: 'select',
name: 'grantType',
label: 'Grant Type',
type: "select",
name: "grantType",
label: "Grant Type",
defaultValue: defaultGrantType,
options: grantTypes,
},
{
type: 'select',
name: 'clientCredentialsMethod',
label: 'Authentication Method',
type: "select",
name: "clientCredentialsMethod",
label: "Authentication Method",
description:
'"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.',
defaultValue: 'client_secret',
defaultValue: "client_secret",
options: [
{ label: 'Client Secret', value: 'client_secret' },
{ label: 'Client Assertion', value: 'client_assertion' },
{ label: "Client Secret", value: "client_secret" },
{ label: "Client Assertion", value: "client_assertion" },
],
dynamic: hiddenIfNot(['client_credentials']),
dynamic: hiddenIfNot(["client_credentials"]),
},
{
type: 'text',
name: 'clientId',
label: 'Client ID',
type: "text",
name: "clientId",
label: "Client ID",
optional: true,
},
{
type: 'text',
name: 'clientSecret',
label: 'Client Secret',
type: "text",
name: "clientSecret",
label: "Client Secret",
optional: true,
password: true,
dynamic: hiddenIfNot(
['authorization_code', 'password', 'client_credentials'],
(values) => values.clientCredentialsMethod === 'client_secret',
["authorization_code", "password", "client_credentials"],
(values) => values.clientCredentialsMethod === "client_secret",
),
},
{
type: 'select',
name: 'clientAssertionAlgorithm',
label: 'JWT Algorithm',
type: "select",
name: "clientAssertionAlgorithm",
label: "JWT Algorithm",
defaultValue: defaultJwtAlgorithm,
options: jwtAlgorithms.map((value) => ({
label: value === 'none' ? 'None' : value,
label: value === "none" ? "None" : value,
value,
})),
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: 'text',
name: 'clientAssertionSecret',
label: 'JWT Secret',
type: "text",
name: "clientAssertionSecret",
label: "JWT Secret",
description:
'Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.',
"Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.",
password: true,
optional: true,
multiLine: true,
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: 'checkbox',
name: 'clientAssertionSecretBase64',
label: 'JWT secret is base64 encoded',
type: "checkbox",
name: "clientAssertionSecretBase64",
label: "JWT secret is base64 encoded",
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: 'text',
name: 'authorizationUrl',
type: "text",
name: "authorizationUrl",
optional: true,
label: 'Authorization URL',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
label: "Authorization URL",
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map((url) => ({
label: url,
@@ -237,103 +237,103 @@ export const plugin: PluginDefinition = {
})),
},
{
type: 'text',
name: 'accessTokenUrl',
type: "text",
name: "accessTokenUrl",
optional: true,
label: 'Access Token URL',
label: "Access Token URL",
placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"]),
completionOptions: accessTokenUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: 'banner',
type: "banner",
inputs: [
{
type: 'checkbox',
name: 'useExternalBrowser',
label: 'Use External Browser',
type: "checkbox",
name: "useExternalBrowser",
label: "Use External Browser",
description:
'Open authorization URL in your system browser instead of the embedded browser. ' +
'Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
"Open authorization URL in your system browser instead of the embedded browser. " +
"Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.",
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{
type: 'text',
name: 'redirectUri',
label: 'Redirect URI (can be any valid URL)',
placeholder: 'https://mysite.example.com/oauth/callback',
type: "text",
name: "redirectUri",
label: "Redirect URI (can be any valid URL)",
placeholder: "https://mysite.example.com/oauth/callback",
description:
'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.',
"URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.",
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
["authorization_code", "implicit"],
({ useExternalBrowser }) => !useExternalBrowser,
),
},
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'select',
name: 'callbackType',
label: 'Callback Type',
type: "select",
name: "callbackType",
label: "Callback Type",
description:
'"Hosted Redirect" uses an external Yaak-hosted endpoint. "Localhost" starts a local server to receive the callback.',
defaultValue: 'hosted',
defaultValue: "hosted",
options: [
{ label: 'Hosted Redirect', value: 'hosted' },
{ label: 'Localhost', value: 'localhost' },
{ label: "Hosted Redirect", value: "hosted" },
{ label: "Localhost", value: "localhost" },
],
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
["authorization_code", "implicit"],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
{
type: 'text',
name: 'callbackPort',
label: 'Callback Port',
type: "text",
name: "callbackPort",
label: "Callback Port",
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
description:
'Port for the local callback server. Defaults to ' +
"Port for the local callback server. Defaults to " +
DEFAULT_LOCALHOST_PORT +
' if empty.',
" if empty.",
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
["authorization_code", "implicit"],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
],
},
{
type: 'banner',
color: 'info',
type: "banner",
color: "info",
inputs: [
{
type: 'markdown',
content: 'Redirect URI to Register',
type: "markdown",
content: "Redirect URI to Register",
async dynamic(_ctx, { values }) {
const grantType = String(values.grantType ?? defaultGrantType);
const useExternalBrowser = !!values.useExternalBrowser;
const callbackType = (stringArg(values, 'callbackType') ||
'localhost') as CallbackType;
const callbackType = (stringArg(values, "callbackType") ||
"localhost") as CallbackType;
// Only show for authorization_code and implicit with external browser enabled
if (
!['authorization_code', 'implicit'].includes(grantType) ||
!["authorization_code", "implicit"].includes(grantType) ||
!useExternalBrowser
) {
return { hidden: true };
}
// Compute the redirect URI based on callback type
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
const port = intArg(values, "callbackPort") || DEFAULT_LOCALHOST_PORT;
let redirectUri: string;
if (callbackType === 'hosted') {
if (callbackType === "hosted") {
redirectUri = buildHostedCallbackRedirectUri(port);
} else {
redirectUri = `http://127.0.0.1:${port}/callback`;
@@ -350,149 +350,149 @@ export const plugin: PluginDefinition = {
],
},
{
type: 'text',
name: 'state',
label: 'State',
type: "text",
name: "state",
label: "State",
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{ type: 'text', name: 'audience', label: 'Audience', optional: true },
{ type: "text", name: "scope", label: "Scope", optional: true },
{ type: "text", name: "audience", label: "Audience", optional: true },
{
type: 'select',
name: 'tokenName',
label: 'Token for authorization',
type: "select",
name: "tokenName",
label: "Token for authorization",
description:
'Select which token to send in the "Authorization: Bearer" header. Most APIs expect ' +
'access_token, but some (like OpenID Connect) require id_token.',
defaultValue: 'access_token',
"access_token, but some (like OpenID Connect) require id_token.",
defaultValue: "access_token",
options: [
{ label: 'access_token', value: 'access_token' },
{ label: 'id_token', value: 'id_token' },
{ label: "access_token", value: "access_token" },
{ label: "id_token", value: "id_token" },
],
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{
type: 'banner',
type: "banner",
inputs: [
{
type: 'checkbox',
name: 'usePkce',
label: 'Use PKCE',
dynamic: hiddenIfNot(['authorization_code']),
type: "checkbox",
name: "usePkce",
label: "Use PKCE",
dynamic: hiddenIfNot(["authorization_code"]),
},
{
type: 'select',
name: 'pkceChallengeMethod',
label: 'Code Challenge Method',
type: "select",
name: "pkceChallengeMethod",
label: "Code Challenge Method",
options: [
{ label: 'SHA-256', value: PKCE_SHA256 },
{ label: 'Plain', value: PKCE_PLAIN },
{ label: "SHA-256", value: PKCE_SHA256 },
{ label: "Plain", value: PKCE_PLAIN },
],
defaultValue: DEFAULT_PKCE_METHOD,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
},
{
type: 'text',
name: 'pkceCodeChallenge',
label: 'Code Verifier',
placeholder: 'Automatically generated when not set',
type: "text",
name: "pkceCodeChallenge",
label: "Code Verifier",
placeholder: "Automatically generated when not set",
optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
},
],
},
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'text',
name: 'username',
label: 'Username',
type: "text",
name: "username",
label: "Username",
optional: true,
dynamic: hiddenIfNot(['password']),
dynamic: hiddenIfNot(["password"]),
},
{
type: 'text',
name: 'password',
label: 'Password',
type: "text",
name: "password",
label: "Password",
password: true,
optional: true,
dynamic: hiddenIfNot(['password']),
dynamic: hiddenIfNot(["password"]),
},
],
},
{
type: 'select',
name: 'responseType',
label: 'Response Type',
defaultValue: 'token',
type: "select",
name: "responseType",
label: "Response Type",
defaultValue: "token",
options: [
{ label: 'Access Token', value: 'token' },
{ label: 'ID Token', value: 'id_token' },
{ label: 'ID and Access Token', value: 'id_token token' },
{ label: "Access Token", value: "token" },
{ label: "ID Token", value: "id_token" },
{ label: "ID and Access Token", value: "id_token token" },
],
dynamic: hiddenIfNot(['implicit']),
dynamic: hiddenIfNot(["implicit"]),
},
{
type: 'accordion',
label: 'Advanced',
type: "accordion",
label: "Advanced",
inputs: [
{
type: 'text',
name: 'headerName',
label: 'Header Name',
defaultValue: 'Authorization',
type: "text",
name: "headerName",
label: "Header Name",
defaultValue: "Authorization",
},
{
type: 'text',
name: 'headerPrefix',
label: 'Header Prefix',
type: "text",
name: "headerPrefix",
label: "Header Prefix",
optional: true,
defaultValue: 'Bearer',
defaultValue: "Bearer",
},
{
type: 'select',
name: 'credentials',
label: 'Send Credentials',
defaultValue: 'body',
type: "select",
name: "credentials",
label: "Send Credentials",
defaultValue: "body",
options: [
{ label: 'In Request Body', value: 'body' },
{ label: 'As Basic Authentication', value: 'basic' },
{ label: "In Request Body", value: "body" },
{ label: "As Basic Authentication", value: "basic" },
],
dynamic: (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => ({
hidden:
values.grantType === 'client_credentials' &&
values.clientCredentialsMethod === 'client_assertion',
values.grantType === "client_credentials" &&
values.clientCredentialsMethod === "client_assertion",
}),
},
],
},
{
type: 'accordion',
label: 'Access Token Response',
type: "accordion",
label: "Access Token Response",
inputs: [],
async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return { hidden: true };
}
return {
label: 'Access Token Response',
label: "Access Token Response",
inputs: [
{
type: 'editor',
name: 'response',
type: "editor",
name: "response",
defaultValue: JSON.stringify(token.response, null, 2),
hideLabel: true,
readOnly: true,
language: 'json',
language: "json",
},
],
};
@@ -500,101 +500,101 @@ export const plugin: PluginDefinition = {
},
],
async onApply(ctx, { values, contextId }) {
const headerPrefix = stringArg(values, 'headerPrefix');
const grantType = stringArg(values, 'grantType') as GrantType;
const credentialsInBody = values.credentials === 'body';
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
const headerPrefix = stringArg(values, "headerPrefix");
const grantType = stringArg(values, "grantType") as GrantType;
const credentialsInBody = values.credentials === "body";
const tokenName = values.tokenName === "id_token" ? "id_token" : "access_token";
// Build external browser options if enabled
const useExternalBrowser = !!values.useExternalBrowser;
const externalBrowserOptions = useExternalBrowser
? {
useExternalBrowser: true,
callbackType: (stringArg(values, 'callbackType') || 'localhost') as CallbackType,
callbackPort: intArg(values, 'callbackPort') ?? undefined,
callbackType: (stringArg(values, "callbackType") || "localhost") as CallbackType,
callbackPort: intArg(values, "callbackPort") ?? undefined,
}
: undefined;
let token: AccessToken;
if (grantType === 'authorization_code') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
if (grantType === "authorization_code") {
const authorizationUrl = stringArg(values, "authorizationUrl");
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl:
accessTokenUrl === '' || accessTokenUrl.match(/^https?:\/\//)
accessTokenUrl === "" || accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl:
authorizationUrl === '' || authorizationUrl.match(/^https?:\/\//)
authorizationUrl === "" || authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
redirectUri: stringArgOrNull(values, 'redirectUri'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
redirectUri: stringArgOrNull(values, "redirectUri"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state"),
credentialsInBody,
pkce: values.usePkce
? {
challengeMethod: stringArg(values, 'pkceChallengeMethod') || DEFAULT_PKCE_METHOD,
codeVerifier: stringArg(values, 'pkceCodeVerifier') || genPkceCodeVerifier(),
challengeMethod: stringArg(values, "pkceChallengeMethod") || DEFAULT_PKCE_METHOD,
codeVerifier: stringArg(values, "pkceCodeVerifier") || genPkceCodeVerifier(),
}
: null,
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
} else if (grantType === "implicit") {
const authorizationUrl = stringArg(values, "authorizationUrl");
token = await getImplicit(ctx, contextId, {
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
redirectUri: stringArgOrNull(values, 'redirectUri'),
responseType: stringArg(values, 'responseType'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
clientId: stringArg(values, "clientId"),
redirectUri: stringArgOrNull(values, "redirectUri"),
responseType: stringArg(values, "responseType"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state"),
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
} else if (grantType === "client_credentials") {
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getClientCredentials(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientAssertionAlgorithm: stringArg(values, 'clientAssertionAlgorithm') as Algorithm,
clientSecret: stringArg(values, 'clientSecret'),
clientCredentialsMethod: stringArg(values, 'clientCredentialsMethod'),
clientAssertionSecret: stringArg(values, 'clientAssertionSecret'),
clientId: stringArg(values, "clientId"),
clientAssertionAlgorithm: stringArg(values, "clientAssertionAlgorithm") as Algorithm,
clientSecret: stringArg(values, "clientSecret"),
clientCredentialsMethod: stringArg(values, "clientCredentialsMethod"),
clientAssertionSecret: stringArg(values, "clientAssertionSecret"),
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody,
});
} else if (grantType === 'password') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
} else if (grantType === "password") {
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getPassword(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
username: stringArg(values, 'username'),
password: stringArg(values, 'password'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
username: stringArg(values, "username"),
password: stringArg(values, "password"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody,
});
} else {
throw new Error(`Invalid grant type ${String(grantType)}`);
}
const headerName = stringArg(values, 'headerName') || 'Authorization';
const headerValue = `${headerPrefix} ${token.response[tokenName] ?? ''}`.trim();
const headerName = stringArg(values, "headerName") || "Authorization";
const headerValue = `${headerPrefix} ${token.response[tokenName] ?? ""}`.trim();
return { setHeaders: [{ name: headerName, value: headerValue }] };
},
},
@@ -605,19 +605,19 @@ function stringArgOrNull(
name: string,
): string | null {
const arg = values[name];
if (arg == null || arg === '') return null;
if (arg == null || arg === "") return null;
return `${arg}`;
}
function stringArg(values: Record<string, JsonPrimitive | undefined>, name: string): string {
const arg = stringArgOrNull(values, name);
if (!arg) return '';
if (!arg) return "";
return arg;
}
function intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {
const arg = values[name];
if (arg == null || arg === '') return null;
if (arg == null || arg === "") return null;
const num = parseInt(`${arg}`, 10);
return Number.isNaN(num) ? null : num;
}

View File

@@ -1,14 +1,14 @@
import { createHash } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import { createHash } from "node:crypto";
import type { Context } from "@yaakapp/api";
export async function storeToken(
ctx: Context,
args: TokenStoreArgs,
response: AccessTokenRawResponse,
tokenName: 'access_token' | 'id_token' = 'access_token',
tokenName: "access_token" | "id_token" = "access_token",
) {
if (!response[tokenName]) {
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(', ')}`);
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
}
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;
@@ -34,7 +34,7 @@ export async function resetDataDirKey(ctx: Context, contextId: string) {
}
export async function getDataDirKey(ctx: Context, contextId: string) {
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? 'default';
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? "default";
return `${contextId}::${key}`;
}
@@ -50,17 +50,17 @@ export interface TokenStoreArgs {
* account for slight variations (like domains with and without a protocol scheme).
*/
function tokenStoreKey(args: TokenStoreArgs) {
const hash = createHash('md5');
const hash = createHash("md5");
if (args.contextId) hash.update(args.contextId.trim());
if (args.clientId) hash.update(args.clientId.trim());
if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\/\//, ''));
if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\/\//, ''));
const key = hash.digest('hex');
return ['token', key].join('::');
if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\/\//, ""));
if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\/\//, ""));
const key = hash.digest("hex");
return ["token", key].join("::");
}
function dataDirStoreKey(contextId: string) {
return ['data_dir', contextId].join('::');
return ["data_dir", contextId].join("::");
}
export interface AccessToken {

View File

@@ -1,4 +1,4 @@
import type { AccessToken } from './store';
import type { AccessToken } from "./store";
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
@@ -8,24 +8,24 @@ export function extractCode(urlStr: string, redirectUri: string | null): string
const url = new URL(urlStr);
if (!urlMatchesRedirect(url, redirectUri)) {
console.log('[oauth2] URL does not match redirect origin/path; skipping.');
console.log("[oauth2] URL does not match redirect origin/path; skipping.");
return null;
}
// Prefer query param; fall back to fragment if query lacks it
const query = url.searchParams;
const queryError = query.get('error');
const queryDesc = query.get('error_description');
const queryUri = query.get('error_uri');
const queryError = query.get("error");
const queryDesc = query.get("error_description");
const queryUri = query.get("error_uri");
let hashParams: URLSearchParams | null = null;
if (url.hash && url.hash.length > 1) {
hashParams = new URLSearchParams(url.hash.slice(1));
}
const hashError = hashParams?.get('error');
const hashDesc = hashParams?.get('error_description');
const hashUri = hashParams?.get('error_uri');
const hashError = hashParams?.get("error");
const hashDesc = hashParams?.get("error_description");
const hashUri = hashParams?.get("error_uri");
const error = queryError || hashError;
if (error) {
@@ -37,13 +37,13 @@ export function extractCode(urlStr: string, redirectUri: string | null): string
throw new Error(message);
}
const queryCode = query.get('code');
const queryCode = query.get("code");
if (queryCode) return queryCode;
const hashCode = hashParams?.get('code');
const hashCode = hashParams?.get("code");
if (hashCode) return hashCode;
console.log('[oauth2] Code not found');
console.log("[oauth2] Code not found");
return null;
}
@@ -54,7 +54,7 @@ export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolea
try {
redirect = new URL(redirectUrl);
} catch {
console.log('[oauth2] Invalid redirect URI; skipping.');
console.log("[oauth2] Invalid redirect URI; skipping.");
return false;
}
@@ -63,17 +63,17 @@ export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolea
const sameHost = url.hostname.toLowerCase() === redirect.hostname.toLowerCase();
const normalizePort = (u: URL) =>
(u.protocol === 'https:' && (!u.port || u.port === '443')) ||
(u.protocol === 'http:' && (!u.port || u.port === '80'))
? ''
(u.protocol === "https:" && (!u.port || u.port === "443")) ||
(u.protocol === "http:" && (!u.port || u.port === "80"))
? ""
: u.port;
const samePort = normalizePort(url) === normalizePort(redirect);
const normPath = (p: string) => {
const withLeading = p.startsWith('/') ? p : `/${p}`;
const withLeading = p.startsWith("/") ? p : `/${p}`;
// strip trailing slashes, keep root as "/"
return withLeading.replace(/\/+$/g, '') || '/';
return withLeading.replace(/\/+$/g, "") || "/";
};
// Require redirect path to be a prefix of the navigated URL path

View File

@@ -1,109 +1,109 @@
import { describe, expect, test } from 'vite-plus/test';
import { extractCode } from '../src/util';
import { describe, expect, test } from "vite-plus/test";
import { extractCode } from "../src/util";
describe('extractCode', () => {
test('extracts code from query when same origin + path', () => {
const url = 'https://app.example.com/cb?code=abc123&state=xyz';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc123');
describe("extractCode", () => {
test("extracts code from query when same origin + path", () => {
const url = "https://app.example.com/cb?code=abc123&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc123");
});
test('extracts code from query with weird path', () => {
const url = 'https://app.example.com/cbwithextra?code=abc123&state=xyz';
const redirect = 'https://app.example.com/cb';
test("extracts code from query with weird path", () => {
const url = "https://app.example.com/cbwithextra?code=abc123&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBeNull();
});
test('allows trailing slash differences', () => {
expect(extractCode('https://app.example.com/cb/?code=abc', 'https://app.example.com/cb')).toBe(
'abc',
test("allows trailing slash differences", () => {
expect(extractCode("https://app.example.com/cb/?code=abc", "https://app.example.com/cb")).toBe(
"abc",
);
expect(extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com/cb/')).toBe(
'abc',
expect(extractCode("https://app.example.com/cb?code=abc", "https://app.example.com/cb/")).toBe(
"abc",
);
});
test('treats default ports as equal (https:443, http:80)', () => {
test("treats default ports as equal (https:443, http:80)", () => {
expect(
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:443/cb'),
).toBe('abc');
expect(extractCode('http://app.example.com/cb?code=abc', 'http://app.example.com:80/cb')).toBe(
'abc',
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:443/cb"),
).toBe("abc");
expect(extractCode("http://app.example.com/cb?code=abc", "http://app.example.com:80/cb")).toBe(
"abc",
);
});
test('rejects different port', () => {
test("rejects different port", () => {
expect(
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:8443/cb'),
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:8443/cb"),
).toBeNull();
});
test('rejects different hostname (including subdomain changes)', () => {
test("rejects different hostname (including subdomain changes)", () => {
expect(
extractCode('https://evil.example.com/cb?code=abc', 'https://app.example.com/cb'),
extractCode("https://evil.example.com/cb?code=abc", "https://app.example.com/cb"),
).toBeNull();
});
test('requires path to start with redirect path (ignoring query/hash)', () => {
test("requires path to start with redirect path (ignoring query/hash)", () => {
// same origin but wrong path -> null
expect(
extractCode('https://app.example.com/other?code=abc', 'https://app.example.com/cb'),
extractCode("https://app.example.com/other?code=abc", "https://app.example.com/cb"),
).toBeNull();
// deeper subpath under the redirect path -> allowed (prefix match)
expect(
extractCode('https://app.example.com/cb/deep?code=abc', 'https://app.example.com/cb'),
).toBe('abc');
extractCode("https://app.example.com/cb/deep?code=abc", "https://app.example.com/cb"),
).toBe("abc");
});
test('works with custom schemes', () => {
expect(extractCode('myapp://cb?code=abc', 'myapp://cb')).toBe('abc');
test("works with custom schemes", () => {
expect(extractCode("myapp://cb?code=abc", "myapp://cb")).toBe("abc");
});
test('prefers query over fragment when both present', () => {
const url = 'https://app.example.com/cb?code=queryCode#code=hashCode';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('queryCode');
test("prefers query over fragment when both present", () => {
const url = "https://app.example.com/cb?code=queryCode#code=hashCode";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("queryCode");
});
test('extracts code from fragment when query lacks code', () => {
const url = 'https://app.example.com/cb#code=fromHash&state=xyz';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('fromHash');
test("extracts code from fragment when query lacks code", () => {
const url = "https://app.example.com/cb#code=fromHash&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("fromHash");
});
test('returns null if no code present (query or fragment)', () => {
const url = 'https://app.example.com/cb?state=only';
const redirect = 'https://app.example.com/cb';
test("returns null if no code present (query or fragment)", () => {
const url = "https://app.example.com/cb?state=only";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBeNull();
});
test('returns null when provider reports an error', () => {
const url = 'https://app.example.com/cb?error=access_denied&error_description=oopsy';
const redirect = 'https://app.example.com/cb';
expect(() => extractCode(url, redirect)).toThrow('Failed to authorize: access_denied');
test("returns null when provider reports an error", () => {
const url = "https://app.example.com/cb?error=access_denied&error_description=oopsy";
const redirect = "https://app.example.com/cb";
expect(() => extractCode(url, redirect)).toThrow("Failed to authorize: access_denied");
});
test('when redirectUri is null, extracts code from any URL', () => {
expect(extractCode('https://random.example.com/whatever?code=abc', null)).toBe('abc');
test("when redirectUri is null, extracts code from any URL", () => {
expect(extractCode("https://random.example.com/whatever?code=abc", null)).toBe("abc");
});
test('handles extra params gracefully', () => {
const url = 'https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc');
test("handles extra params gracefully", () => {
const url = "https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
test('ignores fragment noise when code is in query', () => {
const url = 'https://app.example.com/cb?code=abc#some=thing';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc');
test("ignores fragment noise when code is in query", () => {
const url = "https://app.example.com/cb?code=abc#some=thing";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
// If you decide NOT to support fragment-based codes, flip these to expect null or mark as .skip
test('supports fragment-only code for response_mode=fragment providers', () => {
const url = 'https://app.example.com/cb#state=xyz&code=abc';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc');
test("supports fragment-only code for response_mode=fragment providers", () => {
const url = "https://app.example.com/cb#state=xyz&code=abc";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
});

View File

@@ -1,14 +1,14 @@
{
"name": "@yaak/filter-jsonpath",
"displayName": "JSONPath Filter",
"version": "0.1.0",
"private": true,
"description": "Filter JSON response data using JSONPath expressions",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/filter-jsonpath"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,17 +1,20 @@
import type { PluginDefinition } from '@yaakapp/api';
import { JSONPath } from 'jsonpath-plus';
import type { PluginDefinition } from "@yaakapp/api";
import { JSONPath } from "jsonpath-plus";
export const plugin: PluginDefinition = {
filter: {
name: 'JSONPath',
description: 'Filter JSONPath',
name: "JSONPath",
description: "Filter JSONPath",
onFilter(_ctx, args) {
const parsed = JSON.parse(args.payload);
try {
const filtered = JSONPath({ path: args.filter, json: parsed });
return { content: JSON.stringify(filtered, null, 2) };
} catch (err) {
return { content: '', error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}` };
return {
content: "",
error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
},

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/filter-xpath",
"displayName": "XPath Filter",
"description": "Filter response XML data using XPath expressions",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Filter response XML data using XPath expressions",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,24 +1,27 @@
/* oxlint-disable no-base-to-string */
import { DOMParser } from '@xmldom/xmldom';
import type { PluginDefinition } from '@yaakapp/api';
import xpath from 'xpath';
import { DOMParser } from "@xmldom/xmldom";
import type { PluginDefinition } from "@yaakapp/api";
import xpath from "xpath";
export const plugin: PluginDefinition = {
filter: {
name: 'XPath',
description: 'Filter XPath',
name: "XPath",
description: "Filter XPath",
onFilter(_ctx, args) {
// oxlint-disable-next-line no-explicit-any
const doc: any = new DOMParser().parseFromString(args.payload, 'text/xml');
const doc: any = new DOMParser().parseFromString(args.payload, "text/xml");
try {
const result = xpath.select(args.filter, doc, false);
if (Array.isArray(result)) {
return { content: result.map((r) => String(r)).join('\n') };
return { content: result.map((r) => String(r)).join("\n") };
}
// Not sure what cases this happens in (?)
return { content: String(result) };
} catch (err) {
return { content: '', error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}` };
return {
content: "",
error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
},

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/importer-curl",
"displayName": "cURL Importer",
"description": "Import requests from cURL commands",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Import requests from cURL commands",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -6,38 +6,38 @@ import type {
HttpUrlParameter,
PluginDefinition,
Workspace,
} from '@yaakapp/api';
import { split } from 'shlex';
} from "@yaakapp/api";
import { split } from "shlex";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
}
const DATA_FLAGS = ['d', 'data', 'data-raw', 'data-urlencode', 'data-binary', 'data-ascii'];
const DATA_FLAGS = ["d", "data", "data-raw", "data-urlencode", "data-binary", "data-ascii"];
const SUPPORTED_FLAGS = [
['cookie', 'b'],
['d', 'data'], // Add url encoded data
['data-ascii'],
['data-binary'],
['data-raw'],
['data-urlencode'],
['digest'], // Apply auth as digest
['form', 'F'], // Add multipart data
['get', 'G'], // Put the post data in the URL
['header', 'H'],
['request', 'X'], // Request method
['url'], // Specify the URL explicitly
['url-query'],
['user', 'u'], // Authentication
["cookie", "b"],
["d", "data"], // Add url encoded data
["data-ascii"],
["data-binary"],
["data-raw"],
["data-urlencode"],
["digest"], // Apply auth as digest
["form", "F"], // Add multipart data
["get", "G"], // Put the post data in the URL
["header", "H"],
["request", "X"], // Request method
["url"], // Specify the URL explicitly
["url-query"],
["user", "u"], // Authentication
DATA_FLAGS,
].flat();
const BOOLEAN_FLAGS = ['G', 'get', 'digest'];
const BOOLEAN_FLAGS = ["G", "get", "digest"];
type FlagValue = string | boolean;
@@ -45,8 +45,8 @@ type FlagsByName = Record<string, FlagValue[]>;
export const plugin: PluginDefinition = {
importer: {
name: 'cURL',
description: 'Import cURL commands',
name: "cURL",
description: "Import cURL commands",
onImport(_ctx: Context, args: { text: string }) {
// oxlint-disable-next-line no-explicit-any
return convertCurl(args.text) as any;
@@ -60,14 +60,14 @@ export const plugin: PluginDefinition = {
*/
function splitCommands(rawData: string): string[] {
// Join line continuations (backslash-newline, and backslash-CRLF for Windows)
const joined = rawData.replace(/\\\r?\n/g, ' ');
const joined = rawData.replace(/\\\r?\n/g, " ");
// Count consecutive backslashes immediately before position i.
// An even count means the quote at i is NOT escaped; odd means it IS escaped.
function isEscaped(i: number): boolean {
let backslashes = 0;
let j = i - 1;
while (j >= 0 && joined[j] === '\\') {
while (j >= 0 && joined[j] === "\\") {
backslashes++;
j--;
}
@@ -76,7 +76,7 @@ function splitCommands(rawData: string): string[] {
// Split on semicolons and newlines to separate commands
const commands: string[] = [];
let current = '';
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let inDollarQuote = false;
@@ -108,7 +108,7 @@ function splitCommands(rawData: string): string[] {
current += ch;
continue;
}
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === '$' && next === "'") {
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === "$" && next === "'") {
inDollarQuote = true;
current += ch + next;
i++; // Skip the opening quote
@@ -126,13 +126,13 @@ function splitCommands(rawData: string): string[] {
if (
!inQuote &&
!isEscaped(i) &&
(ch === ';' || ch === '\n' || (ch === '\r' && next === '\n'))
(ch === ";" || ch === "\n" || (ch === "\r" && next === "\n"))
) {
if (ch === '\r') i++; // Skip the \n in \r\n
if (ch === "\r") i++; // Skip the \n in \r\n
if (current.trim()) {
commands.push(current.trim());
}
current = '';
current = "";
continue;
}
@@ -156,21 +156,21 @@ export function convertCurl(rawData: string) {
// Break up squished arguments like `-XPOST` into `-X POST`
return tokens.flatMap((token) => {
if (token.startsWith('-') && !token.startsWith('--') && token.length > 2) {
if (token.startsWith("-") && !token.startsWith("--") && token.length > 2) {
return [token.slice(0, 2), token.slice(2)];
}
return token;
});
});
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
id: generateId('workspace'),
name: 'Curl Import',
const workspace: ExportResources["workspaces"][0] = {
model: "workspace",
id: generateId("workspace"),
name: "Curl Import",
};
const requests: ExportResources['httpRequests'] = commands
.filter((command) => command[0] === 'curl')
const requests: ExportResources["httpRequests"] = commands
.filter((command) => command[0] === "curl")
.map((v) => importCommand(v, workspace.id));
return {
@@ -191,13 +191,13 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Start at 1 so we can skip the ^curl part
for (let i = 1; i < parseEntries.length; i++) {
let parseEntry = parseEntries[i];
if (typeof parseEntry === 'string') {
if (typeof parseEntry === "string") {
parseEntry = parseEntry.trim();
}
if (typeof parseEntry === 'string' && parseEntry.match(/^-{1,2}[\w-]+/)) {
const isSingleDash = parseEntry[0] === '-' && parseEntry[1] !== '-';
let name = parseEntry.replace(/^-{1,2}/, '');
if (typeof parseEntry === "string" && parseEntry.match(/^-{1,2}[\w-]+/)) {
const isSingleDash = parseEntry[0] === "-" && parseEntry[1] !== "-";
let name = parseEntry.replace(/^-{1,2}/, "");
if (!SUPPORTED_FLAGS.includes(name)) {
continue;
@@ -211,13 +211,13 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// - Double dash followed by a letter: --data-raw, --header
// This prevents mistaking data that starts with dashes (like multipart boundaries ------) as flags
const nextEntryIsFlag =
typeof nextEntry === 'string' &&
typeof nextEntry === "string" &&
(nextEntry.match(/^-[a-zA-Z]/) || nextEntry.match(/^--[a-zA-Z]/));
if (isSingleDash && name.length > 1) {
// Handle squished arguments like -XPOST
value = name.slice(1);
name = name.slice(0, 1);
} else if (typeof nextEntry === 'string' && hasValue && !nextEntryIsFlag) {
} else if (typeof nextEntry === "string" && hasValue && !nextEntryIsFlag) {
// Next arg is not a flag, so assign it as the value
value = nextEntry;
i++; // Skip next one
@@ -236,14 +236,14 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Build the request //
// ~~~~~~~~~~~~~~~~~ //
const urlArg = getPairValue(flagsByName, (singletons[0] as string) || '', ['url']);
const [baseUrl, search] = splitOnce(urlArg, '?');
const urlArg = getPairValue(flagsByName, (singletons[0] as string) || "", ["url"]);
const [baseUrl, search] = splitOnce(urlArg, "?");
const urlParameters: HttpUrlParameter[] =
search?.split('&').map((p) => {
const v = splitOnce(p, '=');
search?.split("&").map((p) => {
const v = splitOnce(p, "=");
return {
name: decodeURIComponent(v[0] ?? ''),
value: decodeURIComponent(v[1] ?? ''),
name: decodeURIComponent(v[0] ?? ""),
value: decodeURIComponent(v[1] ?? ""),
enabled: true,
};
}) ?? [];
@@ -251,27 +251,27 @@ function importCommand(parseEntries: string[], workspaceId: string) {
const url = baseUrl ?? urlArg;
// Query params
for (const p of flagsByName['url-query'] ?? []) {
if (typeof p !== 'string') {
for (const p of flagsByName["url-query"] ?? []) {
if (typeof p !== "string") {
continue;
}
const [name, value] = p.split('=');
const [name, value] = p.split("=");
urlParameters.push({
name: name ?? '',
value: value ?? '',
name: name ?? "",
value: value ?? "",
enabled: true,
});
}
// Authentication
const [username, password] = getPairValue(flagsByName, '', ['u', 'user']).split(/:(.*)$/);
const [username, password] = getPairValue(flagsByName, "", ["u", "user"]).split(/:(.*)$/);
const isDigest = getPairValue(flagsByName, false, ['digest']);
const authenticationType = username ? (isDigest ? 'digest' : 'basic') : null;
const isDigest = getPairValue(flagsByName, false, ["digest"]);
const authenticationType = username ? (isDigest ? "digest" : "basic") : null;
const authentication = username
? {
username: username.trim(),
password: (password ?? '').trim(),
password: (password ?? "").trim(),
}
: {};
@@ -284,13 +284,13 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// remove final colon from header name if present
if (!value) {
return {
name: (name ?? '').trim().replace(/;$/, ''),
value: '',
name: (name ?? "").trim().replace(/;$/, ""),
value: "",
enabled: true,
};
}
return {
name: (name ?? '').trim(),
name: (name ?? "").trim(),
value: value.trim(),
enabled: true,
};
@@ -302,14 +302,14 @@ function importCommand(parseEntries: string[], workspaceId: string) {
...((flagsByName.b as string[] | undefined) || []),
]
.map((str) => {
const name = str.split('=', 1)[0];
const value = str.replace(`${name}=`, '');
const name = str.split("=", 1)[0];
const value = str.replace(`${name}=`, "");
return `${name}=${value}`;
})
.join('; ');
.join("; ");
// Convert cookie value to header
const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === 'cookie');
const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === "cookie");
if (cookieHeaderValue && existingCookieHeader) {
// Has existing cookie header, so let's update it
@@ -317,15 +317,15 @@ function importCommand(parseEntries: string[], workspaceId: string) {
} else if (cookieHeaderValue) {
// No existing cookie header, so let's make a new one
headers.push({
name: 'Cookie',
name: "Cookie",
value: cookieHeaderValue,
enabled: true,
});
}
// Body (Text or Blob)
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === 'content-type');
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(';')[0]?.trim() : null;
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === "content-type");
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(";")[0]?.trim() : null;
// Extract boundary from Content-Type header for multipart parsing
const boundaryMatch = contentTypeHeader?.value.match(/boundary=([^\s;]+)/i);
@@ -333,19 +333,19 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Get raw data from --data-raw flags (before splitting by &)
const rawDataValues = [
...((flagsByName['data-raw'] as string[] | undefined) || []),
...((flagsByName["data-raw"] as string[] | undefined) || []),
...((flagsByName.d as string[] | undefined) || []),
...((flagsByName.data as string[] | undefined) || []),
...((flagsByName['data-binary'] as string[] | undefined) || []),
...((flagsByName['data-ascii'] as string[] | undefined) || []),
...((flagsByName["data-binary"] as string[] | undefined) || []),
...((flagsByName["data-ascii"] as string[] | undefined) || []),
];
// Check if this is multipart form data in --data-raw (Chrome DevTools format)
let multipartFormDataFromRaw:
| { name: string; value?: string; file?: string; enabled: boolean }[]
| null = null;
if (mimeType === 'multipart/form-data' && boundary && rawDataValues.length > 0) {
const rawBody = rawDataValues.join('');
if (mimeType === "multipart/form-data" && boundary && rawDataValues.length > 0) {
const rawBody = rawDataValues.join("");
multipartFormDataFromRaw = parseMultipartFormData(rawBody, boundary);
}
@@ -356,15 +356,15 @@ function importCommand(parseEntries: string[], workspaceId: string) {
...((flagsByName.form as string[] | undefined) || []),
...((flagsByName.F as string[] | undefined) || []),
].map((str) => {
const parts = str.split('=');
const name = parts[0] ?? '';
const value = parts[1] ?? '';
const parts = str.split("=");
const name = parts[0] ?? "";
const value = parts[1] ?? "";
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
name,
enabled: true,
};
if (value.indexOf('@') === 0) {
if (value.indexOf("@") === 0) {
item.file = value.slice(1);
} else {
item.value = value;
@@ -376,11 +376,11 @@ function importCommand(parseEntries: string[], workspaceId: string) {
// Body
let body = {};
let bodyType: string | null = null;
const bodyAsGET = getPairValue(flagsByName, false, ['G', 'get']);
const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]);
if (multipartFormDataFromRaw) {
// Handle multipart form data parsed from --data-raw (Chrome DevTools format)
bodyType = 'multipart/form-data';
bodyType = "multipart/form-data";
body = {
form: multipartFormDataFromRaw,
};
@@ -388,57 +388,57 @@ function importCommand(parseEntries: string[], workspaceId: string) {
urlParameters.push(...dataParameters);
} else if (
dataParameters.length > 0 &&
(mimeType == null || mimeType === 'application/x-www-form-urlencoded')
(mimeType == null || mimeType === "application/x-www-form-urlencoded")
) {
bodyType = mimeType ?? 'application/x-www-form-urlencoded';
bodyType = mimeType ?? "application/x-www-form-urlencoded";
body = {
form: dataParameters.map((parameter) => ({
...parameter,
name: decodeURIComponent(parameter.name || ''),
value: decodeURIComponent(parameter.value || ''),
name: decodeURIComponent(parameter.name || ""),
value: decodeURIComponent(parameter.value || ""),
})),
};
headers.push({
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
});
} else if (dataParameters.length > 0) {
bodyType =
mimeType === 'application/json' || mimeType === 'text/xml' || mimeType === 'text/plain'
mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain"
? mimeType
: 'other';
: "other";
body = {
text: dataParameters
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
.join('&'),
.join("&"),
};
} else if (formDataParams.length) {
bodyType = mimeType ?? 'multipart/form-data';
bodyType = mimeType ?? "multipart/form-data";
body = {
form: formDataParams,
};
if (mimeType == null) {
headers.push({
name: 'Content-Type',
value: 'multipart/form-data',
name: "Content-Type",
value: "multipart/form-data",
enabled: true,
});
}
}
// Method
let method = getPairValue(flagsByName, '', ['X', 'request']).toUpperCase();
let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase();
if (method === '' && body) {
method = 'text' in body || 'form' in body ? 'POST' : 'GET';
if (method === "" && body) {
method = "text" in body || "form" in body ? "POST" : "GET";
}
const request: ExportResources['httpRequests'][0] = {
id: generateId('http_request'),
model: 'http_request',
const request: ExportResources["httpRequests"][0] = {
id: generateId("http_request"),
model: "http_request",
workspaceId,
name: '',
name: "",
urlParameters,
url,
method,
@@ -473,22 +473,22 @@ function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
}
for (const p of pairs) {
if (typeof p !== 'string') continue;
const params = p.split('&');
if (typeof p !== "string") continue;
const params = p.split("&");
for (const param of params) {
const [name, value] = splitOnce(param, '=');
if (param.startsWith('@')) {
const [name, value] = splitOnce(param, "=");
if (param.startsWith("@")) {
// Yaak doesn't support files in url-encoded data, so
dataParameters.push({
name: name ?? '',
value: '',
name: name ?? "",
value: "",
filePath: param.slice(1),
enabled: true,
});
} else {
dataParameters.push({
name: name ?? '',
value: flagName === 'data-urlencode' ? encodeURIComponent(value ?? '') : (value ?? ''),
name: name ?? "",
value: flagName === "data-urlencode" ? encodeURIComponent(value ?? "") : (value ?? ""),
enabled: true,
});
}
@@ -537,12 +537,12 @@ function parseMultipartFormData(
for (const part of parts) {
// Skip empty parts and the closing boundary marker
if (!part || part.trim() === '--' || part.trim() === '--\r\n') {
if (!part || part.trim() === "--" || part.trim() === "--\r\n") {
continue;
}
// Each part has headers and content separated by \r\n\r\n
const headerContentSplit = part.indexOf('\r\n\r\n');
const headerContentSplit = part.indexOf("\r\n\r\n");
if (headerContentSplit === -1) {
continue;
}
@@ -551,7 +551,7 @@ function parseMultipartFormData(
let content = part.slice(headerContentSplit + 4); // Skip \r\n\r\n
// Remove trailing \r\n from content
if (content.endsWith('\r\n')) {
if (content.endsWith("\r\n")) {
content = content.slice(0, -2);
}
@@ -564,7 +564,7 @@ function parseMultipartFormData(
continue;
}
const name = contentDispositionMatch[1] ?? '';
const name = contentDispositionMatch[1] ?? "";
const filename = contentDispositionMatch[2];
const item: { name: string; value?: string; file?: string; enabled: boolean } = {

View File

@@ -1,159 +1,182 @@
import type { HttpRequest, Workspace } from '@yaakapp/api';
import { describe, expect, test } from 'vite-plus/test';
import { convertCurl } from '../src';
import type { HttpRequest, Workspace } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { convertCurl } from "../src";
describe('importer-curl', () => {
test('Imports basic GET', () => {
expect(convertCurl('curl https://yaak.app')).toEqual({
describe("importer-curl", () => {
test("Imports basic GET", () => {
expect(convertCurl("curl https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
}),
],
},
});
});
test('Explicit URL', () => {
expect(convertCurl('curl --url https://yaak.app')).toEqual({
test("Explicit URL", () => {
expect(convertCurl("curl --url https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
}),
],
},
});
});
test('Missing URL', () => {
expect(convertCurl('curl -X POST')).toEqual({
test("Missing URL", () => {
expect(convertCurl("curl -X POST")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
method: "POST",
}),
],
},
});
});
test('URL between', () => {
expect(convertCurl('curl -v https://yaak.app -X POST')).toEqual({
test("URL between", () => {
expect(convertCurl("curl -v https://yaak.app -X POST")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
url: "https://yaak.app",
method: "POST",
}),
],
},
});
});
test('Random flags', () => {
expect(convertCurl('curl --random -Z -Y -S --foo https://yaak.app')).toEqual({
test("Random flags", () => {
expect(convertCurl("curl --random -Z -Y -S --foo https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
}),
],
},
});
});
test('Imports --request method', () => {
expect(convertCurl('curl --request POST https://yaak.app')).toEqual({
test("Imports --request method", () => {
expect(convertCurl("curl --request POST https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
url: "https://yaak.app",
method: "POST",
}),
],
},
});
});
test('Imports -XPOST method', () => {
expect(convertCurl('curl -XPOST --request POST https://yaak.app')).toEqual({
test("Imports -XPOST method", () => {
expect(convertCurl("curl -XPOST --request POST https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
url: "https://yaak.app",
method: "POST",
}),
],
},
});
});
test('Imports multiple requests', () => {
test("Imports multiple requests", () => {
expect(
convertCurl('curl \\\n https://yaak.app\necho "foo"\ncurl example.com;curl foo.com'),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({ url: 'https://yaak.app' }),
baseRequest({ url: 'example.com' }),
baseRequest({ url: 'foo.com' }),
baseRequest({ url: "https://yaak.app" }),
baseRequest({ url: "example.com" }),
baseRequest({ url: "foo.com" }),
],
},
});
});
test('Imports with Windows CRLF line endings', () => {
expect(
convertCurl('curl \\\r\n -X POST \\\r\n https://yaak.app'),
).toEqual({
test("Imports with Windows CRLF line endings", () => {
expect(convertCurl("curl \\\r\n -X POST \\\r\n https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({ url: 'https://yaak.app', method: 'POST' }),
],
httpRequests: [baseRequest({ url: "https://yaak.app", method: "POST" })],
},
});
});
test('Throws on malformed quotes', () => {
expect(() =>
convertCurl('curl -X POST -F "a=aaa" -F b=bbb" https://yaak.app'),
).toThrow();
test("Throws on malformed quotes", () => {
expect(() => convertCurl('curl -X POST -F "a=aaa" -F b=bbb" https://yaak.app')).toThrow();
});
test('Imports form data', () => {
expect(
convertCurl('curl -X POST -F "a=aaa" -F b=bbb -F f=@filepath https://yaak.app'),
).toEqual({
test("Imports form data", () => {
expect(convertCurl('curl -X POST -F "a=aaa" -F b=bbb -F f=@filepath https://yaak.app')).toEqual(
{
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: "POST",
url: "https://yaak.app",
headers: [
{
name: "Content-Type",
value: "multipart/form-data",
enabled: true,
},
],
bodyType: "multipart/form-data",
body: {
form: [
{ enabled: true, name: "a", value: "aaa" },
{ enabled: true, name: "b", value: "bbb" },
{ enabled: true, name: "f", file: "filepath" },
],
},
}),
],
},
},
);
});
test("Imports data params as form url-encoded", () => {
expect(convertCurl("curl -d a -d b -d c=ccc https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
method: "POST",
url: "https://yaak.app",
bodyType: "application/x-www-form-urlencoded",
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
bodyType: 'multipart/form-data',
body: {
form: [
{ enabled: true, name: 'a', value: 'aaa' },
{ enabled: true, name: 'b', value: 'bbb' },
{ enabled: true, name: 'f', file: 'filepath' },
{ name: "a", value: "", enabled: true },
{ name: "b", value: "", enabled: true },
{ name: "c", value: "ccc", enabled: true },
],
},
}),
@@ -162,56 +185,27 @@ describe('importer-curl', () => {
});
});
test('Imports data params as form url-encoded', () => {
expect(convertCurl('curl -d a -d b -d c=ccc https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
bodyType: 'application/x-www-form-urlencoded',
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
enabled: true,
},
],
body: {
form: [
{ name: 'a', value: '', enabled: true },
{ name: 'b', value: '', enabled: true },
{ name: 'c', value: 'ccc', enabled: true },
],
},
}),
],
},
});
});
test('Imports combined data params as form url-encoded', () => {
test("Imports combined data params as form url-encoded", () => {
expect(convertCurl(`curl -d 'a=aaa&b=bbb&c' https://yaak.app`)).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
bodyType: 'application/x-www-form-urlencoded',
method: "POST",
url: "https://yaak.app",
bodyType: "application/x-www-form-urlencoded",
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
body: {
form: [
{ name: 'a', value: 'aaa', enabled: true },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: '', enabled: true },
{ name: "a", value: "aaa", enabled: true },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "", enabled: true },
],
},
}),
@@ -220,38 +214,38 @@ describe('importer-curl', () => {
});
});
test('Imports data params as text', () => {
test("Imports data params as text", () => {
expect(
convertCurl('curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app'),
convertCurl("curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app"),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'text/plain', enabled: true }],
bodyType: 'text/plain',
body: { text: 'a&b&c=ccc' },
method: "POST",
url: "https://yaak.app",
headers: [{ name: "Content-Type", value: "text/plain", enabled: true }],
bodyType: "text/plain",
body: { text: "a&b&c=ccc" },
}),
],
},
});
});
test('Imports post data into URL', () => {
expect(convertCurl('curl -G https://api.stripe.com/v1/payment_links -d limit=3')).toEqual({
test("Imports post data into URL", () => {
expect(convertCurl("curl -G https://api.stripe.com/v1/payment_links -d limit=3")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'GET',
url: 'https://api.stripe.com/v1/payment_links',
method: "GET",
url: "https://api.stripe.com/v1/payment_links",
urlParameters: [
{
enabled: true,
name: 'limit',
value: '3',
name: "limit",
value: "3",
},
],
}),
@@ -260,7 +254,7 @@ describe('importer-curl', () => {
});
});
test('Imports multi-line JSON', () => {
test("Imports multi-line JSON", () => {
expect(
convertCurl(
`curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`,
@@ -270,10 +264,10 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
method: 'POST',
url: 'https://yaak.app',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
method: "POST",
url: "https://yaak.app",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "application/json",
body: { text: '{\n "foo":"bar"\n}' },
}),
],
@@ -281,20 +275,20 @@ describe('importer-curl', () => {
});
});
test('Imports multiple headers', () => {
test("Imports multiple headers", () => {
expect(
convertCurl('curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'),
convertCurl("curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app"),
).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
headers: [
{ name: 'Name', value: '', enabled: true },
{ name: 'Foo', value: 'bar', enabled: true },
{ name: 'AAA', value: 'bbb', enabled: true },
{ name: '', value: 'ccc', enabled: true },
{ name: "Name", value: "", enabled: true },
{ name: "Foo", value: "bar", enabled: true },
{ name: "AAA", value: "bbb", enabled: true },
{ name: "", value: "ccc", enabled: true },
],
}),
],
@@ -302,17 +296,17 @@ describe('importer-curl', () => {
});
});
test('Imports basic auth', () => {
expect(convertCurl('curl --user user:pass https://yaak.app')).toEqual({
test("Imports basic auth", () => {
expect(convertCurl("curl --user user:pass https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
authenticationType: 'basic',
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
],
@@ -320,17 +314,17 @@ describe('importer-curl', () => {
});
});
test('Imports digest auth', () => {
expect(convertCurl('curl --digest --user user:pass https://yaak.app')).toEqual({
test("Imports digest auth", () => {
expect(convertCurl("curl --digest --user user:pass https://yaak.app")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
authenticationType: 'digest',
url: "https://yaak.app",
authenticationType: "digest",
authentication: {
username: 'user',
password: 'pass',
username: "user",
password: "pass",
},
}),
],
@@ -338,30 +332,30 @@ describe('importer-curl', () => {
});
});
test('Imports cookie as header', () => {
test("Imports cookie as header", () => {
expect(convertCurl('curl --cookie "foo=bar" https://yaak.app')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [{ name: 'Cookie', value: 'foo=bar', enabled: true }],
url: "https://yaak.app",
headers: [{ name: "Cookie", value: "foo=bar", enabled: true }],
}),
],
},
});
});
test('Imports query params', () => {
test("Imports query params", () => {
expect(convertCurl('curl "https://yaak.app" --url-query foo=bar --url-query baz=qux')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
urlParameters: [
{ name: 'foo', value: 'bar', enabled: true },
{ name: 'baz', value: 'qux', enabled: true },
{ name: "foo", value: "bar", enabled: true },
{ name: "baz", value: "qux", enabled: true },
],
}),
],
@@ -369,16 +363,16 @@ describe('importer-curl', () => {
});
});
test('Imports query params from the URL', () => {
test("Imports query params from the URL", () => {
expect(convertCurl('curl "https://yaak.app?foo=bar&baz=a%20a"')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
url: "https://yaak.app",
urlParameters: [
{ name: 'foo', value: 'bar', enabled: true },
{ name: 'baz', value: 'a a', enabled: true },
{ name: "foo", value: "bar", enabled: true },
{ name: "baz", value: "a a", enabled: true },
],
}),
],
@@ -386,23 +380,23 @@ describe('importer-curl', () => {
});
});
test('Imports weird body', () => {
test("Imports weird body", () => {
expect(convertCurl(`curl 'https://yaak.app' -X POST --data-raw 'foo=bar=baz'`)).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'foo', value: 'bar=baz', enabled: true }],
form: [{ name: "foo", value: "bar=baz", enabled: true }],
},
headers: [
{
enabled: true,
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
},
],
}),
@@ -411,7 +405,7 @@ describe('importer-curl', () => {
});
});
test('Imports data with Unicode escape sequences', () => {
test("Imports data with Unicode escape sequences", () => {
expect(
convertCurl(
`curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{"query":"SearchQueryInput\\u0021"}' -X POST`,
@@ -421,10 +415,10 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
url: "https://yaak.app",
method: "POST",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "application/json",
body: { text: '{"query":"SearchQueryInput!"}' },
}),
],
@@ -432,7 +426,7 @@ describe('importer-curl', () => {
});
});
test('Imports data with multiple escape sequences', () => {
test("Imports data with multiple escape sequences", () => {
expect(
convertCurl(
`curl 'https://yaak.app' --data-raw $'Line1\\nLine2\\tTab\\u0021Exclamation' -X POST`,
@@ -442,17 +436,17 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'Line1\nLine2\tTab!Exclamation', value: '', enabled: true }],
form: [{ name: "Line1\nLine2\tTab!Exclamation", value: "", enabled: true }],
},
headers: [
{
enabled: true,
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
},
],
}),
@@ -461,7 +455,7 @@ describe('importer-curl', () => {
});
});
test('Imports multipart form data from --data-raw (Chrome DevTools format)', () => {
test("Imports multipart form data from --data-raw (Chrome DevTools format)", () => {
// This is the format Chrome DevTools uses when copying a multipart form submission as cURL
const curlCommand = `curl 'http://localhost:8080/system' \
-H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd' \
@@ -472,21 +466,21 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'http://localhost:8080/system',
method: 'POST',
url: "http://localhost:8080/system",
method: "POST",
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd',
name: "Content-Type",
value: "multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd",
enabled: true,
},
],
bodyType: 'multipart/form-data',
bodyType: "multipart/form-data",
body: {
form: [
{ name: 'username', value: 'jsgj', enabled: true },
{ name: 'password', value: '654321', enabled: true },
{ name: 'captcha', file: 'test.xlsx', enabled: true },
{ name: "username", value: "jsgj", enabled: true },
{ name: "password", value: "654321", enabled: true },
{ name: "captcha", file: "test.xlsx", enabled: true },
],
},
}),
@@ -495,7 +489,7 @@ describe('importer-curl', () => {
});
});
test('Imports JSON body with newlines in $quotes', () => {
test("Imports JSON body with newlines in $quotes", () => {
expect(
convertCurl(
`curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{\\n "foo": "bar",\\n "baz": "qux"\\n}' -X POST`,
@@ -505,10 +499,10 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json', enabled: true }],
bodyType: 'application/json',
url: "https://yaak.app",
method: "POST",
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
bodyType: "application/json",
body: { text: '{\n "foo": "bar",\n "baz": "qux"\n}' },
}),
],
@@ -516,110 +510,94 @@ describe('importer-curl', () => {
});
});
test('Handles double-quoted string ending with even backslashes before semicolon', () => {
test("Handles double-quoted string ending with even backslashes before semicolon", () => {
// "C:\\" has two backslashes which escape each other, so the closing " is real.
// The ; after should split into a second command.
expect(
convertCurl(
'curl -d "C:\\\\" https://yaak.app;curl https://example.com',
),
).toEqual({
expect(convertCurl('curl -d "C:\\\\" https://yaak.app;curl https://example.com')).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
form: [{ name: "C:\\", value: "", enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
baseRequest({ url: "https://example.com" }),
],
},
});
});
test('Handles $quoted string ending with a literal backslash before semicolon', () => {
test("Handles $quoted string ending with a literal backslash before semicolon", () => {
// $'C:\\\\' has two backslashes which become one literal backslash.
// The closing ' must not be misinterpreted as escaped.
// The ; after should split into a second command.
expect(
convertCurl(
"curl -d $'C:\\\\' https://yaak.app;curl https://example.com",
),
).toEqual({
expect(convertCurl("curl -d $'C:\\\\' https://yaak.app;curl https://example.com")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'C:\\', value: '', enabled: true }],
form: [{ name: "C:\\", value: "", enabled: true }],
},
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
}),
baseRequest({ url: 'https://example.com' }),
baseRequest({ url: "https://example.com" }),
],
},
});
});
test('Imports $quoted header with escaped single quotes', () => {
expect(
convertCurl(
`curl https://yaak.app -H $'X-Custom: it\\'s a test'`,
),
).toEqual({
test("Imports $quoted header with escaped single quotes", () => {
expect(convertCurl(`curl https://yaak.app -H $'X-Custom: it\\'s a test'`)).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
headers: [{ name: 'X-Custom', value: "it's a test", enabled: true }],
url: "https://yaak.app",
headers: [{ name: "X-Custom", value: "it's a test", enabled: true }],
}),
],
},
});
});
test('Does not split on escaped semicolon outside quotes', () => {
test("Does not split on escaped semicolon outside quotes", () => {
// In shell, \; is a literal semicolon and should not split commands.
// This should be treated as a single curl command with the URL "https://yaak.app?a=1;b=2"
expect(
convertCurl('curl https://yaak.app?a=1\\;b=2'),
).toEqual({
expect(convertCurl("curl https://yaak.app?a=1\\;b=2")).toEqual({
resources: {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'https://yaak.app',
urlParameters: [
{ name: 'a', value: '1;b=2', enabled: true },
],
url: "https://yaak.app",
urlParameters: [{ name: "a", value: "1;b=2", enabled: true }],
}),
],
},
});
});
test('Imports multipart form data with text-only fields from --data-raw', () => {
test("Imports multipart form data with text-only fields from --data-raw", () => {
const curlCommand = `curl 'http://example.com/api' \
-H 'Content-Type: multipart/form-data; boundary=----FormBoundary123' \
--data-raw $'------FormBoundary123\r\nContent-Disposition: form-data; name="field1"\r\n\r\nvalue1\r\n------FormBoundary123\r\nContent-Disposition: form-data; name="field2"\r\n\r\nvalue2\r\n------FormBoundary123--\r\n'`;
@@ -629,20 +607,20 @@ describe('importer-curl', () => {
workspaces: [baseWorkspace()],
httpRequests: [
baseRequest({
url: 'http://example.com/api',
method: 'POST',
url: "http://example.com/api",
method: "POST",
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data; boundary=----FormBoundary123',
name: "Content-Type",
value: "multipart/form-data; boundary=----FormBoundary123",
enabled: true,
},
],
bodyType: 'multipart/form-data',
bodyType: "multipart/form-data",
body: {
form: [
{ name: 'field1', value: 'value1', enabled: true },
{ name: 'field2', value: 'value2', enabled: true },
{ name: "field1", value: "value1", enabled: true },
{ name: "field2", value: "value2", enabled: true },
],
},
}),
@@ -658,17 +636,17 @@ function baseRequest(mergeWith: Partial<HttpRequest>) {
idCount.http_request = (idCount.http_request ?? -1) + 1;
return {
id: `GENERATE_ID::HTTP_REQUEST_${idCount.http_request}`,
model: 'http_request',
model: "http_request",
authentication: {},
authenticationType: null,
body: {},
bodyType: null,
folderId: null,
headers: [],
method: 'GET',
name: '',
method: "GET",
name: "",
sortPriority: 0,
url: '',
url: "",
urlParameters: [],
workspaceId: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,
...mergeWith,
@@ -679,8 +657,8 @@ function baseWorkspace(mergeWith: Partial<Workspace> = {}) {
idCount.workspace = (idCount.workspace ?? -1) + 1;
return {
id: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,
model: 'workspace',
name: 'Curl Import',
model: "workspace",
name: "Curl Import",
...mergeWith,
};
}

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/importer-insomnia",
"displayName": "Insomnia Importer",
"description": "Import data from Insomnia",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Import data from Insomnia",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,13 +1,13 @@
export function isJSObject(obj: unknown) {
return Object.prototype.toString.call(obj) === '[object Object]';
return Object.prototype.toString.call(obj) === "[object Object]";
}
export function isJSString(obj: unknown) {
return Object.prototype.toString.call(obj) === '[object String]';
return Object.prototype.toString.call(obj) === "[object String]";
}
export function convertId(id: string): string {
if (id.startsWith('GENERATE_ID::')) {
if (id.startsWith("GENERATE_ID::")) {
return id;
}
return `GENERATE_ID::${id}`;
@@ -17,7 +17,7 @@ export function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
}
if (typeof obj === 'object' && obj != null) {
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
@@ -29,14 +29,14 @@ export function deleteUndefinedAttrs<T>(obj: T): T {
/** Recursively render all nested object properties */
export function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === 'string') {
if (typeof obj === "string") {
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}') as T;
return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T;
}
if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
}
if (typeof obj === 'object' && obj != null) {
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;

View File

@@ -1,13 +1,13 @@
import type { Context, PluginDefinition } from '@yaakapp/api';
import YAML from 'yaml';
import { deleteUndefinedAttrs, isJSObject } from './common';
import { convertInsomniaV4 } from './v4';
import { convertInsomniaV5 } from './v5';
import type { Context, PluginDefinition } from "@yaakapp/api";
import YAML from "yaml";
import { deleteUndefinedAttrs, isJSObject } from "./common";
import { convertInsomniaV4 } from "./v4";
import { convertInsomniaV5 } from "./v5";
export const plugin: PluginDefinition = {
importer: {
name: 'Insomnia',
description: 'Import Insomnia workspaces',
name: "Insomnia",
description: "Import Insomnia workspaces",
async onImport(_ctx: Context, args: { text: string }) {
return convertInsomnia(args.text);
},

View File

@@ -1,6 +1,6 @@
/* oxlint-disable no-explicit-any */
import type { PartialImportResources } from '@yaakapp/api';
import { convertId, convertTemplateSyntax, isJSObject } from './common';
import type { PartialImportResources } from "@yaakapp/api";
import { convertId, convertTemplateSyntax, isJSObject } from "./common";
export function convertInsomniaV4(parsed: any) {
if (!Array.isArray(parsed.resources)) return null;
@@ -16,19 +16,19 @@ export function convertInsomniaV4(parsed: any) {
// Import workspaces
const workspacesToImport = parsed.resources.filter(
(r: any) => isJSObject(r) && r._type === 'workspace',
(r: any) => isJSObject(r) && r._type === "workspace",
);
for (const w of workspacesToImport) {
resources.workspaces.push({
id: convertId(w._id),
createdAt: w.created ? new Date(w.created).toISOString().replace('Z', '') : undefined,
updatedAt: w.updated ? new Date(w.updated).toISOString().replace('Z', '') : undefined,
model: 'workspace',
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
model: "workspace",
name: w.name,
description: w.description || undefined,
});
const environmentsToImport = parsed.resources.filter(
(r: any) => isJSObject(r) && r._type === 'environment',
(r: any) => isJSObject(r) && r._type === "environment",
);
resources.environments.push(
...environmentsToImport.map((r: any) => importEnvironment(r, w._id)),
@@ -39,12 +39,12 @@ export function convertInsomniaV4(parsed: any) {
for (const child of children) {
if (!isJSObject(child)) continue;
if (child._type === 'request_group') {
if (child._type === "request_group") {
resources.folders.push(importFolder(child, w._id));
nextFolder(child._id);
} else if (child._type === 'request') {
} else if (child._type === "request") {
resources.httpRequests.push(importHttpRequest(child, w._id));
} else if (child._type === 'grpc_request') {
} else if (child._type === "grpc_request") {
resources.grpcRequests.push(importGrpcRequest(child, w._id));
}
}
@@ -63,48 +63,48 @@ export function convertInsomniaV4(parsed: any) {
return { resources: convertTemplateSyntax(resources) };
}
function importHttpRequest(r: any, workspaceId: string): PartialImportResources['httpRequests'][0] {
function importHttpRequest(r: any, workspaceId: string): PartialImportResources["httpRequests"][0] {
let bodyType: string | null = null;
let body = {};
if (r.body.mimeType === 'application/octet-stream') {
bodyType = 'binary';
body = { filePath: r.body.fileName ?? '' };
} else if (r.body?.mimeType === 'application/x-www-form-urlencoded') {
bodyType = 'application/x-www-form-urlencoded';
if (r.body.mimeType === "application/octet-stream") {
bodyType = "binary";
body = { filePath: r.body.fileName ?? "" };
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
bodyType = "application/x-www-form-urlencoded";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
name: p.name ?? "",
value: p.value ?? "",
})),
};
} else if (r.body?.mimeType === 'multipart/form-data') {
bodyType = 'multipart/form-data';
} else if (r.body?.mimeType === "multipart/form-data") {
bodyType = "multipart/form-data";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
name: p.name ?? "",
value: p.value ?? "",
file: p.fileName ?? null,
})),
};
} else if (r.body?.mimeType === 'application/graphql') {
bodyType = 'graphql';
body = { text: r.body.text ?? '' };
} else if (r.body?.mimeType === 'application/json') {
bodyType = 'application/json';
body = { text: r.body.text ?? '' };
} else if (r.body?.mimeType === "application/graphql") {
bodyType = "graphql";
body = { text: r.body.text ?? "" };
} else if (r.body?.mimeType === "application/json") {
bodyType = "application/json";
body = { text: r.body.text ?? "" };
}
let authenticationType: string | null = null;
let authentication = {};
if (r.authentication.type === 'bearer') {
authenticationType = 'bearer';
if (r.authentication.type === "bearer") {
authenticationType = "bearer";
authentication = {
token: r.authentication.token,
};
} else if (r.authentication.type === 'basic') {
authenticationType = 'basic';
} else if (r.authentication.type === "basic") {
authenticationType = "basic";
authentication = {
username: r.authentication.username,
password: r.authentication.password,
@@ -113,19 +113,19 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
return {
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace('Z', '') : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace('Z', '') : undefined,
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: 'http_request',
model: "http_request",
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || undefined,
url: r.url,
urlParameters: (r.parameters ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
name: p.name ?? "",
value: p.value ?? "",
})),
body,
bodyType,
@@ -135,51 +135,51 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
headers: (r.headers ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== '' || value !== ''),
.filter(({ name, value }: any) => name !== "" || value !== ""),
};
}
function importGrpcRequest(r: any, workspaceId: string): PartialImportResources['grpcRequests'][0] {
const parts = r.protoMethodName.split('/').filter((p: any) => p !== '');
function importGrpcRequest(r: any, workspaceId: string): PartialImportResources["grpcRequests"][0] {
const parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: convertId(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace('Z', '') : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace('Z', '') : undefined,
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: 'grpc_request',
model: "grpc_request",
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || undefined,
url: r.url,
service,
method,
message: r.body?.text ?? '',
message: r.body?.text ?? "",
metadata: (r.metadata ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== '' || value !== ''),
.filter(({ name, value }: any) => name !== "" || value !== ""),
};
}
function importFolder(f: any, workspaceId: string): PartialImportResources['folders'][0] {
function importFolder(f: any, workspaceId: string): PartialImportResources["folders"][0] {
return {
id: convertId(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace('Z', '') : undefined,
updatedAt: f.modified ? new Date(f.modified).toISOString().replace('Z', '') : undefined,
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
workspaceId: convertId(workspaceId),
description: f.description || undefined,
model: 'folder',
model: "folder",
name: f.name,
};
}
@@ -188,17 +188,17 @@ function importEnvironment(
e: any,
workspaceId: string,
isParentOg?: boolean,
): PartialImportResources['environments'][0] {
): PartialImportResources["environments"][0] {
const isParent = isParentOg ?? e.parentId === workspaceId;
return {
id: convertId(e._id),
createdAt: e.created ? new Date(e.created).toISOString().replace('Z', '') : undefined,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace('Z', '') : undefined,
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
sortPriority: e.metaSortKey,
parentModel: isParent ? 'workspace' : 'environment',
parentModel: isParent ? "workspace" : "environment",
parentId: null,
model: 'environment',
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,

View File

@@ -1,14 +1,14 @@
/* oxlint-disable no-explicit-any */
import type { PartialImportResources } from '@yaakapp/api';
import { convertId, convertTemplateSyntax, isJSObject } from './common';
import type { PartialImportResources } from "@yaakapp/api";
import { convertId, convertTemplateSyntax, isJSObject } from "./common";
export function convertInsomniaV5(parsed: any) {
// Assert parsed is object
if (parsed == null || typeof parsed !== 'object') {
if (parsed == null || typeof parsed !== "object") {
return null;
}
if (!('collection' in parsed) || !Array.isArray(parsed.collection)) {
if (!("collection" in parsed) || !Array.isArray(parsed.collection)) {
return null;
}
@@ -22,12 +22,12 @@ export function convertInsomniaV5(parsed: any) {
};
// Import workspaces
const meta = ('meta' in parsed ? parsed.meta : {}) as Record<string, any>;
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
resources.workspaces.push({
id: convertId(meta.id ?? 'collection'),
createdAt: meta.created ? new Date(meta.created).toISOString().replace('Z', '') : undefined,
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace('Z', '') : undefined,
model: 'workspace',
id: convertId(meta.id ?? "collection"),
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined,
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
model: "workspace",
name: parsed.name,
description: meta.description || undefined,
...importHeaders(parsed),
@@ -76,7 +76,7 @@ function importHttpRequest(
r: any,
workspaceId: string,
parentId: string,
): PartialImportResources['httpRequests'][0] {
): PartialImportResources["httpRequests"][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
@@ -84,51 +84,51 @@ function importHttpRequest(
let bodyType: string | null = null;
let body = {};
if (r.body?.mimeType === 'application/octet-stream') {
bodyType = 'binary';
body = { filePath: r.body.fileName ?? '' };
} else if (r.body?.mimeType === 'application/x-www-form-urlencoded') {
bodyType = 'application/x-www-form-urlencoded';
if (r.body?.mimeType === "application/octet-stream") {
bodyType = "binary";
body = { filePath: r.body.fileName ?? "" };
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
bodyType = "application/x-www-form-urlencoded";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
name: p.name ?? "",
value: p.value ?? "",
})),
};
} else if (r.body?.mimeType === 'multipart/form-data') {
bodyType = 'multipart/form-data';
} else if (r.body?.mimeType === "multipart/form-data") {
bodyType = "multipart/form-data";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
name: p.name ?? "",
value: p.value ?? "",
file: p.fileName ?? null,
})),
};
} else if (r.body?.mimeType === 'application/graphql') {
bodyType = 'graphql';
body = { text: r.body.text ?? '' };
} else if (r.body?.mimeType === 'application/json') {
bodyType = 'application/json';
body = { text: r.body.text ?? '' };
} else if (r.body?.mimeType === "application/graphql") {
bodyType = "graphql";
body = { text: r.body.text ?? "" };
} else if (r.body?.mimeType === "application/json") {
bodyType = "application/json";
body = { text: r.body.text ?? "" };
}
return {
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
model: 'http_request',
model: "http_request",
name: r.name,
description: r.meta?.description || undefined,
url: r.url,
urlParameters: (r.parameters ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
name: p.name ?? "",
value: p.value ?? "",
})),
body,
bodyType,
@@ -142,22 +142,22 @@ function importGrpcRequest(
r: any,
workspaceId: string,
parentId: string,
): PartialImportResources['grpcRequests'][0] {
): PartialImportResources["grpcRequests"][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
const parts = r.protoMethodName.split('/').filter((p: any) => p !== '');
const parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
model: 'grpc_request',
model: "grpc_request",
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
@@ -165,14 +165,14 @@ function importGrpcRequest(
url: r.url,
service,
method,
message: r.body?.text ?? '',
message: r.body?.text ?? "",
metadata: (r.metadata ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== '' || value !== ''),
.filter(({ name, value }: any) => name !== "" || value !== ""),
};
}
@@ -180,24 +180,24 @@ function importWebsocketRequest(
r: any,
workspaceId: string,
parentId: string,
): PartialImportResources['websocketRequests'][0] {
): PartialImportResources["websocketRequests"][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
return {
model: 'websocket_request',
model: "websocket_request",
id: convertId(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || undefined,
url: r.url,
message: r.body?.text ?? '',
message: r.body?.text ?? "",
...importHeaders(r),
...importAuthentication(r),
};
@@ -207,23 +207,23 @@ function importHeaders(obj: any) {
const headers = (obj.headers ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? '',
value: h.value ?? '',
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== '' || value !== '');
.filter(({ name, value }: any) => name !== "" || value !== "");
return { headers } as const;
}
function importAuthentication(obj: any) {
let authenticationType: string | null = null;
let authentication = {};
if (obj.authentication?.type === 'bearer') {
authenticationType = 'bearer';
if (obj.authentication?.type === "bearer") {
authenticationType = "bearer";
authentication = {
token: obj.authentication.token,
};
} else if (obj.authentication?.type === 'basic') {
authenticationType = 'basic';
} else if (obj.authentication?.type === "basic") {
authenticationType = "basic";
authentication = {
username: obj.authentication.username,
password: obj.authentication.password,
@@ -238,26 +238,26 @@ function importFolder(
workspaceId: string,
parentId: string,
): {
folder: PartialImportResources['folders'][0];
environment: PartialImportResources['environments'][0] | null;
folder: PartialImportResources["folders"][0];
environment: PartialImportResources["environments"][0] | null;
} {
const id = f.meta?.id ?? f._id;
const created = f.meta?.created ?? f.created;
const updated = f.meta?.modified ?? f.updated;
const sortKey = f.meta?.sortKey ?? f.sortKey;
let environment: PartialImportResources['environments'][0] | null = null;
let environment: PartialImportResources["environments"][0] | null = null;
if (Object.keys(f.environment ?? {}).length > 0) {
environment = {
id: convertId(`${id}folder`),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
public: true,
parentModel: 'folder',
parentModel: "folder",
parentId: convertId(id),
model: 'environment',
name: 'Folder Environment',
model: "environment",
name: "Folder Environment",
variables: Object.entries(f.environment ?? {}).map(([name, value]) => ({
enabled: true,
name,
@@ -268,10 +268,10 @@ function importFolder(
return {
folder: {
model: 'folder',
model: "folder",
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
workspaceId: convertId(workspaceId),
@@ -288,7 +288,7 @@ function importEnvironment(
e: any,
workspaceId: string,
isParent?: boolean,
): PartialImportResources['environments'][0] {
): PartialImportResources["environments"][0] {
const id = e.meta?.id ?? e._id;
const created = e.meta?.created ?? e.created;
const updated = e.meta?.modified ?? e.updated;
@@ -296,14 +296,14 @@ function importEnvironment(
return {
id: convertId(id),
createdAt: created ? new Date(created).toISOString().replace('Z', '') : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
public: !e.isPrivate,
sortPriority: sortKey,
parentModel: isParent ? 'workspace' : 'environment',
parentModel: isParent ? "workspace" : "environment",
parentId: null,
model: 'environment',
model: "environment",
name: e.name,
variables: Object.entries(e.data ?? {}).map(([name, value]) => ({
enabled: true,

View File

@@ -1,23 +1,23 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { describe, expect, test } from 'vite-plus/test';
import YAML from 'yaml';
import { convertInsomnia } from '../src';
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import YAML from "yaml";
import { convertInsomnia } from "../src";
describe('importer-yaak', () => {
const p = path.join(__dirname, 'fixtures');
describe("importer-yaak", () => {
const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p);
for (const fixture of fixtures) {
if (fixture.includes('.output')) {
if (fixture.includes(".output")) {
continue;
}
test(`Imports ${fixture}`, () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const contents = fs.readFileSync(path.join(p, fixture), "utf-8");
const expected = fs.readFileSync(
path.join(p, fixture.replace(/.input\..*/, '.output.json')),
'utf-8',
path.join(p, fixture.replace(/.input\..*/, ".output.json")),
"utf-8",
);
const result = convertInsomnia(contents);
// console.log(JSON.stringify(result, null, 2))

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/importer-openapi",
"displayName": "OpenAPI Importer",
"description": "Import API specifications from OpenAPI/Swagger format",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Import API specifications from OpenAPI/Swagger format",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,12 +1,12 @@
import { convertPostman } from '@yaak/importer-postman/src';
import type { Context, PluginDefinition } from '@yaakapp/api';
import type { ImportPluginResponse } from '@yaakapp/api/lib/plugins/ImporterPlugin';
import { convert } from 'openapi-to-postmanv2';
import { convertPostman } from "@yaak/importer-postman/src";
import type { Context, PluginDefinition } from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import { convert } from "openapi-to-postmanv2";
export const plugin: PluginDefinition = {
importer: {
name: 'OpenAPI',
description: 'Import OpenAPI collections',
name: "OpenAPI",
description: "Import OpenAPI collections",
onImport(_ctx: Context, args: { text: string }) {
return convertOpenApi(args.text);
},
@@ -19,7 +19,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
try {
postmanCollection = await new Promise((resolve, reject) => {
// oxlint-disable-next-line no-explicit-any
convert({ type: 'string', data: contents }, {}, (err, result: any) => {
convert({ type: "string", data: contents }, {}, (err, result: any) => {
if (err != null) reject(err);
if (Array.isArray(result.output) && result.output.length > 0) {

View File

@@ -13,23 +13,23 @@ info:
- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)
version: 1.0.20-SNAPSHOT
title: Swagger Petstore - OpenAPI 3.0
termsOfService: 'http://swagger.io/terms/'
termsOfService: "http://swagger.io/terms/"
contact:
email: apiteam@swagger.io
license:
name: Apache 2.0
url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
url: "http://www.apache.org/licenses/LICENSE-2.0.html"
tags:
- name: pet
description: Everything about your Pets
externalDocs:
description: Find out more
url: 'http://swagger.io'
url: "http://swagger.io"
- name: store
description: Access to Petstore orders
externalDocs:
description: Find out more about our store
url: 'http://swagger.io'
url: "http://swagger.io"
- name: user
description: Operations about user
paths:
@@ -41,34 +41,34 @@ paths:
description: Add a new pet to the store
operationId: addPet
responses:
'200':
"200":
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/json:
schema:
$ref: '#/components/schemas/Pet'
'405':
$ref: "#/components/schemas/Pet"
"405":
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
- "write:pets"
- "read:pets"
requestBody:
description: Create a new pet in the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/xml:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
put:
tags:
- pet
@@ -76,38 +76,38 @@ paths:
description: Update an existing pet by Id
operationId: updatePet
responses:
'200':
"200":
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
$ref: "#/components/schemas/Pet"
"400":
description: Invalid ID supplied
'404':
"404":
description: Pet not found
'405':
"405":
description: Validation exception
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
- "write:pets"
- "read:pets"
requestBody:
description: Update an existent pet in the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/xml:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
/pet/findByStatus:
get:
tags:
@@ -129,25 +129,25 @@ paths:
- sold
default: available
responses:
'200':
"200":
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
$ref: "#/components/schemas/Pet"
"400":
description: Invalid status value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
- "write:pets"
- "read:pets"
/pet/findByTags:
get:
tags:
@@ -168,26 +168,26 @@ paths:
items:
type: string
responses:
'200':
"200":
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
$ref: "#/components/schemas/Pet"
"400":
description: Invalid tag value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}':
- "write:pets"
- "read:pets"
"/pet/{petId}":
get:
tags:
- pet
@@ -203,29 +203,29 @@ paths:
type: integer
format: int64
responses:
'200':
"200":
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
$ref: "#/components/schemas/Pet"
"400":
description: Invalid ID supplied
'404':
"404":
description: Pet not found
security:
- api_key: []
- petstore_auth:
- 'write:pets'
- 'read:pets'
- "write:pets"
- "read:pets"
post:
tags:
- pet
summary: Updates a pet in the store with form data
description: ''
description: ""
operationId: updatePetWithForm
parameters:
- name: petId
@@ -246,22 +246,22 @@ paths:
schema:
type: string
responses:
'405':
"405":
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
- "write:pets"
- "read:pets"
delete:
tags:
- pet
summary: Deletes a pet
description: ''
description: ""
operationId: deletePet
parameters:
- name: api_key
in: header
description: ''
description: ""
required: false
schema:
type: string
@@ -273,18 +273,18 @@ paths:
type: integer
format: int64
responses:
'400':
"400":
description: Invalid pet value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}/uploadImage':
- "write:pets"
- "read:pets"
"/pet/{petId}/uploadImage":
post:
tags:
- pet
summary: uploads an image
description: ''
description: ""
operationId: uploadFile
parameters:
- name: petId
@@ -301,16 +301,16 @@ paths:
schema:
type: string
responses:
'200':
"200":
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
$ref: "#/components/schemas/ApiResponse"
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
- "write:pets"
- "read:pets"
requestBody:
content:
application/octet-stream:
@@ -326,7 +326,7 @@ paths:
operationId: getInventory
x-swagger-router-controller: OrderController
responses:
'200':
"200":
description: successful operation
content:
application/json:
@@ -346,26 +346,26 @@ paths:
operationId: placeOrder
x-swagger-router-controller: OrderController
responses:
'200':
"200":
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'405':
$ref: "#/components/schemas/Order"
"405":
description: Invalid input
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
$ref: "#/components/schemas/Order"
application/xml:
schema:
$ref: '#/components/schemas/Order'
$ref: "#/components/schemas/Order"
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Order'
'/store/order/{orderId}':
$ref: "#/components/schemas/Order"
"/store/order/{orderId}":
get:
tags:
- store
@@ -384,18 +384,18 @@ paths:
type: integer
format: int64
responses:
'200':
"200":
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Order'
$ref: "#/components/schemas/Order"
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
$ref: "#/components/schemas/Order"
"400":
description: Invalid ID supplied
'404':
"404":
description: Order not found
delete:
tags:
@@ -415,9 +415,9 @@ paths:
type: integer
format: int64
responses:
'400':
"400":
description: Invalid ID supplied
'404':
"404":
description: Order not found
/user:
post:
@@ -432,40 +432,40 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
application/xml:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
application/xml:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
description: Created user object
/user/createWithList:
post:
tags:
- user
summary: Creates list of users with given input array
description: 'Creates list of users with given input array'
description: "Creates list of users with given input array"
x-swagger-router-controller: UserController
operationId: createUsersWithListInput
responses:
'200':
"200":
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
application/json:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
default:
description: successful operation
requestBody:
@@ -474,13 +474,13 @@ paths:
schema:
type: array
items:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
/user/login:
get:
tags:
- user
summary: Logs user into the system
description: ''
description: ""
operationId: loginUser
parameters:
- name: username
@@ -496,7 +496,7 @@ paths:
schema:
type: string
responses:
'200':
"200":
description: successful operation
headers:
X-Rate-Limit:
@@ -516,46 +516,46 @@ paths:
application/json:
schema:
type: string
'400':
"400":
description: Invalid username/password supplied
/user/logout:
get:
tags:
- user
summary: Logs out current logged in user session
description: ''
description: ""
operationId: logoutUser
parameters: []
responses:
default:
description: successful operation
'/user/{username}':
"/user/{username}":
get:
tags:
- user
summary: Get user by user name
description: ''
description: ""
operationId: getUserByName
parameters:
- name: username
in: path
description: 'The name that needs to be fetched. Use user1 for testing. '
description: "The name that needs to be fetched. Use user1 for testing. "
required: true
schema:
type: string
responses:
'200':
"200":
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: "#/components/schemas/User"
"400":
description: Invalid username supplied
'404':
"404":
description: User not found
put:
tags:
@@ -579,13 +579,13 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
application/xml:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
delete:
tags:
- user
@@ -600,13 +600,13 @@ paths:
schema:
type: string
responses:
'400':
"400":
description: Invalid username supplied
'404':
"404":
description: User not found
externalDocs:
description: Find out more about Swagger
url: 'http://swagger.io'
url: "http://swagger.io"
components:
schemas:
Order:
@@ -652,7 +652,7 @@ components:
address:
type: array
items:
$ref: '#/components/schemas/Address'
$ref: "#/components/schemas/Address"
xml:
wrapped: true
name: addresses
@@ -747,7 +747,7 @@ components:
type: string
example: doggie
category:
$ref: '#/components/schemas/Category'
$ref: "#/components/schemas/Category"
photoUrls:
type: array
xml:
@@ -761,7 +761,7 @@ components:
xml:
wrapped: true
items:
$ref: '#/components/schemas/Tag'
$ref: "#/components/schemas/Tag"
xml:
name: tag
status:
@@ -784,17 +784,17 @@ components:
message:
type: string
xml:
name: '##default'
name: "##default"
type: object
requestBodies:
Pet:
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
application/xml:
schema:
$ref: '#/components/schemas/Pet'
$ref: "#/components/schemas/Pet"
description: Pet object that needs to be added to the store
UserArray:
content:
@@ -802,17 +802,17 @@ components:
schema:
type: array
items:
$ref: '#/components/schemas/User'
$ref: "#/components/schemas/User"
description: List of user object
securitySchemes:
petstore_auth:
type: oauth2
flows:
implicit:
authorizationUrl: 'https://petstore.swagger.io/oauth/authorize'
authorizationUrl: "https://petstore.swagger.io/oauth/authorize"
scopes:
'write:pets': modify pets in your account
'read:pets': read your pets
"write:pets": modify pets in your account
"read:pets": read your pets
api_key:
type: apiKey
name: api_key

View File

@@ -1,22 +1,22 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { describe, expect, test } from 'vite-plus/test';
import { convertOpenApi } from '../src';
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { convertOpenApi } from "../src";
describe('importer-openapi', () => {
const p = path.join(__dirname, 'fixtures');
describe("importer-openapi", () => {
const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p);
test('Maps operation description to request description', async () => {
test("Maps operation description to request description", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: '3.0.0',
info: { title: 'Description Test', version: '1.0.0' },
openapi: "3.0.0",
info: { title: "Description Test", version: "1.0.0" },
paths: {
'/klanten': {
"/klanten": {
get: {
description: 'Lijst van klanten',
responses: { '200': { description: 'ok' } },
description: "Lijst van klanten",
responses: { "200": { description: "ok" } },
},
},
},
@@ -25,24 +25,24 @@ describe('importer-openapi', () => {
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
description: 'Lijst van klanten',
description: "Lijst van klanten",
}),
]);
});
test('Skips invalid file', async () => {
const imported = await convertOpenApi('{}');
test("Skips invalid file", async () => {
const imported = await convertOpenApi("{}");
expect(imported).toBeUndefined();
});
for (const fixture of fixtures) {
test(`Imports ${fixture}`, async () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const contents = fs.readFileSync(path.join(p, fixture), "utf-8");
const imported = await convertOpenApi(contents);
expect(imported?.resources.workspaces).toEqual([
expect.objectContaining({
name: 'Swagger Petstore - OpenAPI 3.0',
description: expect.stringContaining('This is a sample Pet Store Server'),
name: "Swagger Petstore - OpenAPI 3.0",
description: expect.stringContaining("This is a sample Pet Store Server"),
}),
]);
expect(imported?.resources.httpRequests.length).toBe(19);

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/importer-postman-environment",
"displayName": "Postman Environment Importer",
"description": "Import environments from Postman",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Import environments from Postman",
"main": "./build/index.js",
"scripts": {
"build": "yaakcli build",

View File

@@ -5,20 +5,20 @@ import type {
PartialImportResources,
PluginDefinition,
Workspace,
} from '@yaakapp/api';
import type { ImportPluginResponse } from '@yaakapp/api/lib/plugins/ImporterPlugin';
} from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId">[];
}
export const plugin: PluginDefinition = {
importer: {
name: 'Postman Environment',
description: 'Import postman environment exports',
name: "Postman Environment",
description: "Import postman environment exports",
onImport(_ctx: Context, args: { text: string }) {
return convertPostmanEnvironment(args.text);
},
@@ -38,9 +38,9 @@ export function convertPostmanEnvironment(contents: string): ImportPluginRespons
type?: string;
}>(root.values);
const scope = root._postman_variable_scope;
const hasEnvMarkers = typeof scope === 'string';
const hasEnvMarkers = typeof scope === "string";
if (values.length === 0 || (!hasEnvMarkers && typeof root.name !== 'string')) {
if (values.length === 0 || (!hasEnvMarkers && typeof root.name !== "string")) {
// Not a Postman environment file, skip
return;
}
@@ -53,18 +53,18 @@ export function convertPostmanEnvironment(contents: string): ImportPluginRespons
const envVariables = values
.map((v) => ({
enabled: v.enabled ?? true,
name: String(v.key ?? ''),
name: String(v.key ?? ""),
value: String(v.value),
description: v.description ? String(v.description) : null,
}))
.filter((v) => v.name.length > 0);
const environment: ExportResources['environments'][0] = {
model: 'environment',
id: generateId('environment'),
name: root.name ? String(root.name) : 'Environment',
workspaceId: 'CURRENT_WORKSPACE',
parentModel: 'environment',
const environment: ExportResources["environments"][0] = {
model: "environment",
id: generateId("environment"),
name: root.name ? String(root.name) : "Environment",
workspaceId: "CURRENT_WORKSPACE",
parentModel: "environment",
parentId: null,
variables: envVariables,
};
@@ -86,26 +86,26 @@ function parseJSONToRecord<T>(jsonStr: string): Record<string, T> | null {
}
function toRecord<T>(value: unknown): Record<string, T> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, T>;
}
return {} as Record<string, T>;
}
function toArray<T>(value: unknown): T[] {
if (Object.prototype.toString.call(value) === '[object Array]') return value as T[];
if (Object.prototype.toString.call(value) === "[object Array]") return value as T[];
return [] as T[];
}
/** Recursively render all nested object properties */
function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === 'string') {
if (typeof obj === "string") {
return obj.replace(/{{\s*(_\.)?([^}]*)\s*}}/g, (_m, _dot, expr) => `\${[${expr.trim()}]}`) as T;
}
if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
}
if (typeof obj === 'object' && obj != null) {
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;
@@ -117,7 +117,7 @@ function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
}
if (typeof obj === 'object' && obj != null) {
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>)
.filter(([, v]) => v !== undefined)

View File

@@ -1,20 +1,20 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { describe, expect, test } from 'vite-plus/test';
import { convertPostmanEnvironment } from '../src';
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { convertPostmanEnvironment } from "../src";
describe('importer-postman-environment', () => {
const p = path.join(__dirname, 'fixtures');
describe("importer-postman-environment", () => {
const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p);
for (const fixture of fixtures) {
if (fixture.includes('.output')) {
if (fixture.includes(".output")) {
continue;
}
test(`Imports ${fixture}`, () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const expected = fs.readFileSync(path.join(p, fixture.replace('.input', '.output')), 'utf-8');
const contents = fs.readFileSync(path.join(p, fixture), "utf-8");
const expected = fs.readFileSync(path.join(p, fixture.replace(".input", ".output")), "utf-8");
const result = convertPostmanEnvironment(contents);
expect(result).toEqual(JSON.parse(expected));
});

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/importer-postman",
"displayName": "Postman Importer",
"description": "Import collections from Postman",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Import collections from Postman",
"main": "./build/index.js",
"scripts": {
"build": "yaakcli build",

View File

@@ -9,26 +9,26 @@ import type {
PartialImportResources,
PluginDefinition,
Workspace,
} from '@yaakapp/api';
import type { ImportPluginResponse } from '@yaakapp/api/lib/plugins/ImporterPlugin';
} from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
const POSTMAN_2_1_0_SCHEMA = 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json';
const POSTMAN_2_0_0_SCHEMA = 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json';
const POSTMAN_2_1_0_SCHEMA = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json";
const POSTMAN_2_0_0_SCHEMA = "https://schema.getpostman.com/json/collection/v2.0.0/collection.json";
const VALID_SCHEMAS = [POSTMAN_2_0_0_SCHEMA, POSTMAN_2_1_0_SCHEMA];
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, 'name' | 'id' | 'model'>[];
environments: AtLeast<Environment, 'name' | 'id' | 'model' | 'workspaceId'>[];
httpRequests: AtLeast<HttpRequest, 'name' | 'id' | 'model' | 'workspaceId'>[];
folders: AtLeast<Folder, 'name' | 'id' | 'model' | 'workspaceId'>[];
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
}
export const plugin: PluginDefinition = {
importer: {
name: 'Postman',
description: 'Import postman collections',
name: "Postman",
description: "Import postman collections",
onImport(_ctx: Context, args: { text: string }) {
return convertPostman(args.text);
},
@@ -41,7 +41,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
const info = toRecord(root.info);
const isValidSchema = VALID_SCHEMAS.includes(
typeof info.schema === 'string' ? info.schema : 'n/a',
typeof info.schema === "string" ? info.schema : "n/a",
);
if (!isValidSchema || !Array.isArray(root.item)) {
return;
@@ -56,22 +56,22 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
folders: [],
};
const workspace: ExportResources['workspaces'][0] = {
model: 'workspace',
id: generateId('workspace'),
name: info.name ? String(info.name) : 'Postman Import',
const workspace: ExportResources["workspaces"][0] = {
model: "workspace",
id: generateId("workspace"),
name: info.name ? String(info.name) : "Postman Import",
description: importDescription(info.description),
...globalAuth,
};
exportResources.workspaces.push(workspace);
// Create the base environment
const environment: ExportResources['environments'][0] = {
model: 'environment',
id: generateId('environment'),
name: 'Global Variables',
const environment: ExportResources["environments"][0] = {
model: "environment",
id: generateId("environment"),
name: "Global Variables",
workspaceId: workspace.id,
parentModel: 'workspace',
parentModel: "workspace",
parentId: null,
variables:
toArray<{ key: string; value: string }>(root.variable).map((v) => ({
@@ -83,12 +83,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
let sortPriorityIndex = 0;
const importItem = (v: Record<string, unknown>, folderId: string | null = null) => {
if (typeof v.name === 'string' && Array.isArray(v.item)) {
const folder: ExportResources['folders'][0] = {
model: 'folder',
if (typeof v.name === "string" && Array.isArray(v.item)) {
const folder: ExportResources["folders"][0] = {
model: "folder",
sortPriority: sortPriorityIndex++,
workspaceId: workspace.id,
id: generateId('folder'),
id: generateId("folder"),
name: v.name,
folderId,
};
@@ -96,7 +96,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
for (const child of v.item) {
importItem(child, folder.id);
}
} else if (typeof v.name === 'string' && 'request' in v) {
} else if (typeof v.name === "string" && "request" in v) {
const r = toRecord(v.request);
const bodyPatch = importBody(r.body);
const requestAuth = importAuth(r.auth);
@@ -126,14 +126,14 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
const { url, urlParameters } = convertUrl(r.url);
const request: ExportResources['httpRequests'][0] = {
model: 'http_request',
id: generateId('http_request'),
const request: ExportResources["httpRequests"][0] = {
model: "http_request",
id: generateId("http_request"),
workspaceId: workspace.id,
folderId,
name: v.name,
description: importDescription(r.description),
method: typeof r.method === 'string' ? r.method : 'GET',
method: typeof r.method === "string" ? r.method : "GET",
url,
urlParameters,
body: bodyPatch.body,
@@ -144,7 +144,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
};
exportResources.httpRequests.push(request);
} else {
console.log('Unknown item', v, folderId);
console.log("Unknown item", v, folderId);
}
};
@@ -159,53 +159,53 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
return { resources };
}
function convertUrl(rawUrl: unknown): Pick<HttpRequest, 'url' | 'urlParameters'> {
if (typeof rawUrl === 'string') {
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
if (typeof rawUrl === "string") {
return { url: rawUrl, urlParameters: [] };
}
const url = toRecord(rawUrl);
let v = '';
let v = "";
if ('protocol' in url && typeof url.protocol === 'string') {
if ("protocol" in url && typeof url.protocol === "string") {
v += `${url.protocol}://`;
}
if ('host' in url) {
v += `${Array.isArray(url.host) ? url.host.join('.') : String(url.host)}`;
if ("host" in url) {
v += `${Array.isArray(url.host) ? url.host.join(".") : String(url.host)}`;
}
if ('port' in url && typeof url.port === 'string') {
if ("port" in url && typeof url.port === "string") {
v += `:${url.port}`;
}
if ('path' in url && Array.isArray(url.path) && url.path.length > 0) {
v += `/${Array.isArray(url.path) ? url.path.join('/') : url.path}`;
if ("path" in url && Array.isArray(url.path) && url.path.length > 0) {
v += `/${Array.isArray(url.path) ? url.path.join("/") : url.path}`;
}
const params: HttpUrlParameter[] = [];
if ('query' in url && Array.isArray(url.query) && url.query.length > 0) {
if ("query" in url && Array.isArray(url.query) && url.query.length > 0) {
for (const query of url.query) {
params.push({
name: query.key ?? '',
value: query.value ?? '',
name: query.key ?? "",
value: query.value ?? "",
enabled: !query.disabled,
});
}
}
if ('variable' in url && Array.isArray(url.variable) && url.variable.length > 0) {
if ("variable" in url && Array.isArray(url.variable) && url.variable.length > 0) {
for (const v of url.variable) {
params.push({
name: `:${v.key ?? ''}`,
value: v.value ?? '',
name: `:${v.key ?? ""}`,
value: v.value ?? "",
enabled: !v.disabled,
});
}
}
if ('hash' in url && typeof url.hash === 'string') {
if ("hash" in url && typeof url.hash === "string") {
v += `#${url.hash}`;
}
@@ -214,7 +214,7 @@ function convertUrl(rawUrl: unknown): Pick<HttpRequest, 'url' | 'urlParameters'>
return { url: v, urlParameters: params };
}
function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'authenticationType'> {
function importAuth(rawAuth: unknown): Pick<HttpRequest, "authentication" | "authenticationType"> {
const auth = toRecord<Record<string, string>>(rawAuth);
// Helper: Postman stores auth params as an array of { key, value, ... }
@@ -223,7 +223,7 @@ function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'aut
const o: Record<string, unknown> = {};
for (const i of v) {
const ii = toRecord(i);
if (typeof ii.key === 'string') {
if (typeof ii.key === "string") {
o[ii.key] = ii.value;
}
}
@@ -232,39 +232,39 @@ function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'aut
const authType: string | undefined = auth.type ? String(auth.type) : undefined;
if (authType === 'noauth') {
if (authType === "noauth") {
return {
authenticationType: 'none',
authenticationType: "none",
authentication: {},
};
}
if ('basic' in auth && authType === 'basic') {
if ("basic" in auth && authType === "basic") {
const b = pmArrayToObj(auth.basic);
return {
authenticationType: 'basic',
authenticationType: "basic",
authentication: {
username: String(b.username ?? ''),
password: String(b.password ?? ''),
username: String(b.username ?? ""),
password: String(b.password ?? ""),
},
};
}
if ('bearer' in auth && authType === 'bearer') {
if ("bearer" in auth && authType === "bearer") {
const b = pmArrayToObj(auth.bearer);
// Postman uses key "token"
return {
authenticationType: 'bearer',
authenticationType: "bearer",
authentication: {
token: String(b.token ?? ''),
token: String(b.token ?? ""),
},
};
}
if ('awsv4' in auth && authType === 'awsv4') {
if ("awsv4" in auth && authType === "awsv4") {
const a = pmArrayToObj(auth.awsv4);
return {
authenticationType: 'awsv4',
authenticationType: "awsv4",
authentication: {
accessKeyId: a.accessKey != null ? String(a.accessKey) : undefined,
secretAccessKey: a.secretKey != null ? String(a.secretKey) : undefined,
@@ -275,51 +275,51 @@ function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'aut
};
}
if ('apikey' in auth && authType === 'apikey') {
if ("apikey" in auth && authType === "apikey") {
const a = pmArrayToObj(auth.apikey);
return {
authenticationType: 'apikey',
authenticationType: "apikey",
authentication: {
location: a.in === 'query' ? 'query' : 'header',
location: a.in === "query" ? "query" : "header",
key: a.value != null ? String(a.value) : undefined,
value: a.key != null ? String(a.key) : undefined,
},
};
}
if ('jwt' in auth && authType === 'jwt') {
if ("jwt" in auth && authType === "jwt") {
const a = pmArrayToObj(auth.jwt);
return {
authenticationType: 'jwt',
authenticationType: "jwt",
authentication: {
algorithm: a.algorithm != null ? String(a.algorithm).toUpperCase() : undefined,
secret: a.secret != null ? String(a.secret) : undefined,
secretBase64: !!a.isSecretBase64Encoded,
payload: a.payload != null ? String(a.payload) : undefined,
headerPrefix: a.headerPrefix != null ? String(a.headerPrefix) : undefined,
location: a.addTokenTo === 'header' ? 'header' : 'query',
location: a.addTokenTo === "header" ? "header" : "query",
},
};
}
if ('oauth2' in auth && authType === 'oauth2') {
if ("oauth2" in auth && authType === "oauth2") {
const o = pmArrayToObj(auth.oauth2);
let grantType = o.grant_type ? String(o.grant_type) : 'authorization_code';
let grantType = o.grant_type ? String(o.grant_type) : "authorization_code";
let pkcePatch: Record<string, unknown> = {};
if (grantType === 'authorization_code_with_pkce') {
grantType = 'authorization_code';
if (grantType === "authorization_code_with_pkce") {
grantType = "authorization_code";
pkcePatch =
o.grant_type === 'authorization_code_with_pkce'
o.grant_type === "authorization_code_with_pkce"
? {
usePkce: true,
pkceChallengeMethod: o.challengeAlgorithm ?? undefined,
pkceCodeVerifier: o.code_verifier != null ? String(o.code_verifier) : undefined,
}
: {};
} else if (grantType === 'password_credentials') {
grantType = 'password';
} else if (grantType === "password_credentials") {
grantType = "password";
}
const accessTokenUrl = o.accessTokenUrl != null ? String(o.accessTokenUrl) : undefined;
@@ -327,8 +327,8 @@ function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'aut
const authorizationUrl = o.authUrl != null ? String(o.authUrl) : undefined;
const clientId = o.clientId != null ? String(o.clientId) : undefined;
const clientSecret = o.clientSecret != null ? String(o.clientSecret) : undefined;
const credentials = o.client_authentication === 'body' ? 'body' : undefined;
const headerPrefix = o.headerPrefix ?? 'Bearer';
const credentials = o.client_authentication === "body" ? "body" : undefined;
const headerPrefix = o.headerPrefix ?? "Bearer";
const password = o.password != null ? String(o.password) : undefined;
const redirectUri = o.redirect_uri != null ? String(o.redirect_uri) : undefined;
const scope = o.scope != null ? String(o.scope) : undefined;
@@ -336,7 +336,7 @@ function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'aut
const username = o.username != null ? String(o.username) : undefined;
let grantPatch: Record<string, unknown> = {};
if (grantType === 'authorization_code') {
if (grantType === "authorization_code") {
grantPatch = {
clientSecret,
authorizationUrl,
@@ -345,16 +345,16 @@ function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'aut
state,
...pkcePatch,
};
} else if (grantType === 'implicit') {
} else if (grantType === "implicit") {
grantPatch = { authorizationUrl, redirectUri, state };
} else if (grantType === 'password') {
} else if (grantType === "password") {
grantPatch = { clientSecret, accessTokenUrl, username, password };
} else if (grantType === 'client_credentials') {
} else if (grantType === "client_credentials") {
grantPatch = { clientSecret, accessTokenUrl };
}
const authentication = {
name: 'oauth2',
name: "oauth2",
grantType,
audience,
clientId,
@@ -364,13 +364,13 @@ function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'aut
...grantPatch,
} as Record<string, unknown>;
return { authenticationType: 'oauth2', authentication };
return { authenticationType: "oauth2", authentication };
}
return { authenticationType: null, authentication: {} };
}
function importBody(rawBody: unknown): Pick<HttpRequest, 'body' | 'bodyType' | 'headers'> {
function importBody(rawBody: unknown): Pick<HttpRequest, "body" | "bodyType" | "headers"> {
const body = toRecord(rawBody) as {
mode: string;
graphql: { query?: string; variables?: string };
@@ -386,21 +386,21 @@ function importBody(rawBody: unknown): Pick<HttpRequest, 'body' | 'bodyType' | '
options?: { raw?: { language?: string } };
file?: { src?: string };
};
if (body.mode === 'graphql') {
if (body.mode === "graphql") {
return {
headers: [
{
name: 'Content-Type',
value: 'application/json',
name: "Content-Type",
value: "application/json",
enabled: true,
},
],
bodyType: 'graphql',
bodyType: "graphql",
body: {
text: JSON.stringify(
{
query: body.graphql?.query || '',
variables: parseJSONToRecord(body.graphql?.variables || '{}'),
query: body.graphql?.query || "",
variables: parseJSONToRecord(body.graphql?.variables || "{}"),
},
null,
2,
@@ -408,72 +408,72 @@ function importBody(rawBody: unknown): Pick<HttpRequest, 'body' | 'bodyType' | '
},
};
}
if (body.mode === 'urlencoded') {
if (body.mode === "urlencoded") {
return {
headers: [
{
name: 'Content-Type',
value: 'application/x-www-form-urlencoded',
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
},
],
bodyType: 'application/x-www-form-urlencoded',
bodyType: "application/x-www-form-urlencoded",
body: {
form: toArray<NonNullable<typeof body.urlencoded>[0]>(body.urlencoded).map((f) => ({
enabled: !f.disabled,
name: f.key ?? '',
value: f.value ?? '',
name: f.key ?? "",
value: f.value ?? "",
})),
},
};
}
if (body.mode === 'formdata') {
if (body.mode === "formdata") {
return {
headers: [
{
name: 'Content-Type',
value: 'multipart/form-data',
name: "Content-Type",
value: "multipart/form-data",
enabled: true,
},
],
bodyType: 'multipart/form-data',
bodyType: "multipart/form-data",
body: {
form: toArray<NonNullable<typeof body.formdata>[0]>(body.formdata).map((f) =>
f.src != null
? {
enabled: !f.disabled,
contentType: f.contentType ?? null,
name: f.key ?? '',
file: f.src ?? '',
name: f.key ?? "",
file: f.src ?? "",
}
: {
enabled: !f.disabled,
name: f.key ?? '',
value: f.value ?? '',
name: f.key ?? "",
value: f.value ?? "",
},
),
},
};
}
if (body.mode === 'raw') {
if (body.mode === "raw") {
return {
headers: [
{
name: 'Content-Type',
value: body.options?.raw?.language === 'json' ? 'application/json' : '',
name: "Content-Type",
value: body.options?.raw?.language === "json" ? "application/json" : "",
enabled: true,
},
],
bodyType: body.options?.raw?.language === 'json' ? 'application/json' : 'other',
bodyType: body.options?.raw?.language === "json" ? "application/json" : "other",
body: {
text: body.raw ?? '',
text: body.raw ?? "",
},
};
}
if (body.mode === 'file') {
if (body.mode === "file") {
return {
headers: [],
bodyType: 'binary',
bodyType: "binary",
body: {
filePath: body.file?.src,
},
@@ -491,14 +491,14 @@ function parseJSONToRecord<T>(jsonStr: string): Record<string, T> | null {
}
function toRecord<T>(value: unknown): Record<string, T> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, T>;
}
return {};
}
function toArray<T>(value: unknown): T[] {
if (Object.prototype.toString.call(value) === '[object Array]') return value as T[];
if (Object.prototype.toString.call(value) === "[object Array]") return value as T[];
return [];
}
@@ -507,13 +507,13 @@ function importDescription(rawDescription: unknown): string | undefined {
return undefined;
}
if (typeof rawDescription === 'string') {
if (typeof rawDescription === "string") {
return rawDescription;
}
if (typeof rawDescription === 'object' && !Array.isArray(rawDescription)) {
if (typeof rawDescription === "object" && !Array.isArray(rawDescription)) {
const description = toRecord(rawDescription);
if ('content' in description && description.content != null) {
if ("content" in description && description.content != null) {
return String(description.content);
}
return undefined;
@@ -524,16 +524,16 @@ function importDescription(rawDescription: unknown): string | undefined {
/** Recursively render all nested object properties */
function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === 'string') {
if (typeof obj === "string") {
return obj.replace(
/{{\s*(_\.)?([^}]*)\s*}}/g,
(_m, _dot, expr) => `\${[${expr.trim().replace(/^vault:/, '')}]}`,
(_m, _dot, expr) => `\${[${expr.trim().replace(/^vault:/, "")}]}`,
) as T;
}
if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
}
if (typeof obj === 'object' && obj != null) {
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;
@@ -545,7 +545,7 @@ function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
}
if (typeof obj === 'object' && obj != null) {
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)

View File

@@ -1,20 +1,20 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { describe, expect, test } from 'vite-plus/test';
import { convertPostman } from '../src';
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { convertPostman } from "../src";
describe('importer-postman', () => {
const p = path.join(__dirname, 'fixtures');
describe("importer-postman", () => {
const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p);
for (const fixture of fixtures) {
if (fixture.includes('.output')) {
if (fixture.includes(".output")) {
continue;
}
test(`Imports ${fixture}`, () => {
const contents = fs.readFileSync(path.join(p, fixture), 'utf-8');
const expected = fs.readFileSync(path.join(p, fixture.replace('.input', '.output')), 'utf-8');
const contents = fs.readFileSync(path.join(p, fixture), "utf-8");
const expected = fs.readFileSync(path.join(p, fixture.replace(".input", ".output")), "utf-8");
const result = convertPostman(contents);
// console.log(JSON.stringify(result, null, 2))
expect(JSON.stringify(result, null, 2)).toEqual(
@@ -23,21 +23,21 @@ describe('importer-postman', () => {
});
}
test('Imports object descriptions without [object Object]', () => {
test("Imports object descriptions without [object Object]", () => {
const result = convertPostman(
JSON.stringify({
info: {
name: 'Description Test',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
name: "Description Test",
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
item: [
{
name: 'Request 1',
name: "Request 1",
request: {
method: 'GET',
method: "GET",
description: {
content: 'Lijst van klanten',
type: 'text/plain',
content: "Lijst van klanten",
type: "text/plain",
},
},
},
@@ -47,13 +47,13 @@ describe('importer-postman', () => {
expect(result?.resources.workspaces).toEqual([
expect.objectContaining({
name: 'Description Test',
name: "Description Test",
}),
]);
expect(result?.resources.httpRequests).toEqual([
expect.objectContaining({
name: 'Request 1',
description: 'Lijst van klanten',
name: "Request 1",
description: "Lijst van klanten",
}),
]);
});

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/importer-yaak",
"displayName": "Yaak Importer",
"description": "Import data from Yaak export files",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Import data from Yaak export files",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,9 +1,9 @@
import type { Environment, PluginDefinition } from '@yaakapp/api';
import type { Environment, PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
importer: {
name: 'Yaak',
description: 'Yaak official format',
name: "Yaak",
description: "Yaak official format",
onImport(_ctx, args) {
return migrateImport(args.text);
},
@@ -23,24 +23,24 @@ export function migrateImport(contents: string) {
return undefined;
}
const isYaakExport = 'yaakSchema' in parsed;
const isYaakExport = "yaakSchema" in parsed;
if (!isYaakExport) {
return;
}
// Migrate v1 to v2 -- changes requests to httpRequests
if ('requests' in parsed.resources) {
if ("requests" in parsed.resources) {
parsed.resources.httpRequests = parsed.resources.requests;
parsed.resources.requests = undefined;
}
// Migrate v2 to v3
for (const workspace of parsed.resources.workspaces ?? []) {
if ('variables' in workspace) {
if ("variables" in workspace) {
// Create the base environment
const baseEnvironment: Partial<Environment> = {
id: `GENERATE_ID::base_env_${workspace.id}`,
name: 'Global Variables',
name: "Global Variables",
variables: workspace.variables,
workspaceId: workspace.id,
};
@@ -61,7 +61,7 @@ export function migrateImport(contents: string) {
// Migrate v3 to v4
for (const environment of parsed.resources.environments ?? []) {
if ('environmentId' in environment) {
if ("environmentId" in environment) {
environment.base = environment.environmentId == null;
environment.environmentId = undefined;
}
@@ -69,12 +69,12 @@ export function migrateImport(contents: string) {
// Migrate v4 to v5
for (const environment of parsed.resources.environments ?? []) {
if ('base' in environment && environment.base && environment.parentModel == null) {
environment.parentModel = 'workspace';
if ("base" in environment && environment.base && environment.parentModel == null) {
environment.parentModel = "workspace";
environment.parentId = null;
environment.base = undefined;
} else if ('base' in environment && !environment.base && environment.parentModel == null) {
environment.parentModel = 'environment';
} else if ("base" in environment && !environment.base && environment.parentModel == null) {
environment.parentModel = "environment";
environment.parentId = null;
environment.base = undefined;
}
@@ -84,5 +84,5 @@ export function migrateImport(contents: string) {
}
function isJSObject(obj: unknown) {
return Object.prototype.toString.call(obj) === '[object Object]';
return Object.prototype.toString.call(obj) === "[object Object]";
}

View File

@@ -1,14 +1,14 @@
import { describe, expect, test } from 'vite-plus/test';
import { migrateImport } from '../src';
import { describe, expect, test } from "vite-plus/test";
import { migrateImport } from "../src";
describe('importer-yaak', () => {
test('Skips invalid imports', () => {
expect(migrateImport('not JSON')).toBeUndefined();
expect(migrateImport('[]')).toBeUndefined();
describe("importer-yaak", () => {
test("Skips invalid imports", () => {
expect(migrateImport("not JSON")).toBeUndefined();
expect(migrateImport("[]")).toBeUndefined();
expect(migrateImport(JSON.stringify({ resources: {} }))).toBeUndefined();
});
test('converts schema 1 to 2', () => {
test("converts schema 1 to 2", () => {
const imported = migrateImport(
JSON.stringify({
yaakSchema: 1,
@@ -26,23 +26,23 @@ describe('importer-yaak', () => {
}),
);
});
test('converts schema 2 to 3', () => {
test("converts schema 2 to 3", () => {
const imported = migrateImport(
JSON.stringify({
yaakSchema: 2,
resources: {
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
id: "e_1",
workspaceId: "w_1",
name: "Production",
variables: [{ name: "E1", value: "E1!" }],
},
],
workspaces: [
{
id: 'w_1',
variables: [{ name: 'W1', value: 'W1!' }],
id: "w_1",
variables: [{ name: "W1", value: "W1!" }],
},
],
},
@@ -54,23 +54,23 @@ describe('importer-yaak', () => {
resources: {
workspaces: [
{
id: 'w_1',
id: "w_1",
},
],
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
parentModel: 'environment',
id: "e_1",
workspaceId: "w_1",
name: "Production",
variables: [{ name: "E1", value: "E1!" }],
parentModel: "environment",
parentId: null,
},
{
id: 'GENERATE_ID::base_env_w_1',
workspaceId: 'w_1',
name: 'Global Variables',
variables: [{ name: 'W1', value: 'W1!' }],
id: "GENERATE_ID::base_env_w_1",
workspaceId: "w_1",
name: "Global Variables",
variables: [{ name: "W1", value: "W1!" }],
},
],
},
@@ -78,35 +78,35 @@ describe('importer-yaak', () => {
);
});
test('converts schema 4 to 5', () => {
test("converts schema 4 to 5", () => {
const imported = migrateImport(
JSON.stringify({
yaakSchema: 2,
resources: {
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
id: "e_1",
workspaceId: "w_1",
base: false,
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
name: "Production",
variables: [{ name: "E1", value: "E1!" }],
},
{
id: 'e_1',
workspaceId: 'w_1',
id: "e_1",
workspaceId: "w_1",
base: true,
name: 'Global Variables',
variables: [{ name: 'G1', value: 'G1!' }],
name: "Global Variables",
variables: [{ name: "G1", value: "G1!" }],
},
],
folders: [
{
id: 'f_1',
id: "f_1",
},
],
workspaces: [
{
id: 'w_1',
id: "w_1",
},
],
},
@@ -118,30 +118,30 @@ describe('importer-yaak', () => {
resources: {
workspaces: [
{
id: 'w_1',
id: "w_1",
},
],
folders: [
{
id: 'f_1',
id: "f_1",
},
],
environments: [
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Production',
variables: [{ name: 'E1', value: 'E1!' }],
parentModel: 'environment',
id: "e_1",
workspaceId: "w_1",
name: "Production",
variables: [{ name: "E1", value: "E1!" }],
parentModel: "environment",
parentId: null,
},
{
id: 'e_1',
workspaceId: 'w_1',
name: 'Global Variables',
parentModel: 'workspace',
id: "e_1",
workspaceId: "w_1",
name: "Global Variables",
parentModel: "workspace",
parentId: null,
variables: [{ name: 'G1', value: 'G1!' }],
variables: [{ name: "G1", value: "G1!" }],
},
],
},

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-1password",
"displayName": "1Password Template Functions",
"description": "Template function for accessing 1Password secrets",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template function for accessing 1Password secrets",
"scripts": {
"build": "run-s build:*",
"build:1-build": "yaakcli build",

View File

@@ -1,8 +1,8 @@
import crypto from 'node:crypto';
import type { Client } from '@1password/sdk';
import { createClient, DesktopAuth } from '@1password/sdk';
import type { JsonPrimitive, PluginDefinition } from '@yaakapp/api';
import type { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import crypto from "node:crypto";
import type { Client } from "@1password/sdk";
import { createClient, DesktopAuth } from "@1password/sdk";
import type { JsonPrimitive, PluginDefinition } from "@yaakapp/api";
import type { CallTemplateFunctionArgs } from "@yaakapp-internal/plugins";
const _clients: Record<string, Client> = {};
@@ -23,32 +23,32 @@ async function op(
let authMethod: string | DesktopAuth;
let hash: string;
switch (args.values.authMethod) {
case 'desktop': {
case "desktop": {
const account = args.values.token;
if (typeof account !== 'string' || !account) return { error: 'Missing account name' };
if (typeof account !== "string" || !account) return { error: "Missing account name" };
hash = crypto.createHash('sha256').update(`desktop:${account}`).digest('hex');
hash = crypto.createHash("sha256").update(`desktop:${account}`).digest("hex");
authMethod = new DesktopAuth(account);
break;
}
case 'token': {
case "token": {
const token = args.values.token;
if (typeof token !== 'string' || !token) return { error: 'Missing service token' };
if (typeof token !== "string" || !token) return { error: "Missing service token" };
hash = crypto.createHash('sha256').update(`token:${token}`).digest('hex');
hash = crypto.createHash("sha256").update(`token:${token}`).digest("hex");
authMethod = token;
break;
}
default:
return { error: 'Invalid authentication method' };
return { error: "Invalid authentication method" };
}
if (!_clients[hash]) {
try {
_clients[hash] = await createClient({
auth: authMethod,
integrationName: 'Yaak 1Password Plugin',
integrationVersion: 'v1.0.0',
integrationName: "Yaak 1Password Plugin",
integrationVersion: "v1.0.0",
});
} catch (e) {
return { error: e };
@@ -66,18 +66,18 @@ async function getValue(
fieldId?: JsonPrimitive,
): Promise<Result<{ value: string }>> {
const res = await op(args);
if ('error' in res) return { error: res.error };
if ("error" in res) return { error: res.error };
const clientHash = res.clientHash;
const client = res.client;
if (!vaultId || typeof vaultId !== 'string') {
return { error: 'No vault specified' };
if (!vaultId || typeof vaultId !== "string") {
return { error: "No vault specified" };
}
if (!itemId || typeof itemId !== 'string') {
return { error: 'No item specified' };
if (!itemId || typeof itemId !== "string") {
return { error: "No item specified" };
}
if (!fieldId || typeof fieldId !== 'string') {
return { error: 'No field specified' };
if (!fieldId || typeof fieldId !== "string") {
return { error: "No field specified" };
}
try {
@@ -98,47 +98,47 @@ async function getValue(
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: '1password.item',
description: 'Get a secret',
previewArgs: ['field'],
name: "1password.item",
description: "Get a secret",
previewArgs: ["field"],
args: [
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
name: 'authMethod',
type: 'select',
label: 'Authentication Method',
defaultValue: 'token',
name: "authMethod",
type: "select",
label: "Authentication Method",
defaultValue: "token",
options: [
{
label: 'Service Account',
value: 'token',
label: "Service Account",
value: "token",
},
{
label: 'Desktop App',
value: 'desktop',
label: "Desktop App",
value: "desktop",
},
],
},
{
name: 'token',
type: 'text',
name: "token",
type: "text",
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
defaultValue: '${[1PASSWORD_TOKEN]}',
defaultValue: "${[1PASSWORD_TOKEN]}",
dynamic(_ctx, args) {
switch (args.values.authMethod) {
case 'desktop':
case "desktop":
return {
label: 'Account Name',
label: "Account Name",
description:
'Account name can be taken from the sidebar of the 1Password App. Make sure you\'re on the BETA version of the 1Password app and have "Integrate with other apps" enabled in Settings > Developer.',
};
case 'token':
case "token":
return {
label: 'Token',
label: "Token",
description:
'Token can be generated from the 1Password website by visiting Developer > Service Accounts',
"Token can be generated from the 1Password website by visiting Developer > Service Accounts",
password: true,
};
}
@@ -149,13 +149,13 @@ export const plugin: PluginDefinition = {
],
},
{
name: 'vault',
label: 'Vault',
type: 'select',
name: "vault",
label: "Vault",
type: "select",
options: [],
async dynamic(_ctx, args) {
const res = await op(args);
if ('error' in res) return { hidden: true };
if ("error" in res) return { hidden: true };
const clientHash = res.clientHash;
const client = res.client;
@@ -169,9 +169,9 @@ export const plugin: PluginDefinition = {
return {
options: vaults.map((vault) => {
let title = vault.id;
if ('title' in vault) {
if ("title" in vault) {
title = vault.title;
} else if ('name' in vault) {
} else if ("name" in vault) {
// The SDK returns 'name' instead of 'title' but the bindings still use 'title'
title = (vault as { name: string }).name;
}
@@ -185,18 +185,18 @@ export const plugin: PluginDefinition = {
},
},
{
name: 'item',
label: 'Item',
type: 'select',
name: "item",
label: "Item",
type: "select",
options: [],
async dynamic(_ctx, args) {
const res = await op(args);
if ('error' in res) return { hidden: true };
if ("error" in res) return { hidden: true };
const clientHash = res.clientHash;
const client = res.client;
const vaultId = args.values.vault;
if (typeof vaultId !== 'string') return { hidden: true };
if (typeof vaultId !== "string") return { hidden: true };
try {
const cacheKey = `${clientHash}:items:${vaultId}`;
@@ -216,19 +216,19 @@ export const plugin: PluginDefinition = {
},
},
{
name: 'field',
label: 'Field',
type: 'select',
name: "field",
label: "Field",
type: "select",
options: [],
async dynamic(_ctx, args) {
const res = await op(args);
if ('error' in res) return { hidden: true };
if ("error" in res) return { hidden: true };
const clientHash = res.clientHash;
const client = res.client;
const vaultId = args.values.vault;
const itemId = args.values.item;
if (typeof vaultId !== 'string' || typeof itemId !== 'string') {
if (typeof vaultId !== "string" || typeof itemId !== "string") {
return { hidden: true };
}
@@ -252,7 +252,7 @@ export const plugin: PluginDefinition = {
const itemId = args.values.item;
const fieldId = args.values.field;
const res = await getValue(args, vaultId, itemId, fieldId);
if ('error' in res) {
if ("error" in res) {
throw res.error;
}

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-cookie",
"displayName": "Cookie Template Functions",
"description": "Template functions for working with cookies",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for working with cookies",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,16 +1,16 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'cookie.value',
description: 'Read the value of a cookie in the jar, by name',
previewArgs: ['name'],
name: "cookie.value",
description: "Read the value of a cookie in the jar, by name",
previewArgs: ["name"],
args: [
{
type: 'text',
name: 'name',
label: 'Cookie Name',
type: "text",
name: "name",
label: "Cookie Name",
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-ctx",
"displayName": "Window Template Functions",
"description": "Template functions for accessing attributes of the current window",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for accessing attributes of the current window",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,26 +1,26 @@
import type { PluginDefinition } from '@yaakapp/api';
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'ctx.request',
description: 'Get the ID of the currently active request',
name: "ctx.request",
description: "Get the ID of the currently active request",
args: [],
async onRender(ctx) {
return ctx.window.requestId();
},
},
{
name: 'ctx.environment',
description: 'Get the ID of the currently active environment',
name: "ctx.environment",
description: "Get the ID of the currently active environment",
args: [],
async onRender(ctx) {
return ctx.window.environmentId();
},
},
{
name: 'ctx.workspace',
description: 'Get the ID of the currently active workspace',
name: "ctx.workspace",
description: "Get the ID of the currently active workspace",
args: [],
async onRender(ctx) {
return ctx.window.workspaceId();

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-encode",
"displayName": "Encoding Template Functions",
"description": "Template functions for encoding and decoding data",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for encoding and decoding data",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,60 +1,60 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'base64.encode',
description: 'Encode a value to base64',
name: "base64.encode",
description: "Encode a value to base64",
args: [
{
label: 'Encoding',
type: 'select',
name: 'encoding',
defaultValue: 'base64',
label: "Encoding",
type: "select",
name: "encoding",
defaultValue: "base64",
options: [
{
label: 'Base64',
value: 'base64',
label: "Base64",
value: "base64",
},
{
label: 'Base64 URL-safe',
value: 'base64url',
label: "Base64 URL-safe",
value: "base64url",
},
],
},
{ label: 'Plain Text', type: 'text', name: 'value', multiLine: true },
{ label: "Plain Text", type: "text", name: "value", multiLine: true },
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return Buffer.from(String(args.values.value ?? '')).toString(
args.values.encoding === 'base64url' ? 'base64url' : 'base64',
return Buffer.from(String(args.values.value ?? "")).toString(
args.values.encoding === "base64url" ? "base64url" : "base64",
);
},
},
{
name: 'base64.decode',
description: 'Decode a value from base64',
args: [{ label: 'Encoded Value', type: 'text', name: 'value', multiLine: true }],
name: "base64.decode",
description: "Decode a value from base64",
args: [{ label: "Encoded Value", type: "text", name: "value", multiLine: true }],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return Buffer.from(String(args.values.value ?? ''), 'base64').toString('utf-8');
return Buffer.from(String(args.values.value ?? ""), "base64").toString("utf-8");
},
},
{
name: 'url.encode',
description: 'Encode a value for use in a URL (percent-encoding)',
args: [{ label: 'Plain Text', type: 'text', name: 'value', multiLine: true }],
name: "url.encode",
description: "Encode a value for use in a URL (percent-encoding)",
args: [{ label: "Plain Text", type: "text", name: "value", multiLine: true }],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return encodeURIComponent(String(args.values.value ?? ''));
return encodeURIComponent(String(args.values.value ?? ""));
},
},
{
name: 'url.decode',
description: 'Decode a percent-encoded URL value',
args: [{ label: 'Encoded Value', type: 'text', name: 'value', multiLine: true }],
name: "url.decode",
description: "Decode a percent-encoded URL value",
args: [{ label: "Encoded Value", type: "text", name: "value", multiLine: true }],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
try {
return decodeURIComponent(String(args.values.value ?? ''));
return decodeURIComponent(String(args.values.value ?? ""));
} catch {
return '';
return "";
}
},
},

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-fs",
"displayName": "File System Template Functions",
"description": "Template functions for working with the file system",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for working with the file system",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,45 +1,45 @@
import fs from 'node:fs';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import fs from "node:fs";
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
const UTF8 = 'utf8';
const UTF8 = "utf8";
const options = [
{ label: 'ASCII', value: 'ascii' },
{ label: 'UTF-8', value: UTF8 },
{ label: 'UTF-16 LE', value: 'utf16le' },
{ label: 'Base64', value: 'base64' },
{ label: 'Base64 URL-safe', value: 'base64url' },
{ label: 'Latin-1', value: 'latin1' },
{ label: 'Hexadecimal', value: 'hex' },
{ label: "ASCII", value: "ascii" },
{ label: "UTF-8", value: UTF8 },
{ label: "UTF-16 LE", value: "utf16le" },
{ label: "Base64", value: "base64" },
{ label: "Base64 URL-safe", value: "base64url" },
{ label: "Latin-1", value: "latin1" },
{ label: "Hexadecimal", value: "hex" },
];
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'fs.readFile',
description: 'Read the contents of a file as utf-8',
previewArgs: ['encoding'],
name: "fs.readFile",
description: "Read the contents of a file as utf-8",
previewArgs: ["encoding"],
args: [
{ title: 'Select File', type: 'file', name: 'path', label: 'File' },
{ title: "Select File", type: "file", name: "path", label: "File" },
{
type: 'select',
name: 'encoding',
label: 'Encoding',
type: "select",
name: "encoding",
label: "Encoding",
defaultValue: UTF8,
description: "Specifies how the file's bytes are decoded into text when read",
options,
},
{
type: 'checkbox',
name: 'trim',
label: 'Trim Whitespace',
description: 'Remove leading and trailing whitespace from the file contents',
type: "checkbox",
name: "trim",
label: "Trim Whitespace",
description: "Remove leading and trailing whitespace from the file contents",
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.path || !args.values.encoding) return null;
try {
const v = await fs.promises.readFile(String(args.values.path ?? ''), {
encoding: String(args.values.encoding ?? 'utf-8') as BufferEncoding,
const v = await fs.promises.readFile(String(args.values.path ?? ""), {
encoding: String(args.values.encoding ?? "utf-8") as BufferEncoding,
});
return args.values.trim ? v.trim() : v;
} catch {

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-hash",
"displayName": "Hash Template Functions",
"description": "Template functions for generating hash values",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for generating hash values",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,27 +1,27 @@
import { createHash, createHmac } from 'node:crypto';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import { createHash, createHmac } from "node:crypto";
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
const algorithms = ['md5', 'sha1', 'sha256', 'sha512'] as const;
const encodings = ['base64', 'hex'] as const;
const algorithms = ["md5", "sha1", "sha256", "sha512"] as const;
const encodings = ["base64", "hex"] as const;
type TemplateFunctionPlugin = NonNullable<PluginDefinition['templateFunctions']>[number];
type TemplateFunctionPlugin = NonNullable<PluginDefinition["templateFunctions"]>[number];
const hashFunctions: TemplateFunctionPlugin[] = algorithms.map((algorithm) => ({
name: `hash.${algorithm}`,
description: 'Hash a value to its hexadecimal representation',
description: "Hash a value to its hexadecimal representation",
args: [
{
type: 'text',
name: 'input',
label: 'Input',
placeholder: 'input text',
type: "text",
name: "input",
label: "Input",
placeholder: "input text",
multiLine: true,
},
{
type: 'select',
name: 'encoding',
label: 'Encoding',
defaultValue: 'base64',
type: "select",
name: "encoding",
label: "Encoding",
defaultValue: "base64",
options: encodings.map((encoding) => ({
label: capitalize(encoding),
value: encoding,
@@ -32,32 +32,32 @@ const hashFunctions: TemplateFunctionPlugin[] = algorithms.map((algorithm) => ({
const input = String(args.values.input);
const encoding = String(args.values.encoding) as (typeof encodings)[number];
return createHash(algorithm).update(input, 'utf-8').digest(encoding);
return createHash(algorithm).update(input, "utf-8").digest(encoding);
},
}));
const hmacFunctions: TemplateFunctionPlugin[] = algorithms.map((algorithm) => ({
name: `hmac.${algorithm}`,
description: 'Compute the HMAC of a value',
description: "Compute the HMAC of a value",
args: [
{
type: 'text',
name: 'input',
label: 'Input',
placeholder: 'input text',
type: "text",
name: "input",
label: "Input",
placeholder: "input text",
multiLine: true,
},
{
type: 'text',
name: 'key',
label: 'Key',
type: "text",
name: "key",
label: "Key",
password: true,
},
{
type: 'select',
name: 'encoding',
label: 'Encoding',
defaultValue: 'base64',
type: "select",
name: "encoding",
label: "Encoding",
defaultValue: "base64",
options: encodings.map((encoding) => ({
value: encoding,
label: capitalize(encoding),

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-json",
"displayName": "JSON Template Functions",
"description": "Template functions for working with JSON data",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for working with JSON data",
"main": "build/index.js",
"types": "src/index.ts",
"scripts": {

View File

@@ -1,45 +1,45 @@
import type { XPathResult } from '@yaak/template-function-xml';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import { JSONPath } from 'jsonpath-plus';
import type { XPathResult } from "@yaak/template-function-xml";
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
import { JSONPath } from "jsonpath-plus";
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
const RETURN_FIRST = "first";
const RETURN_ALL = "all";
const RETURN_JOIN = "join";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'json.jsonpath',
description: 'Filter JSON-formatted text using JSONPath syntax',
previewArgs: ['query'],
name: "json.jsonpath",
description: "Filter JSON-formatted text using JSONPath syntax",
previewArgs: ["query"],
args: [
{
type: 'editor',
name: 'input',
label: 'Input',
language: 'json',
type: "editor",
name: "input",
label: "Input",
language: "json",
placeholder: '{ "foo": "bar" }',
},
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'select',
name: 'result',
label: 'Return Format',
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 },
{ 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',
name: "join",
type: "text",
label: "Separator",
optional: true,
defaultValue: ', ',
defaultValue: ", ",
dynamic(_ctx, args) {
return { hidden: args.values.result !== RETURN_JOIN };
},
@@ -47,15 +47,15 @@ export const plugin: PluginDefinition = {
],
},
{
type: 'checkbox',
name: 'formatted',
label: 'Pretty Print',
description: 'Format the output as JSON',
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" },
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
try {
@@ -72,36 +72,36 @@ export const plugin: PluginDefinition = {
},
},
{
name: 'json.escape',
description: 'Escape a JSON string, useful when using the output in JSON values',
name: "json.escape",
description: "Escape a JSON string, useful when using the output in JSON values",
args: [
{
type: 'text',
name: 'input',
label: 'Input',
type: "text",
name: "input",
label: "Input",
multiLine: true,
placeholder: 'Hello "World"',
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? '');
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const input = String(args.values.input ?? "");
return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
},
},
{
name: 'json.minify',
description: 'Remove unnecessary whitespace from a valid JSON string.',
name: "json.minify",
description: "Remove unnecessary whitespace from a valid JSON string.",
args: [
{
type: 'editor',
language: 'json',
name: 'input',
label: 'Input',
type: "editor",
language: "json",
name: "input",
label: "Input",
placeholder: '{ "foo": "bar" }',
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? '');
const input = String(args.values.input ?? "");
try {
return JSON.stringify(JSON.parse(input));
} catch {
@@ -112,7 +112,7 @@ export const plugin: PluginDefinition = {
],
};
export type JSONPathResult = 'first' | 'join' | 'all';
export type JSONPathResult = "first" | "join" | "all";
export function filterJSONPath(
body: string,
@@ -125,15 +125,15 @@ export function filterJSONPath(
let items = JSONPath({ path, json: parsed });
if (items == null) {
return '';
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 ?? '');
} else if (result === "first") {
items = items[0] ?? "";
} else if (result === "join") {
items = items.map((i) => objToStr(i, false)).join(join ?? "");
}
return objToStr(items, formatted);
@@ -141,8 +141,8 @@ export function filterJSONPath(
function objToStr(o: unknown, formatted = false): string {
if (
Object.prototype.toString.call(o) === '[object Array]' ||
Object.prototype.toString.call(o) === '[object Object]'
Object.prototype.toString.call(o) === "[object Array]" ||
Object.prototype.toString.call(o) === "[object Object]"
) {
return formatted ? JSON.stringify(o, null, 2) : JSON.stringify(o);
}

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-prompt",
"displayName": "Prompt Template Functions",
"description": "Template functions for prompting for user input",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for prompting for user input",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,9 +1,9 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import slugify from 'slugify';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
import slugify from "slugify";
const STORE_NONE = 'none';
const STORE_FOREVER = 'forever';
const STORE_EXPIRE = 'expire';
const STORE_NONE = "none";
const STORE_FOREVER = "forever";
const STORE_EXPIRE = "expire";
interface Saved {
value: string;
@@ -13,15 +13,15 @@ interface Saved {
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'prompt.text',
description: 'Prompt the user for input when sending a request',
previewType: 'click',
previewArgs: ['label'],
name: "prompt.text",
description: "Prompt the user for input when sending a request",
previewType: "click",
previewArgs: ["label"],
args: [
{
type: 'text',
name: 'label',
label: 'Label',
type: "text",
name: "label",
label: "Label",
optional: true,
dynamic(_ctx, args) {
if (
@@ -33,45 +33,45 @@ export const plugin: PluginDefinition = {
},
},
{
type: 'select',
name: 'store',
label: 'Store Input',
type: "select",
name: "store",
label: "Store Input",
defaultValue: STORE_NONE,
options: [
{ label: 'Never', value: STORE_NONE },
{ label: 'Expire', value: STORE_EXPIRE },
{ label: 'Forever', value: STORE_FOREVER },
{ label: "Never", value: STORE_NONE },
{ label: "Expire", value: STORE_EXPIRE },
{ label: "Forever", value: STORE_FOREVER },
],
},
{
type: 'h_stack',
type: "h_stack",
dynamic(_ctx, args) {
return { hidden: args.values.store === STORE_NONE };
},
inputs: [
{
type: 'text',
name: 'namespace',
label: 'Namespace',
type: "text",
name: "namespace",
label: "Namespace",
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
defaultValue: '${[ctx.workspace()]}',
defaultValue: "${[ctx.workspace()]}",
optional: true,
},
{
type: 'text',
name: 'key',
label: 'Key (defaults to Label)',
type: "text",
name: "key",
label: "Key (defaults to Label)",
optional: true,
dynamic(_ctx, args) {
return { placeholder: String(args.values.label || '') };
return { placeholder: String(args.values.label || "") };
},
},
{
type: 'text',
name: 'ttl',
label: 'TTL (seconds)',
placeholder: '0',
defaultValue: '0',
type: "text",
name: "ttl",
label: "TTL (seconds)",
placeholder: "0",
defaultValue: "0",
optional: true,
dynamic(_ctx, args) {
return { hidden: args.values.store !== STORE_EXPIRE };
@@ -80,49 +80,49 @@ export const plugin: PluginDefinition = {
],
},
{
type: 'banner',
color: 'info',
type: "banner",
color: "info",
inputs: [],
dynamic(_ctx, args) {
let key: string;
try {
key = buildKey(args);
} catch (err) {
return { color: 'danger', inputs: [{ type: 'markdown', content: String(err) }] };
return { color: "danger", inputs: [{ type: "markdown", content: String(err) }] };
}
return {
hidden: args.values.store === STORE_NONE,
inputs: [
{
type: 'markdown',
content: [`Value will be saved under: \`${key}\``].join('\n\n'),
type: "markdown",
content: [`Value will be saved under: \`${key}\``].join("\n\n"),
},
],
};
},
},
{
type: 'accordion',
label: 'Advanced',
type: "accordion",
label: "Advanced",
inputs: [
{
type: 'text',
name: 'title',
label: 'Prompt Title',
type: "text",
name: "title",
label: "Prompt Title",
optional: true,
placeholder: 'Enter Value',
placeholder: "Enter Value",
},
{ type: 'text', name: 'defaultValue', label: 'Default Value', optional: true },
{ type: 'text', name: 'placeholder', label: 'Input Placeholder', optional: true },
{ type: 'checkbox', name: 'password', label: 'Mask Value' },
{ type: "text", name: "defaultValue", label: "Default Value", optional: true },
{ type: "text", name: "placeholder", label: "Input Placeholder", optional: true },
{ type: "checkbox", name: "password", label: "Mask Value" },
],
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (args.purpose !== 'send') return null;
if (args.purpose !== "send") return null;
if (args.values.store !== STORE_NONE && !args.values.namespace) {
throw new Error('Namespace is required when storing values');
throw new Error("Namespace is required when storing values");
}
const existing = await maybeGetValue(ctx, args);
@@ -131,17 +131,17 @@ export const plugin: PluginDefinition = {
}
const value = await ctx.prompt.text({
id: `prompt-${args.values.label ?? 'none'}`,
label: String(args.values.label || 'Value'),
title: String(args.values.title ?? 'Enter Value'),
defaultValue: String(args.values.defaultValue ?? ''),
placeholder: String(args.values.placeholder ?? ''),
id: `prompt-${args.values.label ?? "none"}`,
label: String(args.values.label || "Value"),
title: String(args.values.title ?? "Enter Value"),
defaultValue: String(args.values.defaultValue ?? ""),
placeholder: String(args.values.placeholder ?? ""),
password: Boolean(args.values.password),
required: false,
});
if (value == null) {
throw new Error('Prompt cancelled');
throw new Error("Prompt cancelled");
}
if (args.values.store !== STORE_NONE) {
@@ -156,12 +156,12 @@ export const plugin: PluginDefinition = {
function buildKey(args: CallTemplateFunctionArgs) {
if (!args.values.key && !args.values.label) {
throw new Error('A label or key is required when storing values');
throw new Error("A label or key is required when storing values");
}
return [args.values.namespace, args.values.key || args.values.label]
.filter((v) => !!v)
.map((v) => slugify(String(v), { lower: true, trim: true }))
.join('.');
.join(".");
}
async function maybeGetValue(ctx: Context, args: CallTemplateFunctionArgs) {

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-random",
"displayName": "Random Template Functions",
"description": "Template functions for generating random values",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for generating random values",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,36 +1,36 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'random.range',
description: 'Generate a random number between two values',
previewArgs: ['min', 'max'],
name: "random.range",
description: "Generate a random number between two values",
previewArgs: ["min", "max"],
args: [
{
type: 'text',
name: 'min',
label: 'Minimum',
defaultValue: '0',
type: "text",
name: "min",
label: "Minimum",
defaultValue: "0",
},
{
type: 'text',
name: 'max',
label: 'Maximum',
defaultValue: '1',
type: "text",
name: "max",
label: "Maximum",
defaultValue: "1",
},
{
type: 'text',
name: 'decimals',
type: "text",
name: "decimals",
optional: true,
label: 'Decimal Places',
label: "Decimal Places",
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const min = args.values.min ? Number.parseInt(String(args.values.min ?? '0'), 10) : 0;
const max = args.values.max ? Number.parseInt(String(args.values.max ?? '1'), 10) : 1;
const min = args.values.min ? Number.parseInt(String(args.values.min ?? "0"), 10) : 0;
const max = args.values.max ? Number.parseInt(String(args.values.max ?? "1"), 10) : 1;
const decimals = args.values.decimals
? Number.parseInt(String(args.values.decimals ?? '0'), 10)
? Number.parseInt(String(args.values.decimals ?? "0"), 10)
: null;
let value = Math.random() * (max - min) + min;

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-regex",
"displayName": "Regex Template Functions",
"description": "Template functions for working with regular expressions",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for working with regular expressions",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,73 +1,73 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
import type { TemplateFunctionArg } from "@yaakapp-internal/plugins";
const inputArg: TemplateFunctionArg = {
type: 'text',
name: 'input',
label: 'Input Text',
type: "text",
name: "input",
label: "Input Text",
multiLine: true,
};
const regexArg: TemplateFunctionArg = {
type: 'text',
name: 'regex',
label: 'Regular Expression',
placeholder: '\\w+',
defaultValue: '.*',
type: "text",
name: "regex",
label: "Regular Expression",
placeholder: "\\w+",
defaultValue: ".*",
description:
'A JavaScript regular expression. Use a capture group to reference parts of the match in the replacement.',
"A JavaScript regular expression. Use a capture group to reference parts of the match in the replacement.",
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'regex.match',
description: 'Extract text using a regular expression',
name: "regex.match",
description: "Extract text using a regular expression",
args: [inputArg, regexArg],
previewArgs: [regexArg.name],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? '');
const regex = new RegExp(String(args.values.regex ?? ''));
const input = String(args.values.input ?? "");
const regex = new RegExp(String(args.values.regex ?? ""));
const match = input.match(regex);
return match?.groups
? (Object.values(match.groups)[0] ?? '')
: (match?.[1] ?? match?.[0] ?? '');
? (Object.values(match.groups)[0] ?? "")
: (match?.[1] ?? match?.[0] ?? "");
},
},
{
name: 'regex.replace',
description: 'Replace text using a regular expression',
name: "regex.replace",
description: "Replace text using a regular expression",
previewArgs: [regexArg.name],
args: [
inputArg,
regexArg,
{
type: 'text',
name: 'replacement',
label: 'Replacement Text',
placeholder: 'hello $1',
type: "text",
name: "replacement",
label: "Replacement Text",
placeholder: "hello $1",
description:
'The replacement text. Use $1, $2, ... to reference capture groups or $& to reference the entire match.',
"The replacement text. Use $1, $2, ... to reference capture groups or $& to reference the entire match.",
},
{
type: 'text',
name: 'flags',
label: 'Flags',
placeholder: 'g',
defaultValue: 'g',
type: "text",
name: "flags",
label: "Flags",
placeholder: "g",
defaultValue: "g",
optional: true,
description:
'Regular expression flags (g for global, i for case-insensitive, m for multiline, etc.)',
"Regular expression flags (g for global, i for case-insensitive, m for multiline, etc.)",
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? '');
const replacement = String(args.values.replacement ?? '');
const flags = String(args.values.flags || '');
const input = String(args.values.input ?? "");
const replacement = String(args.values.replacement ?? "");
const flags = String(args.values.flags || "");
const regex = String(args.values.regex);
if (!regex) return '';
if (!regex) return "";
return input.replace(new RegExp(String(args.values.regex), flags), replacement);
},

View File

@@ -1,196 +1,196 @@
import type { Context } from '@yaakapp/api';
import { describe, expect, it } from 'vite-plus/test';
import { plugin } from '../src';
import type { Context } from "@yaakapp/api";
import { describe, expect, it } from "vite-plus/test";
import { plugin } from "../src";
describe('regex.match', () => {
const matchFunction = plugin.templateFunctions?.find((f) => f.name === 'regex.match');
describe("regex.match", () => {
const matchFunction = plugin.templateFunctions?.find((f) => f.name === "regex.match");
it('should exist', () => {
it("should exist", () => {
expect(matchFunction).toBeDefined();
});
it('should extract first capture group', async () => {
it("should extract first capture group", async () => {
const result = await matchFunction?.onRender({} as Context, {
values: {
regex: 'Hello (\\w+)',
input: 'Hello World',
regex: "Hello (\\w+)",
input: "Hello World",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('World');
expect(result).toBe("World");
});
it('should extract named capture group', async () => {
it("should extract named capture group", async () => {
const result = await matchFunction?.onRender({} as Context, {
values: {
regex: 'Hello (?<name>\\w+)',
input: 'Hello World',
regex: "Hello (?<name>\\w+)",
input: "Hello World",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('World');
expect(result).toBe("World");
});
it('should return full match when no capture groups', async () => {
it("should return full match when no capture groups", async () => {
const result = await matchFunction?.onRender({} as Context, {
values: {
regex: 'Hello \\w+',
input: 'Hello World',
regex: "Hello \\w+",
input: "Hello World",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('Hello World');
expect(result).toBe("Hello World");
});
it('should return empty string when no match', async () => {
it("should return empty string when no match", async () => {
const result = await matchFunction?.onRender({} as Context, {
values: {
regex: 'Goodbye',
input: 'Hello World',
regex: "Goodbye",
input: "Hello World",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('');
expect(result).toBe("");
});
it('should return empty string when regex is empty', async () => {
it("should return empty string when regex is empty", async () => {
const result = await matchFunction?.onRender({} as Context, {
values: {
regex: '',
input: 'Hello World',
regex: "",
input: "Hello World",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('');
expect(result).toBe("");
});
it('should return empty string when input is empty', async () => {
it("should return empty string when input is empty", async () => {
const result = await matchFunction?.onRender({} as Context, {
values: {
regex: 'Hello',
input: '',
regex: "Hello",
input: "",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('');
expect(result).toBe("");
});
});
describe('regex.replace', () => {
const replaceFunction = plugin.templateFunctions?.find((f) => f.name === 'regex.replace');
describe("regex.replace", () => {
const replaceFunction = plugin.templateFunctions?.find((f) => f.name === "regex.replace");
it('should exist', () => {
it("should exist", () => {
expect(replaceFunction).toBeDefined();
});
it('should replace one occurrence by default', async () => {
it("should replace one occurrence by default", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: 'o',
input: 'Hello World',
replacement: 'a',
regex: "o",
input: "Hello World",
replacement: "a",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('Hella World');
expect(result).toBe("Hella World");
});
it('should replace with capture groups', async () => {
it("should replace with capture groups", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: '(\\w+) (\\w+)',
input: 'Hello World',
replacement: '$2 $1',
regex: "(\\w+) (\\w+)",
input: "Hello World",
replacement: "$2 $1",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('World Hello');
expect(result).toBe("World Hello");
});
it('should replace with full match reference', async () => {
it("should replace with full match reference", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: 'World',
input: 'Hello World',
replacement: '[$&]',
regex: "World",
input: "Hello World",
replacement: "[$&]",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('Hello [World]');
expect(result).toBe("Hello [World]");
});
it('should respect flags parameter', async () => {
it("should respect flags parameter", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: 'hello',
input: 'Hello World',
replacement: 'Hi',
flags: 'i',
regex: "hello",
input: "Hello World",
replacement: "Hi",
flags: "i",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('Hi World');
expect(result).toBe("Hi World");
});
it('should handle empty replacement', async () => {
it("should handle empty replacement", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: 'World',
input: 'Hello World',
replacement: '',
regex: "World",
input: "Hello World",
replacement: "",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('Hello ');
expect(result).toBe("Hello ");
});
it('should return original input when no match', async () => {
it("should return original input when no match", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: 'Goodbye',
input: 'Hello World',
replacement: 'Hi',
regex: "Goodbye",
input: "Hello World",
replacement: "Hi",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('Hello World');
expect(result).toBe("Hello World");
});
it('should return empty string when regex is empty', async () => {
it("should return empty string when regex is empty", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: '',
input: 'Hello World',
replacement: 'Hi',
regex: "",
input: "Hello World",
replacement: "Hi",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('');
expect(result).toBe("");
});
it('should return empty string when input is empty', async () => {
it("should return empty string when input is empty", async () => {
const result = await replaceFunction?.onRender({} as Context, {
values: {
regex: 'Hello',
input: '',
replacement: 'Hi',
regex: "Hello",
input: "",
replacement: "Hi",
},
purpose: 'send',
purpose: "send",
});
expect(result).toBe('');
expect(result).toBe("");
});
it('should throw on invalid regex', async () => {
it("should throw on invalid regex", async () => {
const fn = replaceFunction?.onRender({} as Context, {
values: {
regex: '[',
input: 'Hello World',
replacement: 'Hi',
regex: "[",
input: "Hello World",
replacement: "Hi",
},
purpose: 'send',
purpose: "send",
});
await expect(fn).rejects.toThrow(
'Invalid regular expression: /[/: Unterminated character class',
"Invalid regular expression: /[/: Unterminated character class",
);
});
});

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-request",
"displayName": "Request Template Functions",
"description": "Template functions for extracting value from requests",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for extracting value from requests",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,64 +1,64 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import type { JSONPathResult } from '../../template-function-json';
import { filterJSONPath } from '../../template-function-json';
import type { XPathResult } from '../../template-function-xml';
import { filterXPath } from '../../template-function-xml';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
import type { AnyModel, HttpUrlParameter } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import type { JSONPathResult } from "../../template-function-json";
import { filterJSONPath } from "../../template-function-json";
import type { XPathResult } from "../../template-function-xml";
import { filterXPath } from "../../template-function-xml";
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
const RETURN_FIRST = "first";
const RETURN_ALL = "all";
const RETURN_JOIN = "join";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'request.body.raw',
aliases: ['request.body'],
name: "request.body.raw",
aliases: ["request.body"],
args: [
{
name: 'requestId',
label: 'Http Request',
type: 'http_request',
name: "requestId",
label: "Http Request",
type: "http_request",
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const requestId = String(args.values.requestId ?? 'n/a');
const requestId = String(args.values.requestId ?? "n/a");
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
return String(
await ctx.templates.render({
data: httpRequest.body?.text ?? '',
data: httpRequest.body?.text ?? "",
purpose: args.purpose,
}),
);
},
},
{
name: 'request.body.path',
previewArgs: ['path'],
name: "request.body.path",
previewArgs: ["path"],
args: [
{ name: 'requestId', label: 'Http Request', type: 'http_request' },
{ name: "requestId", label: "Http Request", type: "http_request" },
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'select',
name: 'result',
label: 'Return Format',
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 },
{ 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',
name: "join",
type: "text",
label: "Separator",
optional: true,
defaultValue: ', ',
defaultValue: ", ",
dynamic(_ctx, args) {
return { hidden: args.values.result !== RETURN_JOIN };
},
@@ -66,52 +66,52 @@ export const plugin: PluginDefinition = {
],
},
{
type: 'text',
name: 'path',
label: 'JSONPath or XPath',
placeholder: '$.books[0].id or /books[0]/id',
type: "text",
name: "path",
label: "JSONPath or XPath",
placeholder: "$.books[0].id or /books[0]/id",
dynamic: async (ctx, args) => {
const requestId = String(args.values.requestId ?? 'n/a');
const requestId = String(args.values.requestId ?? "n/a");
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const contentType =
httpRequest.headers
.find((h) => h.name.toLowerCase() === 'content-type')
?.value.toLowerCase() ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) {
.find((h) => h.name.toLowerCase() === "content-type")
?.value.toLowerCase() ?? "";
if (contentType.includes("xml") || contentType?.includes("html")) {
return {
label: 'XPath',
placeholder: '/books[0]/id',
description: 'Enter an XPath expression used to filter the results',
label: "XPath",
placeholder: "/books[0]/id",
description: "Enter an XPath expression used to filter the results",
};
}
return {
label: 'JSONPath',
placeholder: '$.books[0].id',
description: 'Enter a JSONPath expression used to filter the results',
label: "JSONPath",
placeholder: "$.books[0].id",
description: "Enter a JSONPath expression used to filter the results",
};
},
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const requestId = String(args.values.requestId ?? 'n/a');
const requestId = String(args.values.requestId ?? "n/a");
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const body = httpRequest.body?.text ?? '';
const body = httpRequest.body?.text ?? "";
try {
const result: JSONPathResult =
args.values.result === RETURN_ALL
? 'all'
? "all"
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
? "join"
: "first";
return filterJSONPath(
body,
String(args.values.path || ''),
String(args.values.path || ""),
result,
args.values.join == null ? null : String(args.values.join),
);
@@ -122,13 +122,13 @@ export const plugin: PluginDefinition = {
try {
const result: XPathResult =
args.values.result === RETURN_ALL
? 'all'
? "all"
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
? "join"
: "first";
return filterXPath(
body,
String(args.values.path || ''),
String(args.values.path || ""),
result,
args.values.join == null ? null : String(args.values.join),
);
@@ -140,21 +140,21 @@ export const plugin: PluginDefinition = {
},
},
{
name: 'request.header',
description: 'Read the value of a request header, by name',
previewArgs: ['header'],
name: "request.header",
description: "Read the value of a request header, by name",
previewArgs: ["header"],
args: [
{
name: 'requestId',
label: 'Http Request',
type: 'http_request',
name: "requestId",
label: "Http Request",
type: "http_request",
},
{
name: 'header',
label: 'Header Name',
type: 'text',
name: "header",
label: "Header Name",
type: "text",
async dynamic(ctx, args) {
if (typeof args.values.requestId !== 'string') return null;
if (typeof args.values.requestId !== "string") return null;
const request = await ctx.httpRequest.getById({ id: args.values.requestId });
if (request == null) return null;
@@ -164,15 +164,15 @@ export const plugin: PluginDefinition = {
placeholder: validHeaders[0]?.name,
completionOptions: validHeaders.map<GenericCompletionOption>((h) => ({
label: h.name,
type: 'constant',
type: "constant",
})),
};
},
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const headerName = String(args.values.header ?? '');
const requestId = String(args.values.requestId ?? 'n/a');
const headerName = String(args.values.header ?? "");
const requestId = String(args.values.requestId ?? "n/a");
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const header = httpRequest.headers.find(
@@ -180,29 +180,29 @@ export const plugin: PluginDefinition = {
);
return String(
await ctx.templates.render({
data: header?.value ?? '',
data: header?.value ?? "",
purpose: args.purpose,
}),
);
},
},
{
name: 'request.param',
name: "request.param",
args: [
{
name: 'requestId',
label: 'Http Request',
type: 'http_request',
name: "requestId",
label: "Http Request",
type: "http_request",
},
{
name: 'param',
label: 'Param Name',
type: 'text',
name: "param",
label: "Param Name",
type: "text",
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const paramName = String(args.values.param ?? '');
const requestId = String(args.values.requestId ?? 'n/a');
const paramName = String(args.values.param ?? "");
const requestId = String(args.values.requestId ?? "n/a");
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
@@ -211,7 +211,7 @@ export const plugin: PluginDefinition = {
purpose: args.purpose,
});
const querystring = renderedUrl.split('?')[1] ?? '';
const querystring = renderedUrl.split("?")[1] ?? "";
const paramsFromUrl: HttpUrlParameter[] = new URLSearchParams(querystring)
.entries()
.map(([name, value]): HttpUrlParameter => ({ name, value }))
@@ -222,23 +222,23 @@ export const plugin: PluginDefinition = {
const foundParam = allEnabledParams.find((p) => p.name === paramName);
const renderedValue = await ctx.templates.render({
data: foundParam?.value ?? '',
data: foundParam?.value ?? "",
purpose: args.purpose,
});
return renderedValue;
},
},
{
name: 'request.name',
name: "request.name",
args: [
{
name: 'requestId',
label: 'Http Request',
type: 'http_request',
name: "requestId",
label: "Http Request",
type: "http_request",
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const requestId = String(args.values.requestId ?? 'n/a');
const requestId = String(args.values.requestId ?? "n/a");
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
@@ -250,37 +250,37 @@ export const plugin: PluginDefinition = {
// TODO: Use a common function for this, but it fails to build on windows during CI if I try importing it here
export function resolvedModelName(r: AnyModel | null): string {
if (r == null) return '';
if (r == null) return "";
if (!('url' in r) || r.model === 'plugin') {
return 'name' in r ? r.name : '';
if (!("url" in r) || r.model === "plugin") {
return "name" in r ? r.name : "";
}
// Return name if it has one
if ('name' in r && r.name) {
if ("name" in r && r.name) {
return r.name;
}
// Replace variable syntax with variable name
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, '$1');
if (withoutVariables.trim() === '') {
return r.model === 'http_request'
? r.bodyType && r.bodyType === 'graphql'
? 'GraphQL Request'
: 'HTTP Request'
: r.model === 'websocket_request'
? 'WebSocket Request'
: 'gRPC Request';
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, "$1");
if (withoutVariables.trim() === "") {
return r.model === "http_request"
? r.bodyType && r.bodyType === "graphql"
? "GraphQL Request"
: "HTTP Request"
: r.model === "websocket_request"
? "WebSocket Request"
: "gRPC Request";
}
// GRPC gets nice short names
if (r.model === 'grpc_request' && r.service != null && r.method != null) {
const shortService = r.service.split('.').pop();
if (r.model === "grpc_request" && r.service != null && r.method != null) {
const shortService = r.service.split(".").pop();
return `${shortService}/${r.method}`;
}
// Strip unnecessary protocol
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, '');
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
return withoutProto;
}

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-response",
"displayName": "Response Template Functions",
"description": "Template functions for request chaining",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for request chaining",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,4 +1,4 @@
import { readFileSync } from 'node:fs';
import { readFileSync } from "node:fs";
import type {
CallTemplateFunctionArgs,
Context,
@@ -7,41 +7,41 @@ import type {
HttpResponse,
PluginDefinition,
RenderPurpose,
} from '@yaakapp/api';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import type { JSONPathResult } from '../../template-function-json';
import { filterJSONPath } from '../../template-function-json';
import type { XPathResult } from '../../template-function-xml';
import { filterXPath } from '../../template-function-xml';
} from "@yaakapp/api";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import type { JSONPathResult } from "../../template-function-json";
import { filterJSONPath } from "../../template-function-json";
import type { XPathResult } from "../../template-function-xml";
import { filterXPath } from "../../template-function-xml";
const BEHAVIOR_TTL = 'ttl';
const BEHAVIOR_ALWAYS = 'always';
const BEHAVIOR_SMART = 'smart';
const BEHAVIOR_TTL = "ttl";
const BEHAVIOR_ALWAYS = "always";
const BEHAVIOR_SMART = "smart";
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
const RETURN_FIRST = "first";
const RETURN_ALL = "all";
const RETURN_JOIN = "join";
const behaviorArgs: DynamicTemplateFunctionArg = {
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'select',
name: 'behavior',
label: 'Sending Behavior',
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 },
{ 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',
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) {
@@ -52,42 +52,42 @@ const behaviorArgs: DynamicTemplateFunctionArg = {
};
const requestArg: FormInput = {
type: 'http_request',
name: 'request',
label: 'Request',
defaultValue: '', // Make it not select the active one by default
type: "http_request",
name: "request",
label: "Request",
defaultValue: "", // Make it not select the active one by default
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'response.header',
description: 'Read the value of a response header, by name',
previewArgs: ['header'],
name: "response.header",
description: "Read the value of a response header, by name",
previewArgs: ["header"],
args: [
requestArg,
behaviorArgs,
{
type: 'text',
name: 'header',
label: 'Header Name',
type: "text",
name: "header",
label: "Header Name",
async dynamic(ctx, args) {
// Dynamic form config also runs during send-time rendering.
// Keep this preview-only to avoid side-effect request sends.
if (args.purpose !== 'preview') return null;
if (args.purpose !== "preview") return null;
const response = await getResponse(ctx, {
requestId: String(args.values.request || ''),
requestId: String(args.values.request || ""),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
ttl: String(args.values.ttl || ""),
});
return {
placeholder: response?.headers[0]?.name,
completionOptions: response?.headers.map<GenericCompletionOption>((h) => ({
label: h.name,
type: 'constant',
type: "constant",
})),
};
},
@@ -97,47 +97,47 @@ export const plugin: PluginDefinition = {
if (!args.values.request || !args.values.header) return null;
const response = await getResponse(ctx, {
requestId: String(args.values.request || ''),
requestId: String(args.values.request || ""),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
ttl: String(args.values.ttl || ""),
});
if (response == null) return null;
const header = response.headers.find(
(h) => h.name.toLowerCase() === String(args.values.header ?? '').toLowerCase(),
(h) => h.name.toLowerCase() === String(args.values.header ?? "").toLowerCase(),
);
return header?.value ?? null;
},
},
{
name: 'response.body.path',
description: 'Access a field of the response body using JsonPath or XPath',
aliases: ['response'],
previewArgs: ['path'],
name: "response.body.path",
description: "Access a field of the response body using JsonPath or XPath",
aliases: ["response"],
previewArgs: ["path"],
args: [
requestArg,
behaviorArgs,
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'select',
name: 'result',
label: 'Return Format',
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 },
{ 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',
name: "join",
type: "text",
label: "Separator",
optional: true,
defaultValue: ', ',
defaultValue: ", ",
dynamic(_ctx, args) {
return { hidden: args.values.result !== RETURN_JOIN };
},
@@ -145,20 +145,20 @@ export const plugin: PluginDefinition = {
],
},
{
type: 'text',
name: 'path',
label: 'JSONPath or XPath',
placeholder: '$.books[0].id or /books[0]/id',
type: "text",
name: "path",
label: "JSONPath or XPath",
placeholder: "$.books[0].id or /books[0]/id",
dynamic: async (ctx, args) => {
// Dynamic form config also runs during send-time rendering.
// Keep this preview-only to avoid side-effect request sends.
if (args.purpose !== 'preview') return null;
if (args.purpose !== "preview") return null;
const resp = await getResponse(ctx, {
requestId: String(args.values.request || ''),
purpose: 'preview',
requestId: String(args.values.request || ""),
purpose: "preview",
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
ttl: String(args.values.ttl || ""),
});
if (resp == null) {
@@ -167,20 +167,20 @@ export const plugin: PluginDefinition = {
const contentType =
resp?.headers
.find((h) => h.name.toLowerCase() === 'content-type')
?.value.toLowerCase() ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) {
.find((h) => h.name.toLowerCase() === "content-type")
?.value.toLowerCase() ?? "";
if (contentType.includes("xml") || contentType?.includes("html")) {
return {
label: 'XPath',
placeholder: '/books[0]/id',
description: 'Enter an XPath expression used to filter the results',
label: "XPath",
placeholder: "/books[0]/id",
description: "Enter an XPath expression used to filter the results",
};
}
return {
label: 'JSONPath',
placeholder: '$.books[0].id',
description: 'Enter a JSONPath expression used to filter the results',
label: "JSONPath",
placeholder: "$.books[0].id",
description: "Enter a JSONPath expression used to filter the results",
};
},
},
@@ -189,10 +189,10 @@ export const plugin: PluginDefinition = {
if (!args.values.request || !args.values.path) return null;
const response = await getResponse(ctx, {
requestId: String(args.values.request || ''),
requestId: String(args.values.request || ""),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
ttl: String(args.values.ttl || ""),
});
if (response == null) return null;
@@ -200,10 +200,10 @@ export const plugin: PluginDefinition = {
return null;
}
const BOM = '\ufeff';
const BOM = "\ufeff";
let body: string;
try {
body = readFileSync(response.bodyPath, 'utf-8').replace(BOM, '');
body = readFileSync(response.bodyPath, "utf-8").replace(BOM, "");
} catch {
return null;
}
@@ -211,13 +211,13 @@ export const plugin: PluginDefinition = {
try {
const result: JSONPathResult =
args.values.result === RETURN_ALL
? 'all'
? "all"
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
? "join"
: "first";
return filterJSONPath(
body,
String(args.values.path || ''),
String(args.values.path || ""),
result,
args.values.join == null ? null : String(args.values.join),
);
@@ -228,13 +228,13 @@ export const plugin: PluginDefinition = {
try {
const result: XPathResult =
args.values.result === RETURN_ALL
? 'all'
? "all"
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
? "join"
: "first";
return filterXPath(
body,
String(args.values.path || ''),
String(args.values.path || ""),
result,
args.values.join == null ? null : String(args.values.join),
);
@@ -246,18 +246,18 @@ export const plugin: PluginDefinition = {
},
},
{
name: 'response.body.raw',
description: 'Access the entire response body, as text',
aliases: ['response'],
name: "response.body.raw",
description: "Access the entire response body, as text",
aliases: ["response"],
args: [requestArg, behaviorArgs],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.request) return null;
const response = await getResponse(ctx, {
requestId: String(args.values.request || ''),
requestId: String(args.values.request || ""),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
ttl: String(args.values.ttl || ""),
});
if (response == null) return null;
@@ -267,7 +267,7 @@ export const plugin: PluginDefinition = {
let body: string;
try {
body = readFileSync(response.bodyPath, 'utf-8');
body = readFileSync(response.bodyPath, "utf-8");
} catch {
return null;
}
@@ -294,14 +294,14 @@ async function getResponse(
): Promise<HttpResponse | null> {
if (!requestId) return null;
const httpRequest = await ctx.httpRequest.getById({ id: requestId ?? 'n/a' });
const httpRequest = await ctx.httpRequest.getById({ id: requestId ?? "n/a" });
if (httpRequest == null) {
return null;
}
const responses = await ctx.httpResponse.find({ requestId: httpRequest.id, limit: 1 });
if (behavior === 'never' && responses.length === 0) {
if (behavior === "never" && responses.length === 0) {
return null;
}
@@ -309,12 +309,12 @@ async function getResponse(
// Previews happen a ton, and we don't want to send too many times on "always," so treat
// it as "smart" during preview.
const finalBehavior = behavior === 'always' && purpose === 'preview' ? 'smart' : behavior;
const finalBehavior = behavior === "always" && purpose === "preview" ? "smart" : behavior;
// Send if no responses and "smart," or "always"
if (
(finalBehavior === 'smart' && response == null) ||
finalBehavior === 'always' ||
(finalBehavior === "smart" && response == null) ||
finalBehavior === "always" ||
(finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl))
) {
// Explicitly render the request before send (instead of relying on send() to render) so that we can
@@ -328,7 +328,7 @@ async function getResponse(
function shouldSendExpired(response: HttpResponse | null, ttl: string | null): boolean {
if (response == null) return true;
const ttlSeconds = Number.parseInt(ttl || '0', 10) || 0;
const ttlSeconds = Number.parseInt(ttl || "0", 10) || 0;
if (ttlSeconds === 0) return false;
const nowMillis = Date.now();
const respMillis = new Date(`${response.createdAt}Z`).getTime();

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-timestamp",
"displayName": "Timestamp Template Functions",
"description": "Template functions for dealing with timestamps",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for dealing with timestamps",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",

View File

@@ -1,7 +1,7 @@
import type { PluginDefinition } from '@yaakapp/api';
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
import type { PluginDefinition } from "@yaakapp/api";
import type { TemplateFunctionArg } from "@yaakapp-internal/plugins";
import type { ContextFn } from 'date-fns';
import type { ContextFn } from "date-fns";
import {
addDays,
addHours,
@@ -18,75 +18,75 @@ import {
subMonths,
subSeconds,
subYears,
} from 'date-fns';
} from "date-fns";
const dateArg: TemplateFunctionArg = {
type: 'text',
name: 'date',
label: 'Timestamp',
type: "text",
name: "date",
label: "Timestamp",
optional: true,
description:
'Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`',
"Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`",
placeholder: new Date().toISOString(),
};
const expressionArg: TemplateFunctionArg = {
type: 'text',
name: 'expression',
label: 'Expression',
type: "text",
name: "expression",
label: "Expression",
description: "Modification expression (eg. '-5d +2h 3m'). Available units: y, M, d, h, m, s",
optional: true,
placeholder: '-5d +2h 3m',
placeholder: "-5d +2h 3m",
};
const formatArg: TemplateFunctionArg = {
name: 'format',
label: 'Format String',
name: "format",
label: "Format String",
description: "Format string to describe the output (eg. 'yyyy-MM-dd at HH:mm:ss')",
optional: true,
placeholder: 'yyyy-MM-dd HH:mm:ss',
type: 'text',
placeholder: "yyyy-MM-dd HH:mm:ss",
type: "text",
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'timestamp.unix',
description: 'Get the timestamp in seconds',
name: "timestamp.unix",
description: "Get the timestamp in seconds",
args: [dateArg],
onRender: async (_ctx, args) => {
const d = parseDateString(String(args.values.date ?? ''));
const d = parseDateString(String(args.values.date ?? ""));
return String(Math.floor(d.getTime() / 1000));
},
},
{
name: 'timestamp.unixMillis',
description: 'Get the timestamp in milliseconds',
name: "timestamp.unixMillis",
description: "Get the timestamp in milliseconds",
args: [dateArg],
onRender: async (_ctx, args) => {
const d = parseDateString(String(args.values.date ?? ''));
const d = parseDateString(String(args.values.date ?? ""));
return String(d.getTime());
},
},
{
name: 'timestamp.iso8601',
description: 'Get the date in ISO8601 format',
name: "timestamp.iso8601",
description: "Get the date in ISO8601 format",
args: [dateArg],
onRender: async (_ctx, args) => {
const d = parseDateString(String(args.values.date ?? ''));
const d = parseDateString(String(args.values.date ?? ""));
return d.toISOString();
},
},
{
name: 'timestamp.format',
description: 'Format a date using a dayjs-compatible format string',
name: "timestamp.format",
description: "Format a date using a dayjs-compatible format string",
args: [dateArg, formatArg],
previewArgs: [formatArg.name],
onRender: async (_ctx, args) => formatDatetime(args.values),
},
{
name: 'timestamp.offset',
description: 'Get the offset of a date based on an expression',
name: "timestamp.offset",
description: "Get the offset of a date based on an expression",
args: [dateArg, expressionArg],
previewArgs: [expressionArg.name],
onRender: async (_ctx, args) => calculateDatetime(args.values),
@@ -96,18 +96,18 @@ export const plugin: PluginDefinition = {
function applyDateOp(d: Date, sign: string, amount: number, unit: string): Date {
switch (unit) {
case 'y':
return sign === '-' ? subYears(d, amount) : addYears(d, amount);
case 'M':
return sign === '-' ? subMonths(d, amount) : addMonths(d, amount);
case 'd':
return sign === '-' ? subDays(d, amount) : addDays(d, amount);
case 'h':
return sign === '-' ? subHours(d, amount) : addHours(d, amount);
case 'm':
return sign === '-' ? subMinutes(d, amount) : addMinutes(d, amount);
case 's':
return sign === '-' ? subSeconds(d, amount) : addSeconds(d, amount);
case "y":
return sign === "-" ? subYears(d, amount) : addYears(d, amount);
case "M":
return sign === "-" ? subMonths(d, amount) : addMonths(d, amount);
case "d":
return sign === "-" ? subDays(d, amount) : addDays(d, amount);
case "h":
return sign === "-" ? subHours(d, amount) : addHours(d, amount);
case "m":
return sign === "-" ? subMinutes(d, amount) : addMinutes(d, amount);
case "s":
return sign === "-" ? subSeconds(d, amount) : addSeconds(d, amount);
default:
throw new Error(`Invalid data calculation unit: ${unit}`);
}
@@ -120,7 +120,7 @@ function parseOp(op: string): { sign: string; amount: number; unit: string } | n
}
const [, sign, amount, unit] = match;
if (!unit) return null;
return { sign: sign ?? '+', amount: Number(amount ?? 0), unit };
return { sign: sign ?? "+", amount: Number(amount ?? 0), unit };
}
function parseDateString(date: string): Date {
@@ -143,11 +143,11 @@ function parseDateString(date: string): Date {
export function calculateDatetime(args: { date?: string; expression?: string }): string {
const { date, expression } = args;
let jsDate = parseDateString(date ?? '');
let jsDate = parseDateString(date ?? "");
if (expression) {
const ops = String(expression)
.split(' ')
.split(" ")
.map((s) => s.trim())
.filter(Boolean);
for (const op of ops) {
@@ -167,6 +167,6 @@ export function formatDatetime(args: {
in?: ContextFn<Date>;
}): string {
const { date, format } = args;
const d = parseDateString(date ?? '');
return formatDate(d, String(format || 'yyyy-MM-dd HH:mm:ss'), { in: args.in });
const d = parseDateString(date ?? "");
return formatDate(d, String(format || "yyyy-MM-dd HH:mm:ss"), { in: args.in });
}

View File

@@ -1,72 +1,72 @@
import { tz } from '@date-fns/tz';
import { describe, expect, it } from 'vite-plus/test';
import { calculateDatetime, formatDatetime } from '../src';
import { tz } from "@date-fns/tz";
import { describe, expect, it } from "vite-plus/test";
import { calculateDatetime, formatDatetime } from "../src";
describe('formatDatetime', () => {
it('returns formatted current date', () => {
describe("formatDatetime", () => {
it("returns formatted current date", () => {
const result = formatDatetime({});
expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
it('returns formatted specific date', () => {
const result = formatDatetime({ date: '2025-07-13T12:34:56' });
expect(result).toBe('2025-07-13 12:34:56');
it("returns formatted specific date", () => {
const result = formatDatetime({ date: "2025-07-13T12:34:56" });
expect(result).toBe("2025-07-13 12:34:56");
});
it('returns formatted specific timestamp', () => {
const result = formatDatetime({ date: '1752435296000', in: tz('America/Vancouver') });
expect(result).toBe('2025-07-13 12:34:56');
it("returns formatted specific timestamp", () => {
const result = formatDatetime({ date: "1752435296000", in: tz("America/Vancouver") });
expect(result).toBe("2025-07-13 12:34:56");
});
it('returns formatted specific timestamp with decimals', () => {
const result = formatDatetime({ date: '1752435296000.19', in: tz('America/Vancouver') });
expect(result).toBe('2025-07-13 12:34:56');
it("returns formatted specific timestamp with decimals", () => {
const result = formatDatetime({ date: "1752435296000.19", in: tz("America/Vancouver") });
expect(result).toBe("2025-07-13 12:34:56");
});
it('returns formatted date with custom output', () => {
const result = formatDatetime({ date: '2025-07-13T12:34:56', format: 'dd/MM/yyyy' });
expect(result).toBe('13/07/2025');
it("returns formatted date with custom output", () => {
const result = formatDatetime({ date: "2025-07-13T12:34:56", format: "dd/MM/yyyy" });
expect(result).toBe("13/07/2025");
});
it('handles invalid date gracefully', () => {
expect(() => formatDatetime({ date: 'invalid-date' })).toThrow('Invalid date: invalid-date');
it("handles invalid date gracefully", () => {
expect(() => formatDatetime({ date: "invalid-date" })).toThrow("Invalid date: invalid-date");
});
});
describe('calculateDatetime', () => {
it('returns ISO string for current date', () => {
describe("calculateDatetime", () => {
it("returns ISO string for current date", () => {
const result = calculateDatetime({});
expect(result).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('returns ISO string for specific date', () => {
const result = calculateDatetime({ date: '2025-07-13T12:34:56Z' });
expect(result).toBe('2025-07-13T12:34:56.000Z');
it("returns ISO string for specific date", () => {
const result = calculateDatetime({ date: "2025-07-13T12:34:56Z" });
expect(result).toBe("2025-07-13T12:34:56.000Z");
});
it('applies calc operations', () => {
const result = calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1d 2h' });
expect(result).toBe('2025-07-14T14:00:00.000Z');
it("applies calc operations", () => {
const result = calculateDatetime({ date: "2025-07-13T12:00:00Z", expression: "+1d 2h" });
expect(result).toBe("2025-07-14T14:00:00.000Z");
});
it('applies negative calc operations', () => {
const result = calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '-1d -2h 1m' });
expect(result).toBe('2025-07-12T10:01:00.000Z');
it("applies negative calc operations", () => {
const result = calculateDatetime({ date: "2025-07-13T12:00:00Z", expression: "-1d -2h 1m" });
expect(result).toBe("2025-07-12T10:01:00.000Z");
});
it('throws error for invalid unit', () => {
expect(() => calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1x' })).toThrow(
'Invalid date expression: +1x',
it("throws error for invalid unit", () => {
expect(() => calculateDatetime({ date: "2025-07-13T12:00:00Z", expression: "+1x" })).toThrow(
"Invalid date expression: +1x",
);
});
it('throws error for invalid unit weird', () => {
expect(() => calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: '+1&#^%' })).toThrow(
'Invalid date expression: +1&#^%',
it("throws error for invalid unit weird", () => {
expect(() => calculateDatetime({ date: "2025-07-13T12:00:00Z", expression: "+1&#^%" })).toThrow(
"Invalid date expression: +1&#^%",
);
});
it('throws error for bad expression', () => {
it("throws error for bad expression", () => {
expect(() =>
calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: 'bad expr' }),
).toThrow('Invalid date expression: bad');
calculateDatetime({ date: "2025-07-13T12:00:00Z", expression: "bad expr" }),
).toThrow("Invalid date expression: bad");
});
});

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-uuid",
"displayName": "UUID Template Functions",
"description": "Template functions for generating UUIDs",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for generating UUIDs",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"

View File

@@ -1,27 +1,27 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import { v1, v3, v4, v5, v6, v7 } from 'uuid';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
import { v1, v3, v4, v5, v6, v7 } from "uuid";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'uuid.v1',
description: 'Generate a UUID V1',
name: "uuid.v1",
description: "Generate a UUID V1",
args: [],
async onRender(): Promise<string | null> {
return v1();
},
},
{
name: 'uuid.v3',
description: 'Generate a UUID V3',
name: "uuid.v3",
description: "Generate a UUID V3",
args: [
{ type: 'text', name: 'name', label: 'Name' },
{ type: "text", name: "name", label: "Name" },
{
type: 'text',
name: 'namespace',
label: 'Namespace UUID',
description: 'A valid UUID to use as the namespace',
placeholder: '24ced880-3bf4-11f0-8329-cd053d577f0e',
type: "text",
name: "namespace",
label: "Namespace UUID",
description: "A valid UUID to use as the namespace",
placeholder: "24ced880-3bf4-11f0-8329-cd053d577f0e",
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
@@ -29,35 +29,35 @@ export const plugin: PluginDefinition = {
},
},
{
name: 'uuid.v4',
description: 'Generate a UUID V4',
name: "uuid.v4",
description: "Generate a UUID V4",
args: [],
async onRender(): Promise<string | null> {
return v4();
},
},
{
name: 'uuid.v5',
description: 'Generate a UUID V5',
name: "uuid.v5",
description: "Generate a UUID V5",
args: [
{ type: 'text', name: 'name', label: 'Name' },
{ type: 'text', name: 'namespace', label: 'Namespace' },
{ type: "text", name: "name", label: "Name" },
{ type: "text", name: "namespace", label: "Namespace" },
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return v5(String(args.values.name), String(args.values.namespace));
},
},
{
name: 'uuid.v6',
description: 'Generate a UUID V6',
name: "uuid.v6",
description: "Generate a UUID V6",
args: [
{
type: 'text',
name: 'timestamp',
label: 'Timestamp',
type: "text",
name: "timestamp",
label: "Timestamp",
optional: true,
description: 'Can be any format that can be parsed by JavaScript new Date(...)',
placeholder: '2025-05-28T11:15:00Z',
description: "Can be any format that can be parsed by JavaScript new Date(...)",
placeholder: "2025-05-28T11:15:00Z",
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
@@ -65,8 +65,8 @@ export const plugin: PluginDefinition = {
},
},
{
name: 'uuid.v7',
description: 'Generate a UUID V7',
name: "uuid.v7",
description: "Generate a UUID V7",
args: [],
async onRender(): Promise<string | null> {
return v7();

View File

@@ -1,9 +1,9 @@
{
"name": "@yaak/template-function-xml",
"displayName": "XML Template Functions",
"description": "Template functions for working with XML data",
"private": true,
"version": "0.1.0",
"private": true,
"description": "Template functions for working with XML data",
"main": "build/index.js",
"types": "src/index.ts",
"scripts": {

View File

@@ -1,53 +1,53 @@
/* oxlint-disable no-base-to-string */
import { DOMParser } from '@xmldom/xmldom';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import xpath from 'xpath';
import { DOMParser } from "@xmldom/xmldom";
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from "@yaakapp/api";
import xpath from "xpath";
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
const RETURN_FIRST = "first";
const RETURN_ALL = "all";
const RETURN_JOIN = "join";
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'xml.xpath',
description: 'Filter XML-formatted text using XPath syntax',
previewArgs: ['query'],
name: "xml.xpath",
description: "Filter XML-formatted text using XPath syntax",
previewArgs: ["query"],
args: [
{
type: 'text',
name: 'input',
label: 'Input',
type: "text",
name: "input",
label: "Input",
multiLine: true,
placeholder: '<foo></foo>',
placeholder: "<foo></foo>",
},
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'select',
name: 'result',
label: 'Return Format',
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 },
{ 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',
name: "join",
type: "text",
label: "Separator",
optional: true,
defaultValue: ', ',
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> {
try {
@@ -62,7 +62,7 @@ export const plugin: PluginDefinition = {
],
};
export type XPathResult = 'first' | 'join' | 'all';
export type XPathResult = "first" | "join" | "all";
export function filterXPath(
body: string,
path: string,
@@ -70,17 +70,17 @@ export function filterXPath(
join: string | null,
): string {
// oxlint-disable-next-line no-explicit-any
const doc: any = new DOMParser().parseFromString(body, 'text/xml');
const doc: any = new DOMParser().parseFromString(body, "text/xml");
const items = xpath.select(path, doc, false);
if (!Array.isArray(items)) {
return String(items);
}
if (!Array.isArray(items) || result === 'first') {
return items[0] != null ? String(items[0].firstChild ?? '') : '';
if (!Array.isArray(items) || result === "first") {
return items[0] != null ? String(items[0].firstChild ?? "") : "";
}
if (result === 'join') {
return items.map((item) => String(item.firstChild ?? '')).join(join ?? '');
if (result === "join") {
return items.map((item) => String(item.firstChild ?? "")).join(join ?? "");
}
// Not sure what cases this happens in (?)
return String(items);

Some files were not shown because too many files have changed in this diff Show More