package httpapi import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime" "net/http" "strings" ) const ( HeaderRequestID = "X-Request-ID" JSONContentType = "application/json" ProblemContentType = "application/problem+json" maxRequestIDLength = 128 ) var ( ErrUnsupportedMediaType = errors.New("unsupported media type") ErrInvalidJSON = errors.New("invalid JSON request body") ErrBodyTooLarge = errors.New("request body is too large") ErrInvalidRequestID = errors.New("invalid request ID") ) type InvalidParam struct { Name string `json:"name"` Reason string `json:"reason"` } type Problem struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Code string `json:"code"` Detail string `json:"detail,omitempty"` RequestID string `json:"requestId,omitempty"` InvalidParams []InvalidParam `json:"invalidParams,omitempty"` } func NewProblem(status int, code, title, detail, requestID string) Problem { return Problem{ Type: "https://proxy-pool.local/problems/" + strings.ToLower(strings.ReplaceAll(code, "_", "-")), Title: title, Status: status, Code: code, Detail: detail, RequestID: requestID, } } func DecodeJSON(writer http.ResponseWriter, request *http.Request, maxBytes int64, target any) error { if request == nil || request.Body == nil || target == nil || maxBytes <= 0 { return ErrInvalidJSON } mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type")) if err != nil || !strings.EqualFold(mediaType, JSONContentType) { return ErrUnsupportedMediaType } request.Body = http.MaxBytesReader(writer, request.Body, maxBytes) decoder := json.NewDecoder(request.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return classifyDecodeError(err) } var trailing json.RawMessage if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { if err == nil { return ErrInvalidJSON } return classifyDecodeError(err) } return nil } func ResolveRequestID(request *http.Request) (string, error) { if request == nil { return fallbackRequestID(ErrInvalidRequestID) } values := request.Header.Values(HeaderRequestID) if len(values) == 0 || (len(values) == 1 && values[0] == "") { return generateRequestID() } if len(values) != 1 { return fallbackRequestID(ErrInvalidRequestID) } requestID := values[0] if len(requestID) > maxRequestIDLength || strings.TrimSpace(requestID) != requestID { return fallbackRequestID(ErrInvalidRequestID) } for _, character := range requestID { if character < 0x20 || character == 0x7f { return fallbackRequestID(ErrInvalidRequestID) } } return requestID, nil } func WriteJSON(writer http.ResponseWriter, status int, value any) error { writer.Header().Set("Content-Type", JSONContentType) writer.WriteHeader(status) return json.NewEncoder(writer).Encode(value) } func WriteProblem(writer http.ResponseWriter, problem Problem) { writer.Header().Set("Content-Type", ProblemContentType) if problem.RequestID != "" { writer.Header().Set(HeaderRequestID, problem.RequestID) } writer.WriteHeader(problem.Status) _ = json.NewEncoder(writer).Encode(problem) } func classifyDecodeError(err error) error { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { return fmt.Errorf("%w: limit is %d bytes", ErrBodyTooLarge, tooLarge.Limit) } return ErrInvalidJSON } func generateRequestID() (string, error) { random := make([]byte, 16) if _, err := rand.Read(random); err != nil { return "", fmt.Errorf("generate request ID: %w", err) } return "req_" + hex.EncodeToString(random), nil } func fallbackRequestID(reason error) (string, error) { requestID, err := generateRequestID() if err != nil { return "", errors.Join(reason, err) } return requestID, reason }