Cosmo CLI (minimum version: 0.98.0) installed and configured
Cosmo Router (minimum version: 0.242.0) installed and configured to use Cosmo Cloud
If you’re new to Cosmo, you should start with the Cosmo Cloud Onboarding guide. This tutorial assumes you’ve created a federated graph and deployed and configured router(s) for it.
gRPC plugins are a powerful new way to write and deploy subgraphs without the need for a GraphQL server. You can use plugins to wrap legacy APIs, mock future subgraphs or fully implement new features. Plugins are written in Go or TypeScript and can be deployed and run automatically by the Cosmo Router.For this tutorial, we’ll create a gRPC plugin called starwars that wraps a small portion of the REST API from SWAPI. SWAPI is a free and open-source public API that provides information about Star Wars characters, planets, and more. For the tutorial, it functions as a stand-in for your own API or external datasource (an SQL database, Stripe, etc.).gRPC plugins support all the same features as gRPC services, but you don’t have to create separate deployments, handle networking, or manage inter-service authentication.Here’s a short version of the steps for reference:
1
Initialize a new plugin
Create a new gRPC plugin using wgc router plugin init <name> and define your GraphQL schema.→ Jump to initialization
2
Design your schema
Define your GraphQL schema that will be used to integrate your plugin into your federated graph.→ Jump to schema design
3
Generate & Implement
Generate Protobuf code with wgc router plugin generate and implement your resolvers in Go.→ Jump to implementation
4
Publish & Deploy
Publish your plugin to Cosmo with wgc router plugin publish and test via GraphQL queries.→ Jump to deployment
The first thing we need to do is take a look at the GraphQL schema in starwars/src/schema.graphql. It doesn’t contain our schema yet, so we’ll start by defining our new service in GraphQL terms.When you open this file, it will have some placeholder schema inside. You can safely remove all of the content and replace it with the following:
src/schema.graphql
Copy
Ask AI
type Person { """ The name of this person """ name: String! """ The height of the person in centimeters """ height: String! """ The mass of the person in kilograms """ mass: String! """ The hair color of this person. Will be "unknown" if not known or "n/a" if the person does not have hair """ hair_color: String! """ The skin color of this person """ skin_color: String! """ The eye color of this person. Will be "unknown" if not known or "n/a" if the person does not have an eye """ eye_color: String! """ The birth year of the person, using BBY or ABY (Before/After Battle of Yavin) """ birth_year: String! """ The gender of this person. Either "Male", "Female" or "unknown", "n/a" if no gender """ gender: String!}type Query { """ get all the people """ people: [Person!]!}
For this example, we won’t utilize the entire SWAPI for this tutorial, only the people resource and its endpoints.This schema has a single type, “Person”, and a query to get all the people or a specific person by ID.
In short, these files are generated helpers based on the schema we wrote. They help either translate GraphQL operations to gRPC or let you write type-safe resolvers in the plugin itself.
We recommend checking this folder into version control (e.g. Git).
Now, let’s start implementing our resolvers. After generating, if you open main.go, you will see some errors about undefined types. These are remnants from the example schema’s resolver, and you can safely delete them to start from scratch.Here’s a starting point for implementing our new resolvers:
main.go
Copy
Ask AI
package mainimport ( "context" "log" "net/http" "time" service "github.com/wundergraph/cosmo/plugin/generated" "github.com/wundergraph/cosmo/router-plugin/httpclient" routerplugin "github.com/wundergraph/cosmo/router-plugin" "google.golang.org/grpc")func main() { // 1. Initialize the HTTP client client := httpclient.New( httpclient.WithBaseURL("https://swapi.info/api/"), httpclient.WithTimeout(30*time.Second), httpclient.WithHeader("Accept", "application/json"), ) // 2. Add the new HTTP client to the service pl, err := routerplugin.NewRouterPlugin(func(s *grpc.Server) { s.RegisterService(&service.StarwarsService_ServiceDesc, &StarwarsService{ client: client, }) }) if err != nil { log.Fatalf("failed to create router plugin: %v", err) } pl.Serve()}// 3. Add a new *httpclient.Client field to be used by the service.type StarwarsService struct { service.UnimplementedStarwarsServiceServer client *httpclient.Client}func (s *StarwarsService) QueryPeople(ctx context.Context, req *service.QueryPeopleRequest) (*service.QueryPeopleResponse, error) { panic("not implemented")}
This updates the main.go that comes with the initial plugin template in a few ways:
Initialize an instance of the httpclient provided by our router-plugin package. This client comes with special features for forwarding telemetry through the router.
We pass the new client to the StarwarsService constructor.
In the StarwarsService struct, we added a field of type *httpclient.Client
This will hold a persistent HTTP client that our endpoints can use for the whole lifetime of the plugin process.
We removed resolvers for the old QueryHello RPC and a field nextId in the StarwarsService struct.
If you published this now, the plugin would just panic (exit ungracefully) when we tried to use the people query because of the panic("not implemented") in the RPC resolver. The plugin would be automatically restarted, but the request would fail. Let’s fix that.
In the main() function, you can use os.Getenv or many available configuration libraries for Go to pull info from the environment to configure your service. A good one to start with is caarlos0/env.
Next, let’s add a struct (Go’s most primitive object type) to help us work with SWAPI responses.
These types are very similar to those generated for our service, close enough that we could potentially use the generated types directly. But for this example, we’ll create a separate type to show what happens if you need more complex mapping from the wrapped API to your service.Now, let’s implement the resolver for our QueryPeople RPC. Right now, your resolver should look like this:
Here’s an implemented version of the QueryPeople RPC resolver:
main.go
Copy
Ask AI
...func (s *StarwarsService) QueryPeople(ctx context.Context, req *service.QueryPeopleRequest) (*service.QueryPeopleResponse, error) { // 1. Send the request and handle the response resp, err := s.client.Get(ctx, "/people") if err != nil { return nil, status.Errorf(codes.Internal, "failed to fetch people: %v", err) } // 2. If the response status is not OK, return an error if resp.StatusCode != http.StatusOK { return nil, status.Errorf(codes.Internal, "failed to fetch people: SWAPI returned status %d", resp.StatusCode) } // 3. Read out the response body into a list of SWAPIPerson people, err := httpclient.UnmarshalTo[[]SWAPIPerson](resp) if err != nil { return nil, status.Errorf(codes.Internal, "failed to decode response: %v", err) } // 4. Convert the []SWAPIPerson to []*service.Person (the type needed for our RPC's return type) protoPeople := make([]*service.Person, len(people)) // You can pre-allocate the new slice to the exact length of the people slice, this won't work if you're filtering while iterating. for i, person := range people { protoPeople[i] = &service.Person{ Name: person.Name, Height: person.Height, Mass: person.Mass, HairColor: person.HairColor, SkinColor: person.SkinColor, EyeColor: person.EyeColor, BirthYear: person.BirthYear, Gender: person.Gender, } } // 5. Return a response containing the converted people objects return &service.QueryPeopleResponse{ People: protoPeople, }, nil}...
Lets go over it step by step:
Start by sending up a request to fetch the list of people from the SWAPI with HTTP GET to /people.
Check the response status code and return an error if it’s not 200 OK.
Decode the JSON response into a slice of SWAPIPerson structs.
Transform the slice of SWAPIPerson structs into a slice of *service.Person structs (this type comes from the generated code for your schema)
In a more complex program, this is where you would do any transformations needed to produce the expected response from your backend, database, or other source.
This method lets you write the GraphQL schema for your client‑facing federated graph in a way that fits that ecosystem, while handling translation from other formats in a fully featured language like Go.
Finally, we return a response containing the converted people objects
The errors here come from google.golang.org/grpc/status and google.golang.org/grpc/codes. They’re an idiomatic way to return errors in a gRPC API.
Now, our plugin is in a semi-working state, and we can publish it to test it out as part of our federated graph.Using our CLI wgc, publish the plugin:
Copy
Ask AI
wgc router plugin publish ./starwars
How does deployment work?
When you run the deploy command, your plugin is built and packaged using the Dockerfile in the plugin directory. We then send this image containing your plugin and any other files it may need off to our Cosmo Cloud Registry where it can be pulled by your routers.Importantly, the router does not run plugins using Docker or in a container, instead we unpack the final target of the image into a working directory and run the plugin directly. This means if your plugin depends on a system dependency, it must be present in your Router image, not the plugin image.
Congratulations! You’ve created your first gRPC plugin.You’ll see the output from a Docker build and push, followed by a successful completion.If you have a router deployed serving your federated graph, you can now query people via GraphQL.
If you get an error, check your router logs to see where it might be coming from. If you can’t solve it, a complete version of the plugin is linked in the Appendix.
You can update the schema or the implementation of the plugin by modifying the schema.graphql file or the main.go file, respectively.If you update the schema, you need to regenerate the code by running wgc router plugin generate.
You can find the full technical documentation for gRPC plugins here.Well, in our case, the SWAPI has much more information than just people. It also includes data about vehicles, planets, and species. We could add these entities to our schema and resolve them using our plugin.In your case, you might add entity resolvers to your plugin’s GraphQL schema to see how federation works with gRPC, or create more plugins for other purposes. Plugins aren’t just for wrapping HTTP APIs either; you can use the full power of Go to query databases, implement business logic, or do anything else you need.You can also implement tests for your plugin to ensure it works correctly, you can find an example test in the src/main_test.go, you can try updating it to work with the new schema.
type Person { """ The name of this person """ name: String! """ The height of the person in centimeters """ height: String! """ The mass of the person in kilograms """ mass: String! """ The hair color of this person. Will be "unknown" if not known or "n/a" if the person does not have hair """ hair_color: String! """ The skin color of this person """ skin_color: String! """ The eye color of this person. Will be "unknown" if not known or "n/a" if the person does not have an eye """ eye_color: String! """ The birth year of the person, using BBY or ABY (Before/After Battle of Yavin) """ birth_year: String! """ The gender of this person. Either "Male", "Female" or "unknown", "n/a" if no gender """ gender: String!}type Query { """ get all the people """ people: [Person!]!}
src/main.go
Copy
Ask AI
package mainimport ( "context" "log" "net/http" "time" service "github.com/wundergraph/cosmo/plugin/generated" "github.com/wundergraph/cosmo/router-plugin/httpclient" routerplugin "github.com/wundergraph/cosmo/router-plugin" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status")type SWAPIPerson struct { Name string `json:"name"` Height string `json:"height"` Mass string `json:"mass"` HairColor string `json:"hair_color"` SkinColor string `json:"skin_color"` EyeColor string `json:"eye_color"` BirthYear string `json:"birth_year"` Gender string `json:"gender"`}func main() { // 1. Initialize the HTTP client client := httpclient.New( httpclient.WithBaseURL("https://swapi.info/api/"), httpclient.WithTimeout(30*time.Second), httpclient.WithHeader("Accept", "application/json"), ) // 2. Add the new HTTP client to the service pl, err := routerplugin.NewRouterPlugin(func(s *grpc.Server) { s.RegisterService(&service.StarwarsService_ServiceDesc, &StarwarsService{ client: client, }) }) if err != nil { log.Fatalf("failed to create router plugin: %v", err) } pl.Serve()}type StarwarsService struct { service.UnimplementedStarwarsServiceServer client *httpclient.Client}func (s *StarwarsService) QueryPeople(ctx context.Context, req *service.QueryPeopleRequest) (*service.QueryPeopleResponse, error) { // 1. Send the request and handle the response resp, err := s.client.Get(ctx, "/people") if err != nil { return nil, status.Errorf(codes.Internal, "failed to fetch people: %v", err) } // 2. If the response status is not OK, return an error if resp.StatusCode != http.StatusOK { return nil, status.Errorf(codes.Internal, "failed to fetch people: SWAPI returned status %d", resp.StatusCode) } // 3. Read out the response body into a list of SWAPIPerson people, err := httpclient.UnmarshalTo[[]SWAPIPerson](resp) if err != nil { return nil, status.Errorf(codes.Internal, "failed to decode response: %v", err) } // 4. Convert the []SWAPIPerson to []*service.Person (the type needed for our RPC's return type) protoPeople := make([]*service.Person, len(people)) // You can pre-allocate the new slice to the exact length of the people slice, this won't work if you're filtering while iterating. for i, person := range people { protoPeople[i] = &service.Person{ Name: person.Name, Height: person.Height, Mass: person.Mass, HairColor: person.HairColor, SkinColor: person.SkinColor, EyeColor: person.EyeColor, BirthYear: person.BirthYear, Gender: person.Gender, } } // 6. Return a response containing the converted people objects return &service.QueryPeopleResponse{ People: protoPeople, }, nil}
In short, these files are generated helpers based on the schema we wrote. They help either translate GraphQL operations to gRPC or let you write type-safe resolvers in the plugin itself.
We recommend checking this folder into version control (e.g. Git).
Now, let’s start implementing our resolvers. After generating, if you open plugin.ts, you will see some errors about undefined types. These are remnants from the example schema’s resolver, and you can safely delete them to start from scratch.Here’s a starting point for implementing our new resolvers:
plugin.ts
Copy
Ask AI
import * as grpc from '@grpc/grpc-js';import axios, { type AxiosInstance } from "axios";import { StarwarsServiceService, IStarwarsServiceServer} from '../generated/service_grpc_pb.js';import { QueryPeopleRequest, QueryPeopleResponse,} from '../generated/service_pb.js';import { PluginServer } from './plugin-server.js';export class StarwarsService implements IStarwarsServiceServer { [name: string]: grpc.UntypedHandleCall; #starWarsClient: AxiosInstance; constructor() { // You can inject dependencies here later if needed this.#starWarsClient = axios.create({ baseURL: "https://swapi.info/api/", timeout: 30_000, headers: { Accept: "application/json" }, }); } queryPeople( call: grpc.ServerUnaryCall<QueryPeopleRequest, QueryPeopleResponse>, callback: grpc.sendUnaryData<QueryPeopleResponse> ) { // TODO: Implement logic process.exit(1); }}function run() { // Create the plugin server (health check automatically initialized) const pluginServer = new PluginServer(); // Add the StarwarsService service pluginServer.addService(StarwarsServiceService, new StarwarsService()); // Start the server pluginServer.serve().catch((error) => { console.error('Failed to start plugin server:', error); process.exit(1); });}run();
This updates the plugin.ts that comes with the initial plugin template in a few ways:
We removed the old QueryHello methods and replaced it with the StarwarsService class.
We create an instance of the StarwarsService class.
In the StarwarsService class constructor, we create an axios #starWarsClient
This will hold a persistent HTTP client that our endpoints can use for the whole lifetime of the plugin process.
If you published this now, the plugin would just exit ungracefully when we tried to use the people query because of the process.exit(1); in the RPC resolver. The plugin would be automatically restarted, but the request would fail. Let’s fix that.Next, let’s define a TypeScript type to help us work with SWAPI responses.
These types are very similar to those generated for our service, close enough that we could potentially use the generated types directly. But for this example, we’ll create a separate type to show what happens if you need more complex mapping from the wrapped API to your service.Now, let’s implement the resolver for our QueryPeople RPC. Right now, your resolver should look like this:
Here’s an implemented version of the QueryPeople RPC resolver:
plugin.ts
Copy
Ask AI
...export class StarwarsService implements IStarwarsServiceServer { #starWarsClient: AxiosInstance; queryPeople( call: grpc.ServerUnaryCall<QueryPeopleRequest, QueryPeopleResponse>, callback: grpc.sendUnaryData<QueryPeopleResponse> ) { // 1. Send the request and handle the response this.#starWarsClient.get<SWAPIPerson[]>("/people") // 2. Response status is automatically validated by axios (throws on non-2xx) .then((resp) => { const people = resp.data; // 3. Convert the SWAPIPerson[] to Person[] (the type needed for our RPC's return type) const protoPeople = people.map((person) => { const protoPerson = new Person(); protoPerson.setName(person.name); protoPerson.setHeight(person.height); protoPerson.setMass(person.mass); protoPerson.setHairColor(person.hair_color); protoPerson.setSkinColor(person.skin_color); protoPerson.setEyeColor(person.eye_color); protoPerson.setBirthYear(person.birth_year); protoPerson.setGender(person.gender); return protoPerson; }); // 4. Return a response containing the converted people objects const response = new QueryPeopleResponse(); response.setPeopleList(protoPeople); callback(null, response); }) .catch((error) => { // 5. Return errors as gRPC errors const grpcError = { code: grpc.status.INTERNAL, message: `failed to fetch people: ${error.message}` }; callback(grpcError, null); }); }}...
Let’s go over it step by step:
Start by sending up a request to fetch the list of people from the SWAPI with HTTP GET to /people.
If the response status code is not 200, we return an error.
Transform the array of SWAPIPerson into an array of Person (this type comes from the generated code for your schema)
In a more complex program, this is where you would do any transformations needed to produce the expected response from your backend, database, or other source.
This method lets you write the GraphQL schema for your client‑facing federated graph in a way that fits that ecosystem, while handling translation from other formats in a fully featured language like Go.
Finally, we return a response containing the converted people objects
In case of errors we return a gRPC error
The errors here come from google.golang.org/grpc/status and google.golang.org/grpc/codes. They’re an idiomatic way to return errors in a gRPC API.
Now, our plugin is in a semi-working state, and we can publish it to test it out as part of our federated graph.Using our CLI wgc, publish the plugin:
Copy
Ask AI
wgc router plugin publish ./starwars
How does deployment work?
When you run the deploy command, your plugin is built and packaged using the Dockerfile in the plugin directory. We then send this image containing your plugin and any other files it may need off to our Cosmo Cloud Registry where it can be pulled by your routers.Importantly, the router does not run plugins using Docker or in a container, instead we unpack the final target of the image into a working directory and run the plugin directly. This means if your plugin depends on a system dependency, it must be present in your Router image, not the plugin image.
Congratulations! You’ve created your first gRPC plugin.You’ll see the output from a Docker build and push, followed by a successful completion.If you have a router deployed serving your federated graph, you can now query people via GraphQL.
If you get an error, check your router logs to see where it might be coming from. If you can’t solve it, a complete version of the plugin is linked in the Appendix.
You can update the schema or the implementation of the plugin by modifying the schema.graphql file or the plugin.ts file, respectively.If you update the schema, you need to regenerate the code by running wgc router plugin generate.
You can find the full technical documentation for gRPC plugins here.Well, in our case, the SWAPI has much more information than just people. It also includes data about vehicles, planets, and species. We could add these entities to our schema and resolve them using our plugin.In your case, you might add entity resolvers to your plugin’s GraphQL schema to see how federation works with gRPC, or create more plugins for other purposes. Plugins aren’t just for wrapping HTTP APIs either; you can use the full power of Go to query databases, implement business logic, or do anything else you need.You can also implement tests for your plugin to ensure it works correctly, you can find an example test in the src/plugin.test.ts, you can try updating it to work with the new schema.
type Person { """ The name of this person """ name: String! """ The height of the person in centimeters """ height: String! """ The mass of the person in kilograms """ mass: String! """ The hair color of this person. Will be "unknown" if not known or "n/a" if the person does not have hair """ hair_color: String! """ The skin color of this person """ skin_color: String! """ The eye color of this person. Will be "unknown" if not known or "n/a" if the person does not have an eye """ eye_color: String! """ The birth year of the person, using BBY or ABY (Before/After Battle of Yavin) """ birth_year: String! """ The gender of this person. Either "Male", "Female" or "unknown", "n/a" if no gender """ gender: String! } type Query { """ get all the people """ people: [Person!]! }
src/plugin.ts
Copy
Ask AI
import * as grpc from '@grpc/grpc-js';import axios, { type AxiosInstance } from "axios";import { StarwarsServiceService, IStarwarsServiceServer} from '../generated/service_grpc_pb.js';import { QueryPeopleRequest, QueryPeopleResponse, Person} from '../generated/service_pb.js';import { PluginServer } from './plugin-server.js';interface SWAPIPerson { name: string; height: string; mass: string; hair_color: string; skin_color: string; eye_color: string; birth_year: string; gender: string;}export class StarwarsService implements IStarwarsServiceServer { [name: string]: grpc.UntypedHandleCall; #starWarsClient: AxiosInstance; constructor() { // You can inject dependencies here later if needed this.#starWarsClient = axios.create({ baseURL: "https://swapi.info/api/", timeout: 30_000, headers: { Accept: "application/json" }, }); } queryPeople( call: grpc.ServerUnaryCall<QueryPeopleRequest, QueryPeopleResponse>, callback: grpc.sendUnaryData<QueryPeopleResponse> ) { // 1. Send the request and handle the response this.#starWarsClient.get<SWAPIPerson[]>("/people") // 2. Response status is automatically validated by axios (throws on non-2xx) .then((resp) => { const people = resp.data; // 3. Convert the SWAPIPerson[] to Person[] (the type needed for our RPC's return type) const protoPeople = people.map((person) => { const protoPerson = new Person(); protoPerson.setName(person.name); protoPerson.setHeight(person.height); protoPerson.setMass(person.mass); protoPerson.setHairColor(person.hair_color); protoPerson.setSkinColor(person.skin_color); protoPerson.setEyeColor(person.eye_color); protoPerson.setBirthYear(person.birth_year); protoPerson.setGender(person.gender); return protoPerson; }); // 4. Return a response containing the converted people objects const response = new QueryPeopleResponse(); response.setPeopleList(protoPeople); callback(null, response); }) .catch((error) => { // 5. Return errors as gRPC errors const grpcError = { code: grpc.status.INTERNAL, message: `failed to fetch list of people: ${error.message}` }; callback(grpcError, null); }); }}function run() { // Create the plugin server (health check automatically initialized) const pluginServer = new PluginServer(); // Add the StarwarsService service pluginServer.addService(StarwarsServiceService, new StarwarsService()); // Start the server pluginServer.serve().catch((error) => { console.error('Failed to start plugin server:', error); process.exit(1); });}run();