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.
Signed Cookie
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)
}
}