Preface

This tiny guide is written as I refresh my knowledge of Go.

I haven't used Go in six years, and the language has improved significantly since then.

We now have enhanced standard libraries, generics, and a better module system.

I prefer a from-scratch approach to gain a deeper understanding of the language and specs, so I will focus solely on vanilla Go.

Overview of Web Framework Components

Core Components

A web application essentially listens for HTTP requests, parse them, performs input/output operations (like calling other services or interacting with databases), and writes responses. To achieve this, several key components are necessary:

Router

The router efficiently maps incoming HTTP requests to specific handlers, utilizing various patterns (URL paths, HTTP methods). It includes features like route parameters, route groups, and middleware for enhanced functionality.

HTTP Utilities

This component manages HTTP communications, parsing requests to extract headers, body, and query parameters. It serializes responses into desired formats (JSON, XML, HTML) and implements caching mechanisms for improved performance while handling cookies for session tracking and authentication.

Logging

Logging captures events, errors, and warnings, aiding in debugging and monitoring application performance. It provides valuable insights into application behavior for troubleshooting.

Error Handling

An effective error-handling mechanism is crucial for delivering informative messages and maintaining application stability. It employs exception handling, custom error pages, and logging to manage issues.

Additional Components

Session Management

This component manages user session data across multiple requests, often using cookies or server-side storage (e.g., databases). It facilitates session creation, destruction, and data retrieval.

Authentication and Authorization

Authentication verifies user identities through various methods (username/password, OAuth). Authorization governs user permissions, ensuring secure access to resources.

Data Validator

This ensures data integrity by validating input against set rules, handling tasks like type checking and length constraints. Integration with the data access layer ensures consistent validation throughout the application.

Data Access (ORM or DB Connectors)

These abstractions simplify database interactions. ORMs map database tables to Object-Oriented models, while DB connectors provide direct API access, offering more control at the cost of increased development effort.

API Architecture Support

Frameworks often support various architectures like RESTful services, RPC, and GraphQL, enabling flexibility based on application needs.

Template Engines

Template engines render dynamic content by merging static templates with data, supporting multiple languages and powerful features like conditional logic and inheritance.

External Service Integrations

Frameworks facilitate integration with external services such as cloud storage (Amazon S3, Google Cloud) and message queues (RabbitMQ, Apache Kafka), enhancing functionality.

Security

Strong security practices defend against vulnerabilities such as SQL injection, XSS, and CSRF. This component ensures robust protection for the application and its users.

Rate Limiter

This protects against abuse by limiting request rates per client, which helps ensure resource fairness and application stability.

Connecting Components with Design Paradigms

Middleware

Middleware provides a chain of functions that run before or after request handling, enabling the addition of custom functionalities like logging, authentication, and error handling without altering core components.

Singleton

A design pattern ensuring a class has a single instance, this is beneficial for shared components like configuration settings or database connections, providing a global access point.

Dependency Injection

This technique promotes loose coupling by injecting dependencies into components. It enhances modularity and testability, making components more reusable.

Specific Architecture Support

MVC (Model-View-Controller)

MVC separates applications into three interconnected components:

  • Model: Represents data and business logic.
  • View: Manages data presentation to users.
  • Controller: Mediates between the model and view, processing user input and updating both accordingly.

This structured approach enhances maintainability and scalability of applications.

Router

The router maps incoming HTTP requests to specific handlers.

While many frameworks provide routing features, Go has it built-in.

Create a file named main.go with the following content:

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello!")
	})

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}

To run the server, execute:

$ go run main.go

You can test the server with:

$ curl localhost:3000
Hello!

Variable in Path

To handle variables in the path, you can modify the router like this:

http.HandleFunc("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Hello %s!", r.PathValue("name"))
})

You can now test this with:

$ curl localhost:3000/hello/go
Hello go!

Logging

Logging is crucial for developers to monitor system behavior and is often the first step in debugging. Logs can be voluminous, so quickly searching and filtering them is essential.

Logging with log/slog

Structured logs, which use key-value pairs, allow for efficient parsing, filtering, searching, and analysis.

The log/slog package introduced in Go 1.21 brings structured logging to the standard library.

Structured Logging

package main

import "log/slog"

func main() {
    slog.Info("hello, world", "key", "value")
}

Output:

2024/10/26 14:46:41 INFO hello, world key=value

TextHandler

To log in a text format:

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("hello, world", "key", "value")

Output:

time=2024-10-26T14:49:19.081+08:00 level=INFO msg="hello, world" key=value

JSONHandler

To log in JSON format:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello, world", "key", "value")

Output:

{"time":"2024-10-26T14:49:52.613743+08:00","level":"INFO","msg":"hello, world","key":"value"}

Logging Context Information

In Go, Context is a mechanism to carry deadlines, cancellation signals, and other request-scoped values across API boundaries and through layers of the application.

package main

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"
)

func processRequest(w http.ResponseWriter, r *http.Request) {
    slog.Info("processRequest",
        "requestId", r.Context().Value("request-id"),
        "name", r.PathValue("name"))
    
    fmt.Fprintf(w, "Hello %s!", r.PathValue("name"))
}

func main() {
    http.HandleFunc("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
        processRequest(w, r.WithContext(context.WithValue(r.Context(), "request-id", r.Header.Get("x-request-id"))))
    })

    if err := http.ListenAndServe(":3000", nil); err != nil {
        panic(err)
    }
}

Example request:

curl localhost:3000/hello/x -H 'X-Request-ID: 8b036946-b46b-4a24-bad0-254e109093a9'

Log:

2024/10/27 11:11:58 INFO processRequest requestId=8b036946-b46b-4a24-bad0-254e109093a9 name=x

Manually printing context information can be inconvenient. The slog package provides a method to automatically include context information.

package main

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"
    "os"
)

func processRequest(w http.ResponseWriter, r *http.Request) {
    r.Context().Value("logger").(*slog.Logger).Info("processRequest", "name", r.PathValue("name"))
    fmt.Fprintf(w, "Hello %s!", r.PathValue("name"))
}

func main() {
    http.HandleFunc("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
        logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
        logger = logger.With("request-id", r.Header.Get("x-request-id"))
        processRequest(w, r.WithContext(context.WithValue(r.Context(), "logger", logger)))
    })

    if err := http.ListenAndServe(":3000", nil); err != nil {
        panic(err)
    }
}

Custom Logger Handler

Alternatively, you can log contextual information using the .InfoContext() method, but this requires a custom log handler.

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
)

type ContextHandler struct {
    slog.Handler
}

func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
    if requestID, ok := ctx.Value("request-id").(string); ok {
        r.AddAttrs(slog.String("request-id", requestID))
    }
    return h.Handler.Handle(ctx, r)
}

func main() {
    logger := slog.New(&ContextHandler{Handler: slog.NewTextHandler(os.Stdout, nil)})

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), "request-id", r.Header.Get("x-request-id"))
        logger.InfoContext(ctx, "hello")
    })

    if err := http.ListenAndServe(":3000", nil); err != nil {
        panic(err)
    }
}

Parsing Request

Accessing Query Parameters

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
		query := r.URL.Query()
		fmt.Fprintf(w, "foo: %s\n", query.Get("foo"))
		fmt.Fprintf(w, "all: %+v", query["foo"])
	})
	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}
$ curl 'localhost:3000/query?foo=2&foo=3'                                                                                                                                                                                                                                                                                                        <<<
foo: 2
all: [2 3]

Parsing Request Body

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

type Person struct {
	Name string
	Age  int
}

func main() {
	http.HandleFunc("POST /person", func(w http.ResponseWriter, r *http.Request) {
		var p Person
		json.NewDecoder(r.Body).Decode(&p)
		fmt.Fprintf(w, "Person: %+v", p)
	})

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}
$ curl localhost:3000/person -id '{"age": 7, "name": "Alice"}'
HTTP/1.1 200 OK
Date: Sat, 26 Oct 2024 05:24:47 GMT
Content-Length: 26
Content-Type: text/plain; charset=utf-8

Person: {Name:Alice Age:7}

Error Handling

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

type Person struct {
    Name string
    Age  int
}

func main() {

	http.HandleFunc("POST /person", func(w http.ResponseWriter, r *http.Request) {
		var p Person
		if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		fmt.Fprintf(w, "Person: %+v", p)
	})

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}
$ curl localhost:3000/person -id "invalide body"
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Sat, 26 Oct 2024 05:24:40 GMT
Content-Length: 53

invalid character 'i' looking for beginning of value

Cookie

package main

import (
	"fmt"
	"log"
	"net/http"
)

func setCookieHandler(w http.ResponseWriter, _r *http.Request) {
	cookie := http.Cookie{
		Name:     "key",
		Value:    "value",
		Path:     "/",
		MaxAge:   3600,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	}
	http.SetCookie(w, &cookie)
	fmt.Fprintf(w, "cookie set!")
}

func getCookieHandler(w http.ResponseWriter, r *http.Request) {
	cookie, err := r.Cookie("key")
	if err != nil {
		http.Error(w, "cookie not found", http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, cookie.Value)
}

func main() {
	http.HandleFunc("POST /cookie", setCookieHandler)
	http.HandleFunc("GET /cookie", getCookieHandler)

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}

Ask for cookie:

$ curl -iX POST localhost:3000/cookie -c cookies.txt                                                                                                                                                                                                                                                                                              <<<
HTTP/1.1 200 OK
Set-Cookie: key=value; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
Date: Mon, 27 Oct 2024 05:37:24 GMT
Content-Length: 11
Content-Type: text/plain; charset=utf-8

cookie set!%

Check cookie is in cookiejar:

$ cat cookies.txt                                                                                                                                                                                                                                                                                                                                <<<
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost     FALSE   /       TRUE    1730007444      key     value

Request with cookie, see whether the server side can read it properly:

$ curl localhost:3000/cookie -vb cookies.txt
> GET /cookie HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.4.0
> Accept: */*
> Cookie: key=value
>
< HTTP/1.1 200 OK
< Date: Mon, 28 Oct 2024 05:38:30 GMT
< Content-Length: 5
< Content-Type: text/plain; charset=utf-8
<
value

Session

A session is a mechanism for storing user-specific information temporarily. It can be implemented in several ways.

For server-side sessions, when a user visits a website, the server generates a unique session ID and sends it to the client’s browser via a cookie. This session ID serves as a reference to the user’s data stored on the server.

Client-side sessions are easier to scale since they don’t require a database. One method is using signed cookies, where a signature is added to the cookie to prevent client-side tampering. However, since signed cookies are not encrypted, they should not contain sensitive information.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"net/http"
	"os"
	"time"
)

func signCookie(key string, name string, value string, expires time.Time) string {
	hash := hmac.New(sha256.New, []byte(key))
	hash.Write([]byte(name))
	hash.Write([]byte(string(expires.Unix())))
	hash.Write([]byte(value))
	return base64.URLEncoding.EncodeToString(append([]byte(value), hash.Sum(nil)...))
}

func setSignedCookie(w http.ResponseWriter, key string, name string, value string, path string, expires time.Time) {
	cookie := http.Cookie{
		Name:     name,
		Value:    signCookie(key, name, value, expires),
		Path:     path,
		HttpOnly: true,
		Secure:   true,
		Expires:  expires,
	}
	http.SetCookie(w, &cookie)
}

func getSingedCookie(r *http.Request, key string, name string) (string, error) {
	cookie, err := r.Cookie(name)
	if err != nil {
		return "", err
	}
	value, err := base64.URLEncoding.DecodeString(cookie.Value)
	if err != nil {
		return "", err
	}
	if cookie.Value == signCookie(key, cookie.Name, string(value[:len(value)-sha256.Size]), cookie.Expires) {
		return string(value[:len(value)-sha256.Size]), nil
	}
	return "", nil
}

func main() {

	key := os.Getenv("COOKIE_KEY")

	http.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
		query := r.URL.Query()
		setSignedCookie(w, key, "user", query.Get("user"), "/", time.Now().Add(time.Hour*1))
		fmt.Fprintln(w, "OK")
	})

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		user, err := getSingedCookie(r, key, "user")
		if err != nil {
			http.Error(w, "Forbidden", http.StatusForbidden)
			return
		}
		fmt.Fprintf(w, "hello, %s!", user)
	})

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}
curl -X POST 'localhost:3000/login?user=alice' -c cookies.txt                                                                                                                                                                                                                                                                                  <<<
OK
$ cat cookies.txt                                                                                                                                                                                                                                                                                                                                <<<
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost     FALSE   /       TRUE    1730000133      user    YWxpY2XyxknUWqVfmzlxWLYG-vFKePWo6wvg4nyzRLe-iLxPRQ==
$ curl localhost:3000 -b cookies.txt                                                                                                                                                                                                                                                                                                             <<<
hello, alice!%
$ curl -i localhost:3000
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Sun, 27 Oct 2024 02:40:40 GMT
Content-Length: 10

Forbidden

Authentication

For web applications, OAuth or password-based authentication is commonly used.

For APIs, token-based authentication is more prevalent.

Authentication with JWT

In simple terms, JWTs (JSON Web Tokens) are signed JSON documents that adhere to a specific format.

Add jwt/main.go:

package jwt

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"time"
)

type Claims struct {
	Issuer    string `json:"iss"`
	Subject   string `json:"sub"`
	Audience  string `json:"aud"`
	ExpiresAt int64  `json:"exp"`
	IssuedAt  int64  `json:"iat"`
	JwtId     string `json:"jti"`
}

func WriteJWT(secretKey []byte, claims Claims) (string, error) {
	payload, err := json.Marshal(claims)
	if err != nil {
		return "", err
	}
	message := base64.URLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) +
		"." + base64.URLEncoding.EncodeToString(payload)

	hash := hmac.New(sha256.New, secretKey)
	hash.Write([]byte(message))
	return message + "." + base64.URLEncoding.EncodeToString(hash.Sum(nil)), nil
}

func ReadJWT(secretKey []byte, jwt string) (Claims, error) {
	parts := strings.Split(jwt, ".")
	if len(parts) != 3 {
		return Claims{}, fmt.Errorf("invalid JWT format")
	}

	payloadBytes, err := base64.URLEncoding.DecodeString(parts[1])
	if err != nil {
		return Claims{}, fmt.Errorf("payload decode error: %w", err)
	}

	signatureBytes, err := base64.URLEncoding.DecodeString(parts[2])
	if err != nil {
		return Claims{}, fmt.Errorf("signature decode error: %w", err)
	}

	hash := hmac.New(sha256.New, secretKey)
	hash.Write([]byte(parts[0] + "." + parts[1]))
	if !hmac.Equal(signatureBytes, hash.Sum(nil)) {
		return Claims{}, fmt.Errorf("invalid JWT signature")
	}

	var claims Claims
	if err := json.Unmarshal(payloadBytes, &claims); err != nil {
		return Claims{}, fmt.Errorf("unmarshal claims error: %w", err)
	}

	if time.Now().Unix() > claims.ExpiresAt {
		return Claims{}, fmt.Errorf("JWT expired")
	}

	return claims, nil
}

Issuing a JWT Token

initialize Go modules:

$ go mod init wdgfs

Update server.go with following content:

package main

import (
	"fmt"
	"net/http"
	"os"
	"strings"
	"time"

	"wdgfs/jwt"
)

func main() {
	if os.Args[1] == "token" {
		claims := jwt.Claims{
			Issuer:    "issuer",
			Subject:   "subject",
			Audience:  "audience",
			ExpiresAt: time.Now().Add(time.Hour * 1).Unix(),
			IssuedAt:  time.Now().Unix(),
			JwtId:     os.Args[2],
		}
		token, _ := jwt.WriteJWT([]byte(os.Getenv("JWT_SECRET")), claims)
		fmt.Println(token)
		return
	}

	http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
		token := strings.Split(r.Header.Get("Authorization"), " ")
		if (len(token)) != 2 {
			http.Error(w, "Invalid token", http.StatusUnauthorized)
			return
		}
		claims, err := jwt.ReadJWT([]byte(os.Getenv("JWT_SECRET")), token[1])
		if err != nil {
			http.Error(w, err.Error(), http.StatusUnauthorized)
			return
		}
		fmt.Fprintf(w, "%+v", claims)
	})

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}

Issue a token

$ JWT_SECRET=secret go run server.go token admin
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Start server

$ JWT_SECRET=secret go run server.go serve

Make a request

$ curl localhost:3000/api
Invalid Token

Make a request with token

$ curl localhost:3000/api -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
{Issuer:issuer Subject:subject Audience:audience ExpiresAt:1730035781 IssuedAt:1730032181 JwtId:admin}

Data Validation

Data validators, like go-playground/validator utilize struct tags, which are intuitive and concise. I'd like to implement a simple validator for fun. For production use, it's best to choose a library.

Write a Validator

If you haven't initialized Go modules, do so with:

$ go mod init wdgfs

Then, add validation/main.go:

package validation

import (
	"fmt"
	"reflect"
	"regexp"
	"strings"
)

var validators = map[string]func(param string, value interface{}) bool{
	"required": func(param string, value interface{}) bool {
		return value != nil
	},
	"email": func(param string, value interface{}) bool {
		if val, ok := value.(string); ok {
			pattern := regexp.MustCompile(`^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$`)
			return pattern.MatchString(val)
		}
		return false
	},
}

func Validate(data interface{}) error {
	val := reflect.ValueOf(data)
	for i := range val.NumField() {
		field := val.Type().Field(i)
		for _, rule := range strings.Split(field.Tag.Get("validate"), ",") {
			if validator, ok := validators[rule]; ok {
				if !validator(rule, val.Field(i).Interface()) {
					return fmt.Errorf("%s failed validation rule %s", field.Name, rule)
				}
			} else {
				return fmt.Errorf("invalide validation rule %s", rule)
			}
		}
	}
	return nil
}

Use the Validator

Update server.go

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"wdgfs/validation"
)

type User struct {
	Name  string `validate:"required"`
	Email string `validate:"required,email"`
}

func main() {

	http.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
		var p User

		if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
			http.Error(w, "Invalid request payload", http.StatusBadRequest)
			return
		}

		if err := validation.Validate(p); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		fmt.Fprintf(w, "User: %+v", p)
	})

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}

Test the Validator

$ curl -i http://localhost:3000/users -d '{"name": "Alice", "email": "alice@test"}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Tue, 28 Oct 2024 01:13:21 GMT
Content-Length: 35

Email failed validation rule email
$ curl -i http://localhost:3000/users -d '{"name": "Alice", "email": "alice@test.co"}'
HTTP/1.1 200 OK
Date: Tue, 28 Oct 2024 01:13:25 GMT
Content-Length: 38
Content-Type: text/plain; charset=utf-8

User: {Name:Alice Email:alice@test.co}

Data Access

While it's certainly a challenging and rewarding endeavor to write a database driver from scratch, it's not practical for the complexity.

We will use pgx to talk to a PostgresQL database.

Install pgx

If you haven't initialized Go modules, do so with:

$ go mod init wdgfs

Next, install pgx v5:

$ go get github.com/jackc/pgx/v5/pgxpool

Take a look at the dependencies:

$ cat go.mod
module wdgfs

go 1.23.1

require (
        github.com/jackc/pgpassfile v1.0.0 // indirect
        github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
        github.com/jackc/pgx/v5 v5.7.1 // indirect
        github.com/jackc/puddle/v2 v2.2.2 // indirect
        golang.org/x/crypto v0.27.0 // indirect
        golang.org/x/sync v0.8.0 // indirect
        golang.org/x/text v0.18.0 // indirect
)

Setup PostgresQL

$ docker run --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=my_password -d postgres

Create Database

$ docker exec -it postgres createdb -U postgres mydb

Create Table

Add schema.sql

DROP TABLE IF EXISTS album;
CREATE TABLE album (
  id         SERIAL PRIMARY KEY,
  title      VARCHAR(128) NOT NULL,
  artist     VARCHAR(255) NOT NULL,
  price      NUMERIC(5,2) NOT NULL
);

INSERT INTO album
  (title, artist, price)
VALUES
  ('Blue Train', 'John Coltrane', 56.99),
  ('Giant Steps', 'John Coltrane', 63.99),
  ('Jeru', 'Gerry Mulligan', 17.99),
  ('Sarah Vaughan', 'Sarah Vaughan', 34.98);

Execute the sql file:

$ docker exec -i postgres psql -U postgres mydb < schema.sql

Query PostgresQL

Update server.go with following content:

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type Album struct {
	ID     int64
	Title  string
	Artist string
	Price  float32
}

func main() {
	dbpool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
	if err != nil {
		fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err)
		os.Exit(1)
	}
	defer dbpool.Close()

	rows, err := dbpool.Query(context.Background(), "select * from album limit 2")
	if err != nil {
		fmt.Fprintf(os.Stderr, "Query failed: %v\n", err)
		os.Exit(1)
	}

	albums, err := pgx.CollectRows(rows, pgx.RowToStructByName[Album])
	if err != nil {
		fmt.Fprintf(os.Stderr, "Failed to collect rows: %v\n", err)
		os.Exit(1)
	}
	fmt.Println(albums)
}
$ export DATABASE_URL=postgresql://postgres:my_password@localhost:5432/mydb
$ go run server.go
[{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]

RESTful APIs

Writing a simple API using mostly vanilla go.

The file structure is as follows:

├── go.mod
├── handler
│   └── album.go
├── rest
│   └── rest.go
├── db
│   └── psql.go
└── server.go

The handler/album.go looks like following:

package handler

import (
	"net/http"
	"wdgfs/db"
	"wdgfs/rest"
)

type Album struct {
	ID     int64   `json:"id"`
	Title  string  `json:"title"`
	Artist string  `json:"artist"`
	Price  float32 `json:"price"`
}

func (p *Album) Index(r *http.Request) (any, error) {
	return db.Query[Album](r.Context(), `select * from album`)
}

func (p *Album) Create(r *http.Request) (any, error) {
	body := r.Context().Value(rest.Body).(Album)
	return db.Exec(r.Context(),
        `insert into album (title, artist, price) values ($1, $2, $3)`,
		body.Title, body.Artist, body.Price)
}

func (p *Album) Retrive(r *http.Request, id string) (any, error) {
	return db.QueryRow[Album](r.Context(), `select * from album where id = $1`, id)
}

func (p *Album) Update(r *http.Request, id string) (any, error) {
	body := r.Context().Value(rest.Body).(Album)
	return db.Exec(r.Context(), `update album set title=$1, artist=$2, price=$3`,
		body.Title, body.Artist, body.Price)
}

func (p *Album) Delete(r *http.Request, id string) (any, error) {
	return db.Exec(r.Context(), `delete from album where id=$1`, id)
}

A simple DB query layer is provided in db/psql.go:

package db

import (
	"context"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"
)

var dbpool *pgxpool.Pool

func GetPool(ctx context.Context) (*pgxpool.Pool, error) {
	if dbpool == nil {
		var err error
		dbpool, err = pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
		if err != nil {
			return dbpool, err
		}
	}
	return dbpool, nil
}

func Query[T any](ctx context.Context, sql string, params ...any) ([]T, error) {
	if _, err := GetPool(ctx); err != nil {
		return nil, err
	}
	rows, err := dbpool.Query(ctx, sql, params...)
	if err != nil {
		return nil, err
	}
	return pgx.CollectRows(rows, pgx.RowToStructByName[T])
}

func QueryRow[T any](ctx context.Context, sql string, params ...any) (T, error) {
	var t T
	if _, err := GetPool(ctx); err != nil {
		return t, err
	}
	rows, err := dbpool.Query(ctx, sql, params...)
	if err != nil {
		return t, err
	}
	return pgx.CollectOneRow(rows, pgx.RowToStructByName[T])
}

func Exec(ctx context.Context, sql string, params ...any) (pgconn.CommandTag, error) {
	if _, err := GetPool(ctx); err != nil {
		return pgconn.CommandTag{}, err
	}
	return dbpool.Exec(ctx, sql, params...)
}

REST style routing is done in rest/rest.go:

package rest

import (
	"context"
	"encoding/json"
	"net/http"
)

type HttpError struct {
	Code    int
	Message string
}

func (err HttpError) Error() string {
	return err.Message
}

type RestfulResource interface {
	Index(r *http.Request) (any, error)
	Create(r *http.Request) (any, error)
	Retrive(r *http.Request, id string) (any, error)
	Update(r *http.Request, id string) (any, error)
	Delete(r *http.Request, id string) (any, error)
}

type RestfulController[T any] struct {
	Resource RestfulResource
}

type Handler func(r *http.Request) (any, error)

func HandleError(handler Handler) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		data, err := handler(r)
		if err != nil {
			if e, ok := err.(HttpError); ok {
				http.Error(w, e.Message, e.Code)
			} else {
				http.Error(w, err.Error(), http.StatusInternalServerError)
			}
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(data)
	}
}

type ContextKey struct{}

var Body = ContextKey{}

func ParseBody[T any](handler Handler) Handler {
	return func(r *http.Request) (any, error) {
		var body T
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			return nil, err
		}
		return handler(r.WithContext(
			context.WithValue(r.Context(), Body, body)))
	}
}

type HandlerWithId func(r *http.Request, id string) (any, error)

func WithId(handler HandlerWithId) Handler {
	return func(r *http.Request) (any, error) {
		return handler(r, r.PathValue("id"))
	}
}

func (c *RestfulController[T]) Mount(path string) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("POST "+path, HandleError(ParseBody[T](c.Resource.Create)))
	mux.HandleFunc("GET "+path+"{id}", HandleError(WithId(c.Resource.Retrive)))
	mux.HandleFunc("PUT "+path+"{id}", HandleError(ParseBody[T](WithId(c.Resource.Update))))
	mux.HandleFunc("DELETE "+path+"{id}", HandleError(WithId(c.Resource.Delete)))
	mux.HandleFunc("GET "+path, HandleError(c.Resource.Index))
	return mux
}

Finally connect them all together in server.go:

package main

import (
	"net/http"
	"wdgfs/handler"
	"wdgfs/rest"
)

func main() {

	http.Handle("/albums/", (&rest.RestfulController[handler.Album]{
		Resource: &handler.Album{},
	}).Mount("/albums/"))

	if err := http.ListenAndServe(":3000", nil); err != nil {
		panic(err)
	}
}