snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
75
plugins/action-copy-grpcurl/README.md
Normal file
75
plugins/action-copy-grpcurl/README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
17
plugins/action-copy-grpcurl/package.json
Normal file
17
plugins/action-copy-grpcurl/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
158
plugins/action-copy-grpcurl/src/index.ts
Normal file
158
plugins/action-copy-grpcurl/src/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
195
plugins/action-copy-grpcurl/tests/index.test.ts
Normal file
195
plugins/action-copy-grpcurl/tests/index.test.ts
Normal 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'`);
|
||||
});
|
||||
});
|
||||
3
plugins/action-copy-grpcurl/tsconfig.json
Normal file
3
plugins/action-copy-grpcurl/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user