141 lines
3.9 KiB
Go
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
|
|
}
|