Deploy Your First Router Plugin - WunderGraph

Documentation Index

Fetch the complete documentation index at: /llms.txt

Use this file to discover all available pages before exploring further.

This tutorial is part of Cosmo Connect and focuses on deploying a Router Plugin.

Prerequisites

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.

Overview

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.

  2. Design your schema Define your GraphQL schema that will be used to integrate your plugin into your federated graph.

  3. Generate & Implement Generate Protobuf code with wgc router plugin generate and implement your resolvers in Go.

  4. Publish & Deploy Publish your plugin to Cosmo with wgc router plugin publish and test via GraphQL queries.

Setting up the CLI

Before we start, we should make sure that your version of our CLI wgc is up to date and you’re logged in. Check your version with:

wgc --version

This needs to be >=0.90.1 for the tutorial to work correctly.

Log in with:

wgc auth login

After logging in, verify your session and ensure you’re in the correct organization by running:

wgc auth whoami

Building Plugins

First off, let’s get familiar with some terminology we use to describe different parts of the platform:

Now that we have that sorted, let’s move onto the good stuff.

Initialize Plugin

You can create a gRPC plugin using our CLI tool, wgc (Replace preferredLang with either go for Go, and ts for TypeScript):

wgc router plugin init starwars --language <preferredLang>

You should now have a directory containing a single plugin, starwars. We’ll go over what each file does soon.

Go Directory Structure:

starwars
├── Dockerfile
├── generated
│   ├── mapping.json
│   ├── service.proto
│   └── service.proto.lock.json
├── go.mod
├── Makefile
├── README.md
└── src
    ├── main.go
    ├── main_test.go
    └── schema.graphql

TypeScript Directory Structure:

starwars
├── Dockerfile
├── generated
│   ├── mapping.json
│   ├── service.proto
│   └── service.proto.lock.json
├── package.json
├── tsconfig.json
├── Makefile
├── README.md
├── patches
└── src
  ├── plugin.ts
  ├── plugin.test.ts
  ├── plugin-server.ts
  └── schema.graphql

Design your schema

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:

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.

Generate and Implement

Now we can use wgc again to generate the Protobuf representation of our subgraph and boilerplate code to implement it in Golang.

wgc router plugin generate ./starwars

You’ll now see a few new files in the generated folder. Pick the appropriate tab based on the language you wish to follow the tutorial in.

Publish and Deploy

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:

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.

Updating the plugin

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.

Appendix

Full plugin code

src/schema.graphql

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

package main

import (
  "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)) 
  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
}

src/plugin.ts

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() {
    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>
  ) {
      this.#starWarsClient.get<SWAPIPerson[]>("/people")
          .then((resp) => {
            const people = resp.data;

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

const response = new QueryPeopleResponse();
            response.setPeopleList(protoPeople);
            callback(null, response);
          })
          .catch((error) => {
            const grpcError = {
              code: grpc.status.INTERNAL,
              message: `failed to fetch list of people: ${error.message}`
            };
            callback(grpcError, null);
          });
  }
}

function run() {
  const pluginServer = new PluginServer();
  pluginServer.addService(StarwarsServiceService, new StarwarsService());
  pluginServer.serve().catch((error) => {
    console.error('Failed to start plugin server:', error);
    process.exit(1);
  });
}

run();