From 6a5be6b2c8f630ab921747fc57f0e3786913f271 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 09:16:06 -0400 Subject: [PATCH 01/11] feat(Model.Transfer): describe a transfer --- apps/model/lib/model/transfer.ex | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/model/lib/model/transfer.ex diff --git a/apps/model/lib/model/transfer.ex b/apps/model/lib/model/transfer.ex new file mode 100644 index 00000000..507359b8 --- /dev/null +++ b/apps/model/lib/model/transfer.ex @@ -0,0 +1,73 @@ +defmodule Model.Transfer do + @moduledoc """ + Transfer specifies additional rules and overrides for a transfer between trips, routes, and/or stops. + [GTFS `transfers.txt`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#transferstxt) + """ + + use Recordable, [ + :from_stop_id, + :to_stop_id, + :min_transfer_time, + :min_walk_time, + :min_wheelchair_time, + :suggested_buffer_time, + :from_trip_id, + :to_trip_id, + transfer_type: 0, + wheelchair_transfer: 0 + ] + + @typedoc """ + Amount of time, in seconds, that must be available to permit a transfer between routes at the specified stops. + The `min_transfer_time` should be sufficient to permit a typical rider to move between the two stops, including buffer time to allow for schedule variance on each route. + """ + @type min_transfer_time :: non_neg_integer() + + @typedoc """ + | Value | Description | + |------------|-------------| + | 0 or empty | Recommended between routes | + | 1 | Timed between two routes | + | 2 | Requires a minimum amount of time to ensure connection | + | 3 | Transfers not possible between routes at the location | + | 4 | In-seat transfer between sequential trips | + | 5 | In-seat transfers not allowed between sequential trips | + + See [GTFS `transfers.txt` `transfer_type`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#transferstxt) + """ + @type transfer_type :: 0..5 + + @typedoc """ + | Value | Description | + |------------|-------------| + | 0 or empty | No accessibility information for the transfer | + | 1 | Transfer is wheelchair accessible | + | 2 | Not accessible to persons in wheelchairs | + """ + @type wheelchair_transfer_type :: 0..2 + + @typedoc """ + * `:from_stop_id` - The `Model.Stop.id` of the `Model.Stop.t` where a connection between routes begins. See [GTFS `transfers.txt` `from_stop_id`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#transferstxt) + * `:to_stop_id` - The `Model.Stop.id` of the `Model.Stop.t` where a connection between routes ends. See [GTFS `transfers.txt` `to_stop_id`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#transferstxt) + * `:min_transfer_time` - As specified in the GTFS standard, this field is the sum of `min_walk_time` and `suggested_buffer_time`. See [MBTA GTFS `transfers.txt` `min_transfer_time`](https://github.com/mbta/gtfs-documentation/blob/master/reference/gtfs.md#transferstxt) + * `:min_walk_time` - Experimental. Minimum time required to travel by foot from `from_stop_id` to `to_stop_id`. See [MBTA GTFS `transfers.txt` `min_walk_time`](https://github.com/mbta/gtfs-documentation/blob/master/reference/gtfs.md#transferstxt) + * `:min_wheelchair_time` - Experimental. Minimum time required to travel by wheelchair `from_stop_id` to `to_stop_id`. If the transfer is not wheelchair accessible, this field will be blank. See [MBTA GTFS `transfers.txt` `min_wheelchair_time`](https://github.com/mbta/gtfs-documentation/blob/master/reference/gtfs.md#transferstxt) + * `:suggested_buffer_time` - Experimental. Recommended buffer time to allow to make a successful transfer between two services. This is also partly based on the significance of missing the transfer (due to service frequency). See [MBTA GTFS `transfers.txt` `suggested_buffer_time`](https://github.com/mbta/gtfs-documentation/blob/master/reference/gtfs.md#transferstxt) + * `:wheelchair_transfer` - Experimental. Identifies whether a transfer is accessible to customers using a wheelchair. See [MBTA GTFS `transfers.txt` `wheelchair_transfer`](https://github.com/mbta/gtfs-documentation/blob/master/reference/gtfs.md#transferstxt) + * `:from_trip_id` - If present, specifies that the transfer is exclusively valid from this trip to the trip specified in `to_trip_id`. See [GTFS `transfers.txt` `from_trip_id`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#transferstxt) + * `:to_trip_id` - If present, specifies that the transfer is exclusively valid to this trip from the trip specified in `from_trip_id`. See [GTFS `transfers.txt` `to_trip_id`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#transferstxt) + * `:transfer_type` - Indicates the type of connection for the specified (`from_stop_id`, `to_stop_id`) pair. See [GTFS `transfers.txt` `transfer_type`](https://github.com/google/transit/blob/master/gtfs/spec/en/reference.md#transferstxt) + """ + @type t :: %__MODULE__{ + from_stop_id: Model.Stop.id(), + to_stop_id: Model.Stop.id(), + min_transfer_time: min_transfer_time() | nil, + min_walk_time: non_neg_integer | nil, + min_wheelchair_time: non_neg_integer | nil, + suggested_buffer_time: non_neg_integer | nil, + wheelchair_transfer: wheelchair_transfer_type, + from_trip_id: Model.Trip.id() | nil, + to_trip_id: Model.Trip.id() | nil, + transfer_type: transfer_type + } +end From 2ad01def0aae28ffb7fc250666b52a64c2e2065f Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 09:17:08 -0400 Subject: [PATCH 02/11] feat(Parse.Transfers): parse transfers.txt --- apps/parse/lib/parse/transfers.ex | 60 +++++++++++++ apps/parse/test/parse/transfers_test.exs | 108 +++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 apps/parse/lib/parse/transfers.ex create mode 100644 apps/parse/test/parse/transfers_test.exs diff --git a/apps/parse/lib/parse/transfers.ex b/apps/parse/lib/parse/transfers.ex new file mode 100644 index 00000000..3137fe9c --- /dev/null +++ b/apps/parse/lib/parse/transfers.ex @@ -0,0 +1,60 @@ +defmodule Parse.Transfers do + @moduledoc """ + Parses `transfers.txt` CSV from GTFS zip + + from_stop_id,to_stop_id,transfer_type,min_transfer_time,min_walk_time,min_wheelchair_time,suggested_buffer_time,wheelchair_transfer,from_trip_id,to_trip_id + FR-0301-02,FR-0301-01,0,,,,,,NorthBase-825665-1427,NorthBase-825779-429 + """ + + use Parse.Simple + alias Model.Transfer + + @doc """ + Parses (non-header) row of `transfers.txt` + + ## Columns + + * `"from_stop_id"` - `Model.Stop.id | nil` + * `"to_stop_id"` - `Model.Stop.id | nil` + * `"transfer_type"` - `Model.Transfer.transfer_type` + * `"min_transfer_time"` - `Model.Transfer.min_transfer_time` | nil + * `"min_walk_time"` - `Model.Transfer.t` - `min_walk_time` | nil + * `"min_wheelchair_time"` - `Model.Transfer.t` - `min_wheelchair_time` + * `"suggested_buffer_time"` - `Model.Transfer.t` - `suggested_buffer_time` + * `"wheelchair_transfer"` - `Model.Transfer.wheelchair_transfer_type` + * `"from_trip_id"` - `Model.Trip.id | nil` + * `"to_trip_id"` - `Model.Trip.id | nil` + """ + def parse_row(row) do + %Transfer{ + from_stop_id: copy(row["from_stop_id"]), + to_stop_id: copy(row["to_stop_id"]), + min_transfer_time: to_integer_if_not_blank(row["min_transfer_time"]), + min_walk_time: to_integer_if_not_blank(row["min_walk_time"]), + min_wheelchair_time: to_integer_if_not_blank(row["min_wheelchair_time"]), + suggested_buffer_time: to_integer_if_not_blank(row["suggested_buffer_time"]), + wheelchair_transfer: to_integer(row["wheelchair_transfer"]), + from_trip_id: copy_if_not_blank(row["from_trip_id"]), + to_trip_id: copy_if_not_blank(row["to_trip_id"]), + transfer_type: String.to_integer(row["transfer_type"]) + } + end + + defp to_integer(binary) when byte_size(binary) > 0 do + String.to_integer(binary) + end + + defp to_integer(_), do: 0 + + defp to_integer_if_not_blank(binary) when byte_size(binary) > 0 do + String.to_integer(binary) + end + + defp to_integer_if_not_blank(_), do: nil + + defp copy_if_not_blank(binary) when byte_size(binary) > 0 do + copy(binary) + end + + defp copy_if_not_blank(_), do: nil +end diff --git a/apps/parse/test/parse/transfers_test.exs b/apps/parse/test/parse/transfers_test.exs new file mode 100644 index 00000000..bb7f90c3 --- /dev/null +++ b/apps/parse/test/parse/transfers_test.exs @@ -0,0 +1,108 @@ +defmodule Parse.TransfersTest do + use ExUnit.Case, async: true + alias Model.Transfer + + @header "from_stop_id,to_stop_id,transfer_type,min_transfer_time,min_walk_time,min_wheelchair_time,suggested_buffer_time,wheelchair_transfer,from_trip_id,to_trip_id\n" + + describe "parse_row/1" do + test "parses a fully-populated row" do + row = %{ + "from_stop_id" => "place-north", + "to_stop_id" => "place-south", + "transfer_type" => "2", + "min_transfer_time" => "360", + "min_walk_time" => "300", + "min_wheelchair_time" => "420", + "suggested_buffer_time" => "60", + "wheelchair_transfer" => "1", + "from_trip_id" => "trip-A", + "to_trip_id" => "trip-B" + } + + assert Parse.Transfers.parse_row(row) == %Transfer{ + from_stop_id: "place-north", + to_stop_id: "place-south", + transfer_type: 2, + min_transfer_time: 360, + min_walk_time: 300, + min_wheelchair_time: 420, + suggested_buffer_time: 60, + wheelchair_transfer: 1, + from_trip_id: "trip-A", + to_trip_id: "trip-B" + } + end + + test "parses a row with blank optional fields as nil or 0" do + row = %{ + "from_stop_id" => "FR-0301-02", + "to_stop_id" => "FR-0301-01", + "transfer_type" => "0", + "min_transfer_time" => "", + "min_walk_time" => "", + "min_wheelchair_time" => "", + "suggested_buffer_time" => "", + "wheelchair_transfer" => "", + "from_trip_id" => "NorthBase-825665-1427", + "to_trip_id" => "NorthBase-825779-429" + } + + assert Parse.Transfers.parse_row(row) == %Transfer{ + from_stop_id: "FR-0301-02", + to_stop_id: "FR-0301-01", + transfer_type: 0, + min_transfer_time: nil, + min_walk_time: nil, + min_wheelchair_time: nil, + suggested_buffer_time: nil, + wheelchair_transfer: 0, + from_trip_id: "NorthBase-825665-1427", + to_trip_id: "NorthBase-825779-429" + } + end + + test "parses a row with blank trip IDs as nil" do + row = %{ + "from_stop_id" => "stop-A", + "to_stop_id" => "stop-B", + "transfer_type" => "3", + "min_transfer_time" => "", + "min_walk_time" => "", + "min_wheelchair_time" => "", + "suggested_buffer_time" => "", + "wheelchair_transfer" => "0", + "from_trip_id" => "", + "to_trip_id" => "" + } + + result = Parse.Transfers.parse_row(row) + assert result.from_trip_id == nil + assert result.to_trip_id == nil + end + end + + describe "parse/1" do + test "parses a CSV blob with a header into a list of transfers" do + blob = + @header <> + "FR-0301-02,FR-0301-01,0,,,,,,NorthBase-825665-1427,NorthBase-825779-429\n" + + [transfer] = Parse.Transfers.parse(blob) + assert transfer.from_stop_id == "FR-0301-02" + assert transfer.to_stop_id == "FR-0301-01" + assert transfer.transfer_type == 0 + assert transfer.from_trip_id == "NorthBase-825665-1427" + end + + test "parses multiple rows" do + blob = + @header <> + "stop-A,stop-B,1,120,60,90,60,1,,\n" <> + "stop-C,stop-D,2,300,240,360,60,0,,\n" + + transfers = Parse.Transfers.parse(blob) + assert length(transfers) == 2 + assert Enum.map(transfers, & &1.from_stop_id) == ["stop-A", "stop-C"] + end + end +end From 54d6024708eb06926b3a900e9b47790aa2f5514d Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 09:22:07 -0400 Subject: [PATCH 03/11] feat(State.Transfer): ingest and parse transfers --- apps/state/lib/state.ex | 3 ++- apps/state/lib/state/transfer.ex | 10 ++++++++++ apps/state_mediator/lib/gtfs_decompress.ex | 3 ++- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 apps/state/lib/state/transfer.ex diff --git a/apps/state/lib/state.ex b/apps/state/lib/state.ex index a4d01c72..7a9dab14 100644 --- a/apps/state/lib/state.ex +++ b/apps/state/lib/state.ex @@ -37,7 +37,8 @@ defmodule State do State.Shape, State.Feed, State.CommuterRailOccupancy, - State.StopEvent + State.StopEvent, + State.Transfer ] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html diff --git a/apps/state/lib/state/transfer.ex b/apps/state/lib/state/transfer.ex new file mode 100644 index 00000000..9c22ea3e --- /dev/null +++ b/apps/state/lib/state/transfer.ex @@ -0,0 +1,10 @@ +defmodule State.Transfer do + @moduledoc """ + Maintains the current state of transfers. + """ + + use State.Server, + fetched_filename: "transfers.txt", + parser: Parse.Transfers, + recordable: Model.Transfer +end diff --git a/apps/state_mediator/lib/gtfs_decompress.ex b/apps/state_mediator/lib/gtfs_decompress.ex index b0cc238c..2c843f81 100644 --- a/apps/state_mediator/lib/gtfs_decompress.ex +++ b/apps/state_mediator/lib/gtfs_decompress.ex @@ -22,7 +22,8 @@ defmodule GtfsDecompress do facilities facilities_properties directions - lines) + lines + transfers) def filenames do for filename <- @filename_prefixes do From 393b1efac2e80d0b341b9ec46fc735baa642ae53 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 09:25:15 -0400 Subject: [PATCH 04/11] feat(ApiWeb.TransferController): add /transfers --- .../controllers/transfer_controller.ex | 182 ++++++++++++++++++ apps/api_web/lib/api_web/router.ex | 1 + .../lib/api_web/views/transfer_view.ex | 70 +++++++ .../controllers/transfer_controller_test.exs | 154 +++++++++++++++ apps/state/lib/state/transfer.ex | 64 ++++++ apps/state/test/state/transfer_test.exs | 95 +++++++++ 6 files changed, 566 insertions(+) create mode 100644 apps/api_web/lib/api_web/controllers/transfer_controller.ex create mode 100644 apps/api_web/lib/api_web/views/transfer_view.ex create mode 100644 apps/api_web/test/api_web/controllers/transfer_controller_test.exs create mode 100644 apps/state/test/state/transfer_test.exs diff --git a/apps/api_web/lib/api_web/controllers/transfer_controller.ex b/apps/api_web/lib/api_web/controllers/transfer_controller.ex new file mode 100644 index 00000000..1e4c15ef --- /dev/null +++ b/apps/api_web/lib/api_web/controllers/transfer_controller.ex @@ -0,0 +1,182 @@ +defmodule ApiWeb.TransferController do + @moduledoc """ + Controller for Transfers. Filterable by: + + * trip (multiple) + * type (multiple) + """ + use ApiWeb.Web, :api_controller + + @filters ~w(trip type)s + @pagination_opts ~w(offset limit)a + + def state_module, do: State.Transfer + + swagger_path :index do + get(path(__MODULE__, :index)) + + description(""" + **NOTE:** `filter[type]` or `filter[trip]` **MUST** be present for any transfers to be returned. + + List of transfers. Transfer specifies additional rules and overrides for a transfer between trips, routes, and/or stops. + + ## Transfers of a certain type + + `/transfers?filter[type]=TYPE` + + ## Transfers from a certain trip + + `/transfers?filter[trip]=TRIP_ID` + """) + + common_index_parameters(__MODULE__, :transfer) + + parameter( + "filter[trip]", + :query, + :string, + "Filter by trip ID. Multiple trips #{comma_separated_list()}." + ) + + parameter( + "filter[type]", + :query, + :string, + "Filter by transfer type. Multiple types #{comma_separated_list()}." + ) + + consumes("application/vnd.api+json") + produces("application/vnd.api+json") + response(200, "OK", Schema.ref(:Transfer)) + response(400, "Bad Request", Schema.ref(:BadRequest)) + response(403, "Forbidden", Schema.ref(:Forbidden)) + response(429, "Too Many Requests", Schema.ref(:TooManyRequests)) + end + + def index_data(conn, params) do + case Params.filter_params(params, @filters, conn) do + {:ok, filters} when map_size(filters) > 0 -> + filters + |> format_filters() + |> State.Transfer.filter_by() + |> State.all(Params.filter_opts(params, @pagination_opts, conn)) + + {:error, _, _} = error -> + error + + _ -> + {:error, :filter_required} + end + end + + defp format_filters(filters) do + filters + |> Enum.flat_map(&do_format_filter/1) + |> Enum.into(%{}) + end + + defp do_format_filter({"trip", trip_string}) do + case Params.split_on_comma(trip_string) do + [] -> + [] + + trip_ids -> + %{from_trip_ids: trip_ids} + end + end + + defp do_format_filter({"type", type_string}) do + case Params.split_on_comma(type_string) do + [] -> + [] + + types -> + %{types: types} + end + end + + defp do_format_filter(_), do: [] + + # No show action here + def show_data(_conn, _params), do: [] + + def swagger_definitions do + import PhoenixSwagger.JsonApi, except: [page: 1] + + %{ + TransferResource: + resource do + description("Transfer specifies additional rules and overrides for a transfer.") + + attributes do + min_transfer_time( + :integer, + "Sum of `min_walk_time` and `suggested_buffer_time`", + "x-nullable": true, + example: 9 + ) + + min_walk_time( + :integer, + "Experimental. Minimum time required to travel by foot from `from_stop_id` to `to_stop_id`.", + "x-nullable": true, + example: 4 + ) + + min_wheelchair_time( + :integer, + "Experimental. Minimum time required to travel by wheelchair `from_stop_id` to `to_stop_id`. If the transfer is not wheelchair accessible, this field will be blank.", + "x-nullable": true, + example: 7 + ) + + suggested_buffer_time( + :integer, + "Experimental. Recommended buffer time to allow to make a successful transfer between two services. This is also partly based on the significance of missing the transfer (due to service frequency).", + "x-nullable": true, + example: 5 + ) + + transfer_type( + :integer, + """ + Indicates the type of connection for the specified (`from_stop_id`, `to_stop_id`) pair. + + | Value | Description | + |-------|-------------| + | `0` | Recommended transfer point between route | + | `1` | Timed transfer point between two routes. The departing vehicle is expected to wait for the arriving one and leave sufficient time for a rider to transfer between routes. | + | `2` | Transfer requires a minimum amount of time between arrival and departure to ensure a connection. The time required to transfer is specified by `min_transfer_time`. | + | `3` | Transfers are not possible between routes at the location. | + | `4` | Passengers can transfer from one trip to another by staying onboard the same vehicle (an "in-seat transfer"). | + | `5` | In-seat transfers are not allowed between sequential trips. The passenger must alight from the vehicle and re-board. | + """, + enum: Enum.to_list(0..5), + example: 1 + ) + + wheelchair_transfer( + :integer, + """ + Experimental. Identifies whether a transfer is accessible to customers using a wheelchair. + + | Value | Description | + |-------|-------------| + | `0` | No accessibility information for the transfer | + | `1` | Transfer is wheelchair accessible | + | `2` | Not accessible to persons in wheelchairs | + """, + enum: Enum.to_list(0..2), + example: 0 + ) + end + + relationship(:from_stop) + relationship(:to_stop) + relationship(:from_trip) + relationship(:to_trip) + end, + Transfer: page(:TransferResource) + } + end +end diff --git a/apps/api_web/lib/api_web/router.ex b/apps/api_web/lib/api_web/router.ex index d8be1b90..4f920eaf 100644 --- a/apps/api_web/lib/api_web/router.ex +++ b/apps/api_web/lib/api_web/router.ex @@ -102,6 +102,7 @@ defmodule ApiWeb.Router do resources("/services", ServiceController, only: [:index, :show]) resources("/stop_events", StopEventController, only: [:index, :show]) resources("/stop-events", StopEventController, only: [:index, :show]) + get("/transfers", TransferController, :index) end scope "/docs/swagger" do diff --git a/apps/api_web/lib/api_web/views/transfer_view.ex b/apps/api_web/lib/api_web/views/transfer_view.ex new file mode 100644 index 00000000..6e3fd785 --- /dev/null +++ b/apps/api_web/lib/api_web/views/transfer_view.ex @@ -0,0 +1,70 @@ +defmodule ApiWeb.TransferView do + use ApiWeb.Web, :api_view + + attributes([ + :min_transfer_time, + :min_walk_time, + :min_wheelchair_time, + :suggested_buffer_time, + :transfer_type, + :wheelchair_transfer + ]) + + has_one( + :from_stop, + type: :stop, + serializer: ApiWeb.StopView, + field: :from_stop_id + ) + + has_one( + :to_stop, + type: :stop, + serializer: ApiWeb.StopView, + field: :to_stop_id + ) + + has_one( + :from_trip, + type: :trip, + serializer: ApiWeb.TripView, + field: :from_trip_id + ) + + has_one( + :to_trip, + type: :trip, + serializer: ApiWeb.TripView, + field: :to_trip_id + ) + + def id( + %{ + from_trip_id: from_trip_id, + from_stop_id: from_stop_id, + to_trip_id: to_trip_id, + to_stop_id: to_stop_id + }, + _conn + ) do + from_trip = from_trip_id || "" + to_trip = to_trip_id || "" + from_route = route_id(from_trip) + to_route = route_id(to_trip) + + "transfer-" <> + from_trip <> + "-" <> + from_route <> + "-" <> from_stop_id <> "-" <> to_trip <> "-" <> to_route <> "-" <> to_stop_id + end + + defp route_id(""), do: "" + + defp route_id(trip_id) do + case State.Trip.by_primary_id(trip_id) do + [%Model.Trip{route_id: route_id}] when is_binary(route_id) -> route_id + _ -> "" + end + end +end diff --git a/apps/api_web/test/api_web/controllers/transfer_controller_test.exs b/apps/api_web/test/api_web/controllers/transfer_controller_test.exs new file mode 100644 index 00000000..06f2e4fa --- /dev/null +++ b/apps/api_web/test/api_web/controllers/transfer_controller_test.exs @@ -0,0 +1,154 @@ +defmodule ApiWeb.TransferControllerTest do + @moduledoc false + use ApiWeb.ConnCase + + alias Model.{Stop, Transfer, Trip} + + @transfer1 %Transfer{ + from_stop_id: "place-north", + to_stop_id: "place-south", + transfer_type: 0, + from_trip_id: "trip-A", + to_trip_id: "trip-B", + min_transfer_time: nil, + min_walk_time: nil, + min_wheelchair_time: nil, + suggested_buffer_time: nil, + wheelchair_transfer: 0 + } + + @transfer2 %Transfer{ + from_stop_id: "place-east", + to_stop_id: "place-west", + transfer_type: 2, + from_trip_id: "trip-C", + to_trip_id: "trip-D", + min_transfer_time: 360, + min_walk_time: 300, + min_wheelchair_time: 420, + suggested_buffer_time: 60, + wheelchair_transfer: 1 + } + + @transfer3 %Transfer{ + from_stop_id: "place-north", + to_stop_id: "place-east", + transfer_type: 2, + from_trip_id: nil, + to_trip_id: nil, + min_transfer_time: 120, + min_walk_time: nil, + min_wheelchair_time: nil, + suggested_buffer_time: nil, + wheelchair_transfer: 0 + } + + setup %{conn: conn} do + State.Transfer.new_state([@transfer1, @transfer2, @transfer3]) + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index_data/2" do + test "returns 400 when no filters are provided", %{conn: conn} do + conn = get(conn, "/transfers") + + assert json_response(conn, 400)["errors"] == [ + %{ + "status" => "400", + "code" => "bad_request", + "detail" => "At least one filter[] is required." + } + ] + end + + test "filters by trip", %{conn: conn} do + conn = get(conn, "/transfers", %{"filter" => %{"trip" => "trip-A"}}) + data = json_response(conn, 200)["data"] + assert length(data) == 1 + assert hd(data)["attributes"]["transfer_type"] == 0 + end + + test "filters by multiple trips (comma-separated)", %{conn: conn} do + conn = get(conn, "/transfers", %{"filter" => %{"trip" => "trip-A,trip-C"}}) + data = json_response(conn, 200)["data"] + assert length(data) == 2 + end + + test "filters by type", %{conn: conn} do + conn = get(conn, "/transfers", %{"filter" => %{"type" => "2"}}) + data = json_response(conn, 200)["data"] + assert length(data) == 2 + assert Enum.all?(data, &(&1["attributes"]["transfer_type"] == 2)) + end + + test "filters by multiple types (comma-separated)", %{conn: conn} do + conn = get(conn, "/transfers", %{"filter" => %{"type" => "0,2"}}) + data = json_response(conn, 200)["data"] + assert length(data) == 3 + end + + test "returns empty list when no transfers match the filter", %{conn: conn} do + conn = get(conn, "/transfers", %{"filter" => %{"type" => "5"}}) + assert json_response(conn, 200)["data"] == [] + end + + test "conforms to swagger response", %{swagger_schema: schema, conn: conn} do + response = get(conn, "/transfers", %{"filter" => %{"trip" => "trip-A"}}) + assert validate_resp_schema(response, schema, "Transfer") + end + + test "response includes all documented attributes", %{conn: conn} do + conn = get(conn, "/transfers", %{"filter" => %{"trip" => "trip-C"}}) + [transfer] = json_response(conn, 200)["data"] + attrs = transfer["attributes"] + + assert Map.has_key?(attrs, "transfer_type") + assert Map.has_key?(attrs, "min_transfer_time") + assert Map.has_key?(attrs, "min_walk_time") + assert Map.has_key?(attrs, "min_wheelchair_time") + assert Map.has_key?(attrs, "suggested_buffer_time") + assert Map.has_key?(attrs, "wheelchair_transfer") + end + + test "response includes relationship links for stops and trips", %{conn: conn} do + State.Stop.new_state([%Stop{id: "place-north"}, %Stop{id: "place-south"}]) + State.Trip.new_state([%Trip{id: "trip-A"}, %Trip{id: "trip-B"}]) + + conn = get(conn, "/transfers", %{"filter" => %{"trip" => "trip-A"}}) + [transfer] = json_response(conn, 200)["data"] + + assert transfer["relationships"]["from_stop"] + assert transfer["relationships"]["to_stop"] + assert transfer["relationships"]["from_trip"] + assert transfer["relationships"]["to_trip"] + end + + test "response includes relationship data for stops and trips", %{conn: conn} do + conn = get(conn, "/transfers", %{"filter" => %{"trip" => "trip-A"}}) + [transfer] = json_response(conn, 200)["data"] + + assert transfer["relationships"]["from_stop"]["data"]["id"] == "place-north" + assert transfer["relationships"]["to_stop"]["data"]["id"] == "place-south" + assert transfer["relationships"]["from_trip"]["data"]["id"] == "trip-A" + assert transfer["relationships"]["to_trip"]["data"]["id"] == "trip-B" + end + + test "pagination works", %{conn: conn} do + conn = + get(conn, "/transfers", %{ + "filter" => %{"type" => "2"}, + "page" => %{"limit" => "1", "offset" => "0"} + }) + + response = json_response(conn, 200) + assert length(response["data"]) == 1 + assert response["links"]["next"] + end + end + + describe "state_module/0" do + test "returns State.Transfer" do + assert ApiWeb.TransferController.state_module() == State.Transfer + end + end +end diff --git a/apps/state/lib/state/transfer.ex b/apps/state/lib/state/transfer.ex index 9c22ea3e..6efe8e86 100644 --- a/apps/state/lib/state/transfer.ex +++ b/apps/state/lib/state/transfer.ex @@ -4,7 +4,71 @@ defmodule State.Transfer do """ use State.Server, + indices: [:from_trip_id, :transfer_type], fetched_filename: "transfers.txt", parser: Parse.Transfers, recordable: Model.Transfer + + @type filter_opts :: %{ + optional(:trips) => [Model.Trip.id()], + optional(:types) => [Model.Transfer.transfer_type()] + } + + @type transfer_search :: (-> [Model.Transfer.t()]) + + @doc """ + Applies a filtered search on Transfers based on a map of filter values. + + The allowed filterable keys are: + :trips + :types + """ + @spec filter_by(filter_opts) :: [Model.Transfer.t()] + def filter_by(filters) when is_map(filters) do + filters + |> build_filtered_searches() + |> do_searches() + end + + # Generate the functions needed to search concurrently + @spec build_filtered_searches(filter_opts, [transfer_search]) :: [transfer_search] + defp build_filtered_searches(filters, searches \\ []) + + defp build_filtered_searches(%{types: types} = filters, searches) do + types = Enum.map(types, &String.to_integer/1) + search_operation = fn -> by_transfer_types(types) end + + filters + |> Map.drop([:types]) + |> build_filtered_searches([search_operation | searches]) + end + + defp build_filtered_searches(%{from_trip_ids: trip_ids} = filters, searches) do + search_operation = fn -> by_from_trip_ids(trip_ids) end + + filters + |> Map.drop([:from_trip_ids]) + |> build_filtered_searches([search_operation | searches]) + end + + defp build_filtered_searches(_, searches), do: searches + + @spec do_searches([transfer_search]) :: [Model.Transfer.t()] + defp do_searches(search_operations) do + search_operations + |> Stream.map(fn search_operation -> + case search_operation.() do + results when is_list(results) -> + results + + _ -> + [] + end + end) + |> Enum.reduce(:no_results, fn + results, :no_results -> MapSet.new(results) + results, acc -> results |> MapSet.new() |> MapSet.intersection(acc) + end) + |> Enum.to_list() + end end diff --git a/apps/state/test/state/transfer_test.exs b/apps/state/test/state/transfer_test.exs new file mode 100644 index 00000000..a644fd58 --- /dev/null +++ b/apps/state/test/state/transfer_test.exs @@ -0,0 +1,95 @@ +defmodule State.TransferTest do + use ExUnit.Case + + alias Model.Transfer + + @transfer1 %Transfer{ + from_stop_id: "place-north", + to_stop_id: "place-south", + transfer_type: 0, + from_trip_id: "trip-A", + to_trip_id: "trip-B" + } + + @transfer2 %Transfer{ + from_stop_id: "place-east", + to_stop_id: "place-west", + transfer_type: 2, + min_transfer_time: 300, + from_trip_id: "trip-C", + to_trip_id: "trip-D" + } + + @transfer3 %Transfer{ + from_stop_id: "place-north", + to_stop_id: "place-east", + transfer_type: 2, + from_trip_id: nil, + to_trip_id: nil + } + + setup do + State.Transfer.new_state([]) + :ok + end + + test "returns empty list when no transfers are loaded" do + assert State.Transfer.all() == [] + end + + test "loads and retrieves all transfers" do + State.Transfer.new_state([@transfer1, @transfer2]) + assert length(State.Transfer.all()) == 2 + end + + describe "filter_by/1" do + setup do + State.Transfer.new_state([@transfer1, @transfer2, @transfer3]) + :ok + end + + test "returns empty list when filters map is empty" do + assert State.Transfer.filter_by(%{}) == [] + end + + test "filters by transfer types" do + results = State.Transfer.filter_by(%{types: ["2"]}) + assert length(results) == 2 + assert Enum.all?(results, &(&1.transfer_type == 2)) + end + + test "filters by a single type that matches one record" do + results = State.Transfer.filter_by(%{types: ["0"]}) + assert length(results) == 1 + assert hd(results).transfer_type == 0 + end + + test "filters by from_trip_ids" do + results = State.Transfer.filter_by(%{from_trip_ids: ["trip-A"]}) + assert length(results) == 1 + assert hd(results).from_trip_id == "trip-A" + end + + test "filters by multiple from_trip_ids" do + results = State.Transfer.filter_by(%{from_trip_ids: ["trip-A", "trip-C"]}) + assert length(results) == 2 + end + + test "returns empty list when type matches nothing" do + results = State.Transfer.filter_by(%{types: ["5"]}) + assert results == [] + end + + test "returns intersection when both types and from_trip_ids are provided" do + results = State.Transfer.filter_by(%{types: ["2"], from_trip_ids: ["trip-C"]}) + assert length(results) == 1 + assert hd(results).from_trip_id == "trip-C" + assert hd(results).transfer_type == 2 + end + + test "returns empty list when intersection is empty" do + results = State.Transfer.filter_by(%{types: ["0"], from_trip_ids: ["trip-C"]}) + assert results == [] + end + end +end From a9dc8a15ea1401089fb4b3c8aafbb8d589667c2f Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 09:12:59 -0400 Subject: [PATCH 05/11] feat(StatusView): show transfer last updated --- apps/api_web/lib/api_web/views/status_view.ex | 5 +++++ apps/state/lib/state/metadata.ex | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/api_web/lib/api_web/views/status_view.ex b/apps/api_web/lib/api_web/views/status_view.ex index 043517f3..d4af43af 100644 --- a/apps/api_web/lib/api_web/views/status_view.ex +++ b/apps/api_web/lib/api_web/views/status_view.ex @@ -12,6 +12,7 @@ defmodule ApiWeb.StatusView do :service, :shape, :stop, + :transfer, :trip, :vehicle ]) @@ -54,6 +55,10 @@ defmodule ApiWeb.StatusView do %{last_updated: data.timestamps.stop} end + def transfer(data, _) do + %{last_updated: data.timestamps.transfer} + end + def trip(data, _) do %{last_updated: data.timestamps.trip} end diff --git a/apps/state/lib/state/metadata.ex b/apps/state/lib/state/metadata.ex index cb018ad8..7462b9fd 100644 --- a/apps/state/lib/state/metadata.ex +++ b/apps/state/lib/state/metadata.ex @@ -109,6 +109,7 @@ defmodule State.Metadata do service: last_updated(State.Service), shape: last_updated(State.Shape), stop: last_updated(State.Stop.Cache), + transfer: last_updated(State.Transfer), trip: last_updated(State.Trip), vehicle: last_updated(State.Vehicle) } From 8267d694f8f729da9336803f1cd099a8384af045 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 12:15:25 -0400 Subject: [PATCH 06/11] docs(TransferController): specify seconds --- .../lib/api_web/controllers/transfer_controller.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api_web/lib/api_web/controllers/transfer_controller.ex b/apps/api_web/lib/api_web/controllers/transfer_controller.ex index 1e4c15ef..5fd7a8a3 100644 --- a/apps/api_web/lib/api_web/controllers/transfer_controller.ex +++ b/apps/api_web/lib/api_web/controllers/transfer_controller.ex @@ -111,28 +111,28 @@ defmodule ApiWeb.TransferController do attributes do min_transfer_time( :integer, - "Sum of `min_walk_time` and `suggested_buffer_time`", + "Sum of `min_walk_time` and `suggested_buffer_time`, in seconds.", "x-nullable": true, example: 9 ) min_walk_time( :integer, - "Experimental. Minimum time required to travel by foot from `from_stop_id` to `to_stop_id`.", + "Experimental. Minimum time required to travel by foot from `from_stop_id` to `to_stop_id`, in seconds.", "x-nullable": true, example: 4 ) min_wheelchair_time( :integer, - "Experimental. Minimum time required to travel by wheelchair `from_stop_id` to `to_stop_id`. If the transfer is not wheelchair accessible, this field will be blank.", + "Experimental. Minimum time required to travel by wheelchair `from_stop_id` to `to_stop_id`, in seconds. If the transfer is not wheelchair accessible, this field will be blank.", "x-nullable": true, example: 7 ) suggested_buffer_time( :integer, - "Experimental. Recommended buffer time to allow to make a successful transfer between two services. This is also partly based on the significance of missing the transfer (due to service frequency).", + "Experimental. Recommended buffer time to allow to make a successful transfer between two services, in seconds. This is also partly based on the significance of missing the transfer (due to service frequency).", "x-nullable": true, example: 5 ) From d3c129b1928b337d446ccb17a79c1fa1d32961fb Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 12:19:11 -0400 Subject: [PATCH 07/11] chore(TransferView): omit route ID values from ID --- .../api_web/lib/api_web/views/transfer_view.ex | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/apps/api_web/lib/api_web/views/transfer_view.ex b/apps/api_web/lib/api_web/views/transfer_view.ex index 6e3fd785..a8c66da2 100644 --- a/apps/api_web/lib/api_web/views/transfer_view.ex +++ b/apps/api_web/lib/api_web/views/transfer_view.ex @@ -49,22 +49,6 @@ defmodule ApiWeb.TransferView do ) do from_trip = from_trip_id || "" to_trip = to_trip_id || "" - from_route = route_id(from_trip) - to_route = route_id(to_trip) - - "transfer-" <> - from_trip <> - "-" <> - from_route <> - "-" <> from_stop_id <> "-" <> to_trip <> "-" <> to_route <> "-" <> to_stop_id - end - - defp route_id(""), do: "" - - defp route_id(trip_id) do - case State.Trip.by_primary_id(trip_id) do - [%Model.Trip{route_id: route_id}] when is_binary(route_id) -> route_id - _ -> "" - end + "transfer-#{from_trip}-#{from_stop_id}-#{to_trip}-#{to_stop_id}" end end From 4816ce39203674653123b87202cd9a1dc2014432 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 13:24:06 -0400 Subject: [PATCH 08/11] feat(TripView): include transfers --- .../api_web/controllers/trip_controller.ex | 4 +- apps/api_web/lib/api_web/views/trip_view.ex | 15 +++++++ .../controllers/trip_controller_test.exs | 44 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/api_web/lib/api_web/controllers/trip_controller.ex b/apps/api_web/lib/api_web/controllers/trip_controller.ex index 9fa12731..07ce7b40 100644 --- a/apps/api_web/lib/api_web/controllers/trip_controller.ex +++ b/apps/api_web/lib/api_web/controllers/trip_controller.ex @@ -12,7 +12,7 @@ defmodule ApiWeb.TripController do @filters ~w(id date direction_id route route_pattern name revenue)s @pagination_opts ~w(offset limit order_by)a - @includes ~w(route vehicle service shape predictions route_pattern stops) + @includes ~w(route vehicle service shape predictions route_pattern stops transfers) def state_module, do: State.Trip.Added @@ -265,6 +265,7 @@ defmodule ApiWeb.TripController do relationship(:route) relationship(:shape) relationship(:route_pattern) + relationship(:transfer) relationship(:occupancy) end, Trips: page(:TripResource), @@ -318,6 +319,7 @@ defmodule ApiWeb.TripController do | `route_pattern` | The route pattern for the trip. | | `predictions` | Predictions of when the `vehicle` on this `trip` will arrive at or depart from each stop on the route(s) on the `trip`. | | `stops` | The stops this trip goes through. | + | `transfers` | The transfers possible from this trip. | | `occupancies` | **EXPERIMENTAL:** The trip's static occupancy data. For information on experimental features, see: https://www.mbta.com/developers/v3-api/versioning.| """ ) diff --git a/apps/api_web/lib/api_web/views/trip_view.ex b/apps/api_web/lib/api_web/views/trip_view.ex index 0613ac21..6dc5b8a0 100644 --- a/apps/api_web/lib/api_web/views/trip_view.ex +++ b/apps/api_web/lib/api_web/views/trip_view.ex @@ -9,6 +9,7 @@ defmodule ApiWeb.TripView do ServiceView, ShapeView, StopView, + TransferView, VehicleView } @@ -20,6 +21,7 @@ defmodule ApiWeb.TripView do Service, Shape, Stop, + Transfer, Vehicle } @@ -87,6 +89,12 @@ defmodule ApiWeb.TripView do identifiers: :always ) + has_many( + :transfers, + type: :transfer, + serializer: TransferView + ) + has_many( :occupancies, type: :occupancy, @@ -105,6 +113,13 @@ defmodule ApiWeb.TripView do optional_relationship("route_pattern", route_pattern_id, &RoutePattern.by_id/1, conn) end + def transfers(%{id: trip_id}, _conn) do + case Transfer.by_from_trip_id(trip_id) do + [] -> nil + transfers -> transfers + end + end + def vehicle(%{id: trip_id}, _conn) do case Vehicle.by_trip_id(trip_id) do [] -> nil diff --git a/apps/api_web/test/api_web/controllers/trip_controller_test.exs b/apps/api_web/test/api_web/controllers/trip_controller_test.exs index ee11d7b4..0b6ebced 100644 --- a/apps/api_web/test/api_web/controllers/trip_controller_test.exs +++ b/apps/api_web/test/api_web/controllers/trip_controller_test.exs @@ -300,6 +300,50 @@ defmodule ApiWeb.TripControllerTest do } = json_response(conn, 200)["data"] end + test "including transfers", %{conn: conn} do + trip_id = "trip_with_transfer" + trip = %Model.Trip{id: trip_id} + + transfer = %Model.Transfer{ + from_trip_id: trip_id + } + + State.Trip.new_state([trip]) + State.Transfer.new_state([transfer]) + + conn = + get( + conn, + trip_path( + conn, + :show, + trip.id, + include: "transfers" + ) + ) + + response = json_response(conn, 200) + + assert %{ + "id" => ^trip_id, + "relationships" => %{ + "transfers" => %{ + "data" => [%{"id" => id, "type" => "transfer"}] + } + } + } = response["data"] + + assert id =~ trip_id + + assert [ + %{ + "attributes" => _, + "id" => ^id, + "type" => "transfer" + } + ] = response["included"] + end + test "including occupancies", %{conn: conn} do trip = %Model.Trip{id: "trip", name: "trip_name"} From faa74f966eeb72a8f515591f03e9b2146ef3c0b9 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Fri, 28 Aug 2026 13:30:06 -0400 Subject: [PATCH 09/11] fixup(Parse.Transfers): possibly empty values --- apps/model/lib/model/transfer.ex | 6 +++--- apps/parse/lib/parse/transfers.ex | 26 ++++++++++---------------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/apps/model/lib/model/transfer.ex b/apps/model/lib/model/transfer.ex index 507359b8..e467f958 100644 --- a/apps/model/lib/model/transfer.ex +++ b/apps/model/lib/model/transfer.ex @@ -61,13 +61,13 @@ defmodule Model.Transfer do @type t :: %__MODULE__{ from_stop_id: Model.Stop.id(), to_stop_id: Model.Stop.id(), - min_transfer_time: min_transfer_time() | nil, + min_transfer_time: min_transfer_time | nil, min_walk_time: non_neg_integer | nil, min_wheelchair_time: non_neg_integer | nil, suggested_buffer_time: non_neg_integer | nil, - wheelchair_transfer: wheelchair_transfer_type, + wheelchair_transfer: wheelchair_transfer_type | nil, from_trip_id: Model.Trip.id() | nil, to_trip_id: Model.Trip.id() | nil, - transfer_type: transfer_type + transfer_type: transfer_type | nil } end diff --git a/apps/parse/lib/parse/transfers.ex b/apps/parse/lib/parse/transfers.ex index 3137fe9c..2bfff925 100644 --- a/apps/parse/lib/parse/transfers.ex +++ b/apps/parse/lib/parse/transfers.ex @@ -14,14 +14,14 @@ defmodule Parse.Transfers do ## Columns - * `"from_stop_id"` - `Model.Stop.id | nil` - * `"to_stop_id"` - `Model.Stop.id | nil` - * `"transfer_type"` - `Model.Transfer.transfer_type` - * `"min_transfer_time"` - `Model.Transfer.min_transfer_time` | nil - * `"min_walk_time"` - `Model.Transfer.t` - `min_walk_time` | nil - * `"min_wheelchair_time"` - `Model.Transfer.t` - `min_wheelchair_time` - * `"suggested_buffer_time"` - `Model.Transfer.t` - `suggested_buffer_time` - * `"wheelchair_transfer"` - `Model.Transfer.wheelchair_transfer_type` + * `"from_stop_id"` - `Model.Stop.id` + * `"to_stop_id"` - `Model.Stop.id` + * `"transfer_type"` - `Model.Transfer.transfer_type | nil` + * `"min_transfer_time"` - `Model.Transfer.min_transfer_time | nil` + * `"min_walk_time"` - `Model.Transfer.t` - `min_walk_time | nil` + * `"min_wheelchair_time"` - `Model.Transfer.t` - `min_wheelchair_time | nil` + * `"suggested_buffer_time"` - `Model.Transfer.t` - `suggested_buffer_time | nil` + * `"wheelchair_transfer"` - `Model.Transfer.wheelchair_transfer_type | nil` * `"from_trip_id"` - `Model.Trip.id | nil` * `"to_trip_id"` - `Model.Trip.id | nil` """ @@ -33,19 +33,13 @@ defmodule Parse.Transfers do min_walk_time: to_integer_if_not_blank(row["min_walk_time"]), min_wheelchair_time: to_integer_if_not_blank(row["min_wheelchair_time"]), suggested_buffer_time: to_integer_if_not_blank(row["suggested_buffer_time"]), - wheelchair_transfer: to_integer(row["wheelchair_transfer"]), + wheelchair_transfer: to_integer_if_not_blank(row["wheelchair_transfer"]), from_trip_id: copy_if_not_blank(row["from_trip_id"]), to_trip_id: copy_if_not_blank(row["to_trip_id"]), - transfer_type: String.to_integer(row["transfer_type"]) + transfer_type: to_integer_if_not_blank(row["transfer_type"]) } end - defp to_integer(binary) when byte_size(binary) > 0 do - String.to_integer(binary) - end - - defp to_integer(_), do: 0 - defp to_integer_if_not_blank(binary) when byte_size(binary) > 0 do String.to_integer(binary) end From 482708e70a95d3832446ea14d8e5a2b26edbc307 Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Mon, 31 Aug 2026 16:23:06 -0400 Subject: [PATCH 10/11] feedback(TransferController): rename filter to from_trip --- .../lib/api_web/controllers/transfer_controller.ex | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api_web/lib/api_web/controllers/transfer_controller.ex b/apps/api_web/lib/api_web/controllers/transfer_controller.ex index 5fd7a8a3..b6d521ff 100644 --- a/apps/api_web/lib/api_web/controllers/transfer_controller.ex +++ b/apps/api_web/lib/api_web/controllers/transfer_controller.ex @@ -2,12 +2,12 @@ defmodule ApiWeb.TransferController do @moduledoc """ Controller for Transfers. Filterable by: - * trip (multiple) + * from_trip (multiple) * type (multiple) """ use ApiWeb.Web, :api_controller - @filters ~w(trip type)s + @filters ~w(from_trip type)s @pagination_opts ~w(offset limit)a def state_module, do: State.Transfer @@ -16,7 +16,7 @@ defmodule ApiWeb.TransferController do get(path(__MODULE__, :index)) description(""" - **NOTE:** `filter[type]` or `filter[trip]` **MUST** be present for any transfers to be returned. + **NOTE:** `filter[type]` or `filter[from_trip]` **MUST** be present for any transfers to be returned. List of transfers. Transfer specifies additional rules and overrides for a transfer between trips, routes, and/or stops. @@ -26,13 +26,13 @@ defmodule ApiWeb.TransferController do ## Transfers from a certain trip - `/transfers?filter[trip]=TRIP_ID` + `/transfers?filter[from_trip]=TRIP_ID` """) common_index_parameters(__MODULE__, :transfer) parameter( - "filter[trip]", + "filter[from_trip]", :query, :string, "Filter by trip ID. Multiple trips #{comma_separated_list()}." @@ -75,7 +75,7 @@ defmodule ApiWeb.TransferController do |> Enum.into(%{}) end - defp do_format_filter({"trip", trip_string}) do + defp do_format_filter({"from_trip", trip_string}) do case Params.split_on_comma(trip_string) do [] -> [] From 3b92cce72b4d5e7af861173c352671ec624fe5de Mon Sep 17 00:00:00 2001 From: Cristen Jones Date: Mon, 31 Aug 2026 17:54:19 -0400 Subject: [PATCH 11/11] feedback: rename include to from_trip_transfers --- apps/api_web/lib/api_web/controllers/trip_controller.ex | 6 +++--- apps/api_web/lib/api_web/views/trip_view.ex | 4 ++-- .../test/api_web/controllers/trip_controller_test.exs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api_web/lib/api_web/controllers/trip_controller.ex b/apps/api_web/lib/api_web/controllers/trip_controller.ex index 07ce7b40..3638718d 100644 --- a/apps/api_web/lib/api_web/controllers/trip_controller.ex +++ b/apps/api_web/lib/api_web/controllers/trip_controller.ex @@ -12,7 +12,7 @@ defmodule ApiWeb.TripController do @filters ~w(id date direction_id route route_pattern name revenue)s @pagination_opts ~w(offset limit order_by)a - @includes ~w(route vehicle service shape predictions route_pattern stops transfers) + @includes ~w(route vehicle service shape predictions route_pattern stops from_trip_transfers) def state_module, do: State.Trip.Added @@ -265,7 +265,7 @@ defmodule ApiWeb.TripController do relationship(:route) relationship(:shape) relationship(:route_pattern) - relationship(:transfer) + relationship(:from_trip_transfers) relationship(:occupancy) end, Trips: page(:TripResource), @@ -319,7 +319,7 @@ defmodule ApiWeb.TripController do | `route_pattern` | The route pattern for the trip. | | `predictions` | Predictions of when the `vehicle` on this `trip` will arrive at or depart from each stop on the route(s) on the `trip`. | | `stops` | The stops this trip goes through. | - | `transfers` | The transfers possible from this trip. | + | `from_trip_transfers` | The transfers possible from this trip. | | `occupancies` | **EXPERIMENTAL:** The trip's static occupancy data. For information on experimental features, see: https://www.mbta.com/developers/v3-api/versioning.| """ ) diff --git a/apps/api_web/lib/api_web/views/trip_view.ex b/apps/api_web/lib/api_web/views/trip_view.ex index 6dc5b8a0..cd0ff788 100644 --- a/apps/api_web/lib/api_web/views/trip_view.ex +++ b/apps/api_web/lib/api_web/views/trip_view.ex @@ -90,7 +90,7 @@ defmodule ApiWeb.TripView do ) has_many( - :transfers, + :from_trip_transfers, type: :transfer, serializer: TransferView ) @@ -113,7 +113,7 @@ defmodule ApiWeb.TripView do optional_relationship("route_pattern", route_pattern_id, &RoutePattern.by_id/1, conn) end - def transfers(%{id: trip_id}, _conn) do + def from_trip_transfers(%{id: trip_id}, _conn) do case Transfer.by_from_trip_id(trip_id) do [] -> nil transfers -> transfers diff --git a/apps/api_web/test/api_web/controllers/trip_controller_test.exs b/apps/api_web/test/api_web/controllers/trip_controller_test.exs index 0b6ebced..09efaac4 100644 --- a/apps/api_web/test/api_web/controllers/trip_controller_test.exs +++ b/apps/api_web/test/api_web/controllers/trip_controller_test.exs @@ -318,7 +318,7 @@ defmodule ApiWeb.TripControllerTest do conn, :show, trip.id, - include: "transfers" + include: "from_trip_transfers" ) ) @@ -327,7 +327,7 @@ defmodule ApiWeb.TripControllerTest do assert %{ "id" => ^trip_id, "relationships" => %{ - "transfers" => %{ + "from_trip_transfers" => %{ "data" => [%{"id" => id, "type" => "transfer"}] } }