Compare commits

...

46 Commits

Author SHA1 Message Date
Gregory Schier
20681e5be3 Scoped OAuth 2 tokens 2025-07-23 22:03:03 -07:00
Gregory Schier
a258a80fbd Prevent auth from adding lone ? to URL
https://feedback.yaak.app/p/using-inherited-api-key-causes-a-question-mark-to-be
2025-07-23 17:20:17 -07:00
Gregory Schier
1b90842d30 Regex template function 2025-07-23 13:33:58 -07:00
Carter Costic
f1acb3c925 Merge pull request #245
* Attach cookies to WS Upgrade

* Merge branch 'main' into main

* Move reqwest_cookie_store to workspace dep
2025-07-23 13:14:15 -07:00
Gregory Schier
28630bbb6c Remove template as default value 2025-07-23 12:46:26 -07:00
Gregory Schier
86a09642e7 Rename template-function-datetime 2025-07-23 12:42:54 -07:00
Song
0b38948826 add template-function-datetime (#244) 2025-07-23 12:41:24 -07:00
Gregory Schier
c09083ddec Fix up export dialog 2025-07-21 14:45:13 -07:00
Gregory Schier
44ee020383 Plugins menu item and link to run button 2025-07-21 14:38:29 -07:00
Gregory Schier
c609d0ff0c Fix GraphQL schema getting nuked on codemirror language refresh 2025-07-21 14:17:36 -07:00
Gregory Schier
7eb3f123c6 Add run button link 2025-07-21 07:47:29 -07:00
Gregory Schier
2bd8a50df4 Tweak tab padding 2025-07-21 07:45:11 -07:00
Gregory Schier
178cc88efb Fix Authenticatin typo
https://feedback.yaak.app/p/authentication-misspelled-in-request-auth-tooltip
2025-07-21 07:39:54 -07:00
Gregory Schier
38b2893cbf npm i 2025-07-20 09:48:57 -07:00
Gregory Schier
144faad31f Add API key auth
https://feedback.yaak.app/p/header-as-auth-option
2025-07-20 09:15:03 -07:00
Gregory Schier
947926ca34 Fix deadlock 2025-07-20 08:58:22 -07:00
Gregory Schier
86f23990eb Fixed bugs in Plugin settings pane 2025-07-20 08:28:00 -07:00
Gregory Schier
861b41b5ae JSONPath plugin README 2025-07-20 06:42:33 -07:00
Gregory Schier
7f4ccbe014 OAuth 2 plugin README 2025-07-19 21:47:19 -07:00
Gregory Schier
3b61c836be Merge remote-tracking branch 'origin/main' 2025-07-19 21:39:47 -07:00
Gregory Schier
6616cb67cd JWT plugin README 2025-07-19 21:39:40 -07:00
Song
e5fd4134ba inline url search param and use --data (#239) 2025-07-19 21:28:39 -07:00
Gregory Schier
31b0b14c04 Merge remote-tracking branch 'origin/main' 2025-07-19 21:25:21 -07:00
Gregory Schier
daeaf2a999 Bearer plugin README 2025-07-19 21:25:15 -07:00
Song
ca2fe07265 Optimize request function (#242)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2025-07-19 09:29:42 -07:00
Song
adca071574 fix padding and hover highlight in tabs (#243) 2025-07-19 09:19:48 -07:00
Gregory Schier
d6057aa1ec Basic auth plugin README 2025-07-19 09:15:06 -07:00
Gregory Schier
60883cc1b9 copy grpcurl readme and fix 2025-07-19 09:10:49 -07:00
Gregory Schier
b32fe466b1 Copy as curl readme 2025-07-19 07:38:46 -07:00
Gregory Schier
f81ff27a9e Don't wrap tab content 2025-07-18 14:52:19 -07:00
Gregory Schier
8f737d799b Pad dynamic form for scrollbar 2025-07-18 14:52:08 -07:00
Gregory Schier
b67ea29aff Better error 2025-07-18 14:49:13 -07:00
Gregory Schier
a657c32445 Better authorization URL handling 2025-07-18 14:48:45 -07:00
Andrew Berezovskyi
5061e17700 Update mimetypes.ts with RDF mime types beyond JSON-LD and N3 (#235) 2025-07-18 14:37:14 -07:00
Song
d9d5c4d564 remove unnecessary semicolon in tailwind config file (#236) 2025-07-18 14:36:28 -07:00
Song
343986c018 make monospace font family follows app setting in auto completion menu (#237)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2025-07-18 14:35:57 -07:00
Song
0d4b7bb5e2 Improve <details> component (#238)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2025-07-18 14:28:24 -07:00
Song
4a2fb6ed48 Improve layout resizer (#240)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2025-07-18 13:35:29 -07:00
Gregory Schier
74b6f4fb42 Fix pair editor creating new entry by clicking value 2025-07-18 08:54:37 -07:00
Gregory Schier
bcde4de4a7 Tweak workspace settings and a bunch of small things 2025-07-18 08:47:14 -07:00
Gregory Schier
4c375ed3e9 Tweak 2025-07-15 07:25:34 -07:00
Gregory Schier
2fcd2a3c07 Fix docs explorer cmd+click 2025-07-15 07:02:08 -07:00
Gregory Schier
0c60d190af Fix lint errors and show docs explorer on Cmd click 2025-07-14 14:52:16 -07:00
Gregory Schier
6f1fd7a254 Fix lint errors after upgrades and narrow tsc 2025-07-14 10:09:08 -07:00
Gregory Schier
5c1fba4b0c Fix Postman import description
https://feedback.yaak.app/p/missing-documentation-info-when-importing-postman-requests
2025-07-14 07:36:04 -07:00
Gregory Schier
6df13c452b Upgrade dependencies 2025-07-14 07:35:37 -07:00
191 changed files with 6463 additions and 4715 deletions

View File

@@ -72,9 +72,6 @@ jobs:
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Run JS build
run: npm run build
- name: Run lint
run: npm run lint

7074
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/common-lib",
"plugins/auth-apikey",
"plugins/auth-basic",
"plugins/auth-bearer",
"plugins/auth-jwt",
@@ -24,6 +25,7 @@
"plugins/importer-postman",
"plugins/importer-yaak",
"plugins/template-function-cookie",
"plugins/template-function-timestamp",
"plugins/template-function-encode",
"plugins/template-function-fs",
"plugins/template-function-hash",

View File

@@ -31,7 +31,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@types/node": "^22.5.4"
"@types/node": "^24.0.13"
},
"devDependencies": {
"cpy-cli": "^5.0.0"

View File

@@ -21,7 +21,12 @@ export type CallHttpAuthenticationResponse = {
* HTTP headers to add to the request. Existing headers will be replaced, while
* new headers will be added.
*/
setHeaders: Array<HttpHeader>, };
setHeaders?: Array<HttpHeader>,
/**
* Query parameters to add to the request. Existing params will be replaced, while
* new params will be added.
*/
setQueryParameters?: Array<HttpHeader>, };
export type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };
@@ -31,7 +36,7 @@ export type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key
export type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };
export type CallTemplateFunctionResponse = { value: string | null, };
export type CallTemplateFunctionResponse = { value: string | null, error?: string, };
export type CloseWindowRequest = { label: string, };

View File

@@ -17,8 +17,8 @@ import type {
SendHttpRequestResponse,
ShowToastRequest,
TemplateRenderRequest,
TemplateRenderResponse,
} from '../bindings/gen_events.ts';
import { JsonValue } from '../bindings/serde_json/JsonValue';
export interface Context {
clipboard: {
@@ -59,6 +59,6 @@ export interface Context {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>;
};
templates: {
render(args: TemplateRenderRequest): Promise<TemplateRenderResponse['data']>;
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
};
}

View File

@@ -253,12 +253,11 @@ export class PluginInstance {
const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') {
applyFormInputDefaults(auth.args, payload.values);
const result = await auth.onApply(ctx, payload);
this.#sendPayload(
windowContext,
{
type: 'call_http_authentication_response',
setHeaders: result.setHeaders,
...(await auth.onApply(ctx, payload)),
},
replyId,
);
@@ -309,15 +308,27 @@ export class PluginInstance {
const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name);
if (typeof fn?.onRender === 'function') {
applyFormInputDefaults(fn.args, payload.args.values);
const result = await fn.onRender(ctx, payload.args);
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: result ?? null,
},
replyId,
);
try {
const result = await fn.onRender(ctx, payload.args);
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: result ?? null,
},
replyId,
);
} catch (err) {
this.#sendPayload(
windowContext,
{
type: 'call_template_function_response',
value: null,
error: `${err}`.replace(/^Error:\s*/g, ''),
},
replyId,
);
}
return;
}
}
@@ -579,7 +590,7 @@ export class PluginInstance {
event.windowContext,
payload,
);
return result.data;
return result.data as any;
},
},
store: {

View File

@@ -0,0 +1,68 @@
# Copy as cUrl
A request action plugin for Yaak that converts HTTP requests into [curl](https://curl.se)
commands, making it easy to share, debug, and execute requests outside Yaak.
![Screenshot of context menu](screenshot.png)
## Overview
This plugin adds a 'Copy as Curl' action to HTTP requests, converting any request into its
equivalent curl command. This is useful for debugging, sharing requests with team members,
and executing requests in terminal environments where `curl` is available.
## How It Works
The plugin analyzes the given HTTP request and generates a properly formatted curl command
that includes:
- HTTP method (GET, POST, PUT, DELETE, etc.)
- Request URL with query parameters
- Headers (including authentication headers)
- Request body (for POST, PUT, PATCH requests)
- Authentication credentials
## Usage
1. Configure an HTTP request as usual in Yaak
2. Right-click on the request in the sidebar
3. Select 'Copy as Curl'
4. The command is copied to your clipboard
5. Share or execute the command
## Generated Curl Examples
### Simple GET Request
```bash
curl -X GET 'https://api.example.com/users' \
--header 'Accept: application/json'
```
### POST Request with JSON Data
```bash
curl -X POST 'https://api.example.com/users' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"name": "John Doe",
"email": "john@example.com"
}'
```
### Request with Multi-part Form Data
```bash
curl -X POST 'yaak.app' \
--header 'Content-Type: multipart/form-data' \
--form 'hello=world' \
--form file=@/path/to/file.json
```
### Request with Authentication
```bash
curl -X GET 'https://api.example.com/protected' \
--user 'username:password'
```

View File

@@ -2,11 +2,16 @@
"name": "@yaak/action-copy-curl",
"displayName": "Copy as Curl",
"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",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

View File

@@ -29,16 +29,23 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
// Add method and URL all on first line
if (request.method) xs.push('-X', request.method);
if (request.url) xs.push(quote(request.url));
xs.push(NEWLINE);
// Add URL params
for (const p of (request.urlParameters ?? []).filter(onlyEnabled)) {
xs.push('--url-query', quote(`${p.name}=${p.value}`));
xs.push(NEWLINE);
// Build final URL with parameters (compatible with old curl)
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 queryString = urlParams
.map(p => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join('&');
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
xs.push(quote(finalUrl));
xs.push(NEWLINE);
// Add headers
for (const h of (request.headers ?? []).filter(onlyEnabled)) {
xs.push('--header', quote(`${h.name}: ${h.value}`));
@@ -63,10 +70,10 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
query: request.body.query || '',
variables: maybeParseJSON(request.body.variables, undefined),
};
xs.push('--data-raw', `${quote(JSON.stringify(body))}`);
xs.push('--data', quote(JSON.stringify(body)));
xs.push(NEWLINE);
} else if (typeof request.body?.text === 'string') {
xs.push('--data-raw', `${quote(request.body.text)}`);
xs.push('--data', quote(request.body.text));
xs.push(NEWLINE);
}
@@ -109,4 +116,4 @@ function maybeParseJSON<T>(v: string, fallback: T) {
} catch {
return fallback;
}
}
}

View File

@@ -13,7 +13,22 @@ describe('exporter-curl', () => {
],
}),
).toEqual(
[`curl 'https://yaak.app'`, `--url-query 'a=aaa'`, `--url-query 'b=bbb'`].join(` \\\n `),
[`curl 'https://yaak.app/?a=aaa&b=bbb'`].join(` \\n `),
);
});
test('Exports GET with params and hash', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app/path#section',
urlParameters: [
{ 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 `),
);
});
test('Exports POST with url form data', async () => {
@@ -47,7 +62,7 @@ describe('exporter-curl', () => {
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data-raw '{"query":"{foo,bar}","variables":{"a":"aaa","b":"bbb"}}'`].join(` \\\n `),
[`curl -X POST 'https://yaak.app'`, `--data '{"query":"{foo,bar}","variables":{"a":"aaa","b":"bbb"}}'`].join(` \\\n `),
);
});
@@ -62,7 +77,7 @@ describe('exporter-curl', () => {
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data-raw '{"query":"{foo,bar}"}'`].join(` \\\n `),
[`curl -X POST 'https://yaak.app'`, `--data '{"query":"{foo,bar}"}'`].join(` \\\n `),
);
});
@@ -106,7 +121,7 @@ describe('exporter-curl', () => {
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data-raw '{"foo":"bar\\'s"}'`,
`--data '{"foo":"bar\\'s"}'`,
].join(` \\\n `),
);
});
@@ -126,7 +141,7 @@ describe('exporter-curl', () => {
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data-raw '{"foo":"bar",\n"baz":"qux"}'`,
`--data '{"foo":"bar",\n"baz":"qux"}'`,
].join(` \\\n `),
);
});
@@ -140,7 +155,7 @@ describe('exporter-curl', () => {
{ 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 () => {
@@ -203,4 +218,4 @@ describe('exporter-curl', () => {
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer '`].join(` \\\n `));
});
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,76 @@
# Copy as gRPCurl
An HTTP request action plugin that converts gRPC requests
into [gRPCurl](https://github.com/fullstorydev/grpcurl) commands, enabling easy sharing,
debugging, and execution of gRPC calls outside Yaak.
![Screenshot of context menu](screenshot.png)
## Overview
This plugin adds a "Copy as gRPCurl" action to gRPC requests, converting any gRPC request
into its equivalent executable command. This is useful for debugging gRPC services,
sharing requests with team members, or executing gRPC calls in terminal environments where
`grpcurl` is available.
## How It Works
The plugin analyzes your gRPC request configuration and generates a properly formatted
`grpcurl` command that includes:
- gRPC service and method names
- Server address and port
- Request message data (JSON format)
- Metadata (headers)
- Authentication credentials
- Protocol buffer definitions
## Usage
1. Configure a gRPC request as usual in Yaak
2. Right-click on the request sidebar item
3. Select "Copy as gRPCurl" from the available actions
4. The command is copied to your clipboard
5. Share or execute the command
## Generated gRPCurl Examples
### Simple Unary Call
```bash
grpcurl -plaintext \
-d '{"name": "John Doe"}' \
localhost:9090 \
user.UserService/GetUser
```
### Call with Metadata
```bash
grpcurl -plaintext \
-H "authorization: Bearer my-token" \
-H "x-api-version: v1" \
-d '{"user_id": "12345"}' \
api.example.com:443 \
user.UserService/GetUserProfile
```
### Call with TLS
```bash
grpcurl \
-d '{"query": "search term"}' \
secure-api.example.com:443 \
search.SearchService/Search
```
### Call with Proto Files
```bash
grpcurl -import-path /path/to/protos \
-proto /other/path/to/user.proto \
-d '{"email": "user@example.com"}' \
localhost:9090 \
user.UserService/CreateUser
```

View File

@@ -2,11 +2,16 @@
"name": "@yaak/action-copy-grpcurl",
"displayName": "Copy as gRPCurl",
"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",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

View File

@@ -90,15 +90,15 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
if (request.url) {
const server = request.url.replace(/^https?:\/\//, ''); // remove protocol
xs.push(server);
xs.push(NEWLINE);
}
// Add service + method
if (request.service && request.method) {
xs.push(`${request.service}/${request.method}`);
xs.push(NEWLINE);
}
xs.push(NEWLINE);
// Remove trailing newline
if (xs[xs.length - 1] === NEWLINE) {
xs.splice(xs.length - 1, 1);

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,17 @@
{
"name": "@yaak/auth-apikey",
"displayName": "API Key Authentication",
"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",
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -0,0 +1,53 @@
import type { PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
authentication: {
name: 'apikey',
label: 'API Key',
shortLabel: 'API Key',
args: [
{
type: 'select',
name: 'location',
label: 'Behavior',
defaultValue: 'header',
options: [
{ label: 'Insert Header', value: 'header' },
{ label: 'Append Query Parameter', value: 'query' },
],
},
{
type: 'text',
name: 'key',
label: 'Key',
dynamic: (_ctx, { values }) => {
return values.location === 'query' ? {
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',
};
},
},
{
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 location = String(values.location);
if (location === 'query') {
return { setQueryParameters: [{ name: key, value }] };
} else {
return { setHeaders: [{ name: key, value }] };
}
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,44 @@
# 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
authentication with username and password credentials.
![Screenshot of basic auth UI](screenshot.png)
## Overview
This plugin provides HTTP Basic Authentication support for API requests in Yaak. Basic
Auth is one of the most widely supported authentication methods, making it ideal for APIs
that require simple username/password authentication without the complexity of OAuth
flows.
## How Basic Authentication Works
Basic Authentication encodes your username and password credentials using Base64 encoding
and sends them in the `Authorization` header with each request. The format is:
```
Authorization: Basic <base64-encoded-credentials>
```
Where `<base64-encoded-credentials>` is the Base64 encoding of `username:password`.
## Configuration
The plugin presents two fields:
- **Username**: Username or user identifier
- **Password**: Password or authentication token
## Usage
1. Configure the request, folder, or workspace to use Basic Authentication
2. Enter your username and password in the authentication configuration
3. The plugin will automatically add the proper `Authorization` header to your requests
## Troubleshooting
- **401 Unauthorized**: Verify your username and password are correct
- **403 Forbidden**: Check if your account has the necessary permissions
- **Connection Issues**: Ensure you're using HTTPS for secure transmission

View File

@@ -2,11 +2,16 @@
"name": "@yaak/auth-basic",
"displayName": "Basic Authentication",
"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",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

View File

@@ -1,4 +1,4 @@
import { PluginDefinition } from '@yaakapp/api';
import type { PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
authentication: {

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,47 @@
# Bearer Token Authentication Plugin
A Bearer Token authentication plugin for Yaak that
implements [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750), enabling secure API
access using tokens, API keys, and other bearer credentials.
![Screenshot of bearer auth UI](screenshot.png)
## Overview
This plugin provides Bearer Token authentication support for your API requests in Yaak.
Bearer Token authentication is widely used in modern APIs, especially those following REST
principles and OAuth 2.0 standards. It's the preferred method for APIs that issue access
tokens, API keys, or other bearer credentials.
## How Bearer Token Authentication Works
Bearer Token authentication sends your token in the `Authorization` header with each
request using the Bearer scheme:
```
Authorization: Bearer <your-token>
```
The token is transmitted as-is without any additional encoding, making it simple and
efficient for API authentication.
## Configuration
The plugin requires only one field:
- **Token**: Your bearer token, access token, API key, or other credential
- **Prefix**: The prefix to use for the Authorization header, which will be of the
format "<PREFIX> <TOKEN>"
## Usage
1. Configure the request, folder, or workspace to use Bearer Authentication
2. Enter the token and optional prefix in the authentication configuration
3. The plugin will automatically add the proper `Authorization` header to your requests
## Troubleshooting
- **401 Unauthorized**: Verify your token is valid and not expired
- **403 Forbidden**: Check if your token has the necessary permissions/scopes
- **Invalid Token Format**: Ensure you're using the complete token without truncation
- **Token Expiration**: Refresh or regenerate expired tokens

View File

@@ -2,11 +2,16 @@
"name": "@yaak/auth-bearer",
"displayName": "Bearer Authentication",
"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",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,53 @@
# JSON Web Token (JWT) Authentication
A [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) (JWT) authentication
plugin that supports token generation, signing, and automatic header management.
![Screenshot of JWT auth UI](screenshot.png)
## Overview
This plugin provides JWT authentication support for API requests. JWT is a compact,
URL-safe means of representing claims between two parties, commonly used for
authentication and information exchange in modern web applications and APIs.
## How JWT Authentication Works
JWT authentication involves creating a signed token containing claims about the user or
application. The token is sent in the `Authorization` header:
```
Authorization: Bearer <jwt-token>
```
A JWT consists of three parts separated by dots:
- **Header**: Contains the token type and signing algorithm
- **Payload**: Contains the claims (user data, permissions, expiration, etc.)
- **Signature**: Ensures the token hasn't been tampered with
## Usage
1. Configure the request, folder, or workspace to use JWT Authentication
2. Set up your signing algorithm and secret/key
3. Configure the required claims for your JWT
4. The plugin will generate, sign, and include the JWT in your requests
## Common Use Cases
JWT authentication is commonly used for:
- **Microservices Authentication**: Service-to-service communication
- **API Gateway Integration**: Authenticating with API gateways
- **Single Sign-On (SSO)**: Sharing authentication across applications
- **Stateless Authentication**: No server-side session storage required
- **Mobile App APIs**: Secure authentication for mobile applications
- **Third-party Integrations**: Authenticating with external services
## Troubleshooting
- **Invalid Signature**: Check your secret/key and algorithm configuration
- **Token Expired**: Verify expiration time settings
- **Invalid Claims**: Ensure required claims are properly configured
- **Algorithm Mismatch**: Verify the algorithm matches what the API expects
- **Key Format Issues**: Ensure RSA keys are in the correct PEM format

View File

@@ -1,13 +1,18 @@
{
"name": "@yaak/auth-jwt",
"displayName": "JWT Authentication",
"displayName": "JSON Web Tokens",
"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",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

View File

@@ -1,4 +1,4 @@
import { PluginDefinition } from '@yaakapp/api';
import type { PluginDefinition } from '@yaakapp/api';
import jwt from 'jsonwebtoken';
const algorithms = [
@@ -20,49 +20,49 @@ const algorithms = [
const defaultAlgorithm = algorithms[0];
export const plugin: PluginDefinition = {
authentication: {
name: 'jwt',
label: 'JWT Bearer',
shortLabel: 'JWT',
args: [
{
type: 'select',
name: 'algorithm',
label: 'Algorithm',
hideLabel: true,
defaultValue: defaultAlgorithm,
options: algorithms.map(value => ({ label: value === 'none' ? 'None' : value, value })),
},
{
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: 'editor',
name: 'payload',
label: 'Payload',
language: 'json',
defaultValue: '{\n "foo": "bar"\n}',
placeholder: '{ }',
},
],
async onApply(_ctx, { values }) {
const { algorithm, secret: _secret, secretBase64, payload } = values;
const secret = secretBase64 ? Buffer.from(`${_secret}`, 'base64') : `${_secret}`;
const token = jwt.sign(`${payload}`, secret, { algorithm: algorithm as any });
const value = `Bearer ${token}`;
return { setHeaders: [{ name: 'Authorization', value }] };
}
,
authentication: {
name: 'jwt',
label: 'JWT Bearer',
shortLabel: 'JWT',
args: [
{
type: 'select',
name: 'algorithm',
label: 'Algorithm',
hideLabel: true,
defaultValue: defaultAlgorithm,
options: algorithms.map((value) => ({ label: value === 'none' ? 'None' : value, value })),
},
{
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: 'editor',
name: 'payload',
label: 'Payload',
language: 'json',
defaultValue: '{\n "foo": "bar"\n}',
placeholder: '{ }',
},
],
async onApply(_ctx, { values }) {
const { algorithm, secret: _secret, secretBase64, payload } = values;
const secret = secretBase64 ? Buffer.from(`${_secret}`, 'base64') : `${_secret}`;
const token = jwt.sign(`${payload}`, secret, {
algorithm: algorithm as (typeof algorithms)[number],
});
const value = `Bearer ${token}`;
return { setHeaders: [{ name: 'Authorization', value }] };
},
}
;
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,72 @@
# OAuth 2.0 Authentication
An [OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) authentication plugin that
supports multiple grant types and flows, enabling secure API authentication with OAuth 2.0
providers.
![Screenshot of OAuth 2.0 auth UI](screenshot.png)
## Overview
This plugin implements OAuth 2.0 authentication for requests, supporting the most common
OAuth 2.0 grant types used in modern API integrations. It handles token management,
automatic refresh, and [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key
for Code Exchange) for enhanced security.
## Supported Grant Types
### Authorization Code Flow
The most secure and commonly used OAuth 2.0 flow for web applications.
- Standard Authorization Code flow
- Optional PKCE (Proof Key for Code Exchange) for enhanced security
- Supports automatic token refresh
### Client Credentials Flow
Ideal for server-to-server authentication where no user interaction is required.
### Implicit Flow
Legacy flow for single-page applications (deprecated but still supported):
- Direct access token retrieval
- No refresh token support
- Suitable for legacy integrations
### Resource Owner Password Credentials Flow
Direct username/password authentication.
- User credentials are exchanged directly for tokens
- Should only be used with trusted applications
- Supports automatic token refresh
## Features
- **Automatic Token Management**: Handles token storage, expiration, and refresh
automatically
- **PKCE Support**: Enhanced security for Authorization Code flow
- **Token Persistence**: Stores tokens between sessions
- **Flexible Configuration**: Supports custom authorization and token endpoints
- **Scope Management**: Configure required OAuth scopes for your API
- **Error Handling**: Comprehensive error handling and user feedback
## Usage
1. Configure the request, folder, or workspace to use OAuth 2.0 Authentication
2. Select the appropriate grant type for your use case
3. Fill in the required OAuth 2.0 parameters from your API provider
4. The plugin will handle the authentication flow and token management automatically
## Compatibility
This plugin is compatible with OAuth 2.0 providers including:
- Google APIs
- Microsoft Graph
- GitHub API
- Auth0
- Okta
- And many other OAuth 2.0 compliant services

View File

@@ -1,12 +1,17 @@
{
"name": "@yaak/auth-oauth2",
"displayName": "OAuth 2.0 Authentication",
"displayName": "OAuth 2.0",
"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",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

View File

@@ -1,19 +0,0 @@
import type { Context } from '@yaakapp/api';
import type { AccessToken } from './store';
import { getToken } from './store';
export async function getAccessTokenIfNotExpired(
ctx: Context,
contextId: string,
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
if (token == null || isTokenExpired(token)) {
return null;
}
return token;
}
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
}

View File

@@ -1,12 +1,12 @@
import type { Context, HttpRequest } from '@yaakapp/api';
import { readFileSync } from 'node:fs';
import { isTokenExpired } from './getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse } from './store';
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from './store';
import { deleteToken, getToken, storeToken } from './store';
import { isTokenExpired } from './util';
export async function getOrRefreshAccessToken(
ctx: Context,
contextId: string,
tokenArgs: TokenStoreArgs,
{
scope,
accessTokenUrl,
@@ -23,7 +23,7 @@ export async function getOrRefreshAccessToken(
forceRefresh?: boolean;
},
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return null;
}
@@ -75,7 +75,7 @@ export async function getOrRefreshAccessToken(
// Bad refresh token, so we'll force it to fetch a fresh access token by deleting
// and returning null;
console.log('[oauth2] Unauthorized refresh_token request');
await deleteToken(ctx, contextId);
await deleteToken(ctx, tokenArgs);
return null;
}
@@ -108,5 +108,5 @@ export async function getOrRefreshAccessToken(
refresh_token: response.refresh_token ?? token.response.refresh_token,
};
return storeToken(ctx, contextId, newResponse);
return storeToken(ctx, tokenArgs, newResponse);
}

View File

@@ -2,7 +2,7 @@ import type { Context } from '@yaakapp/api';
import { createHash, randomBytes } from 'node:crypto';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken } from '../store';
import type { AccessToken, TokenStoreArgs } from '../store';
import { getDataDirKey, storeToken } from '../store';
export const PKCE_SHA256 = 'S256';
@@ -41,7 +41,14 @@ export async function getAuthorizationCode(
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
const token = await getOrRefreshAccessToken(ctx, contextId, {
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: authorizationUrlRaw,
};
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
accessTokenUrl,
scope,
clientId,
@@ -52,7 +59,12 @@ export async function getAuthorizationCode(
return token;
}
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
let authorizationUrl: URL;
try {
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 (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
@@ -123,7 +135,7 @@ export async function getAuthorizationCode(
],
});
return storeToken(ctx, contextId, response, tokenName);
return storeToken(ctx, tokenArgs, response, tokenName);
}
export function genPkceCodeVerifier() {

View File

@@ -1,7 +1,8 @@
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import type { TokenStoreArgs } from '../store';
import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
export async function getClientCredentials(
ctx: Context,
@@ -22,7 +23,13 @@ export async function getClientCredentials(
credentialsInBody: boolean;
},
) {
const token = await getToken(ctx, contextId);
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: null,
};
const token = await getToken(ctx, tokenArgs);
if (token && !isTokenExpired(token)) {
return token;
}
@@ -38,5 +45,5 @@ export async function getClientCredentials(
params: [],
});
return storeToken(ctx, contextId, response);
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -1,7 +1,7 @@
import type { Context } from '@yaakapp/api';
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse} from '../store';
import type { AccessToken, AccessTokenRawResponse } from '../store';
import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
export async function getImplicit(
ctx: Context,
@@ -26,12 +26,23 @@ export async function getImplicit(
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
const token = await getToken(ctx, contextId);
const tokenArgs = {
contextId,
clientId,
accessTokenUrl: null,
authorizationUrl: authorizationUrlRaw,
};
const token = await getToken(ctx, tokenArgs);
if (token != null && !isTokenExpired(token)) {
return token;
}
const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
let authorizationUrl: URL;
try {
authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set('response_type', 'token');
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
@@ -77,7 +88,7 @@ export async function getImplicit(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try {
resolve(storeToken(ctx, contextId, response));
resolve(storeToken(ctx, tokenArgs, response));
} catch (err) {
reject(err);
}

View File

@@ -1,7 +1,7 @@
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken} from '../store';
import type { AccessToken, TokenStoreArgs } from '../store';
import { storeToken } from '../store';
export async function getPassword(
@@ -27,7 +27,13 @@ export async function getPassword(
credentialsInBody: boolean;
},
): Promise<AccessToken> {
const token = await getOrRefreshAccessToken(ctx, contextId, {
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: null,
};
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
accessTokenUrl,
scope,
clientId,
@@ -52,5 +58,5 @@ export async function getPassword(
],
});
return storeToken(ctx, contextId, response);
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -15,7 +15,7 @@ import {
import { getClientCredentials } from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import type { AccessToken } from './store';
import type { AccessToken, TokenStoreArgs } from './store';
import { deleteToken, getToken, resetDataDirKey } from './store';
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
@@ -83,8 +83,14 @@ export const plugin: PluginDefinition = {
actions: [
{
label: 'Copy Current Token',
async onSelect(ctx, { contextId }) {
const token = await getToken(ctx, contextId);
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
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' });
} else {
@@ -99,8 +105,14 @@ export const plugin: PluginDefinition = {
},
{
label: 'Delete Token',
async onSelect(ctx, { contextId }) {
if (await deleteToken(ctx, contextId)) {
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
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' });
} else {
await ctx.toast.show({ message: 'No token to delete', color: 'warning' });
@@ -281,8 +293,14 @@ export const plugin: PluginDefinition = {
{
type: 'accordion',
label: 'Access Token Response',
async dynamic(ctx, { contextId }) {
const token = await getToken(ctx, contextId);
async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return { hidden: true };
}
@@ -312,12 +330,14 @@ export const plugin: PluginDefinition = {
const authorizationUrl = stringArg(values, 'authorizationUrl');
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
accessTokenUrl:
accessTokenUrl === '' || accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl:
authorizationUrl === '' || authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
redirectUri: stringArgOrNull(values, 'redirectUri'),

View File

@@ -1,8 +1,9 @@
import { Context } from '@yaakapp/api';
import type { Context } from '@yaakapp/api';
import { createHash } from 'node:crypto';
export async function storeToken(
ctx: Context,
contextId: string,
args: TokenStoreArgs,
response: AccessTokenRawResponse,
tokenName: 'access_token' | 'id_token' = 'access_token',
) {
@@ -15,16 +16,16 @@ export async function storeToken(
response,
expiresAt,
};
await ctx.store.set<AccessToken>(tokenStoreKey(contextId), token);
await ctx.store.set<AccessToken>(tokenStoreKey(args), token);
return token;
}
export async function getToken(ctx: Context, contextId: string) {
return ctx.store.get<AccessToken>(tokenStoreKey(contextId));
export async function getToken(ctx: Context, args: TokenStoreArgs) {
return ctx.store.get<AccessToken>(tokenStoreKey(args));
}
export async function deleteToken(ctx: Context, contextId: string) {
return ctx.store.delete(tokenStoreKey(contextId));
export async function deleteToken(ctx: Context, args: TokenStoreArgs) {
return ctx.store.delete(tokenStoreKey(args));
}
export async function resetDataDirKey(ctx: Context, contextId: string) {
@@ -37,8 +38,25 @@ export async function getDataDirKey(ctx: Context, contextId: string) {
return `${contextId}::${key}`;
}
function tokenStoreKey(contextId: string) {
return ['token', contextId].join('::');
export interface TokenStoreArgs {
contextId: string;
clientId: string;
accessTokenUrl: string | null;
authorizationUrl: string | null;
}
/**
* Generate a store key to use based on some arguments. The arguments will be normalized a bit to
* account for slight variations (like domains with and without a protocol scheme).
*/
function tokenStoreKey(args: TokenStoreArgs) {
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('::');
}
function dataDirStoreKey(contextId: string) {

View File

@@ -0,0 +1,5 @@
import type { AccessToken } from './store';
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,59 @@
# JSONPath
A filter plugin that enables [JSONPath](https://en.wikipedia.org/wiki/JSONPath)
extraction and filtering for JSON responses, making it easy to extract specific values
from complex JSON structures.
![Screenshot of JSONPath filtering](screenshot.png)
## Overview
This plugin provides JSONPath filtering for responses in Yaak. JSONPath is a query
language for JSON, similar to XPath for XML, that provides the ability to extract data
from JSON documents using a simple, expressive syntax. This is useful for working with
complex API responses where you need to only view a small subset of response data.
## How JSONPath Works
JSONPath uses a dot-notation syntax to navigate JSON structures:
- `$` - Root element
- `.` - Child element
- `..` - Recursive descent
- `*` - Wildcard
- `[]` - Array index or filter
## JSONPath Syntax Examples
### Basic Navigation
```
$.store.book[0].title # First book title
$.store.book[*].author # All book authors
$.store.book[-1] # Last book
$.store.book[0,1] # First two books
$.store.book[0:2] # First two books (slice)
```
### Filtering
```
$.store.book[?(@.price < 10)] # Books under $10
$.store.book[?(@.author == 'Tolkien')] # Books by Tolkien
$.store.book[?(@.category == 'fiction')] # Fiction books
```
### Recursive Search
```
$..author # All authors anywhere in the document
$..book[2] # Third book anywhere
$..price # All prices in the document
```
## Usage
1. Make an API request that returns JSON data
2. Below the response body, click the filter icon
3. Enter a JSONPath expression
4. View the extracted data in the results panel

View File

@@ -2,12 +2,17 @@
"name": "@yaak/filter-jsonpath",
"displayName": "JSONPath Filter",
"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",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonpath-plus": "^10.3.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,10 +7,10 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"@xmldom/xmldom": "^0.8.10",
"@xmldom/xmldom": "^0.9.8",
"xpath": "^0.0.34"
}
}

View File

@@ -7,7 +7,8 @@ export const plugin: PluginDefinition = {
name: 'XPath',
description: 'Filter XPath',
onFilter(_ctx, args) {
const doc = new DOMParser().parseFromString(args.payload, 'text/xml');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const doc: any = new DOMParser().parseFromString(args.payload, 'text/xml');
try {
const result = xpath.select(args.filter, doc, false);
if (Array.isArray(result)) {

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"shell-quote": "^1.8.1"

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"yaml": "^2.4.2"

View File

@@ -4,11 +4,11 @@ export function convertSyntax(variable: string): string {
return variable.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, '${[$2]}');
}
export function isJSObject(obj: any) {
export function isJSObject(obj: unknown) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
export function isJSString(obj: any) {
export function isJSString(obj: unknown) {
return Object.prototype.toString.call(obj) === '[object String]';
}

View File

@@ -1,4 +1,4 @@
import { Context, PluginDefinition } from '@yaakapp/api';
import type { Context, PluginDefinition } from '@yaakapp/api';
import YAML from 'yaml';
import { deleteUndefinedAttrs, isJSObject } from './common';
import { convertInsomniaV4 } from './v4';
@@ -15,16 +15,18 @@ export const plugin: PluginDefinition = {
};
export function convertInsomnia(contents: string) {
let parsed: any;
let parsed: unknown;
try {
parsed = JSON.parse(contents);
} catch (e) {
} catch {
// Fall through
}
try {
parsed = parsed ?? YAML.parse(contents);
} catch (e) {
} catch {
// Fall through
}
if (!isJSObject(parsed)) return null;

View File

@@ -1,7 +1,8 @@
import { PartialImportResources } from '@yaakapp/api';
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { PartialImportResources } from '@yaakapp/api';
import { convertId, convertSyntax, isJSObject } from './common';
export function convertInsomniaV4(parsed: Record<string, any>) {
export function convertInsomniaV4(parsed: any) {
if (!Array.isArray(parsed.resources)) return null;
const resources: PartialImportResources = {
@@ -14,7 +15,9 @@ export function convertInsomniaV4(parsed: Record<string, any>) {
};
// Import workspaces
const workspacesToImport = parsed.resources.filter(r => isJSObject(r) && r._type === 'workspace');
const workspacesToImport = parsed.resources.filter(
(r: any) => isJSObject(r) && r._type === 'workspace',
);
for (const w of workspacesToImport) {
resources.workspaces.push({
id: convertId(w._id),
@@ -40,13 +43,9 @@ export function convertInsomniaV4(parsed: Record<string, any>) {
resources.folders.push(importFolder(child, w._id));
nextFolder(child._id);
} else if (child._type === 'request') {
resources.httpRequests.push(
importHttpRequest(child, w._id),
);
resources.httpRequests.push(importHttpRequest(child, w._id));
} else if (child._type === 'grpc_request') {
resources.grpcRequests.push(
importGrpcRequest(child, w._id),
);
resources.grpcRequests.push(importGrpcRequest(child, w._id));
}
}
};
@@ -64,10 +63,7 @@ export function convertInsomniaV4(parsed: Record<string, any>) {
return { 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') {
@@ -141,10 +137,7 @@ function importHttpRequest(
};
}
function importGrpcRequest(
r: any,
workspaceId: string,
): PartialImportResources['grpcRequests'][0] {
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;
@@ -186,13 +179,18 @@ function importFolder(f: any, workspaceId: string): PartialImportResources['fold
};
}
function importEnvironment(e: any, workspaceId: string, isParent?: boolean): PartialImportResources['environments'][0] {
function importEnvironment(
e: any,
workspaceId: string,
isParent?: boolean,
): PartialImportResources['environments'][0] {
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,
workspaceId: convertId(workspaceId),
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
sortPriority: e.metaSortKey, // Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: 'environment',

View File

@@ -1,8 +1,16 @@
import { PartialImportResources } from '@yaakapp/api';
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { PartialImportResources } from '@yaakapp/api';
import { convertId, convertSyntax, isJSObject } from './common';
export function convertInsomniaV5(parsed: Record<string, any>) {
if (!Array.isArray(parsed.collection)) return null;
export function convertInsomniaV5(parsed: any) {
// Assert parsed is object
if (parsed == null || typeof parsed !== 'object') {
return null;
}
if (!('collection' in parsed) || !Array.isArray(parsed.collection)) {
return null;
}
const resources: PartialImportResources = {
environments: [],
@@ -14,7 +22,7 @@ export function convertInsomniaV5(parsed: Record<string, any>) {
};
// Import workspaces
const meta: Record<string, any> = parsed.meta ?? {};
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,
@@ -36,17 +44,11 @@ export function convertInsomniaV5(parsed: Record<string, any>) {
resources.folders.push(importFolder(child, meta.id, parentId));
nextFolder(child.children, child.meta.id);
} else if (child.method) {
resources.httpRequests.push(
importHttpRequest(child, meta.id, parentId),
);
resources.httpRequests.push(importHttpRequest(child, meta.id, parentId));
} else if (child.protoFileId) {
resources.grpcRequests.push(
importGrpcRequest(child, meta.id, parentId),
);
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId));
} else if (child.url) {
resources.websocketRequests.push(
importWebsocketRequest(child, meta.id, parentId),
);
resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId));
}
}
};
@@ -219,7 +221,11 @@ function importAuthentication(r: any) {
return { authenticationType, authentication } as const;
}
function importFolder(f: any, workspaceId: string, parentId: string): PartialImportResources['folders'][0] {
function importFolder(
f: any,
workspaceId: string,
parentId: string,
): PartialImportResources['folders'][0] {
const id = f.meta?.id ?? f._id;
const created = f.meta?.created ?? f.created;
const updated = f.meta?.modified ?? f.updated;
@@ -238,8 +244,11 @@ function importFolder(f: any, workspaceId: string, parentId: string): PartialImp
};
}
function importEnvironment(e: any, workspaceId: string, isParent?: boolean): PartialImportResources['environments'][0] {
function importEnvironment(
e: any,
workspaceId: string,
isParent?: boolean,
): PartialImportResources['environments'][0] {
const id = e.meta?.id ?? e._id;
const created = e.meta?.created ?? e.created;
const updated = e.meta?.modified ?? e.updated;
@@ -251,7 +260,8 @@ function importEnvironment(e: any, workspaceId: string, isParent?: boolean): Par
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
public: !e.isPrivate,
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
sortPriority: sortKey, // Will be added to Yaak later
base: isParent ?? e.parentId === workspaceId,
model: 'environment',

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,13 +7,13 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"openapi-to-postmanv2": "^5.0.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@types/openapi-to-postmanv2": "^3.2.4"
"@types/openapi-to-postmanv2": "^5.0.0"
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -8,6 +8,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -83,10 +83,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
};
exportResources.environments.push(environment);
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',
sortPriority: sortPriorityIndex++,
workspaceId: workspace.id,
id: generateId('folder'),
name: v.name,
@@ -133,7 +135,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
workspaceId: workspace.id,
folderId,
name: v.name,
description: v.description ? String(v.description) : undefined,
description: r.description ? String(r.description) : undefined,
method: typeof r.method === 'string' ? r.method : 'GET',
url,
urlParameters,
@@ -141,6 +143,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
bodyType: bodyPatch.bodyType,
authentication: authPatch.authentication,
authenticationType: authPatch.authenticationType,
sortPriority: sortPriorityIndex++,
headers,
};
exportResources.httpRequests.push(request);

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -1,11 +1,11 @@
import { Environment, PluginDefinition } from '@yaakapp/api';
import type { Environment, PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
importer: {
name: 'Yaak',
description: 'Yaak official format',
onImport(_ctx, args) {
return migrateImport(args.text) as any;
return migrateImport(args.text);
},
},
};
@@ -14,7 +14,7 @@ export function migrateImport(contents: string) {
let parsed;
try {
parsed = JSON.parse(contents);
} catch (err) {
} catch {
return undefined;
}
@@ -69,6 +69,6 @@ export function migrateImport(contents: string) {
return { resources: parsed.resources }; // Should already be in the correct format
}
function isJSObject(obj: any) {
function isJSObject(obj: unknown) {
return Object.prototype.toString.call(obj) === '[object Object]';
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -1,4 +1,4 @@
import { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
templateFunctions: [

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -1,4 +1,4 @@
import { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import { createHash, createHmac } from 'node:crypto';
const algorithms = ['md5', 'sha1', 'sha256', 'sha512'] as const;

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,7 +7,7 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonpath-plus": "^10.3.0"

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -1,32 +1,74 @@
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
const inputArg: TemplateFunctionArg = {
type: 'text',
name: 'input',
label: 'Input Text',
multiLine: true,
};
const regexArg: TemplateFunctionArg = {
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.',
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'regex.match',
description: 'Extract',
args: [
{
type: 'text',
name: 'regex',
label: 'Regular Expression',
placeholder: '^\\w+=(?<value>\\w*)$',
defaultValue: '^(.*)$',
description:
'A JavaScript regular expression, evaluated using the Node.js RegExp engine. Capture groups or named groups can be used to extract values.',
},
{ type: 'text', name: 'input', label: 'Input Text', multiLine: true },
],
description: 'Extract text using a regular expression',
args: [inputArg, regexArg],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.regex || !args.values.input) return '';
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] ?? '');
},
},
{
name: 'regex.replace',
description: 'Replace text using a regular expression',
args: [
inputArg,
regexArg,
{
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.',
},
{
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.)',
},
],
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 regex = String(args.values.regex);
if (!regex) return '';
return input.replace(new RegExp(String(args.values.regex), flags), replacement);
},
},
],
};

View File

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

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,6 +7,6 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
}
}

View File

@@ -1,3 +1,4 @@
import type { HttpUrlParameter } from '@yaakapp-internal/models';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
@@ -53,5 +54,47 @@ export const plugin: PluginDefinition = {
);
},
},
{
name: 'request.param',
args: [
{
name: 'requestId',
label: 'Http Request',
type: 'http_request',
},
{
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 httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const renderedUrl = await ctx.templates.render({
data: httpRequest.url,
purpose: args.purpose,
});
const querystring = renderedUrl.split('?')[1] ?? '';
const paramsFromUrl: HttpUrlParameter[] = new URLSearchParams(querystring)
.entries()
.map(([name, value]): HttpUrlParameter => ({ name, value }))
.toArray();
const allParams = [...paramsFromUrl, ...httpRequest.urlParameters];
const allEnabledParams = allParams.filter((p) => p.enabled !== false);
const foundParam = allEnabledParams.find((p) => p.name === paramName);
const renderedValue = await ctx.templates.render({
data: foundParam?.value ?? '',
purpose: args.purpose,
});
return renderedValue;
},
},
],
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -7,12 +7,12 @@
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "tsc --noEmit"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"jsonpath-plus": "^10.3.0",
"xpath": "^0.0.34",
"@xmldom/xmldom": "^0.8.10"
"@xmldom/xmldom": "^0.9.8"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"

View File

@@ -159,7 +159,8 @@ function filterJSONPath(body: string, path: string): string {
}
function filterXPath(body: string, path: string): string {
const doc = new DOMParser().parseFromString(body, 'text/xml');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const doc: any = new DOMParser().parseFromString(body, 'text/xml');
const items = xpath.select(path, doc, false);
if (Array.isArray(items)) {

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,13 @@
{
"name": "@yaak/template-function-timestamp",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"date-fns": "^4.1.0"
}
}

View File

@@ -0,0 +1,155 @@
import type { TemplateFunctionArg } from '@yaakapp-internal/plugins';
import type { PluginDefinition } from '@yaakapp/api';
import {
addDays,
addHours,
addMinutes,
addMonths,
addSeconds,
addYears,
format as formatDate,
isValid,
parseISO,
subDays,
subHours,
subMinutes,
subMonths,
subSeconds,
subYears,
} from 'date-fns';
const dateArg: TemplateFunctionArg = {
type: 'text',
name: 'date',
label: 'Timestamp',
optional: true,
description: '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',
description: "Modification expression (eg. '-5d +2h 3m'). Available units: y, M, d, h, m, s",
optional: true,
placeholder: '-5d +2h 3m',
};
const formatArg: TemplateFunctionArg = {
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',
};
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'timestamp.unix',
description: 'Get the current timestamp in seconds',
args: [],
onRender: async () => String(Math.floor(Date.now() / 1000)),
},
{
name: 'timestamp.unixMillis',
description: 'Get the current timestamp in milliseconds',
args: [],
onRender: async () => String(Date.now()),
},
{
name: 'timestamp.iso8601',
description: 'Get the current date in ISO8601 format',
args: [],
onRender: async () => new Date().toISOString(),
},
{
name: 'timestamp.format',
description: 'Format a date using a dayjs-compatible format string',
args: [dateArg, formatArg],
onRender: async (_ctx, args) => formatDatetime(args.values),
},
{
name: 'timestamp.offset',
description: 'Get the offset of a date based on an expression',
args: [dateArg, expressionArg],
onRender: async (_ctx, args) => calculateDatetime(args.values),
},
],
};
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);
default:
throw new Error(`Invalid data calculation unit: ${unit}`);
}
}
function parseOp(op: string): { sign: string; amount: number; unit: string } | null {
const match = op.match(/^([+-]?)(\d+)([yMdhms])$/);
if (!match) {
throw new Error(`Invalid date expression: ${op}`);
}
const [, sign, amount, unit] = match;
if (!unit) return null;
return { sign: sign ?? '+', amount: Number(amount ?? 0), unit };
}
function parseDateString(date: string): Date {
if (!date.trim()) {
return new Date();
}
const isoDate = parseISO(date);
if (isValid(isoDate)) {
return isoDate;
}
const jsDate = /^\d+(\.\d+)?$/.test(date) ? new Date(Number(date)) : new Date(date);
if (isValid(jsDate)) {
return jsDate;
}
throw new Error(`Invalid date: ${date}`);
}
export function calculateDatetime(args: { date?: string; expression?: string }): string {
const { date, expression } = args;
let jsDate = parseDateString(date ?? '');
if (expression) {
const ops = String(expression)
.split(' ')
.map((s) => s.trim())
.filter(Boolean);
for (const op of ops) {
const parsed = parseOp(op);
if (parsed) {
jsDate = applyDateOp(jsDate, parsed.sign, parsed.amount, parsed.unit);
}
}
}
return jsDate.toISOString();
}
export function formatDatetime(args: { date?: string; format?: string }): string {
const { date, format = 'yyyy-MM-dd HH:mm:ss' } = args;
const d = parseDateString(date ?? '');
return formatDate(d, String(format));
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { calculateDatetime, formatDatetime } from '../src';
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 timestamp', () => {
const result = formatDatetime({ date: '1752435296000' });
expect(result).toBe('2025-07-13 12:34:56');
});
it('returns formatted specific timestamp with decimals', () => {
const result = formatDatetime({ date: '1752435296000.19' });
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('handles invalid date gracefully', () => {
expect(() => formatDatetime({ date: 'invalid-date' })).toThrow('Invalid date: invalid-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('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('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 bad expression', () => {
expect(() =>
calculateDatetime({ date: '2025-07-13T12:00:00Z', expression: 'bad expr' }),
).toThrow('Invalid date expression: bad');
});
});

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