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:

  1. Starts a webserver that is listening on port 8080
  2. When an HTTP request is made it executes the handler function

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:

http.ListenAndServe

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 Write method 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 succeeded
  • 201 Created: Resource successfully created
  • 400 Bad Request: Invalid request format or parameters
  • 401 Unauthorized: Authentication required
  • 403 Forbidden: Authenticated but not authorized
  • 404 Not Found: Resource not found
  • 405 Method Not Allowed: HTTP method not supported
  • 422 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

  1. Be consistent: Use the same response format across your API
  2. Use appropriate status codes: Choose status codes that clearly communicate what happened
  3. Validate early: Check request data before performing business logic
  4. Set proper headers: Always set Content-Type header
  5. Distinguish between different error types: Use 400 for syntax errors and 422 for validation errors
  6. 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:

  1. When a request comes in, it first goes through timeoutMiddleware
  2. Then it passes through authMiddleware
  3. Next it goes through loggingMiddleware
  4. 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:

  1. Logging: Recording request details, response times, and errors for monitoring and debugging.
  2. Authentication and Authorization: Verifying user identity and checking if they have permission to access certain resources.
  3. Request Validation: Ensuring requests contain required fields or meet specific criteria before they reach your handlers.
  4. CORS (Cross-Origin Resource Sharing): Managing which domains can access your API.
  5. Rate Limiting: Preventing abuse by limiting the number of requests from a single client.
  6. Response Compression: Automatically compressing HTTP responses to reduce bandwidth.
  7. Error Handling: Providing consistent error responses across your API.
  8. Content Type Negotiation: Processing requests based on their content type and formatting responses accordingly.
  9. Request Parsing: Automatically parsing JSON or form data into structured types.
  10. 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:

  1. Test status codes: Verify your handler returns the expected HTTP status codes
  2. Test response headers: Check for expected headers like Content-Type
  3. Test response body: Validate the content of the response
  4. Test error cases: Ensure handlers respond appropriately to invalid inputs
  5. 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:

  1. Tests routing logic: Verifies URL patterns and HTTP methods are correctly mapped
  2. Tests middleware integration: Ensures middleware like authentication works properly
  3. Closer to real-world usage: Tests the API as a client would use it
  4. 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.