initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

View file

@ -0,0 +1,66 @@
package transport
// playgroundHTML is a minimal query console served at GET /graphql when the
// request carries no query. It is deliberately dependency-free: the API has no
// front-end assets to ship, and this is enough to exercise the endpoint from a
// browser during development.
const playgroundHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>go-places GraphQL</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 0; padding: 1.5rem; }
h1 { font-size: 1rem; margin: 0 0 1rem; }
textarea, pre { width: 100%; box-sizing: border-box; padding: .75rem; border: 1px solid #8884;
border-radius: 6px; background: #0001; color: inherit; }
textarea { min-height: 12rem; font: inherit; }
button { margin: .75rem 0; padding: .5rem 1rem; font: inherit; cursor: pointer;
border-radius: 6px; border: 1px solid #8886; background: #0002; color: inherit; }
pre { min-height: 6rem; overflow: auto; }
.row { display: flex; gap: .75rem; align-items: baseline; }
.hint { opacity: .7; }
</style>
</head>
<body>
<h1>go-places GraphQL</h1>
<div class="row">
<label for="token">Bearer token</label>
<input id="token" size="40" value="valid_token" style="font:inherit;padding:.4rem">
<span class="hint">AUTH_MODE=dev accepts <code>valid_token</code></span>
</div>
<p><textarea id="q">query {
places {
id
title
coordinate { latitude longitude }
images { id url badges { label confidence } }
}
}</textarea></p>
<button id="run">Run</button>
<pre id="out"></pre>
<script>
const q = document.getElementById('q');
const out = document.getElementById('out');
async function run() {
out.textContent = 'running...';
const headers = { 'Content-Type': 'application/json' };
const token = document.getElementById('token').value.trim();
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const res = await fetch('/graphql', {
method: 'POST',
headers,
body: JSON.stringify({ query: q.value }),
});
out.textContent = JSON.stringify(await res.json(), null, 2);
} catch (e) {
out.textContent = 'request failed: ' + e;
}
}
document.getElementById('run').addEventListener('click', run);
</script>
</body>
</html>
`

View file

@ -0,0 +1,388 @@
package transport
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"go-places-api/internal/config"
)
// The tests in this file cover the REST surface behind ENABLE_REST, which the
// React client uses. The GraphQL surface has its own tests in transport_test.go.
//
// REST was added without the auth middleware at first, which left every place
// and picture readable by an anonymous caller; the first test here pins that
// closed.
// restServer returns a server with the REST routes enabled.
func restServer(t *testing.T) *Server {
t.Helper()
return newTestServer(t, func(c *config.Config) { c.EnableREST = true })
}
// apiRequest performs an authenticated REST call and returns the status and the
// decoded body. The body is decoded into any because the endpoints answer with
// both objects and arrays.
func apiRequest(t *testing.T, srv *Server, method, path string, body []byte, token string) (int, any) {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
// A non-JSON body (an error page, say) is still worth asserting on, so
// the text is returned as a plain string rather than failing the decode.
out = rec.Body.String()
}
return rec.Code, out
}
// object asserts that a decoded body is a JSON object and returns it.
func object(t *testing.T, body any) map[string]any {
t.Helper()
m, ok := body.(map[string]any)
if !ok {
t.Fatalf("body is %T, want a JSON object: %v", body, body)
}
return m
}
// array asserts that a decoded body is a JSON array and returns it.
func array(t *testing.T, body any) []any {
t.Helper()
a, ok := body.([]any)
if !ok {
t.Fatalf("body is %T, want a JSON array: %v", body, body)
}
return a
}
// restPlace creates a place over REST and returns its id.
func restPlace(t *testing.T, srv *Server, title string, lat, lon float64) string {
t.Helper()
body, err := json.Marshal(map[string]any{
"title": title, "description": "notes", "latitude": lat, "longitude": lon,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", body, "valid_token")
if code != http.StatusCreated {
t.Fatalf("create place status = %d, want 201: %v", code, resp)
}
id, _ := object(t, resp)["id"].(string)
if id == "" {
t.Fatalf("create place returned no id: %v", resp)
}
return id
}
// restUpload posts a PNG to a place and returns the status and image object.
func restUpload(t *testing.T, srv *Server, placeID string) (int, map[string]any) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 8, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255})
}
}
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
var body bytes.Buffer
w := multipart.NewWriter(&body)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="image"; filename="pic.png"`)
h.Set("Content-Type", "image/png")
part, err := w.CreatePart(h)
if err != nil {
t.Fatalf("create part: %v", err)
}
if _, err := part.Write(pngBuf.Bytes()); err != nil {
t.Fatalf("write part: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode upload response %q: %v", rec.Body.String(), err)
}
inner, _ := out["image"].(map[string]any)
return rec.Code, inner
}
func TestRESTRoutesRequireAuthentication(t *testing.T) {
srv := restServer(t)
placeID := restPlace(t, srv, "Guarded", 48.85, 2.29)
// Every route that reads or writes user data has to reject an anonymous
// caller. Without the token these endpoints returned a full listing.
tests := []struct {
name string
method string
path string
}{
{"list places", http.MethodGet, "/api/places"},
{"get place", http.MethodGet, "/api/places/" + placeID},
{"list place images", http.MethodGet, "/api/places/" + placeID + "/images"},
{"create place", http.MethodPost, "/api/places"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte(`{"title":"x"}`)))
if tc.method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
})
}
// And a wrong token is rejected just as firmly.
code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "not-a-real-token")
if code != http.StatusUnauthorized {
t.Errorf("bad token status = %d, want 401", code)
}
}
func TestRESTListsPlacesAndServesOne(t *testing.T) {
srv := restServer(t)
id := restPlace(t, srv, "Listed", 48.8584, 2.2945)
code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("list status = %d: %v", code, list)
}
if got := array(t, list); len(got) != 1 {
t.Errorf("list returned %d places, want 1", len(got))
}
code, rawOne := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawOne)
}
one := object(t, rawOne)
if one["title"] != "Listed" {
t.Errorf("title = %v, want Listed", one["title"])
}
if one["id"] != id {
t.Errorf("id = %v, want %q", one["id"], id)
}
code, _ = apiRequest(t, srv, http.MethodGet, "/api/places/nope", nil, "valid_token")
if code != http.StatusNotFound {
t.Errorf("unknown place status = %d, want 404", code)
}
}
func TestRESTPlaceCarriesImageURLs(t *testing.T) {
// The stored record has no URL column, it is derived from the storage key.
// When the REST handler skipped that step the client received pictures it
// could not render.
srv := restServer(t)
id := restPlace(t, srv, "With Photo", 48.8584, 2.2945)
code, img := restUpload(t, srv, id)
if code != http.StatusCreated {
t.Fatalf("upload status = %d, want 201", code)
}
wantURL, _ := img["url"].(string)
if wantURL == "" {
t.Fatalf("upload response has no url: %v", img)
}
code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawPlace)
}
place := object(t, rawPlace)
images, _ := place["images"].([]any)
if len(images) != 1 {
t.Fatalf("place carries %d images, want 1", len(images))
}
if got, _ := images[0].(map[string]any)["url"].(string); got != wantURL {
t.Errorf("picture url = %q, want %q", got, wantURL)
}
// The image listing has to carry the URL too.
code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/images", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("images status = %d: %v", code, rawListed)
}
listed := array(t, rawListed)
if got, _ := listed[0].(map[string]any)["url"].(string); got != wantURL {
t.Errorf("listed picture url = %q, want %q", got, wantURL)
}
}
func TestRESTListsEmptyArraysRatherThanNull(t *testing.T) {
// Go serialises a nil slice as JSON null. The client iterates these fields
// directly, so an empty result has to be [] rather than null.
srv := restServer(t)
code, list := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("list status = %d: %v", code, list)
}
if got := array(t, list); len(got) != 0 {
t.Errorf("an empty list returned %d places", len(got))
}
id := restPlace(t, srv, "Bare", 10, 10)
code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+id, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawPlace)
}
place := object(t, rawPlace)
for _, field := range []string{"images", "comments", "relatedPlaces"} {
if place[field] == nil {
t.Errorf("%q is null on a place with nothing attached", field)
}
}
}
func TestRESTPlaceReportsRelatedPlaces(t *testing.T) {
// The same spot catalogued twice is the case the feature exists for, so the
// REST response has to surface the relationship.
srv := restServer(t)
first := restPlace(t, srv, "Eiffel (visit 1)", 48.8584, 2.2945)
second := restPlace(t, srv, "Eiffel (visit 2)", 48.8585, 2.2946)
if code, img := restUpload(t, srv, first); code != http.StatusCreated {
t.Fatalf("first upload status = %d", code)
} else if img["id"] == "" {
t.Fatal("first upload returned no id")
}
if code, _ := restUpload(t, srv, second); code != http.StatusCreated {
t.Fatalf("second upload status = %d", code)
}
code, rawPlace := apiRequest(t, srv, http.MethodGet, "/api/places/"+second, nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("get status = %d: %v", code, rawPlace)
}
place := object(t, rawPlace)
related, _ := place["relatedPlaces"].([]any)
if len(related) != 1 {
t.Fatalf("got %d related places, want 1", len(related))
}
entry := related[0].(map[string]any)
if got, _ := entry["place"].(map[string]any)["id"].(string); got != first {
t.Errorf("related place id = %q, want %q", got, first)
}
if _, ok := entry["distanceMeters"].(float64); !ok {
t.Errorf("distanceMeters missing: %v", entry)
}
// The relationship must not nest into itself.
nested, _ := entry["place"].(map[string]any)["relatedPlaces"].([]any)
if len(nested) != 0 {
t.Errorf("the nested place carries %d related places, want 0", len(nested))
}
}
func TestRESTPictureReportsLinkedImages(t *testing.T) {
// Pictures in different place records at the same coordinates are linked, and
// the link has to be visible from the picture listing.
srv := restServer(t)
first := restPlace(t, srv, "Spot A", 48.8584, 2.2945)
second := restPlace(t, srv, "Spot B", 48.8585, 2.2946)
restUpload(t, srv, first)
code, secondImg := restUpload(t, srv, second)
if code != http.StatusCreated {
t.Fatalf("upload status = %d", code)
}
linked, _ := secondImg["linkedImages"].([]any)
if len(linked) != 1 {
t.Fatalf("got %d linked images, want 1: %v", len(linked), secondImg)
}
code, rawListed := apiRequest(t, srv, http.MethodGet, "/api/places/"+second+"/images", nil, "valid_token")
if code != http.StatusOK {
t.Fatalf("images status = %d: %v", code, rawListed)
}
listed := array(t, rawListed)
listedLinks, _ := listed[0].(map[string]any)["linkedImages"].([]any)
if len(listedLinks) != 1 {
t.Errorf("the listing reports %d linked images, want 1", len(listedLinks))
}
}
func TestRESTRejectsInvalidInputWithJSON(t *testing.T) {
// The error shape has to match the success shape, otherwise a client has to
// parse two formats.
srv := restServer(t)
code, resp := apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`{"title":""}`), "valid_token")
if code != http.StatusBadRequest {
t.Errorf("empty title status = %d, want 400: %v", code, resp)
}
if msg, _ := object(t, resp)["error"].(string); msg == "" {
t.Errorf("error body has no message: %v", resp)
}
code, resp = apiRequest(t, srv, http.MethodPost, "/api/places", []byte(`not json`), "valid_token")
if code != http.StatusBadRequest {
t.Errorf("malformed body status = %d, want 400: %v", code, resp)
}
}
func TestRESTUnknownSubresourceIsNotAPlaceLookup(t *testing.T) {
// GET /api/places/ is a prefix match, so a deeper path arrives at the same
// handler. IDs are UUIDs, so a path with a slash in it is an unknown route
// rather than a place that happens not to exist.
srv := restServer(t)
id := restPlace(t, srv, "Spot", 48.8584, 2.2945)
code, body := apiRequest(t, srv, http.MethodGet, "/api/places/"+id+"/comments", nil, "valid_token")
if code != http.StatusNotFound {
t.Errorf("status = %d, want 404 for an unknown subresource: %v", code, body)
}
if msg, _ := object(t, body)["error"].(string); !strings.Contains(msg, "resource") {
t.Errorf("error = %q, want it to name the missing resource", msg)
}
}
func TestRESTDisabledByDefault(t *testing.T) {
// ENABLE_REST defaults to off, so a deployment that did not opt in must not
// expose the routes at all.
srv := newTestServer(t)
code, _ := apiRequest(t, srv, http.MethodGet, "/api/places", nil, "valid_token")
if code != http.StatusNotFound {
t.Errorf("status = %d, want 404 when REST is disabled", code)
}
}

View file

@ -0,0 +1,513 @@
// Package transport exposes the service over HTTP: a GraphQL endpoint for the
// data, a multipart endpoint for picture uploads, read-only serving of stored
// media, and the health probes the Kubernetes charts poll.
package transport
import (
"context"
"errors"
"github.com/graphql-go/graphql"
"go-places-api/internal/auth"
"go-places-api/internal/domain"
"go-places-api/internal/service"
"go-places-api/internal/store"
)
// The GraphQL type set mirrors the domain structs. Field names line up with the
// `json` tags on the domain models, so graphql-go's default resolver already
// populates most fields; explicit resolvers are only needed where a value has to
// be computed (image URLs, linked pictures, related places) or where the raw
// domain type needs converting (AnalysisStatus, LinkRadiusMeters).
//
// Image, Place and RelatedPlace refer to each other, so their fields are built
// by thunks that graphql-go evaluates lazily. Declaring them eagerly would mean
// a type had to exist before it was defined.
var (
badgeType = graphql.NewObject(graphql.ObjectConfig{
Name: "Badge",
Description: "A recognisable attribute detected in a picture.",
Fields: graphql.Fields{
"label": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"display": &graphql.Field{Type: graphql.String},
"confidence": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"source": &graphql.Field{Type: graphql.String},
},
})
coordinateType = graphql.NewObject(graphql.ObjectConfig{
Name: "Coordinate",
Description: "A WGS-84 geographic point.",
Fields: graphql.Fields{
"latitude": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"longitude": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
},
})
userType = graphql.NewObject(graphql.ObjectConfig{
Name: "User",
Description: "A logged-in user, as asserted by the go-loose SSO service.",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"username": &graphql.Field{Type: graphql.String},
"email": &graphql.Field{Type: graphql.String},
},
})
commentType = graphql.NewObject(graphql.ObjectConfig{
Name: "Comment",
Description: "A user reaction on a place.",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"placeId": &graphql.Field{Type: graphql.String},
"content": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"creator": &graphql.Field{Type: graphql.NewNonNull(userType)},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
},
})
// PlaceInput is the createPlace argument. Latitude and longitude are
// optional so an omitted coordinate can be told apart from the origin, which
// is a legitimate point.
placeInput = graphql.NewInputObject(graphql.InputObjectConfig{
Name: "PlaceInput",
Fields: graphql.InputObjectConfigFieldMap{
"title": &graphql.InputObjectFieldConfig{Type: graphql.NewNonNull(graphql.String)},
"description": &graphql.InputObjectFieldConfig{Type: graphql.String},
"latitude": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"longitude": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"linkRadiusMeters": &graphql.InputObjectFieldConfig{Type: graphql.Float},
"caption": &graphql.InputObjectFieldConfig{Type: graphql.String},
},
})
)
// Image, Place and RelatedPlace refer to each other, so each type is created
// once and cached on the builder before any field thunk runs. Building the type
// objects as thunks keeps the references valid: graphql-go evaluates a thunk the
// first time it needs the type's field map, which happens while NewSchema walks
// the type graph, by which point all three instances already exist.
//
// The builder is used exactly once, during startup, before the server serves any
// request, so the lazy caching needs no further synchronisation.
type builder struct {
svc *service.Service
image *graphql.Object
place *graphql.Object
related *graphql.Object
}
// imageType returns the shared Image type.
func (b *builder) imageType() *graphql.Object {
if b.image == nil {
b.image = graphql.NewObject(graphql.ObjectConfig{
Name: "Image",
Description: "A picture attached to a place.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"placeId": &graphql.Field{Type: graphql.String},
"url": &graphql.Field{Type: graphql.String, Resolve: nullOnError(resolveImageURL(b.svc))},
"caption": &graphql.Field{Type: graphql.String},
"contentType": &graphql.Field{Type: graphql.String},
"byteSize": &graphql.Field{Type: graphql.NewNonNull(graphql.Int)},
"width": &graphql.Field{Type: graphql.Int},
"height": &graphql.Field{Type: graphql.Int},
"coordinate": &graphql.Field{Type: coordinateType},
"takenAt": &graphql.Field{Type: graphql.DateTime},
"cameraMake": &graphql.Field{Type: graphql.String},
"cameraModel": &graphql.Field{Type: graphql.String},
"badges": &graphql.Field{Type: graphql.NewList(badgeType)},
"analysisStatus": &graphql.Field{
Type: graphql.String,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return nil, nil
}
return string(img.AnalysisStatus), nil
},
},
"analysisError": &graphql.Field{Type: graphql.String},
// LinkedImages is derived on read, so it is only ever computed
// when a client actually selects it.
"linkedImages": &graphql.Field{
Type: graphql.NewList(b.imageType()),
Resolve: nullOnError(resolveLinkedImages(b.svc)),
},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
}
}),
})
}
return b.image
}
// placeType returns the shared Place type.
func (b *builder) placeType() *graphql.Object {
if b.place == nil {
b.place = graphql.NewObject(graphql.ObjectConfig{
Name: "Place",
Description: "A location the user visited and shared.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"title": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
"description": &graphql.Field{Type: graphql.String},
"coordinate": &graphql.Field{Type: coordinateType},
"linkRadiusMeters": &graphql.Field{
Type: graphql.Float,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
pl, ok := p.Source.(domain.Place)
if !ok {
return nil, nil
}
return pl.LinkRadiusMeters, nil
},
},
"creator": &graphql.Field{Type: graphql.NewNonNull(userType)},
"images": &graphql.Field{Type: graphql.NewList(b.imageType())},
"comments": &graphql.Field{Type: graphql.NewList(commentType)},
"relatedPlaces": &graphql.Field{
Type: graphql.NewList(b.relatedPlaceType()),
Resolve: nullOnError(resolveRelatedPlaces(b.svc)),
},
"createdAt": &graphql.Field{Type: graphql.NewNonNull(graphql.DateTime)},
}
}),
})
}
return b.place
}
// relatedPlaceType returns the shared RelatedPlace type.
func (b *builder) relatedPlaceType() *graphql.Object {
if b.related == nil {
b.related = graphql.NewObject(graphql.ObjectConfig{
Name: "RelatedPlace",
Description: "A place whose pictures overlap the queried one.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"place": &graphql.Field{Type: graphql.NewNonNull(b.placeType())},
"distanceMeters": &graphql.Field{Type: graphql.NewNonNull(graphql.Float)},
"sharedImageIds": &graphql.Field{Type: graphql.NewList(graphql.String)},
}
}),
})
}
return b.related
}
// newSchema assembles the executable schema.
func newSchema(svc *service.Service) (graphql.Schema, error) {
b := &builder{svc: svc}
// Instantiated up front so the thunks above observe cached instances rather
// than building a second copy of each type.
imageType, placeType, relatedType := b.imageType(), b.placeType(), b.relatedPlaceType()
query := graphql.NewObject(graphql.ObjectConfig{
Name: "Query",
Description: "Read access to the travel log.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"place": &graphql.Field{
Type: placeType,
Description: "One place by identifier.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolvePlace(b.svc)),
},
"places": &graphql.Field{
Type: graphql.NewList(placeType),
Description: "Places, newest first.",
Args: graphql.FieldConfigArgument{
"limit": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
"offset": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
},
Resolve: nullOnError(resolvePlaces(b.svc)),
},
"images": &graphql.Field{
Type: graphql.NewList(imageType),
Description: "Pictures, newest first.",
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.String},
"creatorId": &graphql.ArgumentConfig{Type: graphql.String},
"limit": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 20},
"offset": &graphql.ArgumentConfig{Type: graphql.Int, DefaultValue: 0},
},
Resolve: nullOnError(resolveImages(b.svc)),
},
"relatedPlaces": &graphql.Field{
Type: graphql.NewList(relatedType),
Description: "Places whose pictures overlap the given one.",
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveRelatedByID(b.svc)),
},
}
}),
})
mutation := graphql.NewObject(graphql.ObjectConfig{
Name: "Mutation",
Description: "Write access to the travel log.",
Fields: graphql.FieldsThunk(func() graphql.Fields {
return graphql.Fields{
"createPlace": &graphql.Field{
Type: placeType,
Args: graphql.FieldConfigArgument{
"input": &graphql.ArgumentConfig{Type: graphql.NewNonNull(placeInput)},
},
Resolve: nullOnError(resolveCreatePlace(b.svc)),
},
"addComment": &graphql.Field{
Type: commentType,
Args: graphql.FieldConfigArgument{
"placeId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
"content": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveAddComment(b.svc)),
},
"updateImageCaption": &graphql.Field{
Type: imageType,
Args: graphql.FieldConfigArgument{
"imageId": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
"caption": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveUpdateImageCaption(b.svc)),
},
"deletePlace": &graphql.Field{
Type: graphql.Boolean,
Description: "Remove a place and everything attached to it.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveDeletePlace(b.svc)),
},
"deleteImage": &graphql.Field{
Type: graphql.Boolean,
Description: "Remove a picture and its stored bytes.",
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.String)},
},
Resolve: nullOnError(resolveDeleteImage(b.svc)),
},
}
}),
})
return graphql.NewSchema(graphql.SchemaConfig{
Query: query,
Mutation: mutation,
})
}
// ---------------------------------------------------------------- resolvers ---
//
// Each resolver is written as a typed function and wrapped by nullOnError, so a
// failure nulls the field instead of emitting the zero value of the domain type.
// graphql-go uses whatever a resolver returns, error or not, so returning the
// zero struct alongside an error would hand clients a plausible-looking empty
// record alongside the error.
// nullOnError adapts a typed resolver to a GraphQL field resolver, reporting a
// failure as a null field.
func nullOnError[T any](fn func(graphql.ResolveParams) (T, error)) graphql.FieldResolveFn {
return func(p graphql.ResolveParams) (interface{}, error) {
v, err := fn(p)
if err != nil {
return nil, err
}
return v, nil
}
}
func resolvePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
return func(p graphql.ResolveParams) (domain.Place, error) {
return svc.GetPlace(p.Context, p.Args["id"].(string))
}
}
func resolvePlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.Place, error) {
return func(p graphql.ResolveParams) ([]domain.Place, error) {
limit, offset := pageArgs(p.Args)
return svc.ListPlaces(p.Context, limit, offset)
}
}
func resolveImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
return func(p graphql.ResolveParams) ([]domain.Image, error) {
limit, offset := pageArgs(p.Args)
f := store.ImageFilter{Limit: limit, Offset: offset}
if v, ok := p.Args["placeId"].(string); ok {
f.PlaceID = v
}
if v, ok := p.Args["creatorId"].(string); ok {
f.CreatorID = v
}
return svc.ListImages(p.Context, f)
}
}
func resolveRelatedByID(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return svc.RelatedPlaces(p.Context, p.Args["placeId"].(string))
}
}
func resolveCreatePlace(svc *service.Service) func(graphql.ResolveParams) (domain.Place, error) {
return func(p graphql.ResolveParams) (domain.Place, error) {
author, err := requireUser(p.Context)
if err != nil {
return domain.Place{}, err
}
raw, _ := p.Args["input"].(map[string]interface{})
return svc.CreatePlace(p.Context, author, decodePlaceInput(raw))
}
}
func resolveAddComment(svc *service.Service) func(graphql.ResolveParams) (domain.Comment, error) {
return func(p graphql.ResolveParams) (domain.Comment, error) {
author, err := requireUser(p.Context)
if err != nil {
return domain.Comment{}, err
}
return svc.AddComment(p.Context, author, p.Args["placeId"].(string), p.Args["content"].(string))
}
}
func resolveUpdateImageCaption(svc *service.Service) func(graphql.ResolveParams) (domain.Image, error) {
return func(p graphql.ResolveParams) (domain.Image, error) {
return svc.UpdateImageCaption(p.Context, p.Args["imageId"].(string), p.Args["caption"].(string))
}
}
func resolveDeletePlace(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
return func(p graphql.ResolveParams) (bool, error) {
if err := svc.DeletePlace(p.Context, p.Args["id"].(string)); err != nil {
return false, err
}
return true, nil
}
}
func resolveDeleteImage(svc *service.Service) func(graphql.ResolveParams) (bool, error) {
return func(p graphql.ResolveParams) (bool, error) {
if err := svc.DeleteImage(p.Context, p.Args["id"].(string)); err != nil {
return false, err
}
return true, nil
}
}
// resolveImageURL turns the private storage key into the public URL. The key
// itself is never exposed.
func resolveImageURL(svc *service.Service) func(graphql.ResolveParams) (string, error) {
return func(p graphql.ResolveParams) (string, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return "", nil
}
return svc.ImageURL(img.StorageKey), nil
}
}
// resolveLinkedImages hydrates the neighbours discovered by the linking pass. The
// links are already ordered by distance, so the order is preserved.
func resolveLinkedImages(svc *service.Service) func(graphql.ResolveParams) ([]domain.Image, error) {
return func(p graphql.ResolveParams) ([]domain.Image, error) {
img, ok := p.Source.(domain.Image)
if !ok {
return nil, nil
}
links, err := svc.ImageLinks(p.Context, img.ID)
if err != nil {
return nil, err
}
out := make([]domain.Image, 0, len(links))
for _, link := range links {
// Each neighbour is re-read so the nested selection sees its badges
// and URL just like a top-level picture.
related, err := svc.ImageByID(p.Context, link.RelatedImageID)
if err != nil {
// A neighbour that vanished between the two reads is skipped
// rather than failing the whole selection.
continue
}
out = append(out, related)
}
return out, nil
}
}
func resolveRelatedPlaces(svc *service.Service) func(graphql.ResolveParams) ([]domain.RelatedPlace, error) {
return func(p graphql.ResolveParams) ([]domain.RelatedPlace, error) {
place, ok := p.Source.(domain.Place)
if !ok {
return nil, nil
}
related, err := svc.RelatedPlaces(p.Context, place.ID)
if err != nil {
return nil, err
}
if related == nil {
// An empty list rather than null, so clients can iterate safely.
return []domain.RelatedPlace{}, nil
}
return related, nil
}
}
// ------------------------------------------------------------------ helpers ---
// requireUser pulls the verified identity that the auth middleware placed on the
// context. Reaching this point without one means a route was mounted without the
// middleware, which is a wiring bug rather than a client error.
func requireUser(ctx context.Context) (domain.User, error) {
u, ok := auth.FromContext(ctx)
if !ok {
return domain.User{}, errors.New("transport: request is not authenticated")
}
return u.User, nil
}
// pageArgs reads the shared limit/offset arguments, tolerating their absence.
func pageArgs(args map[string]interface{}) (limit, offset int) {
if v, ok := args["limit"].(int); ok {
limit = v
}
if v, ok := args["offset"].(int); ok {
offset = v
}
return limit, offset
}
// decodePlaceInput converts the GraphQL input object into the service payload,
// turning absent numeric fields into nil pointers.
func decodePlaceInput(raw map[string]interface{}) domain.PlaceInput {
var in domain.PlaceInput
if v, ok := raw["title"].(string); ok {
in.Title = v
}
if v, ok := raw["description"].(string); ok {
in.Description = v
}
if v, ok := raw["caption"].(string); ok {
in.Caption = v
}
if v, ok := raw["latitude"].(float64); ok {
in.Latitude = &v
}
if v, ok := raw["longitude"].(float64); ok {
in.Longitude = &v
}
if v, ok := raw["linkRadiusMeters"].(float64); ok {
in.LinkRadiusMeters = &v
}
return in
}

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

View file

@ -0,0 +1,386 @@
package transport
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"go-places-api/internal/analysis"
"go-places-api/internal/auth"
"go-places-api/internal/blobstore"
"go-places-api/internal/config"
"go-places-api/internal/service"
"go-places-api/internal/store"
)
// newTestServer wires a Server against a throwaway database and media
// directory, so a test exercises the real store and blob store rather than
// fakes. mutate, when given, adjusts the configuration before wiring.
func newTestServer(t *testing.T, mutate ...func(*config.Config)) *Server {
t.Helper()
dir := t.TempDir()
cfg := config.Default()
cfg.DatabaseURL = "file:" + filepath.Join(dir, "test.db")
cfg.MediaDir = filepath.Join(dir, "media")
cfg.AuthMode = "dev"
cfg.MaxUploadBytes = 1 << 20
for _, m := range mutate {
m(&cfg)
}
db, err := store.OpenSQL(cfg.DatabaseURL)
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { db.Close() })
blobs, err := blobstore.NewLocalFS(cfg.MediaDir)
if err != nil {
t.Fatalf("open blob store: %v", err)
}
svc := service.New(service.Options{
Store: db,
Blobs: blobs,
Analyzer: stubAnalyzer{},
Config: cfg,
})
srv, err := NewServer(Options{
Service: svc,
Config: cfg,
Auth: auth.NewMiddleware(auth.NewDevVerifier(nil)),
Blobs: blobs,
})
if err != nil {
t.Fatalf("new server: %v", err)
}
return srv
}
// stubAnalyzer keeps the badge rules out of these tests, which are about the
// transport rather than about image classification.
type stubAnalyzer struct{}
var _ analysis.Analyzer = stubAnalyzer{}
func (stubAnalyzer) Name() string { return "stub" }
func (stubAnalyzer) Analyze(ctx context.Context, in analysis.Input) (*analysis.Result, error) {
return &analysis.Result{Width: 8, Height: 4}, nil
}
// do runs a GraphQL request against the handler and returns the decoded body.
func do(t *testing.T, srv *Server, query string, vars map[string]any) (int, map[string]any) {
t.Helper()
body, err := json.Marshal(map[string]any{"query": query, "variables": vars})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/graphql", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", rec.Body.String(), err)
}
return rec.Code, out
}
// data returns the "data" object, failing the test if the response carried
// errors.
func data(t *testing.T, resp map[string]any) map[string]any {
t.Helper()
if errs, ok := resp["errors"]; ok {
t.Fatalf("unexpected GraphQL errors: %v", errs)
}
d, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("response has no data object: %v", resp)
}
return d
}
// TestNewSchemaCompiles guards the self-referential Place/Image/RelatedPlace
// types against being constructed more than once. graphql-go rejects a schema
// that contains two distinct types under the same name, and that failure only
// surfaces at startup, so it is worth pinning down here.
func TestNewSchemaCompiles(t *testing.T) {
srv := newTestServer(t)
if _, err := newSchema(srv.svc); err != nil {
t.Fatalf("schema did not compile: %v", err)
}
for _, name := range []string{"Image", "Place", "RelatedPlace", "Badge", "Coordinate", "Comment", "User"} {
if srv.schema.Type(name) == nil {
t.Errorf("schema is missing the %s type", name)
}
}
}
// TestPlaceLifecycle walks a place through creation, upload and read-back.
func TestPlaceLifecycle(t *testing.T) {
srv := newTestServer(t)
code, resp := do(t, srv,
`mutation($i: PlaceInput!) { createPlace(input: $i) { id title coordinate { latitude longitude } } }`,
map[string]any{"i": map[string]any{
"title": "Test Place", "latitude": 48.85, "longitude": 2.33,
}})
if code != http.StatusOK {
t.Fatalf("createPlace status = %d, want 200", code)
}
created := data(t, resp)["createPlace"].(map[string]any)
placeID := created["id"].(string)
if placeID == "" {
t.Fatal("createPlace returned an empty id")
}
if created["title"] != "Test Place" {
t.Errorf("title = %v, want %q", created["title"], "Test Place")
}
// Upload a real PNG so the analysis and decode paths are exercised.
uploadCode, img := uploadPNG(t, srv, placeID)
if uploadCode != http.StatusCreated {
t.Fatalf("upload status = %d, want 201", uploadCode)
}
imageID := img["id"].(string)
url := img["url"].(string)
if !strings.HasPrefix(url, "/media/") {
t.Errorf("url = %q, want a /media/ prefix", url)
}
if img["analysisStatus"] != "completed" {
t.Errorf("analysisStatus = %v, want completed", img["analysisStatus"])
}
// The picture must be readable back through the place.
_, resp = do(t, srv,
`query($id: String!) { place(id: $id) { images { id url } } }`,
map[string]any{"id": placeID})
images := data(t, resp)["place"].(map[string]any)["images"].([]any)
if len(images) != 1 {
t.Fatalf("got %d images on the place, want 1", len(images))
}
if got := images[0].(map[string]any)["url"]; got != url {
t.Errorf("url = %v, want %q", got, url)
}
// And the media route must serve the stored bytes.
req := httptest.NewRequest(http.MethodGet, url, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", url, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "image/png" {
t.Errorf("Content-Type = %q, want image/png", got)
}
// Deleting the place must remove the stored blob too.
_, resp = do(t, srv, `mutation($id: String!) { deletePlace(id: $id) }`, map[string]any{"id": placeID})
if data(t, resp)["deletePlace"] != true {
t.Fatalf("deletePlace did not return true: %v", resp)
}
entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
if err == nil && len(entries) > 0 {
t.Errorf("blob directory still holds %d entries after delete", len(entries))
}
_ = imageID
}
// TestResolverErrorYieldsNull checks that a failed resolver nulls its field
// instead of emitting the zero value of the domain type, which would otherwise
// hand clients a plausible-looking empty record.
func TestResolverErrorYieldsNull(t *testing.T) {
srv := newTestServer(t)
code, resp := do(t, srv, `{ place(id: "does-not-exist") { id title } }`, nil)
if code != http.StatusOK {
t.Fatalf("status = %d, want 200 (errors travel in the body)", code)
}
if _, ok := resp["errors"]; !ok {
t.Error("expected an error entry for a missing place")
}
if got := data2(t, resp)["place"]; got != nil {
t.Errorf("data.place = %v, want null", got)
}
}
// data2 returns the data object without asserting the absence of errors, for
// the case where errors are expected.
func data2(t *testing.T, resp map[string]any) map[string]any {
t.Helper()
d, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("response has no data object: %v", resp)
}
return d
}
// TestUnauthenticatedIsRejected checks the auth middleware is actually mounted.
func TestUnauthenticatedIsRejected(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/graphql", strings.NewReader(`{"query":"{ places { id } }"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
// TestProbesAreReachableWithoutAuth guards against a probe route accidentally
// being mounted behind the auth middleware: a kubelet cannot present a token.
func TestProbesAreReachableWithoutAuth(t *testing.T) {
srv := newTestServer(t)
for _, path := range []string{"/healthz", "/readyz"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s status = %d, want 200", path, rec.Code)
}
}
}
// TestUploadRejectsUnsupportedType checks the content-type guard.
func TestUploadRejectsUnsupportedType(t *testing.T) {
srv := newTestServer(t)
_, resp := do(t, srv,
`mutation { createPlace(input: {title: "T"}) { id } }`, nil)
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
uploadCode, _ := upload(t, srv, placeID, "notes.txt", "text/plain", []byte("hello"))
if uploadCode != http.StatusUnsupportedMediaType {
t.Errorf("upload status = %d, want 415", uploadCode)
}
}
// TestUploadRejectsOversizedPayload checks the size guard and that no partial
// blob is left behind.
func TestUploadRejectsOversizedPayload(t *testing.T) {
srv := newTestServer(t, func(c *config.Config) { c.MaxUploadBytes = 64 })
_, resp := do(t, srv, `mutation { createPlace(input: {title: "T"}) { id } }`, nil)
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
uploadCode, _ := upload(t, srv, placeID, "big.png", "image/png", bytes.Repeat([]byte{0x41}, 4096))
if uploadCode != http.StatusRequestEntityTooLarge {
t.Errorf("upload status = %d, want 413", uploadCode)
}
entries, err := os.ReadDir(filepath.Join(srv.cfg.MediaDir, placeID))
if err == nil && len(entries) > 0 {
t.Errorf("blob directory holds %d entries after a rejected upload", len(entries))
}
}
// uploadPNG posts a small generated PNG and returns the decoded response.
func uploadPNG(t *testing.T, srv *Server, placeID string) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 8, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 30), G: uint8(y * 60), B: 200, A: 255})
}
}
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
code, resp := upload(t, srv, placeID, "pic.png", "image/png", buf.Bytes())
if code != http.StatusCreated {
t.Fatalf("upload status = %d, want 201: %v", code, resp)
}
inner, _ := resp["image"].(map[string]any)
if inner == nil {
t.Fatalf("upload response has no image object: %v", resp)
}
return code, inner
}
// TestGraphQLGetCannotExecuteQueries checks that the unauthenticated GET route
// only serves the console. If a ?query= URL ran a query, anyone who got hold of
// the link could read the whole travel log.
func TestGraphQLGetCannotExecuteQueries(t *testing.T) {
srv := newTestServer(t)
_, resp := do(t, srv, `mutation { createPlace(input: {title: "Secret"}) { id } }`, nil)
placeID := data(t, resp)["createPlace"].(map[string]any)["id"].(string)
req := httptest.NewRequest(http.MethodGet, "/graphql?"+url.Values{
"query": {`{place(id:"` + placeID + `"){title}}`},
}.Encode(), nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("GET /graphql?query status = %d, want 400", rec.Code)
}
if strings.Contains(rec.Body.String(), "Secret") {
t.Fatalf("GET /graphql?query leaked data: %s", rec.Body.String())
}
// The console itself stays reachable, since it carries no data.
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/graphql", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /graphql status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("GET /graphql content-type = %q, want text/html", ct)
}
}
// upload posts a multipart image and returns the status and decoded body.
func upload(t *testing.T, srv *Server, placeID, filename, contentType string, payload []byte) (int, map[string]any) {
t.Helper()
var body bytes.Buffer
w := multipart.NewWriter(&body)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="image"; filename="`+filename+`"`)
h.Set("Content-Type", contentType)
part, err := w.CreatePart(h)
if err != nil {
t.Fatalf("create part: %v", err)
}
if _, err := part.Write(payload); err != nil {
t.Fatalf("write part: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/places/"+placeID+"/images", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
// Error responses are plain text, which is fine here.
return rec.Code, map[string]any{"error": strings.TrimSpace(rec.Body.String())}
}
return rec.Code, out
}