snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
# 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

@@ -0,0 +1,17 @@
{
"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"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}

View File

@@ -0,0 +1,158 @@
import path from "node:path";
import type { GrpcRequest, PluginDefinition } from "@yaakapp/api";
const NEWLINE = "\\\n ";
export const plugin: PluginDefinition = {
grpcRequestActions: [
{
label: "Copy as gRPCurl",
icon: "copy",
async onSelect(ctx, args) {
const rendered_request = await ctx.grpcRequest.render({
grpcRequest: args.grpcRequest,
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",
});
},
},
],
};
export async function convert(request: Partial<GrpcRequest>, allProtoFiles: string[]) {
const xs = ["grpcurl"];
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 inferredIncludes = new Set<string>();
for (const f of protoFiles) {
const protoDir = findParentProtoDir(f);
if (protoDir) {
inferredIncludes.add(protoDir);
} else {
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(NEWLINE);
}
for (const f of inferredIncludes.values()) {
xs.push("-import-path", quote(f));
xs.push(NEWLINE);
}
for (const f of protoFiles) {
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(NEWLINE);
}
// Add basic authentication
if (request.authentication?.disabled !== true) {
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(NEWLINE);
} else if (request.authenticationType === "bearer") {
// Add bearer authentication
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("?") ? "&" : "?";
request.url = [
request.url,
sep,
encodeURIComponent(request.authentication?.key ?? "token"),
"=",
encodeURIComponent(request.authentication?.value ?? ""),
].join("");
} else {
xs.push(
"-H",
quote(
`${request.authentication?.key ?? "X-Api-Key"}: ${request.authentication?.value ?? ""}`,
),
);
}
xs.push(NEWLINE);
}
}
// Add form params
if (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
xs.push(server);
xs.push(NEWLINE);
}
// Add service + method
if (request.service && request.method) {
xs.push(`${request.service}/${request.method}`);
xs.push(NEWLINE);
}
// Remove trailing newline
if (xs[xs.length - 1] === NEWLINE) {
xs.splice(xs.length - 1, 1);
}
return xs.join(" ");
}
function quote(arg: string): string {
// A single-quoted POSIX string takes no escapes, so `\'` does not close it:
// the string ends one character early and the rest of the command is left
// dangling. Step out of the quotes, emit an escaped quote, step back in.
const escaped = arg.replace(/'/g, `'\\''`);
return `'${escaped}'`;
}
function onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {
return v.enabled !== false && !!v.name;
}
function findParentProtoDir(startPath: string): string | null {
let dir = path.resolve(startPath);
while (true) {
if (path.basename(dir) === "proto") {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
return null; // Reached root
}
dir = parent;
}
}

View File

@@ -0,0 +1,195 @@
import { describe, expect, test } from "vite-plus/test";
import { convert } from "../src";
describe("exporter-curl", () => {
test("Simple example", async () => {
expect(
await convert(
{
url: "https://yaak.app",
},
[],
),
).toEqual(["grpcurl yaak.app"].join(" \\\n "));
});
test("Basic metadata", async () => {
expect(
await convert(
{
url: "https://yaak.app",
metadata: [
{ 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 "));
});
test("Basic auth", async () => {
expect(
await convert(
{
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
username: "user",
password: "pass",
},
},
[],
),
).toEqual([`grpcurl -H 'Authorization: Basic dXNlcjpwYXNz'`, "yaak.app"].join(" \\\n "));
});
test("API key auth", async () => {
expect(
await convert(
{
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
key: "X-Token",
value: "tok",
},
},
[],
),
).toEqual([`grpcurl -H 'X-Token: tok'`, "yaak.app"].join(" \\\n "));
});
test("API key auth", async () => {
expect(
await convert(
{
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: "query",
key: "token",
value: "tok 1",
},
},
[],
),
).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(
[
`grpcurl -import-path '/foo/bar'`,
`-import-path '/foo'`,
`-proto '/foo/bar/baz.proto'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Multiple proto files, same dir", async () => {
expect(
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 "),
);
});
test("Multiple proto files, different dir", async () => {
expect(
await convert({ url: "https://yaak.app" }, ["/aaa/bbb/ccc.proto", "/xxx/yyy/zzz.proto"]),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
`-import-path '/aaa'`,
`-import-path '/xxx/yyy'`,
`-import-path '/xxx'`,
`-proto '/aaa/bbb/ccc.proto'`,
`-proto '/xxx/yyy/zzz.proto'`,
"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("Mixed proto and dirs", async () => {
expect(
await convert({ url: "https://yaak.app" }, ["/aaa/bbb", "/xxx/yyy", "/foo/bar.proto"]),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
`-import-path '/xxx/yyy'`,
`-import-path '/foo'`,
`-import-path '/'`,
`-proto '/foo/bar.proto'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Sends data", async () => {
expect(
await convert(
{
url: "https://yaak.app",
message: JSON.stringify({ foo: "bar", baz: 1.0 }, null, 2),
},
["/foo.proto"],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{\n "foo": "bar",\n "baz": 1\n}'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Sends data with unresolved template tags", async () => {
expect(
await convert(
{
url: "https://yaak.app",
message: '{"timestamp": ${[ faker "timestamp" ]}, "foo": "bar"}',
},
["/foo.proto"],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{"timestamp": \${[ faker "timestamp" ]}, "foo": "bar"}'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Quotes an apostrophe so the command still parses", async () => {
// POSIX single quotes take no escapes: `\'` ends the string one
// character early and leaves the rest of the command dangling.
const command = await convert(
{
url: "https://yaak.app",
service: "Service",
method: "Method",
message: `{"note":"don't stop"}`,
metadata: [{ name: "x-note", value: "it's fine" }],
},
[],
);
expect(command).toContain(`'{"note":"don'\\''t stop"}'`);
expect(command).toContain(`'x-note: it'\\''s fine'`);
});
});

View File

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