Files
mixmaker/backend/internal/adapter/httpapi/server.go

723 lines
23 KiB
Go

package httpapi
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
"mixmaker/backend/internal/realtime"
)
type Config struct {
DiscordClientID, DiscordClientSecret, DiscordRedirectURL string
FrontendURL, CookieName string
SecureCookies bool
SessionTTL time.Duration
AdminDiscordIDs map[string]bool
}
type Server struct {
service *application.Service
store application.Store
hub *realtime.Hub
cfg Config
client *http.Client
}
type identity struct {
account domain.Account
player domain.Player
session string
}
type identityKey struct{}
func New(service *application.Service, store application.Store, hub *realtime.Hub, cfg Config) http.Handler {
s := &Server{service: service, store: store, hub: hub, cfg: cfg, client: &http.Client{Timeout: 10 * time.Second}}
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Get("/readyz", s.ready)
r.Get("/api/auth/discord", s.discordLogin)
r.Get("/api/auth/discord/callback", s.discordCallback)
r.Post("/api/auth/logout", s.logout)
r.Group(func(api chi.Router) {
api.Use(s.authenticate)
api.Get("/api/auth/me", s.me)
api.Get("/api/me", s.me)
api.Patch("/api/me/player", s.updateProfile)
api.Get("/api/players", s.players)
api.Get("/api/events", s.events)
api.Post("/api/events", s.createEvent)
api.Get("/api/events/{eventID}", s.getEvent)
api.Put("/api/events/{eventID}", s.updateEvent)
api.Delete("/api/events/{eventID}", s.deleteEvent)
api.Post("/api/events/{eventID}/cancel", s.cancelEvent)
api.Get("/api/events/{eventID}/tournament", s.getEventTournament)
api.Get("/api/events/{eventID}/rsvps", s.rsvps)
api.Post("/api/events/{eventID}/participants", s.createParticipant)
api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP)
api.Delete("/api/events/{eventID}/rsvps/{playerID}", s.removeParticipant)
api.Post("/api/events/{eventID}/balance", s.balance)
api.Put("/api/events/{eventID}/teams", s.selectBalance)
api.Get("/api/events/{eventID}/teams", s.teams)
api.Post("/api/events/{eventID}/registration/close", s.closeRegistration)
api.Post("/api/events/{eventID}/balance/generate", s.generateWorkflowBalance)
api.Get("/api/events/{eventID}/roster", s.getRoster)
api.Put("/api/events/{eventID}/roster", s.selectWorkflowBalance)
api.Post("/api/events/{eventID}/roster/swap", s.swapRoster)
api.Post("/api/events/{eventID}/roster/substitute", s.substituteRoster)
api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters)
api.Post("/api/events/{eventID}/start", s.startScrim)
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
api.Post("/api/rulesets", s.saveRuleset)
api.Get("/api/rulesets", s.listRulesets)
api.Get("/api/rulesets/{id}", s.getRuleset)
api.Post("/api/coin-toss", s.coinToss)
api.Post("/api/drafts/maps", s.createMapDraft)
api.Post("/api/drafts/{id}/map-bans", s.mapBan)
api.Post("/api/drafts/heroes", s.createHeroDraft)
api.Post("/api/drafts/{id}/hero-bans", s.heroBan)
api.Post("/api/tournaments", s.createTournament)
api.Get("/api/tournaments/{id}", s.getTournament)
api.Get("/api/series/{id}", s.getSeries)
api.Post("/api/series/{id}/coin-toss", s.tossSeriesCoin)
api.Post("/api/series/{id}/map-bans", s.banSeriesMap)
api.Post("/api/series/{id}/map-pick", s.pickSeriesMap)
api.Post("/api/series/{id}/hero-bans", s.banSeriesHero)
api.Post("/api/series/{id}/map-result", s.recordSeriesResult)
api.Post("/api/series/{id}/results", s.recordResult)
api.Patch("/api/series/{id}/results/{index}", s.correctResult)
api.Get("/api/events/stream", s.stream)
})
return r
}
func (s *Server) ready(w http.ResponseWriter, r *http.Request) {
if err := s.store.Ready(r.Context()); err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func randomToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) cookie(name, value string, expires time.Time) *http.Cookie {
return &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, Expires: expires}
}
func (s *Server) discordLogin(w http.ResponseWriter, r *http.Request) {
state := randomToken()
http.SetCookie(w, s.cookie("mixmaker_oauth_state", state, time.Now().Add(10*time.Minute)))
q := url.Values{"client_id": {s.cfg.DiscordClientID}, "redirect_uri": {s.cfg.DiscordRedirectURL}, "response_type": {"code"}, "scope": {"identify"}, "state": {state}}
http.Redirect(w, r, "https://discord.com/oauth2/authorize?"+q.Encode(), http.StatusFound)
}
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) {
stateCookie, err := r.Cookie("mixmaker_oauth_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
writeError(w, fmt.Errorf("%w: invalid OAuth state", domain.ErrInvalid))
return
}
form := url.Values{"client_id": {s.cfg.DiscordClientID}, "client_secret": {s.cfg.DiscordClientSecret}, "grant_type": {"authorization_code"}, "code": {r.URL.Query().Get("code")}, "redirect_uri": {s.cfg.DiscordRedirectURL}}
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
writeError(w, err)
return
}
defer resp.Body.Close()
var token struct {
AccessToken string `json:"access_token"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil {
writeError(w, fmt.Errorf("Discord token exchange failed"))
return
}
req, _ = http.NewRequestWithContext(r.Context(), http.MethodGet, "https://discord.com/api/users/@me", nil)
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
resp, err = s.client.Do(req)
if err != nil {
writeError(w, err)
return
}
defer resp.Body.Close()
var user struct{ ID, Username, Avatar string }
if resp.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&user) != nil {
writeError(w, fmt.Errorf("Discord identity request failed"))
return
}
now := time.Now().UTC()
account := domain.Account{ID: application.NewID(), DiscordID: user.ID, Username: user.Username, AvatarURL: user.Avatar, CreatedAt: now}
account, _, err = s.store.UpsertDiscordAccount(r.Context(), account, s.cfg.AdminDiscordIDs[user.ID])
if err != nil {
writeError(w, err)
return
}
session := randomToken()
ttl := s.cfg.SessionTTL
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
expires := now.Add(ttl)
if err := s.store.CreateSession(r.Context(), session, account.ID, expires); err != nil {
writeError(w, err)
return
}
http.SetCookie(w, s.cookie(s.cfg.CookieName, session, expires))
http.SetCookie(w, s.cookie("mixmaker_oauth_state", "", time.Unix(1, 0)))
http.Redirect(w, r, s.cfg.FrontendURL, http.StatusFound)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(s.cfg.CookieName); err == nil {
_ = s.store.DeleteSession(r.Context(), c.Value)
}
http.SetCookie(w, s.cookie(s.cfg.CookieName, "", time.Unix(1, 0)))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(s.cfg.CookieName)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
a, p, err := s.service.Authenticate(r.Context(), c.Value)
if err != nil {
writeError(w, domain.ErrUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{a, p, c.Value})))
})
}
func who(r *http.Request) identity { return r.Context().Value(identityKey{}).(identity) }
func requireAdmin(r *http.Request) error {
if !who(r).account.IsAdmin() {
return domain.ErrForbidden
}
return nil
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
id := who(r)
writeJSON(w, 200, map[string]any{"account": id.account, "player": id.player})
}
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var in struct {
DisplayName string `json:"displayName"`
Ratings domain.Ratings `json:"ratings"`
PreferredRoles []domain.Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
AvoidedPlayerIDs []string `json:"avoidedPlayerIds"`
}
if !decode(w, r, &in) {
return
}
id := who(r)
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs, in.AvoidedPlayerIDs)
respond(w, out, err, http.StatusOK)
}
func (s *Server) players(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListPlayers(r.Context())
respond(w, out, err, 200)
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
from := time.Unix(0, 0).UTC()
if raw := r.URL.Query().Get("from"); raw != "" {
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
from = parsed
}
}
out, err := s.store.ListEvents(r.Context(), from)
respond(w, out, err, 200)
}
func (s *Server) createEvent(w http.ResponseWriter, r *http.Request) {
var in domain.Event
if !decode(w, r, &in) {
return
}
out, err := s.service.CreateEvent(r.Context(), who(r).account, in)
respond(w, out, err, http.StatusCreated)
}
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetEvent(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, http.StatusOK)
}
func (s *Server) updateEvent(w http.ResponseWriter, r *http.Request) {
var in domain.Event
if !decode(w, r, &in) {
return
}
out, err := s.service.UpdateEvent(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in)
respond(w, out, err, http.StatusOK)
}
func (s *Server) getEventTournament(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetTournamentByEvent(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, http.StatusOK)
}
func (s *Server) rsvps(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListRSVPs(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, 200)
}
func (s *Server) createParticipant(w http.ResponseWriter, r *http.Request) {
var in struct {
DisplayName string `json:"displayName"`
Ratings domain.Ratings `json:"ratings"`
Status domain.RSVPStatus `json:"status"`
}
if !decode(w, r, &in) {
return
}
player, rsvp, err := s.service.CreateParticipant(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.DisplayName, in.Ratings, in.Status)
respond(w, map[string]any{"player": player, "rsvp": rsvp}, err, http.StatusCreated)
}
func (s *Server) setRSVP(w http.ResponseWriter, r *http.Request) {
var in struct {
Status domain.RSVPStatus `json:"status"`
}
if !decode(w, r, &in) {
return
}
id := who(r)
out, err := s.service.SetRSVP(r.Context(), id.account, id.player, chi.URLParam(r, "eventID"), chi.URLParam(r, "playerID"), in.Status)
respond(w, out, err, 200)
}
func (s *Server) removeParticipant(w http.ResponseWriter, r *http.Request) {
err := s.service.RemoveParticipant(r.Context(), who(r).account, chi.URLParam(r, "eventID"), chi.URLParam(r, "playerID"))
respond(w, nil, err, http.StatusNoContent)
}
func (s *Server) balance(w http.ResponseWriter, r *http.Request) {
out, err := s.service.Balance(r.Context(), who(r).account, chi.URLParam(r, "eventID"))
respond(w, out, err, 200)
}
func (s *Server) selectBalance(w http.ResponseWriter, r *http.Request) {
var in domain.BalanceCandidate
if !decode(w, r, &in) {
return
}
err := s.service.SelectBalance(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in)
respond(w, map[string]string{"status": "selected"}, err, 200)
}
func (s *Server) teams(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListTeams(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, 200)
}
func (s *Server) assignCaptain(w http.ResponseWriter, r *http.Request) {
var in struct {
PlayerID string `json:"playerId"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.AssignCaptain(r.Context(), who(r).account, chi.URLParam(r, "teamID"), in.PlayerID)
respond(w, out, err, 200)
}
func (s *Server) saveRuleset(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
writeError(w, err)
return
}
var rules domain.Ruleset
if !decode(w, r, &rules) {
return
}
if rules.ID == "" {
rules.ID = application.NewID()
}
if err := rules.Validate(); err != nil {
writeError(w, err)
return
}
out, err := s.store.SaveRuleset(r.Context(), rules)
respond(w, out, err, 201)
}
func (s *Server) getRuleset(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetRuleset(r.Context(), chi.URLParam(r, "id"))
respond(w, out, err, 200)
}
func (s *Server) listRulesets(w http.ResponseWriter, r *http.Request) {
out, err := s.store.ListRulesets(r.Context())
respond(w, out, err, 200)
}
func (s *Server) coinToss(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamAID string `json:"teamAId"`
TeamBID string `json:"teamBId"`
Seed string `json:"seed"`
EventID string `json:"eventId"`
ActingTeamID string `json:"actingTeamId"`
}
if !decode(w, r, &in) {
return
}
if err := s.authorizeTeam(r, in.EventID, in.ActingTeamID); err != nil {
writeError(w, err)
return
}
out, err := domain.TossCoin(in.TeamAID, in.TeamBID, in.Seed, time.Now().UTC())
if err == nil {
_ = s.store.AppendAudit(r.Context(), who(r).account.ID, "coin.tossed", in.TeamAID+":"+in.TeamBID, out)
}
respond(w, out, err, 200)
}
func (s *Server) createMapDraft(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
writeError(w, err)
return
}
var in struct {
ID, FirstTeamID, RulesetID string
PoolIndex int
TeamIDs [2]string
PlayedMaps []string
}
if !decode(w, r, &in) {
return
}
rules, err := s.store.GetRuleset(r.Context(), in.RulesetID)
if err == nil && (in.PoolIndex < 0 || in.PoolIndex >= len(rules.MapPools)) {
err = fmt.Errorf("%w: map pool index is outside the ruleset", domain.ErrInvalid)
}
var pool []string
if err == nil {
for _, name := range rules.MapPools[in.PoolIndex] {
if !slices.Contains(in.PlayedMaps, name) {
pool = append(pool, name)
}
}
if len(pool) == 0 {
err = fmt.Errorf("%w: no unplayed maps remain in pool", domain.ErrInvalid)
}
}
var draft *domain.MapDraft
if err == nil {
draft, err = domain.NewMapDraft(pool, in.FirstTeamID, in.TeamIDs)
}
if err == nil {
if in.ID == "" {
in.ID = application.NewID()
}
err = s.store.SaveDraft(r.Context(), in.ID, "map", draft, -1)
}
respond(w, map[string]any{"id": in.ID, "draft": draft, "version": 0}, err, 201)
}
func (s *Server) mapBan(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID, Map, EventID string
Version int
}
if !decode(w, r, &in) {
return
}
if err := s.authorizeTeam(r, in.EventID, in.TeamID); err != nil {
writeError(w, err)
return
}
var draft domain.MapDraft
kind, version, err := s.store.GetDraft(r.Context(), chi.URLParam(r, "id"), &draft)
if err == nil && (kind != "map" || version != in.Version) {
err = domain.ErrConflict
}
if err == nil {
err = draft.Ban(in.TeamID, in.Map, who(r).account.ID, time.Now().UTC())
}
if err == nil {
err = s.store.SaveDraft(r.Context(), chi.URLParam(r, "id"), "map", draft, version)
}
respond(w, map[string]any{"draft": draft, "version": version + 1}, err, 200)
}
func (s *Server) createHeroDraft(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
writeError(w, err)
return
}
var in struct {
ID, FirstTeamID, RulesetID string
TeamIDs [2]string
PreviousBans map[string][]string
}
if !decode(w, r, &in) {
return
}
rules, err := s.store.GetRuleset(r.Context(), in.RulesetID)
var draft *domain.HeroDraft
if err == nil {
draft, err = domain.NewHeroDraft(rules.Heroes, in.TeamIDs, in.FirstTeamID, rules.HeroBansPerTeam, in.PreviousBans)
}
if err == nil {
if in.ID == "" {
in.ID = application.NewID()
}
err = s.store.SaveDraft(r.Context(), in.ID, "hero", draft, -1)
}
respond(w, map[string]any{"id": in.ID, "draft": draft, "version": 0}, err, 201)
}
func (s *Server) heroBan(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID, Hero, EventID string
Version int
}
if !decode(w, r, &in) {
return
}
if err := s.authorizeTeam(r, in.EventID, in.TeamID); err != nil {
writeError(w, err)
return
}
var draft domain.HeroDraft
kind, version, err := s.store.GetDraft(r.Context(), chi.URLParam(r, "id"), &draft)
if err == nil && (kind != "hero" || version != in.Version) {
err = domain.ErrConflict
}
if err == nil {
err = draft.Ban(in.TeamID, in.Hero, who(r).account.ID, time.Now().UTC())
}
if err == nil {
err = s.store.SaveDraft(r.Context(), chi.URLParam(r, "id"), "hero", draft, version)
}
respond(w, map[string]any{"draft": draft, "version": version + 1}, err, 200)
}
func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error {
id := who(r)
if id.account.IsAdmin() {
return nil
}
teams, err := s.store.ListTeams(r.Context(), eventID)
if err != nil {
return err
}
for _, team := range teams {
if team.ID == teamID && application.CanActForTeam(id.account, id.player, team) {
return nil
}
}
return domain.ErrForbidden
}
func (s *Server) createTournament(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
writeError(w, err)
return
}
var in struct {
ID, EventID, Name string
TeamIDs []string
}
if !decode(w, r, &in) {
return
}
if in.ID == "" {
in.ID = application.NewID()
}
t, err := domain.NewTournament(in.ID, in.EventID, in.Name, in.TeamIDs)
if err == nil {
_, err = s.store.SaveTournament(r.Context(), *t)
}
if err == nil {
for _, series := range t.Rounds[0] {
if _, err = s.store.SaveSeries(r.Context(), series); err != nil {
break
}
}
}
respond(w, t, err, 201)
}
func (s *Server) getTournament(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetTournament(r.Context(), chi.URLParam(r, "id"))
respond(w, out, err, 200)
}
func (s *Server) getSeries(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
respond(w, out, err, 200)
}
func (s *Server) recordResult(w http.ResponseWriter, r *http.Request) {
var in struct {
MapName string `json:"mapName"`
Outcome domain.MapOutcome `json:"outcome"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.RecordMap(r.Context(), who(r).account, chi.URLParam(r, "id"), in.MapName, in.Outcome, in.ExpectedVersion)
if err == nil && out.WinnerTeamID != "" && out.TournamentID != "" {
var t domain.Tournament
t, err = s.store.GetTournament(r.Context(), out.TournamentID)
if err == nil {
for round := range t.Rounds {
for match := range t.Rounds[round] {
if t.Rounds[round][match].ID == out.ID {
t.Rounds[round][match] = out
err = t.Advance(round, match)
if err == nil {
_, err = s.store.SaveTournament(r.Context(), t)
}
if err == nil && round+1 < len(t.Rounds) {
next := t.Rounds[round+1][match/2]
if next.TeamAID != "" && next.TeamBID != "" {
_, err = s.store.SaveSeries(r.Context(), next)
}
}
}
}
}
}
}
respond(w, out, err, 200)
}
func (s *Server) correctResult(w http.ResponseWriter, r *http.Request) {
index, err := strconv.Atoi(chi.URLParam(r, "index"))
if err != nil || index < 0 {
writeError(w, fmt.Errorf("%w: invalid result index", domain.ErrInvalid))
return
}
var in struct {
MapName string `json:"mapName"`
Outcome domain.MapOutcome `json:"outcome"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.CorrectMap(r.Context(), who(r).account, chi.URLParam(r, "id"), index, in.MapName, in.Outcome, in.ExpectedVersion)
respond(w, out, err, 200)
}
func (s *Server) stream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, errors.New("streaming unsupported"))
return
}
topic := r.URL.Query().Get("topic")
if topic == "" {
topic = "*"
}
ch, cancel := s.hub.Subscribe(topic)
defer cancel()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
fmt.Fprint(w, ": connected\n\n")
flusher.Flush()
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
case event := <-ch:
fmt.Fprintf(w, "event: update\ndata: %s\n\n", mustJSON(event))
flusher.Flush()
}
}
}
func decode(w http.ResponseWriter, r *http.Request, target any) bool {
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
writeError(w, fmt.Errorf("%w: %v", domain.ErrInvalid, err))
return false
}
return true
}
func respond(w http.ResponseWriter, value any, err error, status int) {
if err != nil {
writeError(w, err)
return
}
writeJSON(w, status, value)
}
func writeError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
switch {
case errors.Is(err, domain.ErrInvalid):
status = http.StatusBadRequest
case errors.Is(err, domain.ErrUnauthorized):
status = http.StatusUnauthorized
case errors.Is(err, domain.ErrForbidden):
status = http.StatusForbidden
case errors.Is(err, domain.ErrNotFound):
status = http.StatusNotFound
case errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrDraftComplete):
status = http.StatusConflict
default:
slog.Error("request failed", "error", err)
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func mustJSON(v any) []byte {
out, _ := json.Marshal(v)
return out
}