Introduction
Hello world
If you don't have Golang installed please Follow the instructions here
Now let's start our journey by just saying Hello world
package main
import (
"net/http"
)
func main() {
http.HandleFunc("/", handler)
err := http.ListenAndServe("127.0.0.1:8080", nil)
if err != nil {
panic(err)
}
}
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world"))
}
Run it
All the book examples can be run if you clone the book repo. go to the goexamples directory and run them using
go run ./example-name
start the HTTP server:
cd goexamples
go run ./example-helloworld
Try it:
For testing the web server we are going to use curl
Open another terminal
curl -i http://localhost:8080
What's happenning here
With only 17 lines of code Go:
- Starts a webserver that is listening on port 8080
- When an HTTP request is made it executes the
handlerfunction
Next Steps
In the next section we are going to explain in more detail what is happening.
We are going to exampine the net/http and in particular
we are going to focus on http.Server, http.Request, http.ResponseWriter, http.ServeMux.
net/http package
This package contains implementations for HTTP servers (and clients). Understanding the basic structs and functions this package offers is crucial for getting a solid foundation of how Web Programming can be made in Go.
We are going to explore the package by using our simple Hello world example
On a high level the hello world program does:
- starts an HTTP Server: The HTTP Server is a program that "listens/binds" to a network port is a capable of serving HTTP requests to that port.
- It registers a function to be called when a specific HTTP request is made.
The HTTP server starts using:
The Documentation explains it a clean way:
ListenAndServe starts an HTTP server with a given address and handler. The handler is usually nil, which means to use DefaultServeMux. Handle and HandleFunc add handlers to DefaultServeMux
However there are some terms that the reader may not be familiar with.
http.Handler
A Handler is an implementation of the http.Handler interface
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
This interface has only one method, ServeHTTP. Essentially, this is the code that is responsible to repond to an HTTP request.
http.ResponseWriter
http.ResponseWriter is an interface that is used to construct the HTTP Response.
In the helloworld example we used its Write method.
Write([]byte) (int, error)
The
Writemethod writes the data to the underlying TCP connection as part of an HTTP reply.
Each HTTP Response contains a Body and a Status Code. The write method writes the body.
What about the Status code?
When we run curl -i http://localhost:8080 the response was something like:
HTTP/1.1 200 OK
Date: Sat, 03 May 2025 16:33:22 GMT
Content-Length: 12
Content-Type: text/plain; charset=utf-8
Hello world!
The HTTP response status code is 200.
In order to use another status code you need to call the WriteHeader method before calling Write .
w.WriteHeader(201)
w.Write([]byte("this is other status code`)
The above will make an HTTP response with response code 201
For HTTP response codes go has constants defined and these the ones that should be used
Example:
w.WriteHeader(http.StatusCreated) // 201
WriteHeader method is used to write all the required HTTP Headers.
Example:
w.WriteHeader("Content-Type", "application/json")
http.Request
http.Request is a fundamental component of Go's web programming toolkit. It encapsulates all the information received from an incoming HTTP request and allows you to process and read the data.
What you need to know When your handler function is called, it receives an http.Request pointer that contains everything about the request:
func handler(w http.ResponseWriter, r *http.Request) {
// r contains all the request information
}
Key Features The request objects gives you access to:
- Request Method: via
r.Method - URL and Path: via
r.URL - Headers: via
r.Header.Get("header-name") - Query Params: via
r.URL.Query().Get("param") - Form Data: via
r.ParseForm() - Request Body: via
io.ReadAll(r.Body)) - Path Variables: via
r.PathValue("variable-name")
Understanding the http.Request type is crucial because it's the gateway to all client information. When building a web application you will constantly work with the request object.
I prefer not to explain all the possible methods and fields. We will focus to the ones that
solve the immediate problem. As we build a more complex application, we will discover and use
more and more of the http.Request functionality.
HTTP Request multiplexer
DefaultServerMux: Is a default implementation of the ServeMux.
Quoting the docs:
ServeMux is an HTTP request multiplexer. It matches the URL of each incoming request against a list of registered patterns and calls the handler for the pattern that most closely matches the URL.
Let provide an example of what the above means in the context of our hello world:
http.HandleFunc("/", handler)
That line instructs the DefaultServerMux to "map" any HTTP request with to call
the handler function.
A careful reader will notice that the handler function is just a function and it is not
a struct implementing the http.Handler interface.
http.HandleFunc is a convenience function that allows you to pass a function instead of a struct.
http.HandleFunc("/", handler)
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world"))
}
is equivalent to:
hn := HelloHandler{}
http.Handle("/", hn)
type HelloHandler struct {
}
func (h HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world"))
}
Let's see some examples of different patterns that can be used.
package main
import (
"encoding/json"
"net/http"
)
func main() {
http.HandleFunc("/hello", handler)
http.HandleFunc("GET /say-bye", handler2)
http.HandleFunc("GET /say/{word}", handler3)
http.HandleFunc("GET /json/{word}", handler4)
err := http.ListenAndServe("127.0.0.1:8080", nil)
if err != nil {
panic(err)
}
}
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world"))
}
func handler2(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Bye"))
}
func handler3(w http.ResponseWriter, r *http.Request) {
word := r.PathValue("word")
w.Write([]byte(word))
}
func handler4(w http.ResponseWriter, r *http.Request) {
word := r.PathValue("word")
w.Header().Set("Content-Type", "application/json")
ans := map[string]string{
"word": word,
}
json.NewEncoder(w).Encode(ans)
}
Start the server:
go run ./example-muxpatterns
curl -i http://localhost:8080/hello
curl -XPOST -i http://localhost:8080/hello
curl -XGET -i http://localhost:8080/say-bye`
curl -XPOST -i http://localhost:8080/say-bye
curl -XGET -i http://localhost:8080/say/this-works
See in the documentation how patterns work
Also notice the response of curl -XPOST -i http://localhost:8080/say-bye:
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Sun, 04 May 2025 05:44:41 GMT
Content-Length: 19
Method Not Allowed
We are not going to dive into more details at the moment.
The important part is to understand that Go gives you the ability to register a method that is executed based on the pattern. The pattern can contain a Method a Path and that the path can contain a wildcard (see the handler3 example).
HTTP Server
The http.Server struct provides a configurable to create
an HTTP-server compared to the simple http.ListenAndServe function we have used.
Basic HTTP Server Configuration
http.Server allows you to explicitly define server parameters:
package main
import (
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
server := &http.Server{
Addr: "127.0.0.1:8080",
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
err := server.ListenAndServe()
if err != nil {
panic(err)
}
}
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world"))
}
Key Configuration Options
Timeouts
Proper timeouts are critical for production REST APIs
- ReadTimeout: Maximum duration for reading the entire request, including body
- WriteTimeout: Maximum duration before timing out writes of the response
- IdleTimeout: Maximum amount of time to wait for the next request when keep-alives are enabled
Without proper timeouts, your server may be vulnerable to slow client attacks or resource exhaustion.
TLS Configuration
For secure HTTPS connections, you can configure TLS directly:
server := &http.Server{
Addr: "127.0.0.1:8443",
Handler: mux,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
err := server.ListenAndServeTLS("server.crt", "server.key")
if err != nil {
panic(err)
}
Graceful Shutdown
One of the most important features of using http.Server directly is the ability to implement graceful shutdown, which is essential for production REST APIs:
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
err := run()
if err != nil {
log.Printf("Error: %v", err)
os.Exit(1)
}
log.Println("Server stopped gracefully")
}
func run() error {
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
server := &http.Server{
Addr: "127.0.0.1:8080",
Handler: mux,
}
errc := make(chan error, 1)
// start the server in a goroutine
go func() {
defer close(errc)
// when shutdown is called, the server will immediatelly return and stop accepting new connections
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- err
return
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// we give the server 5 seconds to finish the in progress requests
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Disable keep-alives
server.SetKeepAlivesEnabled(false)
// we should wait for the server to finish
err := server.Shutdown(ctx)
if err != nil {
return err
}
return <-errc
}
func handler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello world"))
}
When to use http.Server and best practices
Always use the http.Server. An exception might be non production test apps
- Always set timeouts
- Implement graceful shutdown
- Use TLS on production
Request/Response Patterns
Parsing JSON Requests
Most modern REST APIs work with JSON. Here's a simple pattern for parsing JSON request bodies:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func handleUserCreate(w http.ResponseWriter, r *http.Request) {
// Check method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the request body
var user User
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Process the user data...
// Send response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
Form Data
func handleFormSubmission(w http.ResponseWriter, r *http.Request) {
// Parse form data
err := r.ParseForm()
if err != nil {
http.Error(w, "Error parsing form data", http.StatusBadRequest)
return
}
// Access form values
name := r.Form.Get("name")
email := r.Form.Get("email")
// Process the data...
// Send response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
Url parameters and Query strings
func getUserHandler(w http.ResponseWriter, r *http.Request) {
// Get URL parameter
userID := r.PathValue("id")
// Get query parameter
format := r.URL.Query().Get("format")
// Use the parameters...
// Send response
w.Header().Set("Content-Type", "application/json")
response := map[string]string{
"userId": userID,
"format": format,
}
json.NewEncoder(w).Encode(response)
}
Structured Response Patterns
Using consistent response structures helps API consumers and maintainers: Always use a structured response
type SuccessResponse struct {
Status string `json:"status"`
Data interface{} `json:"data"`
}
func sendSuccessResponse(w http.ResponseWriter, data interface{}, statusCode int) {
response := SuccessResponse{
Status: "success",
Data: data,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
}
Error Responses
type ErrorResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
func sendErrorResponse(w http.ResponseWriter, message string, statusCode int) {
response := ErrorResponse{
Status: "error",
Message: message,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
}
Status Code Selection
A simplified guide to common status codes:
200 OK: Request succeeded201 Created: Resource successfully created400 Bad Request: Invalid request format or parameters401 Unauthorized: Authentication required403 Forbidden: Authenticated but not authorized404 Not Found: Resource not found405 Method Not Allowed: HTTP method not supported422 Unprocessable Entity: Well-formed request but semantically invalid (validation errors)500 Internal Server Error: Unexpected server error
func createResourceHandler(w http.ResponseWriter, r *http.Request) {
// Parse request
var req CreateRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
// Syntax error in the request
sendErrorResponse(w, "Invalid JSON format", http.StatusBadRequest)
return
}
// Validate request
validationErrors := validateRequest(req)
if len(validationErrors) > 0 {
// Request syntax was valid but the content had validation errors
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"message": "Validation failed",
"errors": validationErrors,
})
return
}
// Create resource
resource, err := createResource(req)
if err != nil {
sendErrorResponse(w, "Failed to create resource", http.StatusInternalServerError)
return
}
// Success
sendSuccessResponse(w, resource, http.StatusCreated)
}
Simple request validation
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (r *CreateUserRequest) Validate() map[string]string {
errors := make(map[string]string)
if r.Name == "" {
errors["name"] = "name is required"
}
if r.Email == "" {
errors["email"] = "email is required"
} else if !strings.Contains(r.Email, "@") {
errors["email"] = "invalid email format"
}
return errors
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
sendErrorResponse(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate
validationErrors := req.Validate()
if len(validationErrors) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"message": "Validation failed",
"errors": validationErrors,
})
return
}
// Process valid request...
}
Best Practices
- Be consistent: Use the same response format across your API
- Use appropriate status codes: Choose status codes that clearly communicate what happened
- Validate early: Check request data before performing business logic
- Set proper headers: Always set Content-Type header
- Distinguish between different error types: Use 400 for syntax errors and 422 for validation errors
- Handle errors gracefully: Provide meaningful error messages
By following these simple patterns, you'll create a consistent, intuitive API that's easy for clients to use and for your team to maintain.
Understanding Middleware in Go REST APIs
What is Middleware?
Middleware in the context of Go web applications acts as an intermediary layer between the incoming HTTP request and your application's handlers.
It intercepts HTTP requests before they reach your final handler, allowing you to perform operations on the request or response.
Essentially, middleware is an implementation of the http.Handler interface that wraps another handler, executing code before and/or after the handler is called.
Basic Example
package main
import (
"log"
"net/http"
"time"
)
func main() {
// Create our handler
handler := http.HandlerFunc(helloHandler)
// Wrap it with the middleware
wrappedHandler := loggingMiddleware(handler)
// Register the wrapped handler
http.Handle("/", wrappedHandler)
// Start the server
log.Println("Server starting on port 8080...")
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal(err)
}
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Code executed before the handler
start := time.Now()
log.Printf("Started %s %s", r.Method, r.URL.Path)
// Call the wrapped handler
next.ServeHTTP(w, r)
// Code executed after the handler
log.Printf("Completed %s %s in %v", r.Method, r.URL.Path, time.Since(start))
})
}
func helloHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
In this example, loggingMiddleware is a function that takes an http.Handler and returns a new http.Handler that wraps the original one.
It logs the start time of the request, calls the original handler, and then logs how long the request took to process.
Chaining Middleware
Middleware chaining is simple but powerful. It's like passing a request through a series of checkpoints before reaching its destination:
func main() {
// Our core handler function
handler := http.HandlerFunc(helloHandler)
// Apply middleware in sequence
handler = loggingMiddleware(handler)
handler = authMiddleware(handler)
handler = timeoutMiddleware(handler)
http.Handle("/", handler)
http.ListenAndServe(":8080", nil)
}
In this example, the middleware execution happens in the opposite order of how they're applied:
- When a request comes in, it first goes through timeoutMiddleware
- Then it passes through authMiddleware
- Next it goes through loggingMiddleware
- Finally, it reaches the helloHandler
This happens because each middleware wraps the next one in the chain, creating nested layers.
Understanding this execution order is crucial when designing your middleware chain, especially when certain middleware depends on the processing done by others.
Common Use Cases
Some common use cases include:
- Logging: Recording request details, response times, and errors for monitoring and debugging.
- Authentication and Authorization: Verifying user identity and checking if they have permission to access certain resources.
- Request Validation: Ensuring requests contain required fields or meet specific criteria before they reach your handlers.
- CORS (Cross-Origin Resource Sharing): Managing which domains can access your API.
- Rate Limiting: Preventing abuse by limiting the number of requests from a single client.
- Response Compression: Automatically compressing HTTP responses to reduce bandwidth.
- Error Handling: Providing consistent error responses across your API.
- Content Type Negotiation: Processing requests based on their content type and formatting responses accordingly.
- Request Parsing: Automatically parsing JSON or form data into structured types.
- Caching: Storing response data to improve performance for frequent requests.
Testing
Testing is an essential part of building robust and reliable REST APIs. Go provides excellent built-in testing capabilities through its standard library, particularly with the testing package and the httptest package, which is specifically designed for testing HTTP servers.
In the following chapters we are going to see some examples how this can be done. We will also explore alternative external packages that are convenient
Basic Handler Testing
Testing HTTP handlers is an essential part of building reliable REST APIs. Go's standard library provides the httptest package, which makes testing HTTP handlers straightforward without requiring a running server.
The httptest Package
The httptest package offers tools to simulate HTTP requests and record responses for verification:
import (
"net/http"
"net/http/httptest"
"testing"
)
Testing a Simple Handler
Let's test our basic "Hello world" handler:
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler(t *testing.T) {
// Create a request to pass to our handler
req, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
// Create a ResponseRecorder to record the response
rr := httptest.NewRecorder()
handler := http.HandlerFunc(handler)
// Call the handler directly, passing in the request and response recorder
handler.ServeHTTP(rr, req)
// Check the status code
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
// Check the response body
expected := "Hello world"
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %v want %v",
rr.Body.String(), expected)
}
}
From within the goexamples/example-helloworld run:
go test -v ./...
output:
=== RUN TestHandler
--- PASS: TestHandler (0.00s)
PASS
ok goexamples/helloworld 0.003s
Best Practices
When writing handler tests:
- Test status codes: Verify your handler returns the expected HTTP status codes
- Test response headers: Check for expected headers like
Content-Type - Test response body: Validate the content of the response
- Test error cases: Ensure handlers respond appropriately to invalid inputs
- Keep tests focused: Each test should verify one specific aspect of the handler
Testing the Full Server
Sometimes you need to test how your entire server works together, including routing. The httptest package provides tools for testing complete HTTP servers without requiring an actual network connection.
Setting up an httptest.Server and testing endpoints
The httptest.Server type creates a real HTTP server for testing purposes:
package main
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestServerEndpoints(t *testing.T) {
// Start the test server
ts := setupTestServer(t)
defer ts.Close()
// Test the root endpoint
resp, err := http.Get(ts.URL + "/")
if err != nil {
t.Fatal(err)
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
// Check status code
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status OK; got %v", resp.Status)
}
// Read and check the response body
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "Hello world" {
t.Errorf("Expected 'Hello world'; got %q", string(body))
}
}
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
// Set up your router with all handlers
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
// Create and return the test server
return httptest.NewServer(mux)
}}
Benefits of Full Server Testing
Testing your complete server offers several advantages:
- Tests routing logic: Verifies URL patterns and HTTP methods are correctly mapped
- Tests middleware integration: Ensures middleware like authentication works properly
- Closer to real-world usage: Tests the API as a client would use it
- Exposes integration issues: Reveals problems that might not appear in isolated handler tests
By combining both handler-level testing and full server testing, you can build confidence in your API's correctness and reliability.
A pragmatic approach is to use full server testing and that is my recommendation.
Using testify & httpexpect
While Go's standard library provides excellent tools for testing HTTP handlers, third-party packages like testify and httpexpect can make your tests easier to write.
Testify
The testify package provides enhanced assertion functions that can make the tests more readable and provide
better error messages.
Installation
go get github.com/stretchr/testify
go get github.com/gavv/httpexpect/v2
Basic Usage
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gavv/httpexpect/v2"
"github.com/stretchr/testify/assert"
)
func TestServer(t *testing.T) {
// Set up routes and testserver
mux := http.NewServeMux()
mux.HandleFunc("GET /json/{word}", handler4)
server := httptest.NewServer(mux)
defer server.Close()
// Create httpexpect instance
e := httpexpect.Default(t, server.URL)
var target map[string]string
e.GET("/json/hello").
Expect().
Status(http.StatusOK).
JSON().Object().Decode(&target)
assert.Equal(t, "hello", target["word"])
}
Basic Rest Principles
REST (Representational State Transfer) is an architectural style for designing networked applications. Understanding these core principles will help you design APIs that are intuitive, maintainable, and align with web standards.
Resources as the Core Concept
In REST, everything is a resource. A resource is any information that can be named: a document, an image, a service, a collection of resources, or even a concept.
// Resources map naturally to URLs
// /users - a collection of users
// /users/42 - a specific user
// /users/42/orders - orders belonging to user 42
HTTP Methods and CRUD Operations
REST APIs use HTTP methods to represent operations on resources:
- POST: Create a new resource (Create)
- GET: Retrieve a resource or collection (Read)
- PUT/PATCH: Update an existing resource (Update)
- DELETE: Remove a resource (Delete)
// Example handler mapping
func setupRoutes() {
http.HandleFunc("GET /users", listUsers)
http.HandleFunc("GET /users/{id}", getUser)
http.HandleFunc("POST /users", createUser)
http.HandleFunc("PUT /users/{id}", updateUser)
http.HandleFunc("DELETE /users/{id}", deleteUser)
}
Statelessness
Each request must contain all the information needed to understand and process it. The server doesn't store client state between requests.
// Each request should be self-contained
func handler(w http.ResponseWriter, r *http.Request) {
// Authentication should be in each request (e.g., via token)
token := r.Header.Get("Authorization")
// Process based only on the request content
// ...
}
Resource URLs
Design clean, hierarchical URLs:
/users // Collection of users
/users/123 // Specific user with ID 123
/users/123/orders // Orders belonging to user 123
/users/123/orders/456 // Specific order 456 for user 123
When designing URLs:
- Use nouns, not verbs (e.g., /users not /getUsers)
- Use plural nouns for collections
- Use parameters for filtering: /users?status=active
- Use hierarchical relationships when they exist
Response Formats
JSON is the most common format for REST APIs:
func getUser(w http.ResponseWriter, r *http.Request) {
// Get user data
user := fetchUserFromDB(r.PathValue("id"))
// Set content type
w.Header().Set("Content-Type", "application/json")
// Return as JSON
json.NewEncoder(w).Encode(user)
}
Idempotence
- Idempotent operations: Performing the same operation multiple times has the same effect as doing it once
- GET, PUT, DELETE should be idempotent
- POST is typically not idempotent
// PUT is idempotent - multiple identical requests should have same result
func updateUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
var userData User
json.NewDecoder(r.Body).Decode(&userData)
userData.ID = userID
// Replace the entire resource
storeUserInDB(userData)
w.WriteHeader(http.StatusOK)
}
Versioning
APIs evolve over time, so versioning is essential:
/api/v1/users
/api/v2/users
Pragmatic REST API
While REST has formal principles, a pragmatic approach focuses on:
- Clear resource naming: Use intuitive, consistent URL structures
- Appropriate HTTP methods: Match HTTP verbs to operations
- Proper status codes: Communicate outcomes clearly
- Consistent responses: Use the same format throughout your API
- Stateless design: Keep requests self-contained
REST is just a helpful approach, not a strict rulebook. What matters most is building APIs that make sense to the people using them. Focus on creating clear, consistent interfaces that are easy to understand and use, rather than worrying about following every academic REST rule perfectly.