Connecting to gRPC APIs
Most custom connectors call HTTP REST APIs, but you can also build connectors for gRPC APIs. This guide shows how to load a gRPC service definition in a custom connector, package it with your build, and call a remote procedure from an action.
The complete example is available in the examples repo.
It calls the SayHello method of Postman's public gRPC echo service.
Install the gRPC packages
Start with a new custom connector project, then install the gRPC client library and the .proto file loader:
npm install @grpc/grpc-js @grpc/proto-loader
Add the service definition
gRPC APIs describe their methods and messages in .proto files.
Get the .proto files for the API you're connecting to and place them in src/protoFiles/:
syntax = "proto3";
service HelloService {
rpc SayHello (HelloRequest) returns (HelloResponse) {}
}
// The request message containing the user's name.
message HelloRequest {
string greeting = 1;
}
// The response message containing the greetings
message HelloResponse {
string reply = 1;
}
Package .proto files with your connector
Your connector loads .proto files from disk when it runs, so they must be in the published bundle alongside your compiled code.
Add a copy entry to tsdown.config.mts that copies src/protoFiles/ into dist/protoFiles/:
import { defineConfig } from "tsdown";
export default defineConfig({
entry: { index: "src/index.ts" },
format: "cjs",
outDir: "dist",
platform: "node",
clean: true,
dts: false,
deps: {
alwaysBundle: () => true,
onlyBundle: false,
},
outExtensions: () => ({ js: ".js" }),
outputOptions: { exports: "named" },
loader: {
".md": "text",
},
copy: [
{ from: "assets/*", to: "dist" },
{ from: "src/protoFiles/*", to: "dist/protoFiles" },
],
});
Without this step, the connector builds successfully but fails at runtime when it can't find the .proto file.
After you run npm run build, confirm that dist/protoFiles/ contains your .proto files before you publish.
Create a gRPC client
Load the service definition and create a client for the service.
Resolve the .proto path relative to __dirname so that it points to dist/protoFiles/ in the built connector.
gRPC sends credentials as request metadata rather than HTTP headers.
This example builds a Basic authentication value from the connection's username and password and adds it to a Metadata object that each call sends:
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import type { Connection } from "@prismatic-io/spectral";
import path from "path";
export const createClient = (connection: Connection, service: string) => {
const PROTO_PATH = path.resolve(__dirname, "protoFiles/helloworld.proto");
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const helloProto = grpc.loadPackageDefinition(packageDefinition) as any;
const client = new helloProto[service](
"grpc.postman-echo.com",
grpc.credentials.createInsecure(),
);
const auth =
"Basic " +
Buffer.from(
`${connection.fields.username}:${connection.fields.password}`,
).toString("base64");
const requestMetadata = new grpc.Metadata();
requestMetadata.set("Authentication", auth);
return { client, requestMetadata };
};
Adjust these details for your API:
- Server address: Replace
grpc.postman-echo.comwith your API's host and port. To let each customer connect to their own server, add a host input to your connection and read it fromconnection.fields. - Channel credentials: The example uses
grpc.credentials.createInsecure(), which sends traffic unencrypted. Usegrpc.credentials.createSsl()for servers that accept TLS connections. - Metadata: Use the metadata key and value format your API expects, such as
authorizationwith a bearer token.
Call a method from an action
Methods on a @grpc/grpc-js client accept a callback.
Wrap the call in a Promise so your action can await the response, and close the client when you're done:
import { action, input } from "@prismatic-io/spectral";
import { createClient } from "./client";
export const sayHello = action({
display: {
label: "Say Hello",
description: "Say hello to the person of the given name",
},
inputs: {
connection: input({
label: "Connection",
type: "connection",
required: true,
}),
name: input({
label: "Name",
type: "string",
comments: "Your name",
required: true,
}),
},
perform: async (context, { connection, name }) => {
const { client, requestMetadata } = createClient(
connection,
"HelloService",
);
const response = await new Promise((resolve, reject) => {
client.sayHello(
{ greeting: name },
requestMetadata,
(err: any, response: any) => {
if (err) {
reject(err);
}
resolve(response);
},
);
});
client.close();
return { data: response };
},
});
Because the example loads the .proto file dynamically, the client has no generated TypeScript types.
If you want type checking for requests and responses, generate types from your .proto files with the proto-loader-gen-types command that @grpc/proto-loader provides.
Test the action
The example includes a unit test that invokes Say Hello against the echo service:
import { createConnection, invoke } from "@prismatic-io/spectral/dist/testing";
import { sayHello } from "./actions";
import { myConnection } from "./connections";
describe("test my action", () => {
test("verify the return value of my action", async () => {
const { result } = await invoke(sayHello, {
connection: createConnection(myConnection, {
username: "myuser",
password: "mypass",
}),
name: "John Doe",
});
expect(result.data).toEqual({ reply: "hello John Doe" });
});
});
Run it with npm run test.
The test makes a network call to grpc.postman-echo.com, so it requires internet access.
Unit tests run against your TypeScript source, so they don't confirm that .proto files are copied into dist/.
Check the build output, or run the action in a test integration after you publish.