Go (standard library)
Build an API with no dependencies or frameworks in Go!
Alright, let’s build an API with Go with just the standard library! To start, go install go from the site and check that it’s working with go version. You should see something like the following:
go version go1.26.1 linux/amd64
Make sure that your version of Go is at least 1.22, since that’s where the routing features in net/http that we’ll be using are (path parameters, method matching).
Let’s create a new folder and initialize a Go module.
mkdir myraspapi
cd myraspapi
go mod init myraspapi
Now, make a file called main.go. Drop the following content in there:
package main
import (
"encoding/json"
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Hello, RaspAPI!",
})
})
fmt.Println("Server running on http://localhost:8080")
http.ListenAndServe(":8080", mux)
}
Run it with go run main.go. Opening http://localhost:8080/hello in your browser should return:
{
"message": "Hello, RaspAPI!"
}
Alright, so what just happened here? http.NewServeMux() creates the router for our requests here. HandleFunc handles a specific pattern, in this case, GET requests at /hello. The handler then receives a ResponseWriter and a Request, which we can use to see the request and send a response back to the client.
Note: There won’t be any autogenerated docs like you might see elsewhere by default. You’ll have to handwrite them, or use something like
swaggo/swagto autogenerate docs.
Structs
When using Go, we define data structures with structs. See something like the following:
type JellybeanJar struct {
Count int `json:"count"`
Color string `json:"color"`
}
We’ve added that tag at the end to make sure this field is counted as lowercase in the output, since capitalizing it isn’t conventional for APIs like this.
Taking input
How do we take input from users for our API? We can use query, path, and body parameters.
Query parameters are what you see after the ”?” in a URL. For example, in Youtube, we have links in the form: https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=19s. Here, v represents the video ID, and t represents the timestamp. They generally modify what you see & are often used as options. We can access them in Go with r.URL.Query().
mux.HandleFunc("GET /greet", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "world"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": fmt.Sprintf("Hello, %s!", name),
})
})
Try going to any path, e.g. http://localhost:8080/greet?name=RaspAPI to see.
Path parameters are part of the path itself. You can match them with curly braces.
mux.HandleFunc("GET /things/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extracts {id}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"thing_id": id,
})
})
Request bodies are how we send POST data. We decode the body into some struct.
type JellyBeans struct {
Flavor string `json:"flavor"`
Color string `json:"color"`
Quantity int `json:"quantity"`
}
mux.HandleFunc("POST /eatbeans", func(w http.ResponseWriter, r *http.Request) {
var beans JellyBeans
if err := json.NewDecoder(r.Body).Decode(&beans); err != nil {
http.Error(w, `{"error": "invalid JSON"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": fmt.Sprintf("You ate %d %s %s jellybeans!", beans.Quantity, beans.Color, beans.Flavor),
})
})
Whoah, what’s going on here? First, we decode the response with json.NewDecoder, reading from the request body. Then, we parse that JSON into a JellyBeans struct - we have to include a pointer to beans to make sure to assigns the value to our variable. In Go, we have to explicitly handle errors, since there is no concept of an exception as you might see in Javascript/Python.
To test it out, use a client like Hoppsotch (in-browser) or Yaak (my choice).
Errors
You saw a bit of this earlier, but since go doesn’t have exceptions, we have to explicitly handle returns of errors from functions. If you want to handle the JSON situation cleaner, you might want to do something like the following:
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
Now, whenever we are handling input or responses, our code is much cleaner. See our example from earlier, with path parameters? Let’s say we actually have some map of things that we’re returning. Here’s how we’d do it without any of these helpers:
mux.HandleFunc("GET /things/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
thing, ok := things[id]
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "thing not found"})
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOk)
json.NewEncoder(w).Encode(thing)
})
And with these helpers, your handlers become a lot cleaner:
mux.HandleFunc("GET /things/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
thing, ok := things[id]
if !ok {
writeError(w, http.StatusNotFound, "thing not found")
}
writeJSON(w, http.StatusOK, thing)
})
CORS
CORS prevents browsers from making requests to your API unless they’re on the same domain as your website. If you want people to be able to use your API directly from browsers, you’ll have to add CORS headers. In this case, we’ll be implementing our own middleware instead of using an external library.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
}
Now when we serve, we just include our middleware:
http.ListenAndServe(":8080", corsMiddleware(mux))
Docs
When serving docs, we could directly return text content on the /docs endpoint. But, in the interest of keeping code tidy, let’s do something a little more efficient. Make a new file called index.html. I’ve creatd some boilerplate for you to get started, and feel free to style it further. Try not to spend all of your time customizing here :P
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JellyBean API</title>
<style>
body {
font-family: monospace;
max-width: 600px;
margin: 2rem auto;
padding: 0 1rem;
}
pre {
background: #f5f5f5;
padding: 0.75rem;
overflow-x: auto;
}
</style>
</head>
<body>
<h1>JellyBean API</h1>
<p>Base URL: <code>raspapi.hackclub.com/api</code></p>
<h2>GET /beans</h2>
<p>Returns all beans in the jar.</p>
<pre>curl raspapi.hackclub.com/api/beans</pre>
<pre>[{"id":"1","flavor":"cherry","color":"red","quantity":12}]</pre>
<h2>POST /beans</h2>
<p>
Adds a bean. Expects JSON with <code>flavor</code>, <code>color</code>,
and <code>quantity</code>.
</p>
<pre>
curl -X POST raspapi.hackclub.com/api/beans \
-H "Content-Type: application/json" \
-d '{"flavor":"grape","color":"purple","quantity":5}'</pre
>
<pre>{"id":"2","flavor":"grape","color":"purple","quantity":5}</pre>
</body>
</html>
Now in our code, we can serve this file like so:
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "index.html")
})
Make sure to check the requirements before submitting, and good luck!