How to Build an API with Go and Huma
This article is written by Daniel G. Taylor, Staff Engineer at Telepathy Labs, and creator of beloved open-source API tooling like Restish, aglio, API Sprout, and now Huma. All opinions expressed are his own.
Introduction#
In this article, we will explore how to build a simple API using Go and the Huma framework. Huma is a lightweight and easy-to-use framework for building APIs in Go, with built-in support for OpenAPI and some neat features that make it a great choice for developers. We will cover the following topics:
- Creating a new Huma project & defining some endpoints
- Setting up MongoDB
- Running the service
- Hosting our API
- Creating a project on Zuplo
- Setting up API key authentication
Prerequisites#
To get started, we need to set up our Go environment. If you haven't already, download and install Go from the official website: https://go.dev/dl/.
Creating a new Huma project & defining some endpoints#
First, we need to create a new Go project. Open your terminal and run the following commands:
$ mkdir projects-api
$ cd projects-api
$ go mod init github.com/YOUR_USERNAME/PROJECT_NAME
Next, create a main.go
file in the project directory and add the following
code:
package main
import (
"context"
"net/http"
"os"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// Project defines a single project with a URL and the langauge it was written in.
type Project struct {
Added time.Time `json:"added" readOnly:"true"`
Language string `json:"language" enum:"go,rust,python,typescript"`
URL string `json:"url" format:"uri"`
}
// ProjectRecord is a MongoDB record for a project.
type ProjectRecord struct {
Name string `json:"name"`
Project Project `json:"project"`
}
// Response is a generic response type for the API with just a simple body.
type Response[T any] struct {
Body T
}
// NewResponse returns the response type with the right body.
func NewResponse[T any](body T) *Response[T] {
return &Response[T]{Body: body}
}
func main() {
// Create a new Go HTTP servemux and Huma API instance with default settings.
router := http.NewServeMux()
api := humago.New(router, huma.DefaultConfig("My API", "1.0.0"))
// Connect to MongoDB
mongoClient, err := mongo.Connect(options.Client().
ApplyURI(os.Getenv("MONGO_URI")).
SetBSONOptions(&options.BSONOptions{
UseJSONStructTags: true,
DefaultDocumentM: true,
}))
if err != nil {
panic(err)
}
collection := mongoClient.Database("demo").Collection("projects")
// Define our API routes.
huma.Get(api, "/projects", func(ctx context.Context, input *struct {
Language string `query:"language" enum:"go,rust,python,typescript" doc:"Filter by language"`
}) (*Response[[]ProjectRecord], error) {
var projects []ProjectRecord
filter := bson.M{}
if input.Language != "" {
// Optional filtering by programming language.
filter["project.language"] = input.Language
}
cursor, err := collection.Find(ctx, filter)
if err != nil {
return nil, huma.Error500InternalServerError("failed to fetch projects")
}
defer cursor.Close(ctx)
if err := cursor.All(ctx, &projects); err != nil {
return nil, huma.Error500InternalServerError("failed to decode projects")
}
return NewResponse(projects), nil
})
huma.Put(api, "/projects/{name}", func(ctx context.Context, input *struct {
Name string `path:"name"`
Body Project
}) (*Response[Project], error) {
input.Body.Added = time.Now()
record := ProjectRecord{
Name: input.Name,
Project: input.Body,
}
_, err := collection.InsertOne(ctx, record)
if err != nil {
return nil, huma.Error500InternalServerError("failed to insert project")
}
return NewResponse(record.Project), nil
})
// Start the server.
port := os.Getenv("PORT")
if port == "" {
port = "8000"
}
if err := http.ListenAndServe(":"+port, router); err != nil {
panic(err)
}
}
Don't forget to fetch the dependencies after saving main.go
:
$ go mod tidy
Breaking Down the Code#
First, we define some structures that will be used for our inputs & outputs. A
Project
is our API model, while a ProjectRecord
will be what is stored in
the database. We add JSON Schema validation tags to the struct, such as making
the added
field read-only and the language
field an enum.
// Project defines a single project with a URL and the langauge it was written in.
type Project struct {
Added time.Time `json:"added" readOnly:"true"`
Language string `json:"language" enum:"go,rust,python,typescript"`
URL string `json:"url" format:"uri"`
}
// ProjectRecord is a MongoDB record for a project.
type ProjectRecord struct {
Name string `json:"name"`
Project Project `json:"project"`
}
Everything in Huma is represented via Go structs, including the entire request/response (all path & query params, cookies, headers, bodies, etc). For simple responses, we only need a body so we add a quick utility to make this easier:
// Response is a generic response type for the API with just a simple body.
type Response[T any] struct {
Body T
}
// NewResponse returns the response type with the right body.
func NewResponse[T any](body T) *Response[T] {
return &Response[T]{Body: body}
}
Now we can use this to tell huma we want to return a Response[[]ProjectRecord]
for our /projects
endpoint. This is a list of projects as the body, and we can
create the response using NewResponse(projects)
.
Next, we set up the server and connect to MongoDB.
// Create a new Go HTTP servemux and Huma API instance with default settings.
router := http.NewServeMux()
api := humago.New(router, huma.DefaultConfig("My API", "1.0.0"))
// Connect to MongoDB
mongoClient, err := mongo.Connect(options.Client().
ApplyURI(os.Getenv("MONGO_URI")).
SetBSONOptions(&options.BSONOptions{
UseJSONStructTags: true,
DefaultDocumentM: true,
}))
if err != nil {
panic(err)
}
collection := mongoClient.Database("demo").Collection("projects")
Then we define our API routes. Each route is registered with the API instance using a signature like:
huma.Get(api, "/route", func(ctx context.Context, input *Input) (*Output, error) {
// Do something with the input and return an output.
})
First comes the GET
route for listing projects. This route takes an optional
query parameter for filtering by programming language. The input
parameter is
automatically populated by Huma based on the request. We query MongoDB using the
correct filters, load the projects list, and return it as a response.
huma.Get(api, "/projects", func(ctx context.Context, input *struct {
Language string `query:"language" enum:"go,rust,python,typescript" doc:"Filter by language"`
}) (*Response[[]ProjectRecord], error) {
var projects []ProjectRecord
filter := bson.M{}
if input.Language != "" {
// Optional filtering by programming language.
filter["project.language"] = input.Language
}
cursor, err := collection.Find(ctx, filter)
if err != nil {
return nil, huma.Error500InternalServerError("failed to fetch projects")
}
defer cursor.Close(ctx)
if err := cursor.All(ctx, &projects); err != nil {
return nil, huma.Error500InternalServerError("failed to decode projects")
}
return NewResponse(projects), nil
})
The second route is a PUT
route for creating a new project. This route takes a
path parameter for the project name and a body parameter for the project
details. We create a new ProjectRecord
and insert it into MongoDB.
huma.Put(api, "/projects/{name}", func(ctx context.Context, input *struct {
Name string `path:"name"`
Body Project
}) (*Response[Project], error) {
input.Body.Added = time.Now()
record := ProjectRecord{
Name: input.Name,
Project: input.Body,
}
_, err := collection.InsertOne(ctx, record)
if err != nil {
return nil, huma.Error500InternalServerError("failed to insert project")
}
return NewResponse(record.Project), nil
})
Lastly, we get the port to listen on from the environment (this will be important when we deploy later) and then start the server, panicking if there are any errors.
// Start the server.
port := os.Getenv("PORT")
if port == "" {
port = "8000"
}
if err := http.ListenAndServe(":"+port, router); err != nil {
panic(err)
}
Setting up MongoDB#
Now, let's get a data store. We'll use MongoDB Atlas for this example. If you don't have an account, sign up for a free tier at https://www.mongodb.com/cloud/atlas/register. Create a free cluster, database, and user. Save the connection string.
Once you have a cluster, just export the connection string in your environment. It should look something like the one below:
$ export MONGO_URI='mongodb+srv://user:pass@project.mongodb.net/?retryWrites=true&w=majority&appName=project'
You should wind up with a cluster running like below:
Running the Service#
Now we are ready to run the service. You can do so like this:
$ go run .
Now open a browser and navigate to http://localhost:8000/docs
. You should see
the Huma API documentation page, which is automatically generated based on the
OpenAPI specification. You can test the endpoints directly from this page.
Creating some test data#
Let's create some projects. First, let's install Restish to
make it easier to test our API. You can install it using Homebrew. Alternatively
you can use curl
if you prefer.
# Install Restish
$ brew install danielgtaylor/restish/restish
# Set it up to talk to our API
$ restish api configure demo-local http://localhost:8000
Now we can create some projects:
$ restish demo-local put-projects-by-name huma 'language: go, url: "https://huma.rocks/"'
$ restish demo-local put-projects-by-name restish 'language: go, url: "https://rest.sh/"'
$ restish demo-local put-projects-by-name typescript 'language: typescript, url: "https://github.com/microsoft/TypeScript"'
$ restish demo-local put-projects-by-name fastapi 'language: python, url: "https://fastapi.tiangolo.com/"'
Now we can list the projects:
$ restish demo-local list-projects
HTTP/1.1 200 OK
Content-Length: 484
Content-Type: application/json
Date: Wed, 09 Apr 2025 00:27:32 GMT
[
{
name: "huma"
project: {
added: "2025-04-09T00:23:30.033Z"
language: "go"
url: "https://huma.rocks/"
}
}
{
name: "restish"
project: {
added: "2025-04-09T00:27:06.424Z"
language: "go"
url: "https://rest.sh/"
}
}
{
name: "typescript"
project: {
added: "2025-04-09T00:27:15.655Z"
language: "typescript"
url: "https://github.com/microsoft/TypeScript"
}
}
{
name: "fastapi"
project: {
added: "2025-04-09T00:27:28.257Z"
language: "python"
url: "https://fastapi.tiangolo.com/"
}
}
]
# You can also filter by programming language
$ restish demo-local list-projects --language go
...
Great! Let's move on to hosting our API on the Internet.
Hosting our API#
Now save your code and create a new repository on GitHub. Push your code to the respository. You can use the following commands:
# Initialize the project repo.
$ git init
$ git add .
$ git commit -m "Initial commit"
# After creating the GitHub repo, push it up to GitHub!
$ git remote add origin git@github.com:YOUR_USERNAME/PROJECT_NAME.git
$ git push -u origin main
Deploying the Service#
We'll use Render to deploy our service for free.
- Go to Render and sign up for a free account.
- Click on the "New" button and select "Web Service".
- Connect your GitHub account and select the repository you just created.
- Set the name of your service to
projects-api
and select the branch you want to deploy from (usuallymain
).
Before deploying, make sure to select the free tier and set the MONGO_URI
environment variable that you saved from earlier:
The project should build and deploy successfully:
MongoDB Network Access#
Set up network access in MongoDB Atlas, so that your render service can connect
to the database. You can see the IPs by clicking on Connect
in the Render UI:
Add these in MongoDB Atlas like so:
Now your render service should be able to talk to MongoDB Atlas. You may need to restart or redeploy your service for the changes to take effect.
Try going to your Render URL and appending /docs
to it. You should see the
Huma API documentation page.
Creating a Project on Zuplo#
Zuplo sits in front of your API and provides important features like authentication, rate limiting, and can be used to make a developer portal. We're going to set up Zuplo to provide API key authentication for our API.
Start by creating an account on Zuplo, and create a new project. You can do this
by going to the Zuplo dashboard and clicking on "Create Project". Give your
project a name like projects-api
and create it.
Navigate to /openapi.json
in your live service. Save that file as we'll use it
to import our routes to Zuplo.
Go to your Zuplo dashboard, to Code
and then routes.oas.json
and import the
file you saved.
You should now see your routes, and just need to update each route's forwarding URL to your Render URL:
Also manually add one more route to the /openapi.json
file so that clients can
use it to get the OpenAPI spec. It should look something like this:
Save the file and you should be able to access your service through your Zuplo
URL. For example, hitting the /projects
route:
Hooray it works! Read on to secure it using API key authentication.
Setting up API Key Authentication#
In the Zuplo routes section, choose a route and click on policies. You should see something like the following, where you can choose to add API key authentication:
Add the API key authentication policy to the route. Select the next route and do the same thing, this time selecting the existing API key policy you created earlier.
Now save the project, and when you try to access the API again you should see an error:
{
"type": "https://httpproblems.com/http-status/401",
"title": "Unauthorized",
"status": 401,
"detail": "No Authorization Header",
"instance": "/projects",
"trace": {
"timestamp": "2025-04-12T20:00:42.862Z",
"requestId": "cb349ede-96cc-494b-92cc-a8b7b53d2cfa",
"buildId": "c4d763b4-3568-4f7c-bf7f-c0a782580cf2",
"rayId": "930e07db7a5dc66f"
}
}
Good, this means the API key authentication is working. Now we need to set up a key that you can use to talk to the API.
Creating an API Key#
Go to Services in the Zuplo dashboard, and click on the API key service configuration.
Create a new consumer and save the resulting API key.
Testing the API Key#
Great! Now let's test out the API key using Restish. First, let's configure the
Zuplo endpoint in Restish. In order for the client to send the API key we need
to use the Authorization
header and make its value like Bearer YOUR_API_KEY
like below:
# Set up the Zuplo endpoint, manually adding the API key header
$ restish api configure demo https://YOUR_PROJECT.d2.zuplo.dev
Setting up a `default` profile
? Select option for profile `default` Add header
? Header name Authorization
? Header value (optional) Bearer YOUR_API_KEY
? Select option for profile `default` Finished with profile
? Select option Save and exit
Now that it's all set up you can call your API:
$ restish demo list-projects
HTTP/2.0 200 OK
Content-Encoding: br
Content-Length: 198
Content-Type: application/json
[
{
name: "huma"
project: {
added: "2025-04-09T00:23:30.033Z"
language: "go"
url: "https://huma.rocks/"
}
}
{
name: "restish"
project: {
added: "2025-04-09T00:27:06.424Z"
language: "go"
url: "https://rest.sh/"
}
}
{
name: "typescript"
project: {
added: "2025-04-09T00:27:15.655Z"
language: "typescript"
url: "https://github.com/microsoft/TypeScript"
}
}
{
name: "typescript"
project: {
added: "2025-04-09T00:27:28.257Z"
language: "python"
url: "https://fastapi.tiangolo.com/"
}
}
]
Congrats! You've got a working secured API powered with Go, Huma, OpenAPI, and Zuplo!
Conclusion#
In this article, we covered how to build a simple API using Go and the Huma framework. We set up a MongoDB database, created a RESTful API, and deployed it to Render. Finally, we secured our API with Zuplo and API key authentication. This is just the beginning of what you can do with Go, Huma, and Zuplo. Happy coding!