Feature 04 task 3 (rebased): Household settings seeding and API (#27)
CI / ci (push) Successful in 1m22s
CI / ci (push) Successful in 1m22s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #27.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// maxSettingsBody bounds a PATCH /settings/household request body.
|
||||
const maxSettingsBody = 16 << 10
|
||||
|
||||
// settingsPatch is the PATCH /settings/household body. Every field is
|
||||
// optional; unknown fields and null values are refused.
|
||||
type settingsPatch struct {
|
||||
Timezone *string `json:"timezone"`
|
||||
DinnerTime *string `json:"dinner_time"`
|
||||
ReminderTime *string `json:"reminder_time"`
|
||||
ReviewTime *string `json:"review_time"`
|
||||
PlanDraftDow *int `json:"plan_draft_dow"`
|
||||
PlanDraftTime *string `json:"plan_draft_time"`
|
||||
SuggestionsDow *int `json:"suggestions_dow"`
|
||||
SuggestionsTime *string `json:"suggestions_time"`
|
||||
ChatAutosendDow *int `json:"chat_autosend_dow"`
|
||||
ChatAutosendTime *string `json:"chat_autosend_time"`
|
||||
}
|
||||
|
||||
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get household settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// patchHouseholdSettings records the session's actor subject as updated_by.
|
||||
func patchHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := auth.ActorFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "PATCH /api/v1/settings/household without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
patch, err := decodeSettingsPatch(http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
settings, err := svc.Update(r.Context(), household.SettingsPatch(patch), actor.Subject)
|
||||
var invalid *household.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "update household settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeSettingsPatch reads a single JSON object, refusing unknown fields,
|
||||
// null values and trailing data.
|
||||
func decodeSettingsPatch(body io.Reader) (settingsPatch, error) {
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return settingsPatch{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||
}
|
||||
return settingsPatch{}, errors.New("cannot read request body")
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil || fields == nil {
|
||||
return settingsPatch{}, errors.New("request body must be a JSON object")
|
||||
}
|
||||
for name, raw := range fields {
|
||||
if bytes.Equal(raw, []byte("null")) {
|
||||
return settingsPatch{}, fmt.Errorf("%s: must not be null", name)
|
||||
}
|
||||
}
|
||||
|
||||
var patch settingsPatch
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&patch); err != nil {
|
||||
var typeErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &typeErr) {
|
||||
return settingsPatch{}, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
|
||||
}
|
||||
return settingsPatch{}, fmt.Errorf("invalid settings: %v", err)
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, v any) {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "encode response", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError responds with status and a non-retryable API error.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
|
||||
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
|
||||
if err != nil {
|
||||
panic(err) // string and bool fields cannot fail to marshal
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if _, err := w.Write(body); err != nil {
|
||||
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user