initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
604
api/internal/transport/server.go
Normal file
604
api/internal/transport/server.go
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/graphql-go/graphql"
|
||||
|
||||
"go-places-api/internal/auth"
|
||||
"go-places-api/internal/blobstore"
|
||||
"go-places-api/internal/config"
|
||||
"go-places-api/internal/domain"
|
||||
"go-places-api/internal/service"
|
||||
"go-places-api/internal/store"
|
||||
)
|
||||
|
||||
// mediaPrefix is the path segment under which stored pictures are served. It
|
||||
// matches the URLs that Service.ImageURL builds.
|
||||
const mediaPrefix = "/media/"
|
||||
|
||||
// maxGraphQLBody bounds a GraphQL request document. A legitimate document is
|
||||
// small, so this keeps a hostile client from filling memory.
|
||||
const maxGraphQLBody = 1 << 20
|
||||
|
||||
// sniffLen is how much of an upload is examined to guess its content type when
|
||||
// the client did not declare one.
|
||||
const sniffLen = 512
|
||||
|
||||
// Options configures a Server.
|
||||
type Options struct {
|
||||
// Service holds the business logic the handlers delegate to.
|
||||
Service *service.Service
|
||||
// Config is the resolved runtime configuration.
|
||||
Config config.Config
|
||||
// Auth resolves bearer tokens on the protected routes.
|
||||
Auth *auth.Middleware
|
||||
// Blobs serves stored picture bytes.
|
||||
Blobs blobstore.BlobStore
|
||||
// Logger receives request and failure logs.
|
||||
Logger *slog.Logger
|
||||
// EnableREST if true, adds REST endpoints alongside GraphQL.
|
||||
EnableREST bool
|
||||
}
|
||||
|
||||
// Server holds the HTTP routes.
|
||||
type Server struct {
|
||||
svc *service.Service
|
||||
cfg config.Config
|
||||
auth *auth.Middleware
|
||||
blobs blobstore.BlobStore
|
||||
logger *slog.Logger
|
||||
schema graphql.Schema
|
||||
}
|
||||
|
||||
// NewServer builds the Server and compiles the GraphQL schema. A schema that
|
||||
// does not compile is a programming error, so it is reported to the caller
|
||||
// rather than deferred to the first request.
|
||||
func NewServer(opts Options) (*Server, error) {
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
schema, err := newSchema(opts.Service)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transport: build graphql schema: %w", err)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
svc: opts.Service,
|
||||
cfg: opts.Config,
|
||||
auth: opts.Auth,
|
||||
blobs: opts.Blobs,
|
||||
logger: logger,
|
||||
schema: schema,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handler returns the fully routed handler.
|
||||
//
|
||||
// The probes and the media route are deliberately unauthenticated: a kubelet
|
||||
// health probe cannot present a bearer token, and picture URLs have to be
|
||||
// renderable where a token cannot be attached, such as an <img> tag. Every
|
||||
// route that reads or writes user data sits behind the auth middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", s.handleReadyz)
|
||||
mux.HandleFunc("GET "+mediaPrefix, s.handleMedia)
|
||||
|
||||
upload := http.HandlerFunc(s.handleUpload)
|
||||
if s.auth != nil {
|
||||
upload = s.auth.WrapFunc(s.handleUpload)
|
||||
}
|
||||
// The place may come from the path or from a form field, so both shapes are
|
||||
// accepted.
|
||||
mux.HandleFunc("POST /upload", upload)
|
||||
mux.HandleFunc("POST /places/{placeId}/images", upload)
|
||||
|
||||
// REST API routes. These read and write user data, so they sit behind the
|
||||
// same auth middleware as the upload and GraphQL routes. Leaving them open
|
||||
// would expose every place, picture and comment to an unauthenticated
|
||||
// caller.
|
||||
if s.cfg.EnableREST {
|
||||
// When no middleware is configured there is nothing to verify against,
|
||||
// so the handlers are mounted unwrapped; they will reject the request
|
||||
// themselves because no identity reaches the context.
|
||||
protect := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
if s.auth == nil {
|
||||
return h
|
||||
}
|
||||
return s.auth.WrapFunc(h)
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /api/places", protect(s.handleListPlaces))
|
||||
mux.HandleFunc("GET /api/places/", protect(s.handleGetPlace))
|
||||
mux.HandleFunc("POST /api/places", protect(s.handleCreatePlace))
|
||||
mux.HandleFunc("GET /api/places/{placeId}/images", protect(s.handleListPlaceImages))
|
||||
}
|
||||
|
||||
var graphqlHandler http.Handler = http.HandlerFunc(s.handleGraphQL)
|
||||
if s.auth != nil {
|
||||
graphqlHandler = s.auth.Wrap(http.HandlerFunc(s.handleGraphQL))
|
||||
}
|
||||
mux.Handle("POST /graphql", graphqlHandler)
|
||||
// GET only ever serves the static query console. Executing a query over GET
|
||||
// would bypass the auth middleware above, so a caller could read the whole
|
||||
// travel log from a link in an email client.
|
||||
mux.HandleFunc("GET /graphql", s.handleGraphQLConsole)
|
||||
|
||||
return s.withLogging(s.recoverPanics(mux))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ probes ---
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleReadyz reports whether the service can serve traffic. It is separate
|
||||
// from healthz so a failing dependency does not trigger a restart loop.
|
||||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.svc.Ping(r.Context()); err != nil {
|
||||
s.logger.Warn("readiness check failed", "error", err)
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"status": "unavailable",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- media ---
|
||||
|
||||
// handleMedia streams a stored picture. The key is the path after the prefix
|
||||
// and is resolved by the blob store, which rejects anything escaping its root.
|
||||
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimPrefix(r.URL.Path, mediaPrefix)
|
||||
if key == "" {
|
||||
http.Error(w, "a media key is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.blobs == nil {
|
||||
http.Error(w, "media storage is not configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// The declared type and size come from the record rather than from the file,
|
||||
// so a mislabelled upload is still served as what it was stored as.
|
||||
if ct, err := s.svc.ContentTypeByStorageKey(r.Context(), key); err == nil && ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
if n, err := s.svc.ImageSize(r.Context(), key); err == nil && n > 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(n, 10))
|
||||
}
|
||||
|
||||
body, err := s.blobs.Get(r.Context(), key)
|
||||
if err != nil {
|
||||
if errors.Is(err, blobstore.ErrNotFound) {
|
||||
http.Error(w, "no such media", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.logger.Warn("failed to read media", "key", key, "error", err)
|
||||
http.Error(w, "media could not be read", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
// Media is immutable once written, so it can be cached hard.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
if _, err := io.Copy(w, body); err != nil {
|
||||
s.logger.Warn("failed to stream media", "key", key, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ upload ---
|
||||
|
||||
// handleUpload accepts a multipart picture and attaches it to a place.
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.FromContext(r.Context())
|
||||
if !ok {
|
||||
// Only reachable if the middleware was not applied, which is a wiring
|
||||
// bug rather than a client error.
|
||||
http.Error(w, "unauthorized: a bearer token is required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
http.Error(w, "a multipart form body is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
placeID := r.PathValue("placeId")
|
||||
if placeID == "" {
|
||||
placeID = r.FormValue("placeId")
|
||||
}
|
||||
if placeID == "" {
|
||||
http.Error(w, "a placeId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, `a multipart field named "image" is required`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// The declared type is authoritative, but a client that omits it gets the
|
||||
// type sniffed from the payload instead of a confusing rejection.
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
var body io.Reader = file
|
||||
if contentType == "" {
|
||||
prefix := make([]byte, sniffLen)
|
||||
n, err := io.ReadFull(file, prefix)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
http.Error(w, "the uploaded image could not be read", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
prefix = prefix[:n]
|
||||
contentType = http.DetectContentType(prefix)
|
||||
body = io.MultiReader(bytes.NewReader(prefix), file)
|
||||
}
|
||||
|
||||
img, err := s.svc.AddImage(r.Context(), user.User, placeID, service.ImageUpload{
|
||||
ContentType: contentType,
|
||||
Caption: r.FormValue("caption"),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The client needs the URLs of the pictures this upload linked to, and the
|
||||
// link evidence lives in the store rather than on the record, so they are
|
||||
// read back here.
|
||||
linked, err := s.svc.LinkedImages(r.Context(), img)
|
||||
if err != nil {
|
||||
// The picture itself was stored, so this is reported rather than failing
|
||||
// a request that already succeeded.
|
||||
s.logger.Warn("failed to load linked pictures", "imageId", img.ID, "error", err)
|
||||
} else {
|
||||
img.LinkedImages = linked
|
||||
}
|
||||
|
||||
s.logger.Info("image stored",
|
||||
"imageId", img.ID,
|
||||
"placeId", img.PlaceID,
|
||||
"bytes", img.ByteSize,
|
||||
"analysisStatus", string(img.AnalysisStatus),
|
||||
)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"image": img})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- graphql ---
|
||||
|
||||
// graphQLRequest is the standard GraphQL-over-HTTP payload.
|
||||
type graphQLRequest struct {
|
||||
Query string `json:"query"`
|
||||
OperationName string `json:"operationName"`
|
||||
Variables map[string]interface{} `json:"variables"`
|
||||
}
|
||||
|
||||
// handleGraphQLConsole serves the static query console. It never executes a
|
||||
// query: this route is unauthenticated, so any data it returned would leak to
|
||||
// whoever opened the URL.
|
||||
func (s *Server) handleGraphQLConsole(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("query") != "" {
|
||||
writeGraphQLRequestError(w, "send queries as POST /graphql with an Authorization header")
|
||||
return
|
||||
}
|
||||
s.servePlayground(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
||||
var req graphQLRequest
|
||||
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxGraphQLBody))
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
writeGraphQLRequestError(w, "the request body must be a GraphQL JSON object")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
writeGraphQLRequestError(w, "a query is required")
|
||||
return
|
||||
}
|
||||
|
||||
result := graphql.Do(graphql.Params{
|
||||
Schema: s.schema,
|
||||
RequestString: req.Query,
|
||||
VariableValues: req.Variables,
|
||||
OperationName: req.OperationName,
|
||||
Context: r.Context(),
|
||||
})
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
s.logger.Warn("graphql reported errors",
|
||||
"operation", req.OperationName,
|
||||
"errors", len(result.Errors),
|
||||
"detail", firstErrorMessage(result),
|
||||
)
|
||||
}
|
||||
|
||||
// A GraphQL response carries its own errors in the body, so the transport
|
||||
// status stays 200 even when a resolver failed. Only a malformed request is
|
||||
// a 4xx.
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleListPlaces lists all places, optionally filtered by limit/offset.
|
||||
func (s *Server) handleListPlaces(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 20
|
||||
offset := 0
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
places, err := s.svc.ListPlaces(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, places)
|
||||
}
|
||||
|
||||
// handleGetPlace returns one place by ID.
|
||||
func (s *Server) handleGetPlace(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/places/")
|
||||
if id == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "place ID is required"})
|
||||
return
|
||||
}
|
||||
// This route is a prefix match so that /api/places/{id}/images can be
|
||||
// mounted alongside it. A deeper path is an unknown subresource, not a place
|
||||
// whose ID happens to contain a slash, and IDs are UUIDs, so say so rather
|
||||
// than reporting a place as missing.
|
||||
if strings.Contains(id, "/") {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such resource"})
|
||||
return
|
||||
}
|
||||
pl, err := s.svc.GetPlace(r.Context(), id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Related places are derived on read, so the REST handler fills them in the
|
||||
// way the GraphQL resolver does. The search is a spatial query, so a failure
|
||||
// is reported rather than swallowed; the place itself has already loaded.
|
||||
related, err := s.svc.RelatedPlaces(r.Context(), id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if related == nil {
|
||||
related = []domain.RelatedPlace{}
|
||||
}
|
||||
pl.RelatedPlaces = related
|
||||
|
||||
// The same-place neighbours of each picture are read back so a client can
|
||||
// render them without a further request.
|
||||
for i := range pl.Images {
|
||||
linked, err := s.svc.LinkedImages(r.Context(), pl.Images[i])
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load linked pictures", "imageId", pl.Images[i].ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if linked != nil {
|
||||
pl.Images[i].LinkedImages = linked
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, pl)
|
||||
}
|
||||
|
||||
// handleCreatePlace creates a new place.
|
||||
func (s *Server) handleCreatePlace(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
LinkRadiusMeters *float64 `json:"linkRadiusMeters,omitempty"`
|
||||
Caption string `json:"caption,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
author, err := requireUser(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized: a bearer token is required"})
|
||||
return
|
||||
}
|
||||
pl, err := s.svc.CreatePlace(r.Context(), author, domain.PlaceInput{
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
Latitude: input.Latitude,
|
||||
Longitude: input.Longitude,
|
||||
LinkRadiusMeters: input.LinkRadiusMeters,
|
||||
Caption: input.Caption,
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, pl)
|
||||
}
|
||||
|
||||
// handleListPlaceImages lists images for a place.
|
||||
func (s *Server) handleListPlaceImages(w http.ResponseWriter, r *http.Request) {
|
||||
placeID := strings.TrimPrefix(r.URL.Path, "/api/places/")
|
||||
idx := strings.Index(placeID, "/images")
|
||||
if idx >= 0 {
|
||||
placeID = placeID[:idx]
|
||||
}
|
||||
if placeID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "place ID is required"})
|
||||
return
|
||||
}
|
||||
// Support ?limit&?offset query params
|
||||
limit := 20
|
||||
offset := 0
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
f := store.ImageFilter{Limit: limit, Offset: offset}
|
||||
if v, ok := r.URL.Query()["placeId"]; ok && len(v) > 0 {
|
||||
f.PlaceID = v[0]
|
||||
}
|
||||
images, err := s.svc.ListImages(r.Context(), f)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
// The same-place neighbours are read back per picture so the client can show
|
||||
// them without a second round trip.
|
||||
for i := range images {
|
||||
linked, err := s.svc.LinkedImages(r.Context(), images[i])
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to load linked pictures", "imageId", images[i].ID, "error", err)
|
||||
continue
|
||||
}
|
||||
images[i].LinkedImages = linked
|
||||
}
|
||||
writeJSON(w, http.StatusOK, images)
|
||||
}
|
||||
|
||||
// servePlayground answers a browser hitting GET /graphql with no query.
|
||||
func (s *Server) servePlayground(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.WriteString(w, playgroundHTML)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- helpers ---
|
||||
|
||||
// writeServiceError maps a service sentinel to an HTTP status and emits the same
|
||||
// JSON envelope as the other handlers, so a client only has to parse one shape.
|
||||
func writeServiceError(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, service.ErrNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, service.ErrInvalidInput):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, service.ErrTooLarge):
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
case errors.Is(err, service.ErrUnsupportedType):
|
||||
status = http.StatusUnsupportedMediaType
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
status = http.StatusGatewayTimeout
|
||||
case errors.Is(err, context.Canceled):
|
||||
// The client hung up, so there is nobody left to read the response.
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// writeGraphQLRequestError reports a malformed request. A request that never
|
||||
// reached the executor is a client error, so this is a 4xx rather than a 200
|
||||
// with an errors array.
|
||||
func writeGraphQLRequestError(w http.ResponseWriter, message string) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"errors": []map[string]string{{"message": message}},
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
// The status line is already committed, so an encoding failure here can only
|
||||
// truncate the body; there is nothing left to tell the client.
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// withLogging records one line per request.
|
||||
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
s.logger.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"bytes", rec.written,
|
||||
"duration", time.Since(start).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// recoverPanics keeps one failing request from taking the whole process down.
|
||||
func (s *Server) recoverPanics(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
s.logger.Error("panic while serving request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"panic", rec,
|
||||
)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// statusRecorder captures the status and byte count for the access log.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(status int) {
|
||||
if !r.wroteHeader {
|
||||
r.status = status
|
||||
r.wroteHeader = true
|
||||
}
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
r.wroteHeader = true
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.written += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// firstErrorMessage returns the first resolver error message for the log line.
|
||||
func firstErrorMessage(result *graphql.Result) string {
|
||||
if result == nil || len(result.Errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return result.Errors[0].Message
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue