Skip to content

Add arrivals-and-departures-for-location endpoint - #1408

Open
ARCoder181105 wants to merge 11 commits into
OneBusAway:mainfrom
ARCoder181105:feat/arrivals-and-departures-for-location
Open

Add arrivals-and-departures-for-location endpoint#1408
ARCoder181105 wants to merge 11 commits into
OneBusAway:mainfrom
ARCoder181105:feat/arrivals-and-departures-for-location

Conversation

@ARCoder181105

Copy link
Copy Markdown
Collaborator

Closes #799. Replaces #787.

Stacked on #1407. The diff below includes that PR's commit until it merges — review only the last three commits here, or wait for #1407 to land and this will narrow on its own.

Summary

Implements GET /api/where/arrivals-and-departures-for-location.json — arrivals for every stop in a bounding box or radius. Built on the arrivals core extracted in #1407, so the handler is a loop over the spatial query's stops plus response assembly.

What changed

arrivals_and_departures_for_location_handler.go New handler
models/stops.go, models/response.go, models/arrival_and_departure.go StopWithDistance, entry type, response constructor
utils/api.go, models/constants.go maxCount ceiling is now per-endpoint; this one needs 1000, the shared default is 250
gtfsdb/query.sql ORDER BY on GetAgenciesForStops — multi-stop agency resolution was otherwise nondeterministic
routes.go Route registration

Parameters

Per the OpenAPI spec: lat/lon required, radius takes precedence over latSpan/lonSpan, minutesBefore 5, minutesAfter 35, maxCount 250 clamped to 1000, plus routeType and emptyReturnsNotFound.

nearbyStopIds

The union of the stops within 100 m of each matched stop, each excluding itself, measured from the centre of the search area and ordered nearest first.

Deliberately not "every stop in the box" — a matched stop with no neighbour within 100 m does not appear, while a stop just outside the box does if it neighbours one inside.

Verified against the live deployed server

Ran locally against the King County Metro feed and compared to api.pugetsound.onebusaway.org on the same query. Puget Sound aggregates several agencies where a maglev instance loads one static feed, so the comparison is restricted to agency 1:

maglev pugetsound (agency-1 subset)
stopIds 18 18 — identical set
nearbyStopIds 19 19 — identical set, distances agree to 0.05 m
response keys 102 shared

Parameter behaviour matches on maxCount (truncates all three lists, sets limitExceeded), maxCount clamping, routeType, radius vs span precedence, and the empty-area case. The two response-key differences are both explained: tripStatus.scheduled is a pre-existing maglev field on the existing for-stop endpoint too, and the situation keys are absent only because the sole alert in that area belongs to agency 40, which this config does not load — alerts resolve correctly elsewhere in the feed.

Divergences from the deployed server — please review these

Five behaviors differ, each because the deployed server contradicts the OpenAPI spec:

  1. stopIds is deduplicated. The Java service adds every matched stop to the list twice (StopWithArrivalsAndDeparturesBeanServiceImpl lines 115 and 122; its own comment says 115 shouldn't be there). Live response: 44 entries, 22 unique.
  2. An empty result keeps the entry/references envelope. The Java empty path skips BeanFactoryV2 and emits a different shape ({stops, nearbyStops, situations, timeZone}, no entry, no references). The spec marks both required.
  3. emptyReturnsNotFound=true returns 404. Java sets the 404 and then overwrites it with a 200, so it never ships.
  4. lat/lon alone search the default 600 m radius. The Java action never configures a default radius, so it builds a zero-area box and always returns empty.
  5. Arrivals are sorted by arrival time. The Java sort compares a distance field that is never populated, so it is a no-op and the order is hash order.

routeType filtering the arrivals and pruning nearby stops but not the stop search is preserved as-is — spec and deployed server agree, and changing it would be the bigger break. Confirmed live: routeType=99 returns the full stopIds list with zero arrivals and zero nearby stops.

Testing

13 tests covering parameter validation, radius vs span precedence, the default-radius fallback, maxCount truncating all three lists, routeType filtering arrivals but not stops, emptyReturnsNotFound, stopIds dedup, nearbyStopIds ordering and reference resolution, and includeReferences=false. Added to TestOpenAPIConformance_LocationEndpoints, which validates the response against the spec schema.

The arrivals-and-departures-for-stop handler carried its entire
pipeline inline: the +/-1 day service window scan, the batch route and
trip resolution, the per-row arrival construction, and the reference
assembly. Nothing was callable from anywhere else, so a second endpoint
needing arrivals for many stops would have to duplicate all of it.

Move that pipeline into arrivals_core.go behind arrivalsForStop and
buildArrivalsReferences, with an arrivalsAccumulator gathering the
routes, trips, stops and situations that the arrivals reference. The
accumulator is what makes the code reusable across several stops: a
caller loops over stops sharing one accumulator and gets a single
deduplicated references block.

Behavior is unchanged; the test suite passes without modification.

Two details worth noting for review:

BuildTripStatus now receives the vehicle the caller already looked up
rather than nil. It is not a fix -- BuildTripStatus falls back to the
same GetVehicleForTrip call when handed nil -- so the result is
identical, but it drops one redundant lookup per arrival row.

The extracted stop reference deliberately keeps its inline literal
instead of calling buildStopModel. That helper defaults Code to the
stop ID when stops.code is NULL, where this endpoint emits an empty
string, so adopting it would be a response change rather than a
refactor.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 59 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c473b30a-47f7-4ae3-9238-b3481254367d

📥 Commits

Reviewing files that changed from the base of the PR and between 4341b04 and e59e3a7.

📒 Files selected for processing (14)
  • gtfsdb/query.sql
  • gtfsdb/query.sql.go
  • internal/models/arrival_and_departure.go
  • internal/models/constants.go
  • internal/models/response.go
  • internal/models/stops.go
  • internal/restapi/arrivals_and_departures_for_location_handler.go
  • internal/restapi/arrivals_and_departures_for_location_handler_test.go
  • internal/restapi/arrivals_and_departures_for_stop_handler.go
  • internal/restapi/arrivals_core.go
  • internal/restapi/openapi_conformance_test.go
  • internal/restapi/response_types.go
  • internal/restapi/routes.go
  • internal/utils/api.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Four functions in the extracted core sat above the quality gate's
cognitive complexity threshold of 15: activeStopTimesForWindow at 21,
appendStopReferences at 18, buildArrival at 17 and batchArrivalEntities
at 16.

Pull the distinct sub-tasks out of each:

  - stopTimesForServiceDay, the per-service-day scan
  - uniqueRouteAndTripIDs and tripStopCounts, the batch input and
    stop-count preparation
  - combinedVehicleID and tripStatusForArrival, the vehicle and trip
    status resolution
  - loadStopReferenceData and collectStopRoutes, the reference batch
    load and per-stop route rendering

One behaviour does change. A failed GetStopsByIDs in the stop reference
load is now returned rather than logged and skipped past. Swallowing it
dropped every stop reference and still answered 200, so the entry named
stops the client had no way to resolve; the handler now surfaces it as
a 500. The routes lookup keeps its warn-and-continue, since losing it
only costs each stop its routeIds.
The query returned rows in whatever order SQLite produced them. Callers
resolving a stop served by several agencies take the first row they see,
so the agency a stop was namespaced under could change between runs on
identical data.

Order by stop then agency so that choice is stable.
parseMaxCount hardcoded models.MaxAllowedCount as the ceiling for every
caller. arrivals-and-departures-for-location documents a maximum of
1000, which that shared value of 250 cannot express.

Take the ceiling as a parameter and add ParseMaxCountClampedTo for
callers that need their own. The existing ParseMaxCount and
ParseMaxCountClamped keep passing MaxAllowedCount, so nothing changes
for current callers.
Serves GET /api/where/arrivals-and-departures-for-location.json, which
returns arrivals for every stop in a bounding box or radius. It reuses
the per-stop arrivals core, so the handler is a loop over the stops the
spatial query returns plus the response assembly around it.

Parameters follow the OpenAPI spec: lat and lon required, radius taking
precedence over latSpan/lonSpan, minutesBefore 5, minutesAfter 35,
maxCount 250 clamped to 1000, plus routeType and emptyReturnsNotFound.

routeType filters the arrivals and prunes nearby stops that serve no
route of a requested type, but deliberately does not filter the stop
search itself -- stopIds is unaffected by it, matching both the Java
implementation and the deployed server.

nearbyStopIds is the union of the stops within 100m of each matched
stop, each excluding itself, measured from the centre of the search
area and ordered nearest first. It is deliberately not "every stop in
the box": a matched stop with no neighbour within 100m does not appear,
while a stop just outside the box does if it neighbours one inside.

Verified against the deployed Puget Sound server over the same query.
Restricted to the one agency a single static feed can load, the stop
and nearby-stop sets match exactly, with distances agreeing to within
0.05m. Five behaviours diverge, each because the deployed server
contradicts the OpenAPI spec:

  - stopIds is deduplicated. The Java service adds every matched stop
    to the list twice.
  - An empty result keeps the entry/references envelope. The Java empty
    path skips the bean factory and emits a different shape.
  - emptyReturnsNotFound returns 404. The Java code sets the 404 and
    then overwrites it with a 200.
  - lat and lon alone search the default 600m radius. The Java action
    never configures a default and builds a zero-area box.
  - Arrivals are sorted by arrival time. The Java sort compares a
    distance that is never populated, so it does nothing.
@ARCoder181105
ARCoder181105 force-pushed the feat/arrivals-and-departures-for-location branch from 6186d40 to d954239 Compare August 28, 2026 13:50
Three functions sat above the quality gate's threshold of 15: the
handler itself at 19, agenciesForStops at 17 and
parseArrivalsForLocationParams at 16.

Pull out the sub-tasks so each reads as a sequence of named steps:

  - sendArrivalsForLocationError, replacing two copies of the
    cancelled-versus-failed branch
  - locationLists with truncateLocationLists, combinedStopIDs,
    registerReferencedStops and locationReferences
  - agencyLocationOrUTC and mostCommonAgency
  - parseRequiredLocation, parseEpochMillisParam,
    parseOptionalBoolParam and forwardFieldErrors, which replaces two
    copies of the field-error funnelling loop

No behaviour change.
@ARCoder181105

Copy link
Copy Markdown
Collaborator Author

Cleared the SonarCloud findings in c5489a0. Quality gate is green (0 issues).

Three functions were over the cognitive complexity threshold of 15. Split each into named steps:

Function Was Extracted
arrivalsAndDeparturesForLocationHandler 19 sendArrivalsForLocationError, truncateLocationLists, combinedStopIDs, registerReferencedStops, locationReferences
agenciesForStops 17 agencyLocationOrUTC, mostCommonAgency
parseArrivalsForLocationParams 16 parseRequiredLocation, parseEpochMillisParam, parseOptionalBoolParam, forwardFieldErrors

Two of these removed real duplication rather than just moving lines: the cancelled-versus-failed error branch was written out twice, and the field-error funnelling loop was written out twice.

No behaviour change — the handler tests and the full suite pass unchanged.

Also rebased onto the latest #1407 after its review fixes. Once that merges, the diff here drops to the endpoint alone.

@burma-shave

Copy link
Copy Markdown
Collaborator

I'll generate a spec for this, hopefully next week. We'll hold off on merging until this can be reviewed against a spec like the other endpoints.

# Conflicts:
#	internal/restapi/arrivals_and_departures_for_stop_handler.go
The extraction in refactor/extract-arrivals-core predates main's GTFS
frequency work, so merging main in silently dropped frequency data
from arrivals-and-departures-for-stop: BuildTripStatus's freqMap
parameter had nothing feeding it.

Batch-fetch frequencies alongside routes and trips in
batchArrivalEntities, thread the map through arrivalInput, and apply
it in a small applyFrequency helper kept separate from buildArrival
to avoid pushing its cognitive complexity back over the SonarCloud
limit. This also gives the location endpoint frequency support for
free, since it shares the same core.
A hard 500 here means a single failed batch stop lookup takes down an
otherwise complete arrivals response. Match the treatment already
given to the sibling route lookup: log and continue with an
incomplete references.stops rather than failing the whole request.
The prior wording read as if any caller could rely on this field to
namespace alerts. It is only ever read by the single-stop handler; a
multi-stop caller must pass a per-stop agency ID directly to
situations.add instead. Reword to state that plainly rather than
scoping the field, since it is genuinely single-caller today.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement arrivals-and-departures-for-location endpoint

2 participants