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/accounts", s.accounts) api.Patch("/api/accounts/{accountID}/moderator", s.setModerator) 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/move", s.moveRosterPlayer) api.Post("/api/events/{eventID}/roster/place-reserve", s.placeReservePlayer) api.Post("/api/events/{eventID}/roster/remove", s.removeRosterPlayer) 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}/workflow/back", s.revertWorkflowStage) api.Post("/api/events/{eventID}/start", s.startScrim) api.Put("/api/teams/{teamID}/captain", s.assignCaptain) api.Put("/api/teams/{teamID}/name", s.renameTeam) 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 requireStaff(r *http.Request) error { if !who(r).account.IsStaff() { 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) accounts(w http.ResponseWriter, r *http.Request) { out, err := s.service.ListAccounts(r.Context(), who(r).account) respond(w, out, err, http.StatusOK) } func (s *Server) setModerator(w http.ResponseWriter, r *http.Request) { var in struct { Moderator bool `json:"moderator"` } if !decode(w, r, &in) { return } out, err := s.service.SetModerator(r.Context(), who(r).account, chi.URLParam(r, "accountID"), in.Moderator) respond(w, out, err, http.StatusOK) } 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) renameTeam(w http.ResponseWriter, r *http.Request) { var in struct { Name string `json:"name"` } if !decode(w, r, &in) { return } id := who(r) out, err := s.service.RenameTeam(r.Context(), id.account, id.player, chi.URLParam(r, "teamID"), in.Name) respond(w, out, err, http.StatusOK) } func (s *Server) saveRuleset(w http.ResponseWriter, r *http.Request) { if err := requireStaff(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 := requireStaff(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 := requireStaff(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.IsStaff() { 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 := requireStaff(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 }