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