Compare commits

...

3 commits

Author SHA1 Message Date
759c274950 Use chi render, improvements 2024-11-10 14:40:50 -05:00
cecbeb78fe Rename ctx keys 2024-11-10 10:28:04 -05:00
e97c9ced0e use profiler middleware 2024-11-10 10:17:02 -05:00
6 changed files with 80 additions and 60 deletions

View file

@ -1,13 +1,13 @@
package api package api
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"dynatron.me/x/stillbox/pkg/talkgroups" "dynatron.me/x/stillbox/pkg/talkgroups"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -29,57 +29,81 @@ func New() API {
func (a *api) Subrouter() http.Handler { func (a *api) Subrouter() http.Handler {
r := chi.NewMux() r := chi.NewMux()
r.Mount("/talkgroup", new(talkgroupAPI).routes()) r.Mount("/talkgroup", new(talkgroupAPI).Subrouter())
return r return r
} }
type errResponse struct { type errResponse struct {
text string Err error `json:"-"`
code int Code int `json:"-"`
Error string `json:"error"`
} }
var statusMapping = map[error]errResponse{ func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error {
talkgroups.ErrNotFound: {talkgroups.ErrNotFound.Error(), http.StatusNotFound}, switch e.Code {
pgx.ErrNoRows: {"no such record", http.StatusNotFound}, case http.StatusNotFound:
case http.StatusBadRequest:
default:
log.Error().Str("path", r.URL.Path).Err(e.Err).Int("code", e.Code).Str("msg", e.Error).Msg("request failed")
}
render.Status(r, e.Code)
return nil
} }
func httpCode(err error) (string, int) { func badRequest(err error) render.Renderer {
return &errResponse{
Err: err,
Code: http.StatusBadRequest,
Error: "Bad request",
}
}
func recordNotFound(err error) render.Renderer {
return &errResponse{
Err: err,
Code: http.StatusNotFound,
Error: "Record not found",
}
}
func internalError(err error) render.Renderer {
return &errResponse{
Err: err,
Code: http.StatusNotFound,
Error: "Internal server error",
}
}
type errResponder func(error) render.Renderer
var statusMapping = map[error]errResponder{
talkgroups.ErrNotFound: recordNotFound,
pgx.ErrNoRows: recordNotFound,
}
func autoError(err error) render.Renderer {
c, ok := statusMapping[err] c, ok := statusMapping[err]
if ok { if ok {
return c.text, c.code c(err)
} }
for e, c := range statusMapping { // check if err wraps an error we know about for e, c := range statusMapping { // check if err wraps an error we know about
if errors.Is(err, e) { if errors.Is(err, e) {
return c.text, c.code return c(err)
} }
} }
return err.Error(), http.StatusInternalServerError return internalError(err)
} }
func writeResponse(w http.ResponseWriter, r *http.Request, data interface{}, err error) { func wErr(w http.ResponseWriter, r *http.Request, v render.Renderer) {
err := render.Render(w, r, v)
if err != nil { if err != nil {
log.Error().Str("path", r.URL.Path).Err(err).Msg("request failed") log.Error().Err(err).Msg("wErr render error")
text, code := httpCode(err)
http.Error(w, text, code)
return
} }
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
err = enc.Encode(data)
if err != nil {
log.Error().Str("path", r.URL.Path).Err(err).Msg("response marshal failed")
text, code := httpCode(err)
http.Error(w, text, code)
return
}
}
func reqErr(w http.ResponseWriter, err error, code int) {
http.Error(w, err.Error(), code)
} }
func decodeParams(d interface{}, r *http.Request) error { func decodeParams(d interface{}, r *http.Request) error {
@ -103,6 +127,6 @@ func decodeParams(d interface{}, r *http.Request) error {
return dec.Decode(m) return dec.Decode(m)
} }
func badReq(w http.ResponseWriter, err error) { func respond(w http.ResponseWriter, r *http.Request, v interface{}) {
reqErr(w, err, http.StatusBadRequest) render.DefaultResponder(w, r, v)
} }

View file

@ -1,7 +1,6 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"dynatron.me/x/stillbox/internal/forms" "dynatron.me/x/stillbox/internal/forms"
@ -14,7 +13,7 @@ import (
type talkgroupAPI struct { type talkgroupAPI struct {
} }
func (tga *talkgroupAPI) routes() http.Handler { func (tga *talkgroupAPI) Subrouter() http.Handler {
r := chi.NewMux() r := chi.NewMux()
r.Get("/{system:\\d+}/{id:\\d+}", tga.talkgroup) r.Get("/{system:\\d+}/{id:\\d+}", tga.talkgroup)
@ -57,7 +56,7 @@ func (tga *talkgroupAPI) talkgroup(w http.ResponseWriter, r *http.Request) {
err := decodeParams(&p, r) err := decodeParams(&p, r)
if err != nil { if err != nil {
badReq(w, err) wErr(w, r, badRequest(err))
return return
} }
@ -71,14 +70,19 @@ func (tga *talkgroupAPI) talkgroup(w http.ResponseWriter, r *http.Request) {
res, err = tgs.TGs(ctx, nil) res, err = tgs.TGs(ctx, nil)
} }
writeResponse(w, r, res, err) if err != nil {
wErr(w, r, autoError(err))
return
}
respond(w, r, res)
} }
func (tga *talkgroupAPI) putTalkgroup(w http.ResponseWriter, r *http.Request) { func (tga *talkgroupAPI) putTalkgroup(w http.ResponseWriter, r *http.Request) {
var id tgParams var id tgParams
err := decodeParams(&id, r) err := decodeParams(&id, r)
if err != nil { if err != nil {
badReq(w, err) wErr(w, r, badRequest(err))
return return
} }
@ -89,19 +93,16 @@ func (tga *talkgroupAPI) putTalkgroup(w http.ResponseWriter, r *http.Request) {
err = forms.Unmarshal(r, &input, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty()) err = forms.Unmarshal(r, &input, forms.WithTag("json"), forms.WithAcceptBlank(), forms.WithOmitEmpty())
if err != nil { if err != nil {
writeResponse(w, r, nil, err) wErr(w, r, badRequest(err))
return return
} }
input.ID = id.ToID().Pack() input.ID = id.ToID().Pack()
record, err := tgs.UpdateTG(ctx, input) record, err := tgs.UpdateTG(ctx, input)
if err != nil { if err != nil {
writeResponse(w, r, nil, err) wErr(w, r, autoError(err))
return return
} }
err = json.NewEncoder(w).Encode(record) respond(w, r, record)
if err != nil {
writeResponse(w, r, nil, err)
}
} }

View file

@ -56,13 +56,13 @@ func NewClient(ctx context.Context, conf config.DB) (*DB, error) {
return db, nil return db, nil
} }
type DBCtxKey string type dBCtxKey string
const DBCtxKeyValue DBCtxKey = "dbctx" const DBCtxKey dBCtxKey = "dbctx"
// FromCtx returns the database handle from the provided Context. // FromCtx returns the database handle from the provided Context.
func FromCtx(ctx context.Context) *DB { func FromCtx(ctx context.Context) *DB {
c, ok := ctx.Value(DBCtxKeyValue).(*DB) c, ok := ctx.Value(DBCtxKey).(*DB)
if !ok { if !ok {
panic("no DB in context") panic("no DB in context")
} }
@ -72,7 +72,7 @@ func FromCtx(ctx context.Context) *DB {
// CtxWithDB returns a Context with the provided database handle. // CtxWithDB returns a Context with the provided database handle.
func CtxWithDB(ctx context.Context, conn *DB) context.Context { func CtxWithDB(ctx context.Context, conn *DB) context.Context {
return context.WithValue(ctx, DBCtxKeyValue, conn) return context.WithValue(ctx, DBCtxKey, conn)
} }
// IsNoRows is a convenience function that returns whether a returned error is a database // IsNoRows is a convenience function that returns whether a returned error is a database

View file

@ -4,14 +4,9 @@
package server package server
import ( import (
"net/http/pprof" "github.com/go-chi/chi/v5/middleware"
) )
func (s *Server) installPprof() { func (s *Server) installPprof() {
r := s.r s.r.Mount("/debug", middleware.Profiler())
r.HandleFunc("/debug/pprof/", pprof.Index)
r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
r.HandleFunc("/debug/pprof/profile", pprof.Profile)
r.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
r.HandleFunc("/debug/pprof/trace", pprof.Trace)
} }

View file

@ -27,8 +27,8 @@ func (s *Server) setupRoutes() {
} }
r := s.r r := s.r
r.Use(middleware.WithValue(database.DBCtxKeyValue, s.db)) r.Use(middleware.WithValue(database.DBCtxKey, s.db))
r.Use(middleware.WithValue(talkgroups.StoreCtxKeyValue, s.tgs)) r.Use(middleware.WithValue(talkgroups.StoreCtxKey, s.tgs))
s.installPprof() s.installPprof()

View file

@ -57,16 +57,16 @@ type Store interface {
HUP(*config.Config) HUP(*config.Config)
} }
type CtxStoreKey string type storeCtxKey string
const StoreCtxKeyValue CtxStoreKey = "store" const StoreCtxKey storeCtxKey = "store"
func CtxWithStore(ctx context.Context, s Store) context.Context { func CtxWithStore(ctx context.Context, s Store) context.Context {
return context.WithValue(ctx, StoreCtxKeyValue, s) return context.WithValue(ctx, StoreCtxKey, s)
} }
func StoreFrom(ctx context.Context) Store { func StoreFrom(ctx context.Context) Store {
s, ok := ctx.Value(StoreCtxKeyValue).(Store) s, ok := ctx.Value(StoreCtxKey).(Store)
if !ok { if !ok {
return NewCache() return NewCache()
} }