Skip to content

search stop

Eric Jutrzenka edited this page Aug 6, 2026 · 7 revisions

Search: Stop

Goal in Context

A rider's client app wants to help the user find stops by name as they type, using a prefix-based autocomplete interface.

Scope

OBA REST API — stop name search endpoint.

Level

User goal.

Primary Actor

Rider (accessing the system via a client app).

Stakeholders and Interests

  • Rider — wants to find boarding locations by entering part of a stop name; expects matches to appear after a few keystrokes and to see enough context (location, served routes) to confirm they have the right stop.

Preconditions

  • The search index has been built from the feed data (this happens asynchronously at application startup; if the application has just started, the index may be empty).
  • The caller supplies a valid API key.

Minimal Guarantees

  • If the input is missing, the server returns HTTP 404 with a validation error body (outside the standard envelope).
  • If no stops match the prefix query, the server returns a 404 response in the standard envelope. Maglev deviates from this — see Implementation Decisions.
  • The response never contains stops that lack revenue service (at least one scheduled stop time with unrestricted pick-up or drop-off).

Success Guarantees

  • The response contains up to maxCount stops whose names match the given prefix.
  • Each stop entry includes its combined ID, name, location, direction, stop code, location type, wheelchair accessibility, associated route IDs, and static route IDs.
  • All routes referenced in the stop entries are fully described in the references block; all agencies owning those routes are also included.
  • The limitExceeded flag correctly indicates whether the number of raw prefix matches (before route-type filtering) exceeded maxCount.

Trigger

An HTTP GET request to /api/where/search/stop.json (or .xml) with a non-empty input parameter.

Main Success Scenario

  1. The server receives a GET request. It converts input to lower case before using it as the lookup key.

  2. The server looks up the key in the stop-name search index. This index is keyed by all prefixes of each whitespace/hyphen/slash/parenthesis/ampersand-delimited token in every stop name. For multi-word names, the index also stores keys that span the first token boundary and extend character by character up to 32 characters, enabling queries like pine st & 9th to match Pine St & 9th Ave. The key is matched exactly — there is no fuzzy matching. (BundleSearchServiceImpl.java:169-201)

  3. Stops within the matching bucket are ordered by their combined stop ID (ascending lexicographic order). (BundleSearchServiceImpl.java:337-345)

  4. If the bucket contains more than maxCount stops, the list is truncated to maxCount and limitExceeded is set to true. (BundleSearchServiceImpl.java:213-223)

  5. The truncated list is filtered to remove stops that are served exclusively by special-vehicle route types. Specifically: a stop with exactly one route is excluded if that route's type is 711, 712, 713, or 714; a stop with two or more routes is always included regardless of their types; a stop with zero routes is excluded. (StopAction.java:61-65)

  6. The server converts each remaining stop to its API representation and collects all routes and agencies referenced by those stops into the references block. Routes in the references block are sorted by short name (plain lexicographic compare, falling back directly to the combined route ID when short name is unset); within each agency, the primary agency's routes appear first when a custom sort is configured. (BeanFactoryV2.java:147-161) Maglev deviates from this — see Implementation Decisions.

  7. The server returns HTTP 200 with the standard envelope containing the list, limitExceeded, outOfRange (always false), and the references block.

Extensions

2a. No stops match the prefix: The server returns HTTP 404 with code 404 and text "resource not found" in the standard envelope. Maglev deviates from this — see Implementation Decisions.

2b. Stops match the prefix but all are excluded by the route-type filter: The server returns HTTP 200 with an empty list. limitExceeded may be true if the pre-filter count exceeded maxCount. (Note: the 404 check occurs before filtering, so an all-filtered result does not produce a 404.)

Input validation failure: If input is absent, Struts2 request validation fires before the action runs. The response is HTTP 404 with the body {"fieldErrors":{"input":["missing input"]}} — a raw validation error object, not the standard OBA response envelope.

Missing or invalid API key: The server returns HTTP 401.

Suspected Defects

Defects that affect the use case

1. includeReferences=false produces a null response body

BeanFactoryV2.java:147-153

When includeReferences=false is passed, no routes are added to the references collection. BeanFactoryV2.getResponse(StopSearchResultBean) then calls .sort() unconditionally on the references route list, which is null, throwing a NullPointerException. The exception propagates before the response bean is set. The serialised response is the JSON literal null with HTTP 200. The intended behaviour is to return the stop list without a references block (as includeReferences=false works on other endpoints). Maglev intentionally corrects this — see Implementation Decisions.

2. limitExceeded reflects the pre-filter cap, not the post-filter result count

StopAction.java:55-66

The maxCount cap is applied before the route-type filter. A response may therefore contain limitExceeded=true alongside a list with fewer than maxCount entries (because some of the capped entries were subsequently filtered out). A caller cannot determine from limitExceeded alone whether there are additional unfiltered results beyond what was returned.

3. Route-type filter does not apply to stops with multiple routes

StopAction.java:61-65

The intent of the filter appears to be to suppress stops that serve only special vehicle types (school buses, etc.). However, the implementation passes any stop with two or more routes unconditionally, regardless of whether both routes are excluded types. Only single-route stops are subject to the type check. A stop with two school-bus routes would therefore appear in results.

Implementation defects only

4. Latent NullPointerException in route-type filter predicate

StopAction.java:61-65

Due to Java's &&/|| operator precedence, the routes != null guard only protects the size() > 1 branch. If routes were null, evaluating routes.size() == 1 in the second branch would throw a NullPointerException. This does not occur in practice because the service layer always initialises the routes list to an empty ArrayList, but the predicate is incorrectly written. A clean reimplementation should guard against null or rely on an always-initialised collection.

5. Unused ArrivalsAndDeparturesQueryBean field in StopAction

StopAction.java:38

StopAction declares and initialises a _query field of type ArrivalsAndDeparturesQueryBean that is never read or passed to any service method. It has no observable effect and can be omitted in a reimplementation.

6. agencyId parameter ignored throughout

StopAction.java:55

The call to _service.getStopSuggestions(null, _input, maxCount) hardcodes null as the agency ID. The service implementation ignores agency ID entirely; the search is always across all agencies. If per-agency scope was intended for future use, it is not implemented.

Implementation Decisions

includeReferences=false returns the stop list without a references block, rather than reproducing the legacy NullPointerException (deviates from legacy).

The legacy implementation throws a NullPointerException when includeReferences=false is passed, serialising the response as the JSON literal null with HTTP 200 (see Suspected Defects). Maglev intentionally corrects this: passing includeReferences=false returns the standard envelope with data.list populated as usual and data.references present but with all arrays (agencies, routes, situations, stops) empty, consistent with how includeReferences=false behaves on other Maglev endpoints.

data.references.routes is sorted using natural (digit-aware) ordering with a short name → long name → agency ID → route ID fallback chain, rather than legacy's plain lexicographic short name → route ID comparator (deviates from legacy).

The legacy implementation (BeanFactoryV2.getResponse(StopSearchResultBean)) sorts references.routes via an injected RouteSorting strategy; the default implementation (DefaultRouteSort.compareRoutes) does a plain String.compareTo on each route's nullSafeShortNameshortName if set, otherwise the combined route ID directly. This is plain alphabetical ordering (e.g. route "10" sorts before route "9") and never considers longName.

Maglev instead sorts references.routes using the same natural-sort, multi-field comparator (utils.SortModelRoutesByName) it already uses for route references on the stop and schedule-for-stop endpoints: sort by shortName, falling back to longName, then agencyId, then id, with natural string ordering (embedded digit runs compared numerically, so "9" sorts before "10"). This favors one consistent, predictable route-reference sort behavior across all Maglev endpoints over exact, endpoint-by-endpoint conformance with each legacy comparator — stop and schedule-for-stop each have their own distinct legacy sort routines that this comparator already matches; search-stop's legacy routine happens to be simpler (lexicographic, no long-name fallback), but Maglev applies the shared rule here too rather than special-casing this endpoint.

Practical effect versus legacy: routes with purely numeric short names sort in numeric rather than lexicographic order, and a route with an empty shortName but a non-empty longName sorts by that longName text rather than falling straight to its ID.

A no-match prefix query returns HTTP 200 with an empty list, rather than HTTP 404 (deviates from legacy).

The legacy implementation (StopAction.java#L56-L57) explicitly checks whether the pre-filter suggestion list is empty and short-circuits to a 404 in that case:

ListBean<StopBean> stopSuggestions = _service.getStopSuggestions(null, _input, maxCount);
if (stopSuggestions == null || stopSuggestions.getList().isEmpty())
    return setResourceNotFoundResponse();

Maglev instead treats a query that matches no stops as a successful, empty result (HTTP 200, data.list: []) — consistent with how an all-filtered result is already handled under Extension 2b. This mirrors the same decision made for the sibling search/route.json endpoint (see search-route § Implementation Decisions).

Practical effect versus legacy: callers that used to receive HTTP 404 with the standard error envelope (code: 404, text: "resource not found") for a no-match query now receive HTTP 200 with data.list: [], data.references present with empty arrays, and data.limitExceeded: false.

Open Questions

None.


Request Parameters

{
  "type": "object",
  "required": ["input", "key"],
  "properties": {
    "input": {
      "type": "string",
      "description": "Prefix to search for. Case-insensitive. Must be non-empty."
    },
    "maxCount": {
      "type": "integer",
      "default": 20,
      "description": "Maximum number of stops to return."
    },
    "key": {
      "type": "string",
      "description": "API key."
    },
    "version": {
      "type": "integer",
      "default": 2
    },
    "includeReferences": {
      "type": "boolean",
      "default": true,
      "description": "Whether to include the references block. Note: passing false triggers a server bug (see Suspected Defects)."
    }
  }
}

input — The string to match against stop names. The server converts it to lower case before lookup. Matching is prefix-based: a value of pine matches any stop whose name contains a token starting with pine. Multi-word prefixes (e.g. pine st) are also matched as long as they correspond to a prefix of the full stop name up to 32 characters. Does not match against stop codes or stop entity IDs.

maxCount — Upper bound on the number of results returned. Defaults to 20. Applied before the route-type filter, so the actual list may be shorter.

key — API authentication key. Missing or invalid key returns HTTP 401.

version — Response version selector. Defaults to 2. Only version 2 is supported.

includeReferences — When true (default), the references block is populated with route and agency objects. Passing false causes a server-side bug that returns a null response body; do not use.


Response Structure

Envelope

{
  "type": "object",
  "properties": {
    "code":        { "type": "integer" },
    "currentTime": { "type": "integer", "description": "Unix ms" },
    "text":        { "type": "string" },
    "version":     { "type": "integer" },
    "data":        { "type": "object" }
  }
}

code — HTTP status mirrored in the body: 200 for success, 404 if no stops matched. Maglev deviates from this — see Implementation Decisions.

currentTime — Server time at the moment the response was generated, in Unix milliseconds.

text — Human-readable status message (e.g. "OK", "resource not found").

version — API version used for the response (always 2).

data — The result payload; absent when code is 404.


data

{
  "type": "object",
  "properties": {
    "list":         { "type": "array", "items": { "type": "object" } },
    "limitExceeded": { "type": "boolean" },
    "outOfRange":   { "type": "boolean" },
    "references":   { "type": "object" }
  }
}

data.list — Array of stop objects matching the query. May be empty if all matched stops were excluded by the route-type filter. Maglev also returns an empty list rather than a 404 when no stops matched the prefix at all — see Implementation Decisions.

data.limitExceededtrue if the raw number of index matches exceeded maxCount before filtering. The actual list may contain fewer than maxCount entries.

data.outOfRange — Always false for this endpoint.

data.references — Full objects for all routes and agencies referenced in the list. Present when includeReferences=true.


data.list[] (stop entry)

{
  "type": "object",
  "properties": {
    "id":                 { "type": "string" },
    "code":               { "type": "string" },
    "name":               { "type": "string" },
    "lat":                { "type": "number" },
    "lon":                { "type": "number" },
    "direction":          { "type": "string" },
    "locationType":       { "type": "integer" },
    "wheelchairBoarding": { "type": "string" },
    "routeIds":           { "type": "array", "items": { "type": "string" } },
    "staticRouteIds":     { "type": "array", "items": { "type": "string" } },
    "parent":             { "type": "string" }
  }
}

data.list[].id — Combined stop ID in {agencyId}_{entityId} form (e.g. 1_1085).

data.list[].code — The human-readable stop code as published in the feed (e.g. "1085"). Typically shown on physical stop signs. Falls back to the entity portion of the combined ID when the feed supplies no code, matching data.entry.code on stop — legacy's stop-suggestion index is built from StopBean objects fetched via the same TransitDataService.getStop() call that backs the single-stop endpoint (BundleSearchServiceImpl.java:100), which ultimately resolves through fillStopBean's StringLibrary.getBestName(narrative.getCode(), stop.getId().getId()).

data.list[].name — The stop name as it appears in the feed (e.g. "Pine St & 9th Ave").

data.list[].lat — Latitude of the stop in decimal degrees.

data.list[].lon — Longitude of the stop in decimal degrees.

data.list[].direction — Cardinal or intercardinal direction of travel at this stop (e.g. "SW", "N"). Taken from the feed when defined; otherwise inferred from shape geometry, or empty/absent if neither is available. See Stop-Direction-Calculation for the inference algorithm.

data.list[].locationType — GTFS location_type value: 0 = stop/platform, 1 = station, 2 = station entrance/exit, 3 = generic node, 4 = boarding area.

data.list[].wheelchairBoarding — Accessibility status: "ACCESSIBLE", "NOT_ACCESSIBLE", "UNKNOWN", or "PARTIALLY_ACCESSIBLE".

data.list[].routeIds — Combined IDs of routes currently serving this stop. Each ID corresponds to an entry in data.references.routes.

data.list[].staticRouteIds — Combined IDs of routes statically configured to serve this stop. In most deployments this is identical to routeIds; it can differ when the feed includes explicit static route overrides for the stop.

data.list[].parent — Combined ID of the parent station if this stop is a sub-component of a station; empty string if there is no parent.


data.references

{
  "type": "object",
  "properties": {
    "agencies": { "type": "array", "items": { "type": "object" } },
    "routes":   { "type": "array", "items": { "type": "object" } },
    "stops":    { "type": "array", "items": { "type": "object" } }
  }
}

data.references.agencies — Full agency objects for all agencies that own a route appearing in data.references.routes. Sorted with the primary agency first (when a custom route sort is configured), then alphabetically by agency ID.

data.references.routes — Full route objects for all routes referenced by routeIds or staticRouteIds across the returned stops. Sorted by route short name (alphabetically). If no short name is set, the combined route ID is used as the sort key. Maglev deviates from this sort rule — see Implementation Decisions.

data.references.stops — Parent station objects for any stop in the list that has a non-empty parent field.


data.references.routes[]

{
  "type": "object",
  "properties": {
    "id":          { "type": "string" },
    "agencyId":    { "type": "string" },
    "shortName":   { "type": "string" },
    "longName":    { "type": "string" },
    "description": { "type": "string" },
    "type":        { "type": "integer" },
    "url":         { "type": "string" },
    "color":       { "type": "string" },
    "textColor":   { "type": "string" }
  }
}

data.references.routes[].id — Combined route ID in {agencyId}_{entityId} form.

data.references.routes[].agencyId — Plain agency ID (not combined).

data.references.routes[].shortName — Public-facing route number or short label (e.g. "10"). May be absent.

data.references.routes[].longName — Full route name (e.g. "Capitol Hill - Downtown Seattle"). May be absent.

data.references.routes[].description — Supplementary description from the feed. May be absent.

data.references.routes[].type — GTFS route_type integer (e.g. 3 for bus).

data.references.routes[].url — URL to the route's timetable or information page. May be absent.

data.references.routes[].color — Route colour as a 6-digit hex string without # (e.g. "FDB71A"). May be absent.

data.references.routes[].textColor — Text colour for use on the route colour background, as a 6-digit hex string. May be absent.


data.references.agencies[]

{
  "type": "object",
  "properties": {
    "id":             { "type": "string" },
    "name":           { "type": "string" },
    "url":            { "type": "string" },
    "timezone":       { "type": "string" },
    "lang":           { "type": "string" },
    "phone":          { "type": "string" },
    "fareUrl":        { "type": "string" },
    "email":          { "type": "string" },
    "disclaimer":     { "type": "string" },
    "privateService": { "type": "boolean" }
  }
}

data.references.agencies[].id — Plain agency ID.

data.references.agencies[].name — Agency name (e.g. "Metro Transit").

data.references.agencies[].url — Agency website URL.

data.references.agencies[].timezone — IANA timezone identifier (e.g. "America/Los_Angeles").

data.references.agencies[].lang — Primary language code (e.g. "en").

data.references.agencies[].phone — Rider contact phone number.

data.references.agencies[].fareUrl — URL to the agency's fare information page.

data.references.agencies[].email — Contact email address.

data.references.agencies[].disclaimer — Optional disclaimer text.

data.references.agencies[].privateService — Whether the agency operates a private (non-public) service.

Clone this wiki locally