How to generate an OpenAPI/Swagger spec with Gnostic
In this tutorial, we’ll start with an OpenAPI document and end with a fully functional gRPC server. We’ll also create a RESTful gateway to the gRPC server, and create SDKs in multiple languages.
You might rightfully ask why we would want to do all of this, and there’s no better way to illustrate a need than by starting with a real example that is entirely plausible and definitely not made up.
Picture this: You’re tasked with setting up an underground bar in the far reaches of the galaxy. Interstellar travelers from far and wide look forward to drinks they’ve never even dreamt of.
Because this is the far future and, obviously, everyone is still using gRPC, we better set up a gRPC server that can handle billions of requests from light years away. gRPC helps us keep throughput high and latency low—both essential elements of an interstellar API. However, even civilizations that have mastered the Dyson Sphere know better than to use gRPC in the browser and other clients. So we’ll need to create an HTTP server to complement our gRPC server. Our users will need SDKs to query our endpoints.
There’s no AI in the Laniakea Supercluster of galaxies who’d be willing to code all of this token by token, so let’s help them generate as much of this as possible.
We’re working with enough acronyms to make our heads spin faster than the neutron star and the space jokes aren’t helping.
Let’s break this down step by step and park the science fiction for the moment. Here’s what we’ll do:
- Create an OpenAPI document describing our API.
- Set up a development environment using Docker and dev containers.
- Install a handful of dependencies.
- Use Gnostic to generate a binary protocol buffer description of our API.
- Use the Gnostic gRPC plugin to generate an annotated protocol buffer description of our API.
- Transcode that description to create a gRPC API.
- Create our server logic as a Go package.
- Generate a gRPC gateway to handle HTTP requests and pass these to our server.
- Use Speakeasy to create SDKs in Python and TypeScript.
- And finally, test all of this by requesting some spectacular drinks.
Example gRPC and REST API Server Repository
The source code for our complete example is available in the Speakeasy gRPC and REST example repository (opens in a new tab).
This repository already contains all the generated code we’ll cover in this tutorial. You can clone it and follow along with the tutorial, or use it as a reference to build your own gRPC and REST API server.
Creating an OpenAPI Document to Describe an API
As a start, and for the sake of shipping our server, we’ll create an API with only two endpoints.
The first endpoint is createDrink
: Create a new drink based on the provided ingredients and return the drink’s name, description, recipe, and possibly a photo.
Our second endpoint is getDrink
: Create a new drink based only on the drink’s name. Return a list of ingredients with quantities, a recipe, and a photo.
Let’s take a detailed tour of our API by exploring bar.yaml
:
We’ll start by creating an OpenAPI 3.0.0 document. Gnostic, unfortunately, only supports OpenAPI 3.0.0, so we’ll have to make sure our document is compliant.
Next, we’ll create an info object that describes our API. This object contains the title, version, and description of our API.
Now let’s define the servers where our API will be hosted. In this case, we’ll have a single server running locally on http://localhost:8080
.
Next, we’ll define the /create-drink
endpoint. This endpoint will accept a POST request with a JSON body containing the ingredients of the drink.
Our /create-drink
endpoint will return a JSON object with the drink’s name, description, recipe, and possibly a photo.
Next, we’ll define the /get-drink
endpoint. This endpoint will accept a POST request with a JSON body containing the name of the drink.
Our /get-drink
endpoint will return a JSON object with the ingredients, recipe, and possibly a photo of the drink.
Our first component, IngredientsRequest
, is a JSON object that contains the ingredients of a drink as an array of strings.
Next up, DrinkResponse
, is a JSON object that contains the name, description, recipe, and a photo of a drink.
The third component, DrinkNameRequest
, is a JSON object that contains the name of a drink.
Our fourth component, DrinkRecipeResponse
, is a JSON object that contains the ingredients, recipe, and a photo of a drink.
Our fifth component, IngredientQuantity
, is a JSON object that contains the name and quantity of an ingredient.
Finally, error
is a JSON object that contains an error code and message.
Setting Up a Development Environment
Here, we’ll take an opinionated approach to setting up a development environment. We’ll use Docker and dev containers in VS Code to ensure that everyone has the same environment and avoid any issues with dependencies.
We’ll start by creating a Dockerfile
in the .devcontainer
directory. The Dockerfile
will install all the dependencies we need to generate our gRPC server.
Our development container is based on mcr.microsoft.com/devcontainers/go
from the Microsoft Dev Container Images repository. The image we’ll use has Go version 1.22 and is based on Debian Bookworm.
We’ll install Speakeasy using the install.sh
script from the Speakeasy repository.
Next, we’ll install Gnostic, the Gnostic gRPC plugin, the Go protocol buffer compiler, Buf, the gRPC Go plugin, gRPCurl, and the gRPC gateway plugin.
We’ll copy our project files into the container and set the working directory to /app
.
We’ll run go mod tidy
to ensure all dependencies are up to date.
Finally, we’ll expose port 50051 and run our server.
openapi: 3.0.0info:title: Hitchhiker's Guide to Drinksdescription: An API for creating and retrieving drinks.version: 1.0.0servers:- url: http://localhost:8080description: Local development serverpaths:/create-drink:post:operationId: createDrinksummary: Create a new drink based on provided ingredientsrequestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/IngredientsRequest"example:ingredients:- Gin- Tonic water- Lime juiceresponses:"200":description: Successful responsecontent:application/json:schema:$ref: "#/components/schemas/DrinkResponse"example:name: Pan Galactic Gin and Tonicdescription: A refreshing twist on the classic gin and tonic, inspired by the Hitchhiker's Guide to the Galaxy.recipe: Fill a glass with ice. Pour in 2 oz of gin, 4 oz of tonic water, and a squeeze of lime juice. Stir gently and garnish with a lime wedge. Enjoy responsibly while contemplating the vastness of the universe.photo: https://example.com/pan-galactic-gin-and-tonic.jpgdefault:description: unexpected errorcontent:application/json:schema:"$ref": "#/components/schemas/error"/get-drink:post:operationId: getDrinksummary: Get a drink recipe by providing its namerequestBody:required: truecontent:application/json:schema:$ref: "#/components/schemas/DrinkNameRequest"example:name: Pan Galactic Gargle Blasterresponses:"200":description: Successful responsecontent:application/json:schema:$ref: "#/components/schemas/DrinkRecipeResponse"example:ingredients:- name: Ol' Janx Spiritquantity: 1 bottle- name: Sea water from Santraginus Vquantity: 1 measure- name: Acturan Mega-ginquantity: 3 cubes- name: Fallian marsh gasquantity: 4 liters- name: Qualactin Hypermint extractquantity: 1 teaspoonrecipe: Pour the Ol' Janx Spirit into a glass, add the sea water and Acturan Mega-gin. Bubble the Fallian marsh gas through the mixture, then add the Qualactin Hypermint extract. Drink... but... very carefully.photo: https://example.com/pan-galactic-gargle-blaster.jpgdefault:description: unexpected errorcontent:application/json:schema:"$ref": "#/components/schemas/error"components:schemas:IngredientsRequest:type: objectdescription: A request containing a list of ingredients for creating a new drink.properties:ingredients:type: arraydescription: An array of ingredient names as strings.example:- Gin- Tonic water- Lime juiceitems:type: stringexample: Ginrequired:- ingredientsDrinkResponse:type: objectdescription: A response containing information about a newly created drink.properties:name:type: stringdescription: The name of the drink.example: Pan Galactic Gin and Tonicdescription:type: stringdescription: A brief description of the drink.example: A refreshing twist on the classic gin and tonic, inspired by the Hitchhiker's Guide to the Galaxy.recipe:type: stringdescription: The recipe for making the drink.example: Fill a glass with ice. Pour in 2 oz of gin, 4 oz of tonic water, and a squeeze of lime juice. Stir gently and garnish with a lime wedge. Enjoy responsibly while contemplating the vastness of the universe.photo:type: stringformat: uridescription: A URL pointing to an image of the drink.example: https://example.com/pan-galactic-gin-and-tonic.jpgDrinkNameRequest:type: objectdescription: A request containing the name of a drink to retrieve the recipe for.properties:name:type: stringdescription: The name of the drink.example: Pan Galactic Gargle Blasterrequired:- nameDrinkRecipeResponse:type: objectdescription: A response containing the recipe and information about a drink.properties:ingredients:type: arraydescription: An array of objects representing ingredients and their quantities.items:$ref: "#/components/schemas/IngredientQuantity"recipe:type: stringdescription: The recipe for making the drink.example: Pour the Ol' Janx Spirit into a glass, add the sea water and Acturan Mega-gin. Bubble the Fallian marsh gas through the mixture, then add the Qualactin Hypermint extract. Drink... but... very carefully.photo:type: stringformat: uridescription: A URL pointing to an image of the drink.example: https://example.com/pan-galactic-gargle-blaster.jpgIngredientQuantity:type: objectdescription: An object representing an ingredient and its quantity.properties:name:type: stringdescription: The name of the ingredient.example: Ol' Janx Spiritquantity:type: stringdescription: The quantity of the ingredient required.example: 1 bottleerror:required:- code- messageproperties:code:type: integerformat: int32message:type: stringtype: object
Dev Container Configuration
Next, we’ll create a devcontainer.json
file in our .devcontainer
directory to configure our development container:
{"name": "Go",// Override the default dockerfile to use our custom Dockerfile"dockerComposeFile": ["../docker-compose.yaml", "docker-compose.yaml"],"service": "app","workspaceFolder": "/app","shutdownAction": "stopCompose"}
For help with the devcontainer.json
file, check out the official documentation (opens in a new tab).
Docker Compose
We’ll also create a docker-compose.yaml
file in our .devcontainer
directory to define our development container:
version: "3"services:app:build:context: .dockerfile: .devcontainer/Dockerfilevolumes:- ..:/workspaces:cachedcommand: /bin/sh -c "while sleep 1000; do :; done"
This Docker Compose file defines a service called app
that uses the Dockerfile
in the .devcontainer
directory. It also mounts the current directory into the container at /app
and overrides the default command to start the server.
We use /bin/sh -c "while sleep 1000; do :; done"
as the default command to keep the container running while we work on our server. This means we can start the server manually when we’re ready.
Starting the Development Container
For this step, you’ll need to have Docker (opens in a new tab) and the Dev Containers extension (opens in a new tab) for VS Code installed.
Start the Docker engine and open the project in VS Code. Press F1 to open the command palette, then select Dev Containers: Reopen in Container
. This will build the development container and open a new VS Code window inside it.
This step might take a while the first time you run it, as it needs to download the base image and install all the dependencies.
Interacting With the Development Container
Once the development container is running, you can interact with it using the terminal in VS Code. You can run commands as you would in a regular terminal, such as go run main.go
to start the server later on.
When we refer to running commands in the development container, we mean running them in the dev container terminal in VS Code.
If you think something isn’t working as expected, try restarting the development container by running Dev Containers: Rebuild Container
from the command palette. You may also restart Docker to ensure everything is working as expected.
Dependencies for Generating a gRPC Server Using Gnostic
To generate a gRPC server using Gnostic, we added the following dependencies to our development container:
- Gnostic (opens in a new tab): A compiler for APIs described by the OpenAPI 3.0.0 specification.
- Gnostic gRPC plugin (opens in a new tab): A plugin for Gnostic that generates gRPC service definitions.
- Go protocol buffer compiler (opens in a new tab): A plugin for the protocol buffer compiler that generates Go code.
- Buf (opens in a new tab): A tool for managing protocol buffer files.
- gRPC Go plugin (opens in a new tab): A plugin for the protocol buffer compiler that generates Go gRPC code.
- gRPCurl (opens in a new tab): A command-line tool for interacting with gRPC servers. We’ll use this to test our gRPC server.
- gRPC gateway plugin (opens in a new tab): A plugin for the protocol buffer compiler that generates a reverse proxy server to translate RESTful HTTP and JSON requests to gRPC.
Generating a gRPC Server Using Gnostic
To generate a gRPC server using Gnostic, we need to follow these steps:
- Compile the API definition to a binary protocol buffer file using Gnostic.
- Create an API definition in a
.proto
file. - Generate a gRPC service definition using the Gnostic gRPC plugin.
- Generate Go code using the Go protocol buffer compiler and the gRPC Go plugin.
We’ve already completed step 1 by creating the bar.yaml
file. Now we’ll compile the API definition to a binary protocol buffer file using Gnostic.
Compiling the API Definition to a Binary Protocol Buffer File
This step is necessary because we’ve found that the Go protocol buffer compiler and the gRPC Go plugin require a binary protocol buffer file as input. To compile the API definition to a binary protocol buffer file, we’ll use the following command:
gnostic --pb-out=. bar.yaml
This command compiles the API definition in bar.yaml
to a binary protocol buffer file named bar.pb
.
Creating an API Definition in a .proto
File
Next, we’ll create an API definition in a .proto
file. We’ll use the bar.pb
file generated in the previous step as input. To accomplish this, we’ll use the following command:
gnostic-grpc -input bar.pb -output .
This command generates a .proto
file named bar.proto
that contains the gRPC service definition:
syntax = "proto3";package bar;import "google/api/annotations.proto";import "google/protobuf/descriptor.proto";import "google/protobuf/empty.proto";message IngredientsRequest {repeated string ingredients = 1;}message DrinkResponse {string name = 1;string description = 2;string recipe = 3;string photo = 4;}message DrinkNameRequest {string name = 1;}message DrinkRecipeResponse {repeated IngredientQuantity ingredients = 1;string recipe = 2;string photo = 3;}message IngredientQuantity {string name = 1;string quantity = 2;}message Error {int32 code = 1;string message = 2;}//CreateDrinkParameters holds parameters to CreateDrinkmessage CreateDrinkRequest {IngredientsRequest ingredients_request = 1;}//GetDrinkParameters holds parameters to GetDrinkmessage GetDrinkRequest {DrinkNameRequest drink_name_request = 1;}service Bar {rpc CreateDrink ( CreateDrinkRequest ) returns ( DrinkResponse ) {option (google.api.http) = { post:"/create-drink" body:"ingredients_request" };}rpc GetDrink ( GetDrinkRequest ) returns ( DrinkRecipeResponse ) {option (google.api.http) = { post:"/get-drink" body:"drink_name_request" };}}
Generating Go Code Using Buf and the gRPC Go Plugin
To set up Buf, we need to run the following command:
buf mod init
This command initializes a new Buf module in the current directory.
Update the buf.yaml
file created in the project root with the following content:
version: v1deps:- buf.build/googleapis/googleapis
We depend on googleapis
because the gRPC Go plugin requires it.
Next, create a buf.gen.yaml
file in the project root with the following content:
We enable managed mode and set the go_package_prefix
to github.com/speakeasy-api/grpc-rest-service/bar
.
We also specify the plugins we want to use and where to output the generated code.
version: v1managed:enabled: truego_package_prefix:default: github.com/speakeasy-api/grpc-rest-service/barexcept:- buf.build/googleapis/googleapis- buf.build/grpc-ecosystem/grpc-gatewayplugins:- name: goout: baropt:- paths=source_relative- name: go-grpcout: baropt:- paths=source_relative- plugin: grpc-gatewayout: baropt:- paths=source_relative
Update Buf’s dependencies using the following command:
buf mod update
Now we can generate Go code using the following command:
buf generate
This command generates Go code in the bar
directory. The generated code includes the gRPC service definition and the gRPC gateway definition.
Implementing the gRPC Server
To implement the gRPC server, we created one big main.go
with our endpoint implementations and business logic all in one. This will make a lot of people very angry and is widely regarded as a bad move. (🫡 Douglas Adams (opens in a new tab))
To make things slightly more confusing, we’re including OpenAI API calls alongside our focus on OpenAPI. The similarities in the names are purely coincidental.
The main.go
file is too large to include here, but you can find it in the root of the example project.
Optional: Adding Your OpenAI API Key
If you want to use the OpenAI API to generate drink recipes, you’ll need to add your OpenAI API key to the project. Without this key, the server will return placeholder data for the drink recipes.
Copy the .env.template
file to a new file named .env
:
cp .env.template .env
Open the .env
file and add your OpenAI API key:
OPENAI_API_KEY=your-openai-api-key
This file is included in the .gitignore
file, so it won’t be checked into version control.
Rebuilding the Docker Image
We’ve installed a bunch of new dependencies and generated a lot of new code. To make sure everything is working as expected, we need to rebuild our Docker image.
This also runs go mod tidy
to ensure all dependencies are up to date, which can take a while.
In VS Code, press F1 to open the command palette, then select Dev Containers: Rebuild Container
. This will rebuild the development container and install all the dependencies.
Running the gRPC Server
To run the gRPC server, we need to start the development container and run the following command:
go run main.go
This command starts the gRPC server on port 50051
and the gRPC gateway on port 8080
.
Testing the gRPC Server
To test the gRPC server, we’ll use the grpcurl
command-line tool.
First, open a new terminal in VS Code by pressing ⌃⇧`.
This next step will use OpenAI credits, so make sure you have credits available before running the command.
Now run the following command to send a request to the gRPC server:
grpcurl -plaintext -d '{"drink_name_request": {"name": "Pan Galactic Gargle Blaster"}}' localhost:50051 bar.Bar/GetDrink
This command sends a request to the GetDrink
endpoint with the name
field set to Pan Galactic Gargle Blaster
.
The response should look something like this:
{"ingredients": [{"name": "Ol' Janx Spirit","quantity": "1 oz"},{"name": "Water from the seas of Santraginus V","quantity": "0.5 oz"},{"name": "Arcturan Mega-gin","quantity": "1 oz"},{"name": "Fallian marsh gas","quantity": "A gentle bubble"},{"name": "Quantum hyper-mint extract","quantity": "1 teaspoon"},{"name": "Zap powder","quantity": "A pinch"},{"name": "Algolian Suntiger tooth extract","quantity": "1 drop"},{"name": "Galaxy-wide famous Olives","quantity": "1 olive"}],"recipe": "In a cosmic shaker, mix Ol' Janx Spirit, Arcturan Mega-gin, and water from Santraginus V. Gently add fallian marsh gas to create a mystery bubble. Stir in quantum hyper-mint extract and a pinch of zap powder with a molecular stirrer (mind the speed, or you'll end up in another dimension). Carefully add a single drop of Algolian Suntiger tooth extract, ensuring not to evaporate your mixing vessel. Serve in a glass forged from comets' ice, garnished with a galaxy-wide famous Olive. Be sure to have your propulsion system set to the nearest recovery planet because after one sip, you'll need it.","photo": "https://example.com/photo.jpg"}
The photo URL returned by OpenAI is only valid for an hour, so be sure to open it in a browser to view it.
You may find that the terminal output escapes the JSON response, breaking the photo URL. You can use the following command to unescape the JSON response:
grpcurl -plaintext -d '{"drink_name_request": {"name": "Pan Galactic Gargle Blaster"}}' localhost:50051 bar.Bar/GetDrink | sed 's/%3A/:/g; s/%2F/\//g; s/%3D/=/g; s/%3F/?/g; s/%26/\&/g' | jq
This command uses sed
to unescape the JSON response and jq
to format the JSON response.
Generating SDKs for the gRPC Gateway
To generate a TypeScript SDK for the gRPC gateway, we run the following command:
speakeasy quickstart
Follow the onscreen prompts to provide the necessary configuration details for your new SDK such as the name, schema location and output path. Enter bar.yaml
when prompted for the OpenAPI document location and select TypeScript when prompted for which language you would like to generate.
To generate a Python SDK for the gRPC gateway, we run the following command:
speakeasy quickstart
Follow the onscreen prompts to provide the necessary configuration details for your new SDK such as the name, schema location and output path. Enter bar.yaml
when prompted for the OpenAPI document location and select Python when prompted for which language you would like to generate.
Now we have generated SDKs for the gRPC gateway in both TypeScript and Python.