diff --git a/backend/internal/proxy/handler.go b/backend/internal/proxy/handler.go index 2d57981d..9f1b93e7 100644 --- a/backend/internal/proxy/handler.go +++ b/backend/internal/proxy/handler.go @@ -2,6 +2,8 @@ package proxy import ( + "bytes" + "encoding/json" "errors" "fmt" "io" @@ -16,6 +18,11 @@ import ( // outside this returns 404. const apiPrefix = "/api/v3/" +// stopsLocationPrefix is the upstream path prefix for the nearby-stops +// endpoint (/v3/stops/location/{lat},{lng}). For this endpoint we strip the +// per-stop `routes` array from the response — see trimStopsLocation. +const stopsLocationPrefix = "/v3/stops/location/" + // maxUpstreamBytes caps how much of PTV's response we'll copy back to the // client. PTV responses are JSON and well under this in practice; the cap is // a belt-and-braces against a misbehaving or compromised upstream. @@ -112,6 +119,33 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if ct := resp.Header.Get("Content-Type"); ct != "" { w.Header().Set("Content-Type", ct) } + + // For the nearby-stops endpoint, trim the heavy per-stop `routes` array the + // mobile pins never read. We only attempt this on a 2xx JSON response; on + // any decode failure we fall back to a verbatim copy so the endpoint can + // never be broken by an unexpected upstream shape. PTV has no query param + // to omit `routes` upstream, so trimming here is the only lever. This is + // the dominant bytes-on-wire cost for that endpoint (~39 KB -> ~7.5 KB). + if resp.StatusCode == http.StatusOK && strings.HasPrefix(upstreamPath, stopsLocationPrefix) { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBytes)) + if err != nil { + h.logger.WarnContext(r.Context(), "response read interrupted", slog.String("err", err.Error())) + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(body) + return + } + if trimmed, ok := trimStopsLocation(body); ok { + body = trimmed + } else { + h.logger.WarnContext(r.Context(), "stops/location trim skipped; passing through verbatim", + slog.String("path", upstreamPath), + ) + } + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(body) + return + } + w.WriteHeader(resp.StatusCode) n, err := io.Copy(w, io.LimitReader(resp.Body, maxUpstreamBytes)) if err != nil { @@ -125,3 +159,59 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ) } } + +// trimStopsLocation removes the per-stop `routes` array from a +// /v3/stops/location response body. The nearby map pins consume only +// stop_id/stop_name/stop_latitude/stop_longitude/route_type, so `routes` +// (each carrying route_name, route_gtfs_id, geopath, etc.) is dead weight on +// the wire. +// +// It is deliberately surgical: it decodes the top level and the `stops` array +// as raw messages, deletes only the `routes` key from each stop object, and +// leaves every other field — top-level (disruptions, status) and per-stop — +// byte-for-byte intact. It returns (body, false) on any structural surprise +// (not an object, stops not an array, a stop that isn't an object, or a +// re-marshal error) so the caller can fall back to a verbatim copy. +func trimStopsLocation(body []byte) ([]byte, bool) { + var top map[string]json.RawMessage + if err := json.Unmarshal(body, &top); err != nil { + return body, false + } + rawStops, ok := top["stops"] + if !ok { + // No stops key at all (e.g. an error envelope) — nothing to trim. + return body, false + } + var stops []map[string]json.RawMessage + if err := json.Unmarshal(rawStops, &stops); err != nil { + return body, false + } + + changed := false + for _, stop := range stops { + if _, has := stop["routes"]; has { + delete(stop, "routes") + changed = true + } + } + if !changed { + // Nothing to strip; avoid re-marshalling (which would also reorder + // keys for no benefit). + return body, true + } + + newStops, err := json.Marshal(stops) + if err != nil { + return body, false + } + top["stops"] = newStops + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(top); err != nil { + return body, false + } + // Encoder appends a trailing newline; trim it to keep the body tight. + return bytes.TrimRight(buf.Bytes(), "\n"), true +} diff --git a/backend/internal/proxy/handler_test.go b/backend/internal/proxy/handler_test.go index 809b6691..8ac9e1e7 100644 --- a/backend/internal/proxy/handler_test.go +++ b/backend/internal/proxy/handler_test.go @@ -1,6 +1,7 @@ package proxy import ( + "encoding/json" "io" "log/slog" "net/http" @@ -77,6 +78,155 @@ func TestHandler_HappyPath(t *testing.T) { } } +// stopsLocationBody mirrors the real PTV /v3/stops/location shape closely +// enough to exercise the trim: each stop carries the fields the pins use plus +// the heavy `routes` array we strip, and the envelope carries sibling keys +// (disruptions, status) that must survive untouched. +const stopsLocationBody = `{` + + `"stops":[` + + `{"stop_id":2720,"stop_name":"Bourke St Mall","route_type":1,"stop_latitude":-37.81,"stop_longitude":144.96,` + + `"routes":[{"route_type":1,"route_id":725,"route_name":"North Coburg - Flinders Street","route_number":"19","route_gtfs_id":"3-019","geopath":[]}]},` + + `{"stop_id":2721,"stop_name":"Elizabeth St","route_type":3,"stop_latitude":-37.82,"stop_longitude":144.97,` + + `"routes":[{"route_type":3,"route_id":1,"route_name":"Some Train","route_number":"","route_gtfs_id":"2-XYZ","geopath":[]}]}` + + `],` + + `"disruptions":{},` + + `"status":{"version":"3.0","health":1}}` + +// TestHandler_StopsLocationTrimsRoutes asserts that the nearby-stops endpoint +// response has the per-stop `routes` array stripped, while every other field +// (the pin fields plus the envelope's disruptions/status) round-trips, and the +// payload shrinks. +func TestHandler_StopsLocationTrimsRoutes(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(stopsLocationBody)) + })) + defer upstream.Close() + + h := newTestHandler(t, upstream) + req := httptest.NewRequest(http.MethodGet, "/api/v3/stops/location/-37.81,144.96?max_results=100&max_distance=500", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + // The body must still be valid JSON with no `routes` key anywhere. + out := rec.Body.String() + if strings.Contains(out, `"routes"`) { + t.Errorf("response still contains routes: %s", out) + } + if strings.Contains(out, "geopath") || strings.Contains(out, "route_gtfs_id") { + t.Errorf("response still contains heavy route fields: %s", out) + } + + var got map[string]any + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("response is not valid JSON: %v\n%s", err, out) + } + + // Envelope siblings survive. + if _, ok := got["disruptions"]; !ok { + t.Errorf("disruptions key dropped: %s", out) + } + if _, ok := got["status"]; !ok { + t.Errorf("status key dropped: %s", out) + } + + stops, ok := got["stops"].([]any) + if !ok || len(stops) != 2 { + t.Fatalf("stops not a 2-element array: %#v", got["stops"]) + } + + // Every pin field survives on each stop. + wantKeys := []string{"stop_id", "stop_name", "route_type", "stop_latitude", "stop_longitude"} + for i, s := range stops { + stop, ok := s.(map[string]any) + if !ok { + t.Fatalf("stop %d not an object: %#v", i, s) + } + if _, has := stop["routes"]; has { + t.Errorf("stop %d still has routes", i) + } + for _, k := range wantKeys { + if _, has := stop[k]; !has { + t.Errorf("stop %d missing pin field %q: %#v", i, k, stop) + } + } + } + + // Sanity: the trimmed body is meaningfully smaller than upstream's. + if len(out) >= len(stopsLocationBody) { + t.Errorf("trimmed body not smaller: %d >= %d", len(out), len(stopsLocationBody)) + } +} + +// TestHandler_NonStopsLocationPassthroughKeepsRoutes asserts the trim is +// scoped to stops/location only: another endpoint whose body happens to carry +// a `routes` array is copied through verbatim, byte-for-byte. +func TestHandler_NonStopsLocationPassthroughKeepsRoutes(t *testing.T) { + t.Parallel() + + // A stops/{id}/route_type/{type} style body that legitimately carries routes. + const detailBody = `{"stop":{"stop_id":2720},"routes":[{"route_id":725,"route_name":"North Coburg"}],"status":{"health":1}}` + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(detailBody)) + })) + defer upstream.Close() + + h := newTestHandler(t, upstream) + req := httptest.NewRequest(http.MethodGet, "/api/v3/stops/2720/route_type/1", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Body.String(); got != detailBody { + t.Errorf("non-stops/location body was modified.\n got: %s\nwant: %s", got, detailBody) + } +} + +// TestTrimStopsLocation_Fallbacks covers the structural-surprise paths where +// the helper must return the body unchanged so ServeHTTP falls back to a +// verbatim copy. +func TestTrimStopsLocation_Fallbacks(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + body string + wantOK bool + }{ + {"malformed json", `{not json`, false}, + {"top level not object", `[1,2,3]`, false}, + {"no stops key", `{"status":{"health":1}}`, false}, + {"stops not array", `{"stops":{"oops":true}}`, false}, + {"stop not object", `{"stops":[1,2,3]}`, false}, + {"no routes present", `{"stops":[{"stop_id":1}],"status":{}}`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + out, ok := trimStopsLocation([]byte(tc.body)) + if ok != tc.wantOK { + t.Errorf("ok = %v, want %v", ok, tc.wantOK) + } + // On any non-trim path the body must be returned untouched. + if string(out) != tc.body { + t.Errorf("body mutated on fallback: got %q want %q", out, tc.body) + } + }) + } +} + func TestHandler_RejectsPOST(t *testing.T) { t.Parallel() upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {