ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002) - argon2id passwords with rehash on login, password policy, lockout with exponential back-off and a per-IP rate limit in Redis - Ed25519 JWT access tokens with kid-based key rotation; rotating refresh tokens with reuse detection (cookie for web, body for Android) - bootstrap admin on first start; must_change_credentials and must_change_password gate every other endpoint - /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password, /me/bootstrap-credentials, /me/sessions; server-info reports bootstrap_required - role policy from docs/auth-and-roles.md with table tests - problem+json errors with field errors; ru/en translations of the codes - install.sh and update.sh generate JWT_PRIVATE_KEY Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
70 lines
2.8 KiB
Go
70 lines
2.8 KiB
Go
// Package httpx — общие HTTP-утилиты: ответы об ошибках (RFC 9457), логирование, middleware.
|
|
package httpx
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"pipu/api/internal/platform/apperr"
|
|
)
|
|
|
|
// Problem — тело ответа application/problem+json. Поле Code переводится клиентом через i18n.
|
|
type Problem struct {
|
|
Type string `json:"type"`
|
|
Status int `json:"status"`
|
|
Code string `json:"code"`
|
|
Detail string `json:"detail,omitempty"`
|
|
Params map[string]any `json:"params,omitempty"`
|
|
Errors []apperr.FieldError `json:"errors,omitempty"`
|
|
}
|
|
|
|
// WriteProblem пишет ответ об ошибке.
|
|
func WriteProblem(w http.ResponseWriter, status int, code, detail string) {
|
|
writeProblem(w, Problem{Type: "about:blank", Status: status, Code: code, Detail: detail})
|
|
}
|
|
|
|
func writeProblem(w http.ResponseWriter, p Problem) {
|
|
w.Header().Set("Content-Type", "application/problem+json")
|
|
w.WriteHeader(p.Status)
|
|
_ = json.NewEncoder(w).Encode(p)
|
|
}
|
|
|
|
// WriteError пишет доменную ошибку *apperr.Error; любую другую — как 500 (подробности в лог).
|
|
func WriteError(log *slog.Logger, w http.ResponseWriter, r *http.Request, err error) {
|
|
var ae *apperr.Error
|
|
if !errors.As(err, &ae) {
|
|
log.ErrorContext(r.Context(), "handler failed", "path", r.URL.Path, "err", err)
|
|
WriteProblem(w, http.StatusInternalServerError, "common.internal", "internal error")
|
|
return
|
|
}
|
|
if ae.RetryAfter > 0 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(ae.RetryAfter))
|
|
}
|
|
writeProblem(w, Problem{
|
|
Type: "about:blank", Status: ae.Status, Code: ae.Code, Detail: ae.Detail, Params: ae.Params, Errors: ae.Fields,
|
|
})
|
|
}
|
|
|
|
// NotFound — обработчик неизвестных путей.
|
|
func NotFound(w http.ResponseWriter, _ *http.Request) {
|
|
WriteProblem(w, http.StatusNotFound, "common.not_found", "route not found")
|
|
}
|
|
|
|
// MethodNotAllowed — обработчик неподдерживаемых методов.
|
|
func MethodNotAllowed(w http.ResponseWriter, _ *http.Request) {
|
|
WriteProblem(w, http.StatusMethodNotAllowed, "common.method_not_allowed", "method not allowed")
|
|
}
|
|
|
|
// RequestError — ошибка разбора запроса (strict-server).
|
|
func RequestError(w http.ResponseWriter, _ *http.Request, err error) {
|
|
WriteProblem(w, http.StatusBadRequest, "common.bad_request", err.Error())
|
|
}
|
|
|
|
// ResponseError — ошибка обработчика: доменные — как есть, прочие — 500 с деталями только в логе.
|
|
func ResponseError(log *slog.Logger) func(http.ResponseWriter, *http.Request, error) {
|
|
return func(w http.ResponseWriter, r *http.Request, err error) { WriteError(log, w, r, err) }
|
|
}
|