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)
	}
}