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 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 }