Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions backend/internal/proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
package proxy

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
150 changes: 150 additions & 0 deletions backend/internal/proxy/handler_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package proxy

import (
"encoding/json"
"io"
"log/slog"
"net/http"
Expand Down Expand Up @@ -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) {
Expand Down