// Developing a RESTful API with Go and ... Go
//
// This is a rewrite of https://golang.org/doc/tutorial/web-service-gin
// using just the Go standard library (and fixing a few issues).
package main
import (
"encoding/json"
"errors"
"flag"
"io"
"log"
"net/http"
"regexp"
"sort"
"strconv"
"sync"
)
func main() {
// Allow user to specify listen port on command line
var port int
flag.IntVar(&port, "port", 8080, "port to listen on")
flag.Parse()
// Create in-memory database and add a couple of test albums
db := NewMemoryDatabase()
db.AddAlbum(Album{ID: "a1", Title: "9th Symphony", Artist: "Beethoven", Price: 795})
db.AddAlbum(Album{ID: "a2", Title: "Hey Jude", Artist: "The Beatles", Price: 2000})
// Create server and wire up database
server := NewServer(db, log.Default())
log.Printf("listening on http://localhost:%d", port)
http.ListenAndServe(":"+strconv.Itoa(port), server)
}
// Server is the album HTTP server.
type Server struct {
db Database
log *log.Logger
}
// Database is the interface used by the server to load and store albums.
type Database interface {
// GetAlbums returns a copy of all albums, sorted by ID.
GetAlbums() ([]Album, error)
// GetAlbumsByID returns a single album by ID, or ErrDoesNotExist if
// an album with that ID does not exist.
GetAlbumByID(id string) (Album, error)
// AddAlbum adds a single album, or ErrAlreadyExists if an album with
// the given ID already exists.
AddAlbum(album Album) error
}
var (
ErrDoesNotExist = errors.New("does not exist")
ErrAlreadyExists = errors.New("already exists")
)
const (
ErrorAlreadyExists = "already-exists"
ErrorDatabase = "database"
ErrorInternal = "internal"
ErrorMalformedJSON = "malformed-json"
ErrorMethodNotAllowed = "method-not-allowed"
ErrorNotFound = "not-found"
ErrorValidation = "validation"
)
// Album represents data about a single album.
type Album struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Price int `json:"price,omitempty"` // use int cents instead of float64 for currency
}
// NewServer creates a new server using the given database implementation.
func NewServer(db Database, log *log.Logger) *Server {
return &Server{db: db, log: log}
}
// Regex to match "/albums/:id" (id must be one or more non-slash chars).
var reAlbumsID = regexp.MustCompile(`^/albums/([^/]+)$`)
// ServeHTTP routes the request and calls the correct handler based on the URL
// and HTTP method. It writes a 404 Not Found if the request URL is unknown,
// or 405 Method Not Allowed if the request method is invalid.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
s.log.Printf("%s %s", r.Method, path)
var id string
switch {
case path == "/albums":
switch r.Method {
case "GET":
s.getAlbums(w, r)
case "POST":
s.addAlbum(w, r)
default:
w.Header().Set("Allow", "GET, POST")
s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
}
case match(path, reAlbumsID, &id):
switch r.Method {
case "GET":
s.getAlbumByID(w, r, id)
default:
w.Header().Set("Allow", "GET")
s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
}
default:
s.jsonError(w, http.StatusNotFound, ErrorNotFound, nil)
}
}
// match returns true if path matches the regex pattern, and binds any
// capturing groups in pattern to the vars.
func match(path string, pattern *regexp.Regexp, vars ...*string) bool {
matches := pattern.FindStringSubmatch(path)
if len(matches) <= 0 {
return false
}
for i, match := range matches[1:] {
*vars[i] = match
}
return true
}
func (s *Server) getAlbums(w http.ResponseWriter, r *http.Request) {
albums, err := s.db.GetAlbums()
if err != nil {
s.log.Printf("error fetching albums: %v", err)
s.jsonError(w, http.StatusInternalServerError, ErrorDatabase, nil)
return
}
s.writeJSON(w, http.StatusOK, albums)
}
func (s *Server) addAlbum(w http.ResponseWriter, r *http.Request) {
var album Album
if !s.readJSON(w, r, &album) {
return
}
// Validate the input and build a map of validation issues
type validationIssue struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
issues := make(map[string]interface{})
if album.ID == "" {
issues["id"] = validationIssue{"required", ""}
}
if album.Title == "" {
issues["title"] = validationIssue{"required", ""}
}
if album.Artist == "" {
issues["artist"] = validationIssue{"required", ""}
}
if album.Price < 0 || album.Price >= 100000 {
issues["price"] = validationIssue{"out-of-range", "price must be between 0 and $1000"}
}
if len(issues) > 0 {
s.jsonError(w, http.StatusBadRequest, ErrorValidation, issues)
return
}
err := s.db.AddAlbum(album)
if errors.Is(err, ErrAlreadyExists) {
s.jsonError(w, http.StatusConflict, ErrorAlreadyExists, nil)
return
} else if err != nil {
s.log.Printf("error adding album ID %q: %v", album.ID, err)
s.jsonError(w, http.StatusInternalServerError, ErrorDatabase, nil)
return
}
s.writeJSON(w, http.StatusCreated, album)
}
func (s *Server) getAlbumByID(w http.ResponseWriter, r *http.Request, id string) {
album, err := s.db.GetAlbumByID(id)
if errors.Is(err, ErrDoesNotExist) {
s.jsonError(w, http.StatusNotFound, ErrorNotFound, nil)
return
} else if err != nil {
s.log.Printf("error fetching album ID %q: %v", id, err)
s.jsonError(w, http.StatusInternalServerError, ErrorDatabase, nil)
return
}
s.writeJSON(w, http.StatusOK, album)
}
// writeJSON marshals v to JSON and writes it to the response, handling
// errors as appropriate. It also sets the Content-Type header to
// "application/json".
func (s *Server) writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
s.log.Printf("error marshaling JSON: %v", err)
http.Error(w, `{"error":"`+ErrorInternal+`"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(status)
_, err = w.Write(b)
if err != nil {
// Very unlikely to happen, but log any error (not much more we can do)
s.log.Printf("error writing JSON: %v", err)
}
}
// jsonError writes a structured error as JSON to the response, with
// optional structured data in the "data" field.
func (s *Server) jsonError(w http.ResponseWriter, status int, error string, data map[string]interface{}) {
response := struct {
Status int `json:"status"`
Error string `json:"error"`
Data map[string]interface{} `json:"data,omitempty"`
}{
Status: status,
Error: error,
Data: data,
}
s.writeJSON(w, status, response)
}
// readJSON reads the request body and unmarshals it from JSON, handling
// errors as appropriate. It returns true on success; the caller should
// return from the handler early if it returns false.
func (s *Server) readJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
b, err := io.ReadAll(r.Body)
if err != nil {
s.log.Printf("error reading JSON body: %v", err)
s.jsonError(w, http.StatusInternalServerError, ErrorInternal, nil)
return false
}
err = json.Unmarshal(b, v)
if err != nil {
data := map[string]interface{}{"message": err.Error()}
s.jsonError(w, http.StatusBadRequest, ErrorMalformedJSON, data)
return false
}
return true
}
// MemoryDatabase is a Database implementation that uses a simple
// in-memory map to store the albums.
type MemoryDatabase struct {
lock sync.RWMutex
albums map[string]Album
}
// NewMemoryDatabase creates a new in-memory database.
func NewMemoryDatabase() *MemoryDatabase {
return &MemoryDatabase{albums: make(map[string]Album)}
}
func (d *MemoryDatabase) GetAlbums() ([]Album, error) {
d.lock.RLock()
defer d.lock.RUnlock()
// Make a copy of the albums map (as a slice)
albums := make([]Album, 0, len(d.albums))
for _, album := range d.albums {
albums = append(albums, album)
}
// Sort by ID so we return them in a defined order
sort.Slice(albums, func(i, j int) bool {
return albums[i].ID < albums[j].ID
})
return albums, nil
}
func (d *MemoryDatabase) GetAlbumByID(id string) (Album, error) {
d.lock.RLock()
defer d.lock.RUnlock()
album, ok := d.albums[id]
if !ok {
return Album{}, ErrDoesNotExist
}
return album, nil
}
func (d *MemoryDatabase) AddAlbum(album Album) error {
d.lock.Lock()
defer d.lock.Unlock()
if _, ok := d.albums[album.ID]; ok {
return ErrAlreadyExists
}
d.albums[album.ID] = album
return nil
}