go-places/api/internal/transport/server.go

605 lines
19 KiB
Go
Raw Normal View History

2026-10-07 15:14:50 +02:00
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
}