Read GBFS v3 feeds

b52e7fa · Gabriel Morell · 2026-09-04 15:46

12 files +333 -33
Message
{commit_body(@commit)}

Files changed

modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

modified apps/room_gbfs/lib/room_gbfs/gbfs_worker.ex
+49 −33
@@ -106,7 +106,9 @@ defmodule RoomGbfs.Worker do
106 106 |> Map.put(:updated_at, dt)
107 107 end
108 108
109 − defp write_data(url, type, id) do
109 + # `lang` is only used to pick one string out of v3's localised name lists;
110 + # a v2 feed never reaches that code.
111 + defp write_data(url, type, id, lang) do
110 112 dt = NaiveDateTime.local_now()
111 113 Logger.info("GBFS::#{id} retrieving #{url}")
112 114
@@ -123,7 +125,9 @@ defmodule RoomGbfs.Worker do
123 125 Repo.insert(
124 126 RoomSanctum.Storage.change_sys_info(
125 127 %RoomSanctum.Storage.GBFS.V1.SysInfo{},
126 − json.data |> Map.put(:source_id, id |> intify)
128 + json.data
129 + |> RoomGbfs.V3.system_information(lang)
130 + |> Map.put(:source_id, id |> intify)
127 131 ),
128 132 on_conflict: {:replace_all_except, [:id]},
129 133 conflict_target: [:source_id, :system_id]
@@ -132,6 +136,7 @@ defmodule RoomGbfs.Worker do
132 136 :stat_info ->
133 137 data =
134 138 json.data.stations
139 + |> Enum.map(&RoomGbfs.V3.station_info(&1, lang))
135 140 |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
136 141 |> Enum.map(fn x ->
137 142 point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326}
@@ -155,6 +160,7 @@ defmodule RoomGbfs.Worker do
155 160 :stat_status ->
156 161 data =
157 162 json.data.stations
163 + |> Enum.map(&RoomGbfs.V3.station_status/1)
158 164 |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
159 165 |> Enum.map(fn x ->
160 166 RoomSanctum.Storage.change_station_status(
@@ -173,7 +179,9 @@ defmodule RoomGbfs.Worker do
173 179
174 180 :free_bike ->
175 181 data =
176 − json.data.bikes
182 + # v3 calls them vehicles and puts them under `vehicles`.
183 + (Map.get(json.data, :bikes) || Map.get(json.data, :vehicles) || [])
184 + |> Enum.map(&RoomGbfs.V3.vehicle_status/1)
177 185 |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
178 186 |> Enum.map(fn x ->
179 187 point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326}
@@ -199,6 +207,7 @@ defmodule RoomGbfs.Worker do
199 207 :vehicle_types ->
200 208 data =
201 209 json.data.vehicle_types
210 + |> Enum.map(&RoomGbfs.V3.vehicle_types(&1, lang))
202 211 |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
203 212 |> Enum.map(fn x ->
204 213 RoomSanctum.Storage.change_vehicle_types(
@@ -359,60 +368,66 @@ defmodule RoomGbfs.Worker do
359 368 %{}
360 369 end
361 370
362 − if json_body["data"] != nil and
363 − json_body["data"]
364 − |> Map.has_key?(cfg.config.lang) do
365 − bcast(state.id, :parsing, 2, @tfh)
371 + case RoomGbfs.V3.feeds(json_body, cfg.config.lang) do
372 + {:ok, version, feeds} ->
373 + if version == :v3 do
374 + Logger.info("GBFS::#{state.id} discovery is v3")
375 + end
376 +
377 + bcast(state.id, :parsing, 2, @tfh)
366 378
367 − json_body["data"][cfg.config.lang]["feeds"]
368 − |> Enum.map(fn %{"name" => name, "url" => url} ->
369 − case name do
379 + feeds
380 + |> Enum.map(fn %{"name" => name, "url" => url} ->
381 + # v3 renamed the loose-vehicle feed; everything below is
382 + # written in v2's names, which is the one dialect this worker
383 + # speaks.
384 + case RoomGbfs.V3.feed_name(name) do
370 385 "ebikes_at_stations" ->
371 − write_data(url, :ebikes_at_stations, state.id)
386 + write_data(url, :ebikes_at_stations, state.id, cfg.config.lang)
372 387 bcast(state.id, :system_information, 3, @tfh)
373 388
374 389 "system_information" ->
375 − write_data(url, :sys_info, state.id)
390 + write_data(url, :sys_info, state.id, cfg.config.lang)
376 391 bcast(state.id, :system_information, 4, @tfh)
377 392
378 393 "station_information" ->
379 − write_data(url, :stat_info, state.id)
394 + write_data(url, :stat_info, state.id, cfg.config.lang)
380 395 bcast(state.id, :system_information, 5, @tfh)
381 396
382 397 "station_status" ->
383 − write_data(url, :stat_status, state.id)
398 + write_data(url, :stat_status, state.id, cfg.config.lang)
384 399 bcast(state.id, :system_information, 6, @tfh)
385 400
386 401 "free_bike_status" ->
387 − write_data(url, :free_bike, state.id)
402 + write_data(url, :free_bike, state.id, cfg.config.lang)
388 403 bcast(state.id, :system_information, 7, @tfh)
389 404
390 405 "system_hours" ->
391 − write_data(url, :sys_hours, state.id)
406 + write_data(url, :sys_hours, state.id, cfg.config.lang)
392 407 bcast(state.id, :system_information, 8, @tfh)
393 408
394 409 "system_calendar" ->
395 − write_data(url, :sys_cal, state.id)
410 + write_data(url, :sys_cal, state.id, cfg.config.lang)
396 411 bcast(state.id, :system_information, 9, @tfh)
397 412
398 413 "system_regions" ->
399 − write_data(url, :sys_regions, state.id)
414 + write_data(url, :sys_regions, state.id, cfg.config.lang)
400 415 bcast(state.id, :system_information, 10, @tfh)
401 416
402 417 "system_alerts" ->
403 − write_data(url, :sys_alerts, state.id)
418 + write_data(url, :sys_alerts, state.id, cfg.config.lang)
404 419 bcast(state.id, :system_alerts, 11, @tfh)
405 420
406 421 "vehicle_types" ->
407 − write_data(url, :vehicle_types, state.id)
422 + write_data(url, :vehicle_types, state.id, cfg.config.lang)
408 423 bcast(state.id, :vehicle_types, 12, @tfh)
409 424
410 425 "system_pricing_plans" ->
411 − write_data(url, :pricing_plans, state.id)
426 + write_data(url, :pricing_plans, state.id, cfg.config.lang)
412 427 bcast(state.id, :pricing_plans, 13, @tfh)
413 428
414 429 "geofencing_zones" ->
415 − write_data(url, :geofencing_zones, state.id)
430 + write_data(url, :geofencing_zones, state.id, cfg.config.lang)
416 431 bcast(state.id, :geofencing_zones, 14, @tfh)
417 432
418 433 otherwise ->
@@ -420,15 +435,16 @@ defmodule RoomGbfs.Worker do
420 435 end
421 436 end)
422 437
423 − {:noreply, state}
424 − else
425 − bcast(
426 − state.id,
427 − :language_error,
428 − "Invalid Language Selected, available #{json_body["data"] |> Map.keys() |> Enum.join(", ")}, selected: #{cfg.config.lang}"
429 − )
438 + {:noreply, state}
439 +
440 + {:error, languages} ->
441 + bcast(
442 + state.id,
443 + :language_error,
444 + "Invalid Language Selected, available #{Enum.join(languages, ", ")}, selected: #{cfg.config.lang}"
445 + )
430 446
431 − {:noreply, state}
447 + {:noreply, state}
432 448 end
433 449
434 450 {:error, info} ->
@@ -463,15 +479,15 @@ defmodule RoomGbfs.Worker do
463 479 |> Enum.map(fn %{"name" => name, "url" => url} ->
464 480 case name do
465 481 "station_information" ->
466 − write_data(url, :stat_info, state.id)
482 + write_data(url, :stat_info, state.id, cfg.config.lang)
467 483 bcast(state.id, :system_information, 3, 5)
468 484
469 485 "ebikes_at_stations" ->
470 − write_data(url, :ebikes_at_stations, state.id)
486 + write_data(url, :ebikes_at_stations, state.id, cfg.config.lang)
471 487 bcast(state.id, :system_information, 4, 5)
472 488
473 489 "free_bike_status" ->
474 − write_data(url, :free_bike, state.id)
490 + write_data(url, :free_bike, state.id, cfg.config.lang)
475 491 bcast(state.id, :system_information, 5, 5)
476 492
477 493 _otherwise ->
added apps/room_gbfs/lib/room_gbfs/v3.ex
+158 −0
@@ -0,0 +1,158 @@
1 +defmodule RoomGbfs.V3 do
2 + @moduledoc """
3 + Reading a GBFS v3 feed as though it were v2.
4 +
5 + v3 renamed things rather than adding them, which is the awkward kind of
6 + change: a v3 feed parses without complaint against a v2 reader and comes out
7 + empty, because `num_vehicles_available` is simply not a field anyone asked
8 + for and Ecto's `cast/3` drops what it was not told about. No error, no log, a
9 + station with no bikes in it.
10 +
11 + So this translates a v3 record into the v2 shape the schemas, condensers and
12 + clients already speak, rather than teaching every one of them both dialects.
13 +
14 + **Every function here is idempotent and version-blind.** A v2 record has
15 + `num_bikes_available` and no `num_vehicles_available`, so the rename is a
16 + no-op; a v3 record has the reverse. That is deliberate: it means nothing
17 + upstream has to know which version it is holding, and there is no version
18 + flag to thread through -- or to get wrong for the one operator who publishes
19 + a v3 document with a v2 version string in it.
20 +
21 + What is *not* handled, and cannot be here: v3 has no `num_ebikes_available`.
22 + The electric count is derivable, but only by joining `vehicle_types_available`
23 + against `vehicle_types.json`, which is a different feed fetched separately
24 + and possibly later. A v3 station reports its total correctly and its electric
25 + count as nothing.
26 + """
27 +
28 + @doc """
29 + The feed list out of a discovery document, whichever version wrote it.
30 +
31 + Told apart by shape rather than by the `version` field. v2 keys `data` by
32 + language -- `data.en.feeds` -- and v3 dropped that, so `data.feeds` is a list
33 + in v3 and cannot be one in v2. Shape is the thing that actually has to be
34 + true for the parse to work; the version string is a claim about it.
35 +
36 + Returns `{:ok, version, feeds}`, or `{:error, languages}` for a v2 document
37 + that does not carry the language the source is configured for -- which is the
38 + one case worth reporting to the operator, since it is a setting they chose.
39 + """
40 + def feeds(%{"data" => %{"feeds" => feeds}}, _lang) when is_list(feeds), do: {:ok, :v3, feeds}
41 +
42 + def feeds(%{"data" => data}, lang) when is_map(data) do
43 + case Map.get(data, lang) do
44 + %{"feeds" => feeds} when is_list(feeds) -> {:ok, :v2, feeds}
45 + _ -> {:error, Map.keys(data)}
46 + end
47 + end
48 +
49 + def feeds(_body, _lang), do: {:error, []}
50 +
51 + @doc """
52 + The v2 name for a feed.
53 +
54 + v3 renamed `free_bike_status` to `vehicle_status` -- the only rename among
55 + the feeds this reads. `system_hours` and `system_calendar` were removed
56 + outright rather than renamed, so they simply stop arriving.
57 + """
58 + def feed_name("vehicle_status"), do: "free_bike_status"
59 + def feed_name(name), do: name
60 +
61 + @doc """
62 + A station's status, in v2 terms.
63 +
64 + v3 counts vehicles where v2 counted bikes, which is the honest name for a
65 + system that rents scooters -- but it is a rename, and a reader looking for
66 + the old key finds nothing rather than failing.
67 + """
68 + def station_status(row) do
69 + row
70 + |> rename(:num_vehicles_available, :num_bikes_available)
71 + |> rename(:num_vehicles_disabled, :num_bikes_disabled)
72 + end
73 +
74 + @doc """
75 + A loose vehicle, in v2 terms.
76 +
77 + `vehicle_status.json` in v3, `free_bike_status.json` in v2, and the id
78 + changed name with the file. The id matters more than most: it is half the
79 + conflict target these rows are written on, so a null one collapses every
80 + bike in the feed onto a single row.
81 + """
82 + def vehicle_status(row), do: rename(row, :vehicle_id, :bike_id)
83 +
84 + @doc """
85 + A station's description, in v2 terms.
86 +
87 + v3 made `name` a list of `{text, language}` rather than a string, so that a
88 + bilingual city can publish both. The schema holds one string, so one is
89 + chosen -- the configured language where the feed offers it.
90 + """
91 + def station_info(row, lang) do
92 + row
93 + |> localize(:name, lang)
94 + |> localize(:short_name, lang)
95 + end
96 +
97 + @doc "A vehicle type, in v2 terms."
98 + def vehicle_types(row, lang), do: localize(row, :name, lang)
99 +
100 + @doc """
101 + A system's own description, in v2 terms.
102 +
103 + Same localisation as everywhere else, plus `languages`: v3 made it a list,
104 + on the same reasoning that made the names lists.
105 + """
106 + def system_information(row, lang) do
107 + row
108 + |> localize(:name, lang)
109 + |> localize(:short_name, lang)
110 + |> localize(:operator, lang)
111 + |> first_language()
112 + end
113 +
114 + @doc """
115 + One string out of a v3 localised value.
116 +
117 + `[%{text: "Bicing", language: "ca"}]` in v3, `"Bicing"` in v2, and a bare
118 + string is returned untouched -- which is what makes this safe to run over a
119 + v2 feed.
120 +
121 + Prefers the language asked for, then falls back to the first the feed gives
122 + rather than to nothing: a name in the wrong language is still a name, and an
123 + operator who publishes only Catalan should not read as an unnamed station.
124 + """
125 + def text(value, lang \\ nil)
126 +
127 + def text([%{} | _] = localized, lang) do
128 + match =
129 + Enum.find(localized, fn entry ->
130 + lang != nil and to_string(Map.get(entry, :language)) == to_string(lang)
131 + end)
132 +
133 + entry = match || List.first(localized)
134 + Map.get(entry, :text)
135 + end
136 +
137 + def text(value, _lang), do: value
138 +
139 + defp localize(row, key, lang) do
140 + case Map.fetch(row, key) do
141 + {:ok, value} -> Map.put(row, key, text(value, lang))
142 + :error -> row
143 + end
144 + end
145 +
146 + # v3 publishes `languages: ["ca", "es"]`; the schema holds one.
147 + defp first_language(%{languages: [first | _]} = row), do: Map.put(row, :language, first)
148 + defp first_language(row), do: row
149 +
150 + # Moves a key only when the old name is absent, so running this over a v2
151 + # record that already has the destination cannot overwrite it.
152 + defp rename(row, from, to) do
153 + case Map.fetch(row, from) do
154 + {:ok, value} -> row |> Map.delete(from) |> Map.put_new(to, value)
155 + :error -> row
156 + end
157 + end
158 +end
added apps/room_gbfs/test/room_gbfs/v3_test.exs
+126 −0
@@ -0,0 +1,126 @@
1 +defmodule RoomGbfs.V3Test do
2 + use ExUnit.Case, async: true
3 +
4 + alias RoomGbfs.V3
5 +
6 + describe "discovery" do
7 + test "v2 keys its feeds by language" do
8 + body = %{
9 + "data" => %{
10 + "en" => %{"feeds" => [%{"name" => "station_status", "url" => "http://x/ss"}]},
11 + "fr" => %{"feeds" => []}
12 + }
13 + }
14 +
15 + assert {:ok, :v2, [%{"name" => "station_status"}]} = V3.feeds(body, "en")
16 + end
17 +
18 + test "v3 does not, and is told apart by that" do
19 + # Shape rather than the version string: this is what actually has to be
20 + # true for the parse to work.
21 + body = %{
22 + "version" => "3.0",
23 + "data" => %{"feeds" => [%{"name" => "vehicle_status", "url" => "http://x/vs"}]}
24 + }
25 +
26 + assert {:ok, :v3, [%{"name" => "vehicle_status"}]} = V3.feeds(body, "en")
27 + end
28 +
29 + test "a v2 feed missing the configured language reports what it does have" do
30 + body = %{"data" => %{"ca" => %{"feeds" => []}, "es" => %{"feeds" => []}}}
31 +
32 + assert {:error, languages} = V3.feeds(body, "en")
33 + assert Enum.sort(languages) == ["ca", "es"]
34 + end
35 +
36 + test "a body that is not a discovery document does not raise" do
37 + assert {:error, []} = V3.feeds(%{}, "en")
38 + assert {:error, []} = V3.feeds(%{"data" => nil}, "en")
39 + end
40 + end
41 +
42 + describe "feed names" do
43 + test "v3's vehicle_status is v2's free_bike_status" do
44 + assert V3.feed_name("vehicle_status") == "free_bike_status"
45 + end
46 +
47 + test "everything else keeps its name" do
48 + assert V3.feed_name("station_status") == "station_status"
49 + end
50 + end
51 +
52 + describe "station status" do
53 + test "v3 counts vehicles where v2 counted bikes" do
54 + row = %{station_id: "1", num_vehicles_available: 7, num_vehicles_disabled: 2}
55 +
56 + assert %{num_bikes_available: 7, num_bikes_disabled: 2} = V3.station_status(row)
57 + end
58 +
59 + test "a v2 row passes through untouched" do
60 + row = %{station_id: "1", num_bikes_available: 4, num_bikes_disabled: 0}
61 +
62 + assert V3.station_status(row) == row
63 + end
64 +
65 + test "the v3 key does not survive the rename" do
66 + # It would be cast away anyway, but leaving it invites a reader to think
67 + # both names are live.
68 + refute Map.has_key?(V3.station_status(%{num_vehicles_available: 3}), :num_vehicles_available)
69 + end
70 + end
71 +
72 + describe "loose vehicles" do
73 + test "v3's vehicle_id is v2's bike_id" do
74 + assert %{bike_id: "b1"} = V3.vehicle_status(%{vehicle_id: "b1", lat: 1.0, lon: 2.0})
75 + end
76 +
77 + test "a v2 row passes through untouched" do
78 + row = %{bike_id: "b1", lat: 1.0, lon: 2.0}
79 + assert V3.vehicle_status(row) == row
80 + end
81 + end
82 +
83 + describe "localised text" do
84 + test "the configured language wins" do
85 + value = [%{text: "Bicing", language: "ca"}, %{text: "Bicing ES", language: "es"}]
86 +
87 + assert V3.text(value, "es") == "Bicing ES"
88 + end
89 +
90 + test "a feed that does not carry that language still gives a name" do
91 + # Wrong language beats no name at all.
92 + value = [%{text: "Bicing", language: "ca"}]
93 +
94 + assert V3.text(value, "en") == "Bicing"
95 + end
96 +
97 + test "a v2 string is returned as it is" do
98 + assert V3.text("Packard Ave", "en") == "Packard Ave"
99 + assert V3.text(nil, "en") == nil
100 + end
101 +
102 + test "a station's name is localised in place" do
103 + row = %{station_id: "1", name: [%{text: "Plaça Espanya", language: "ca"}]}
104 +
105 + assert %{name: "Plaça Espanya", station_id: "1"} = V3.station_info(row, "ca")
106 + end
107 +
108 + test "a station with no short_name does not gain one" do
109 + refute Map.has_key?(V3.station_info(%{name: "X"}, "en"), :short_name)
110 + end
111 + end
112 +
113 + describe "system information" do
114 + test "v3's language list becomes the one language the schema holds" do
115 + row = %{system_id: "bicing", languages: ["ca", "es"], name: [%{text: "Bicing", language: "ca"}]}
116 +
117 + assert %{language: "ca", name: "Bicing"} = V3.system_information(row, "ca")
118 + end
119 +
120 + test "a v2 system is unchanged" do
121 + row = %{system_id: "bluebikes", language: "en", name: "Bluebikes", operator: "Lyft"}
122 +
123 + assert V3.system_information(row, "en") == row
124 + end
125 + end
126 +end

Parents: 59d6dae