package rest import ( "errors" "net/http" "net/url" "dynatron.me/x/stillbox/internal/common" "dynatron.me/x/stillbox/pkg/rbac" "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/talkgroups/tgstore" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/go-viper/mapstructure/v2" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/rs/zerolog/log" ) type API interface { Subrouter() http.Handler } type APIRoot interface { API ShareRouter() http.Handler } type api struct { baseURL *url.URL shares *shareAPI tgs *talkgroupAPI calls *callsAPI users *usersAPI incidents *incidentsAPI } func (a *api) ShareRouter() http.Handler { return a.shares.RootRouter() } func New(baseURL url.URL) *api { s := &api{ baseURL: &baseURL, tgs: new(talkgroupAPI), calls: new(callsAPI), incidents: newIncidentsAPI(&baseURL), users: new(usersAPI), } s.shares = newShareAPI(&baseURL, ShareHandlers{ ShareRequestCall: s.calls.shareCallRoute, ShareRequestCallDL: s.calls.shareCallDLRoute, ShareRequestIncident: s.incidents.getIncident, ShareRequestIncidentM3U: s.incidents.getCallsM3U, ShareRequestTalkgroups: s.tgs.getTGsShareRoute, }, ) return s } func (a *api) Subrouter() http.Handler { r := chi.NewMux() r.Mount("/talkgroup", a.tgs.Subrouter()) r.Mount("/user", a.users.Subrouter()) r.Mount("/call", a.calls.Subrouter()) r.Mount("/incident", a.incidents.Subrouter()) r.Mount("/share", a.shares.Subrouter()) return r } type errResponse struct { Err error `json:"-"` Code int `json:"-"` Error string `json:"error"` } func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error { switch e.Code { 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 badRequest(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusBadRequest, Error: "Bad request", } } func badRequestErrText(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusBadRequest, Error: "Bad request: " + err.Error(), } } func unauthErrText(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusUnauthorized, Error: "Unauthorized: " + err.Error(), } } func forbiddenErrText(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusForbidden, Error: "Forbidden: " + err.Error(), } } func constraintErrText(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusConflict, Error: "Constraint violation: " + err.Error(), } } func recordNotFound(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusNotFound, Error: "Record not found", } } func notFoundErrText(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusNotFound, Error: "Record not found: " + err.Error(), } } func internalError(err error) render.Renderer { return &errResponse{ Err: err, Code: http.StatusInternalServerError, Error: "Internal server error", } } type errResponder func(error) render.Renderer var statusMapping = map[error]errResponder{ tgstore.ErrNoSuchSystem: notFoundErrText, tgstore.ErrNotFound: notFoundErrText, tgstore.ErrInvalidOrderBy: badRequestErrText, tgstore.ErrBadDirection: badRequestErrText, tgstore.ErrBadOrder: badRequestErrText, pgx.ErrNoRows: recordNotFound, ErrMissingTGSys: badRequestErrText, ErrTGIDMismatch: badRequestErrText, ErrSysMismatch: badRequestErrText, tgstore.ErrReference: constraintErrText, rbac.ErrBadSubject: unauthErrText, ErrBadAppName: unauthErrText, common.ErrPageOutOfRange: badRequestErrText, rbac.ErrNotAuthorized: unauthErrText, shares.ErrNoShare: notFoundErrText, ErrBadShare: notFoundErrText, shares.ErrBadType: badRequestErrText, } func autoError(err error) render.Renderer { c, ok := statusMapping[err] if ok { c(err) } for e, c := range statusMapping { // check if err wraps an error we know about if errors.Is(err, e) { return c(err) } } if rbac.ErrAccessDenied(err) != nil { return forbiddenErrText(err) } return internalError(err) } func wErr(w http.ResponseWriter, r *http.Request, v render.Renderer) { err := render.Render(w, r, v) if err != nil { log.Error().Err(err).Msg("wErr render error") } } func decodeParams(d interface{}, r *http.Request) error { params := chi.RouteContext(r.Context()).URLParams m := make(map[string]string, len(params.Keys)) for i, k := range params.Keys { m[k] = params.Values[i] } dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ Metadata: nil, Result: d, TagName: "param", WeaklyTypedInput: true, DecodeHook: mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.TextUnmarshallerHookFunc(), ), }) if err != nil { return err } return dec.Decode(m) } // idOnlyParam checks for a sole URL parameter, id, and writes an errorif this fails. func idOnlyParam(w http.ResponseWriter, r *http.Request) (uuid.UUID, error) { params := struct { ID uuid.UUID `param:"id"` }{} err := decodeParams(¶ms, r) if err != nil { wErr(w, r, badRequest(err)) return uuid.UUID{}, err } return params.ID, nil } func respond(w http.ResponseWriter, r *http.Request, v interface{}) { render.DefaultResponder(w, r, v) }