proxy-pool/internal/platform/httpapi/httpapi.go
2026-07-29 10:14:56 +08:00

141 lines
3.9 KiB
Go

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
}