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, 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(); 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; } }