Skip to content

feat!: add location lookup, children and feed counts to the locations API - #1884

Open
Alessandro100 wants to merge 11 commits into
mainfrom
feat/location-directory-endpoints
Open

Alessandro100 wants to merge 11 commits into
mainfrom
feat/location-directory-endpoints

Conversation

@Alessandro100

@Alessandro100 Alessandro100 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Backs the feed location directory on mobilitydatabase.org (/locations). It is built on the OSM geopolygon read model rather than the feeds' declared Location rows: places, hierarchy and names come from GeopolygonLocationSearch, and feeds link to places through their reverse-geolocated stops (FeedOsmLocationGroup -> OsmLocationGroupGeopolygon). A GTFS Realtime feed, which has no stops, takes the places of the schedule feeds it references.

Each locations endpoint has one job: /v1/locations searches, /v1/locations/{location_id} and /v1/locations/lookup return one place, and /v1/locations/{location_id}/children lists a place's children.

  • GET /v1/locations (search and filter, list only):
    • New parameters: continent (slug or code; the continent row included), has_feeds, include=feed_counts and sort (admin_level, the current order, or feed_count).
    • location_type=continent returns the seven continents.
  • GET /v1/locations/{location_id}: one place, or 404.
  • GET /v1/locations/lookup?path=...: the same place, addressed by its path (e.g. north-america/canada/quebec), or 404. The path is case-insensitive and leading or trailing slashes are ignored.
  • include: on the two single-place endpoints, include=feed_counts,children adds the place's counts and the first page of its direct children. has_feeds, sort, limit and offset then apply to the children.
  • GET /v1/locations/{location_id}/children: a place's direct children, paged, with the same has_feeds, include=feed_counts and sort. 404 for an unknown id.
  • Every location has a non-null location_id and a path:
    • Continents are locations with fixed negative ids: -1 Africa, -2 Antarctica, -3 Asia, -4 Europe, -5 North America, -6 Oceania, -7 South America. Each continent is the parent of its countries.
    • Paths: path, path_slugs and path_names start at the continent, so a location's path is its web URL after /locations/.
    • Result fields: new continent_code, slug, path, path_slugs and feed_counts (GTFS / GTFS-RT / GBFS / total). A place returned by /{location_id} or lookup (LocationDetail) also has children, filled only when include=children is requested.
    • Required fields: location_id, location_type, slug, path, path_slugs and path_names are marked required in the spec.
  • GET /v1/search:
    • location_id (continents included) or location_path restricts results to feeds with stops in that place, sub-areas included. Pass one, not both (400). An unknown path returns no feed.
    • sort=location ranks feeds by the share of their stops inside the place (a city bus feed before a regional train), then by provider, feed name and stable id. It requires location_id or location_path.
  • Continents: new continent and country_continent tables.
    • Seed: liquibase/changes/feat_location_directory.sql (250 countries). The mapping is taken as is from country_converter's seven-continent classification, which follows Our World in Data's region definitions, with no overrides. scripts/country_continent_seed.py regenerates the seed; the library is a dev-only dependency.
    • Ids: feat_location_directory_continent_ids.sql adds the continents' fixed ids. It is a separate changeset because dev already ran the first one.
  • GeopolygonLocationSearch view:
    • Continent rows: adds the continents as rows; each country's parent is its continent.
    • Paths: prefixes every path with the continent.
    • New columns: continent_code, slug, path_slugs and an indexed path.
    • One row per country: it keeps one country row per ISO code, the lowest admin level; the other boundaries resolve to it as a parent and in paths, so their sub-areas are children of the kept country. On dev, French Guiana, Guadeloupe, Martinique, Réunion and Clipperton Island each have several boundaries.
  • Route order: the generated router registers routes by operationId, which put /{location_id} before /lookup. utils/route_order.py moves fixed paths first for the locations router only.
  • Removed before release:
    • the /v1/locations/directory endpoint;
    • the country / subdivision / municipality and continent search parameters;
    • the parent_location_id, path and include_feed_counts parameters of /v1/locations;
    • the Location-based directory code.

Breaking changes:

For existing users of /v1/locations (released since v1.16.0). Nothing released is removed or renamed, but these results change:

  1. Continents appear in results. A search without location_type can return the seven continents (e.g. search_query=america returns North and South America), sorted first.
  2. New location_type value: continent. Clients generated with a strict enum can reject it.
  3. Negative ids. Continents have location_id -1 to -7. Code that treats location_id as an OSM id (for example to build openstreetmap.org links) breaks on them.
  4. A country's parent_location_id is its continent's id instead of null. Code that finds countries by "no parent" stops working.
  5. path_names starts with the continent: ["North America", "Canada", "Québec"] instead of ["Canada", "Québec"]. Code that reads path_names[0] as the country gets the continent. display_name is unchanged.
  6. Duplicate country boundaries are gone (see the view above): about 7 rows on dev (Martinique, Guadeloupe, French Guiana, Réunion, Clipperton). Stored ids of the dropped boundaries no longer resolve.
  7. Unsupported sort and include values return 400 on /v1/locations and /v1/search. Before, a sort parameter was ignored, so a client that already sent one gets an error now.

Not breaking: the new optional parameters, the new response fields, the new endpoints, and the fields newly marked required in the spec. Everything else removed (/v1/locations/directory, parent_location_id, include_feed_counts, continent on /v1/search) was added and removed within this PR.

Deployment order:

  • Migrate first: run Liquibase (the continent changesets and the GeopolygonLocationSearch rebuild) before deploying the API in each environment; the new code fails without them. Done on dev; QA and prod still need it.
  • API before web: feat: add feed location directory mobilitydatabase-web#259 only works against the new endpoints, so merge and deploy it after this PR.

Design notes:

  • Feed counts: distinct feeds with status active, inactive, development or future, with the same operational_status rule as /v1/search. A feed counts in every place where it has stops, so a regional feed appears under each municipality it serves. Continent counts are distinct feeds, never sums of country counts. Counts are only computed when asked for (include=feed_counts, has_feeds, sort=feed_count).
  • Slugs: the name lowercased, without accents, with other characters replaced by -.
    • Collisions: siblings that would share a slug each get --<location_id> appended (springfield--1234). A name never produces --, so a suffixed slug cannot equal another sibling's natural slug. On dev, 1,274 parent and slug pairs collide, mostly same-named US townships directly under their state.
    • Stability: collisions are counted over all places, so a slug doesn't change when feeds do.
    • No ASCII letters or digits: the name's slug is its location id.
    • Paths change when places are renamed in OSM. Store location_id, not paths.
  • Realtime feeds: a GTFS Realtime feed without a location group of its own takes the groups of the schedule feeds it references (feedreference), with their stop counts. A realtime feed with its own groups keeps only those.
  • Coverage: other feeds that were never reverse-geolocated have no location group, so they appear neither in the directory nor in the location filters. On dev, among the feeds with a directory status:
    • GTFS: 1,655 of 3,949 are located (3,155 have a dataset).
    • GBFS: 420 of 1,268 are located.
    • GTFS Realtime: 427 of 1,485 are located, up from 17, through their schedule feeds.
  • Performance: counts are computed per request; the web pages are cached for 6 hours.
    • Filtering inside the union: a feed's location groups are the union of its own groups and the groups a realtime feed inherits from its schedule feeds. Every count, has_feeds check and location search applies the location filter inside each branch of that union, and joins the feed there too. PostgreSQL cannot push a filter into the inherited branch (a join) and estimates it at about one row, so filtering the union afterwards made it rebuild the inherited groups once per location group: continent requests took minutes on dev, some hitting the 300 s timeout.
    • One counting pass per page: with has_feeds or sort=feed_count, one query counts the feeds of the matching locations and uses the counts to filter, sort, page and return the total (a window count). It used to count them up to four times.
    • Measured on dev (Cloud Run request log, pages nobody had opened): a lookup with children and counts takes 0.21 to 0.62 s, a search by location 0.05 to 0.45 s, and the whole web page 0.56 to 0.93 s. Continent lookups (Asia, Africa, South America) took 0.8 to 1.7 s, and the Europe search 4.3 s, before the single-pass change.

Expected behavior:

  • /v1/locations?location_type=continent&include=feed_counts lists the seven continents (ids -1 to -7) with counts.
  • /v1/locations/lookup?path=north-america/canada/quebec&include=feed_counts,children&has_feeds=true&sort=feed_count returns Québec with its children that have feeds, most feeds first. /v1/locations/lookup?path=atlantis returns 404.
  • /v1/locations/-5/children?has_feeds=true&include=feed_counts&sort=feed_count lists the North American countries that have feeds; each country's parent_location_id is -5.
  • /v1/search?location_path=north-america/canada/quebec&status=active,inactive,development,future&sort=location lists Québec's feeds, as does location_id=<Québec's id>.
  • 400 for:
    • both location_id and location_path;
    • sort=location without either of them;
    • include=children on the list or children endpoint;
    • an unknown sort.

Testing tips:

  • Tests:

    • api/tests/integration/location_directory/: a geopolygon fixture covering continents, duplicate country boundaries, slug collisions, feed counts, realtime feeds located through their schedule feed, and every endpoint and filter above.
    • api/tests/unittest/test_locations_api_impl.py.
  • Full suite: the full api/ suite, run with the pytest command scripts/api-tests.sh uses: 882 passed. 6 user_service early-access tests fail locally because my test database lacks the early_access_* tables; this PR doesn't touch user_service. flake8 and black are clean.

  • Migration:

    • Order: the new changesets run before the materialized views are rebuilt (liquibase/changelog.xml).
    • Upgrade path: Liquibase was run on an empty database at the previous commit, then on this one. Only the continent-ids changeset and the view rebuild ran, and REFRESH MATERIALIZED VIEW CONCURRENTLY works on the new view.
  • Dev: the schema is migrated (the continent ids changeset and the view rebuild) and the API is deployed to dev; the web PR's preview uses it.

  • Before merging: this PR redefines GeopolygonLocationSearch, which the geopolygon loader refreshes and /v1/locations already reads; it's worth a review from whoever owns it.

  • Web client: feat: add feed location directory mobilitydatabase-web#259

  • Run the unit tests with ./scripts/api-tests.sh to make sure you didn't break anything (suite run with the script's pytest command directly, see above)

  • Add or update any needed documentation to the repo (OpenAPI spec)

  • Format the title like "feat: [new feature short description]". Title must follow the Conventional Commit Specification(https://www.conventionalcommits.org/en/v1.0.0/).

  • Linked all relevant issues

  • Include screenshot(s) showing how this pull request works and fixes the issue(s)

Follow up tickets

  • Depending on how these pages are found / indexed by crawlers, might have to include them in our site map (Stronger opportunity to appear in searches like "transit data montreal")

Generated with Claude Code

Add GET /v1/locations/directory and GET /v1/locations/directory/feeds to
browse feeds by continent > country > subdivision > municipality, based on
the feeds' declared locations. Counts are split by data type and roll up to
every ancestor; feeds are listed by relevance to the requested node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alessandro100 and others added 3 commits November 3, 2026 08:11
The directory endpoints take a `country` slug (e.g. `portugal`) instead of
`country_code`, so web URLs read /locations/europe/portugal. Slugs come
from a static table so library upgrades cannot change published URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Directory nodes used the country name declared on the feeds (e.g.
"Sverige"), which did not match the English slug in the URL. Country
names now come from pycountry's short English names, with the same
rewordings as the slugs, and a test checks every name slugifies to its
slug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/v1/search accepts the directory's continent, country, subdivision and
municipality parameters, with the same validation, and a `sort` parameter
(`created_at` by default, or `location` for relevance to the requested
location). Location filters combine with the other search filters.

BREAKING CHANGE: /v1/locations/directory/feeds is removed; call /v1/search
with the same location parameters, status=active,inactive,development,future
and sort=location instead. The endpoint was never released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alessandro100 Alessandro100 changed the title feat: add location directory endpoints feat: add location directory endpoint and location filters to search Nov 3, 2026
@Alessandro100 Alessandro100 changed the title feat: add location directory endpoint and location filters to search feat: add location directory filters and feed counts to locations and search Nov 4, 2026
Replace the Location-table directory with OSM geopolygons:

- /v1/locations: continent, parent_location_id, path, has_feeds,
  include_feed_counts and sort (admin_level or feed_count); results add
  continent fields, slug, path_slugs and feed_counts; location_type=continent
  lists continents.
- /v1/search: continent and location_id filters replace the
  country/subdivision/municipality slugs; sort=location ranks feeds by their
  share of stops inside the location.
- continent and country_continent tables, seeded verbatim from
  country_converter's seven-continent classification by
  scripts/country_continent_seed.py.
- GeopolygonLocationSearch: continent_code, slug (suffixed with the osm_id
  when siblings collide), path_slugs and an indexed path; one country row
  per ISO code.
- Remove /v1/locations/directory and the Location-based directory code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Nov 4, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

Alessandro100 and others added 2 commits November 5, 2026 07:29
GTFS Realtime feeds have no stops, so almost none are reverse-geolocated
and the location directory missed them. A realtime feed without location
group of its own now takes the groups of the schedule feeds it references,
in feed counts and in the /v1/search location filters and sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`path=canada/quebec/` returned no location because the value was only
trimmed and lowercased. Slashes at both ends are now stripped, and a path
of only slashes counts as no path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alessandro100 Alessandro100 changed the title feat: add location directory filters and feed counts to locations and search feat: add location lookup, children and feed counts to the locations API Nov 6, 2026
Alessandro100 and others added 3 commits November 6, 2026 08:37
Each locations endpoint now has one job: /v1/locations searches,
/v1/locations/{location_id} and /v1/locations/lookup?path= return one
location, and /v1/locations/{location_id}/children lists its children.
include=feed_counts,children adds counts and the first page of children
to a single location.

Continents become locations with fixed negative ids (-1 to -7) and are
the parent of their countries; paths start at the continent. /v1/search
takes location_id or location_path instead of continent.

BREAKING CHANGE: /v1/locations results now include the continents,
path_names starts with the continent, and a country's
parent_location_id is its continent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Feed counts, has_feeds and the search location filter read the union of
each feed's own location groups and the groups realtime feeds inherit from
their schedule feeds. They filtered that union by location afterwards;
PostgreSQL cannot push the filter into the inherited branch (a join) and
estimates it at one row, so it rebuilt the inherited groups once per
location group in a nested loop: minutes for a continent on dev.

Each branch now applies the location filter, joins the feed and applies
the counted-feed rules itself, and the union is only aggregated. has_feeds
counts the candidate locations once instead of one subquery per row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With has_feeds or sort=feed_count, the locations list counted the feeds of
the matching locations up to four times: in the count query, in the
has_feeds filter of the page query, and in the counts used to sort, which
filtered by has_feeds again. One query now counts them once and uses the
counts to filter (inner join), sort, page and return the total (window
count); a page past the last one runs a small count query instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alessandro100 Alessandro100 changed the title feat: add location lookup, children and feed counts to the locations API feat!: add location lookup, children and feed counts to the locations API Nov 9, 2026
@Alessandro100
Alessandro100 requested a balanced review from Copilot November 9, 2026 13:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved issues can orphan subdivisions after country deduplication, return incorrect totals for zero-limit queries, and generate slug collisions that make locations unreachable by their advertised paths.

3 open issues
What changed in this PR

Adds a geopolygon-backed location directory for mobilitydatabase.org, with place lookup, child navigation, feed counts and location-based feed search. It also introduces documented breaking changes to existing location results, including continent rows and continent-prefixed paths.

Changes:

  • Adds location detail, path lookup and children endpoints, plus feed-count filtering and sorting.
  • Introduces continent reference data and extends the location read model with hierarchy paths and slugs.
  • Adds shared feed-location queries, realtime-feed inheritance and endpoint tests.
File Description
scripts/​country_continent_seed.py Generates country-to-continent seed data.
liquibase/​materialized_views/​geopolygon_location_search.sql Adds continents, paths, slugs and country deduplication.
liquibase/​changes/​feat_location_directory.sql Creates and seeds continent reference tables.
liquibase/​changes/​feat_location_directory_continent_ids.sql Assigns stable negative continent IDs.
liquibase/​changelog.xml Orders migrations before view rebuilding.
functions-python/​test_utils/​database_utils.py Preserves reference data during test cleanup.
docs/​DatabaseCatalogAPI.yaml Defines new endpoints, parameters and response fields.
api/​tests/​unittest/​test_locations_api_impl.py Tests validation, mapping and location responses.
api/​tests/​test_utils/​db_utils.py Preserves continent data during API test cleanup.
api/​tests/​integration/​location_directory/​test_location_directory_api.py Tests directory endpoints and location-based feed search.
api/​tests/​integration/​location_directory/​conftest.py Supplies hierarchy and feed-location fixtures.
api/​src/​utils/​route_order.py Prioritizes fixed routes over parameterized routes.
api/​src/​shared/​db_models/​location_search_result_impl.py Maps expanded location response fields.
api/​src/​shared/​db_models/​location_detail_impl.py Maps location details with optional children.
api/​src/​main.py Registers lookup before the location-ID route.
api/​src/​feeds/​impl/​search_api_impl.py Adds location filtering and stop-share sorting.
api/​src/​feeds/​impl/​locations_api_impl.py Implements lookup, children and feed-count queries.
api/​src/​feeds/​impl/​location_feeds.py Shares location membership, inheritance and counting logic.
api/​src/​feeds/​impl/​error_handling.py Adds location and search validation messages.
api/​.openapi-generator/​FILES Records the new generated response models.

Review effort: Balanced

…ntry boundaries

- Slugs: a collision suffix is now '--<osm_id>', and a name without ASCII
  letters or digits (slug = its id) takes part in the collision count. A
  name never produces '--', so a suffixed slug can no longer equal a
  sibling's natural slug ("Springfield" 8 vs "Springfield 8").
- Duplicate country boundaries resolve to the retained one as a parent
  and as an ancestor, so a sub-area of a discarded boundary is a child of
  the kept country and its path goes through it.
- has_feeds / sort=feed_count with limit=0 returns the real total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alessandro100
Alessandro100 marked this pull request as ready for review November 9, 2026 14:10
@Alessandro100
Alessandro100 requested a review from cka-y November 9, 2026 14:18

This branch has not been deployed

No deployments
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.

3 participants