diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2fc2d1e2..184e55230 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,10 +3,15 @@
## [Unreleased]
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
- Trondheim Bysykkel (Urban Sharing, ~440 stations). The feeds are keyless
- GBFS on `gbfs.urbansharing.com`, which joins the proxy allowlist; the proxy
- now sends the `Client-Identifier: gods-eye-view` header the operators ask
- for. Attributed under NLOD 2.0. `get_bike_share` covers the three cities too.
+ Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
+ Farte Bysykkel in Skien/Porsgrunn (Entur Mobility), and Bysykkel
+ Lillestrøm (JCDecaux), about 760 stations in all. Every feed is keyless
+ GBFS. `gbfs.urbansharing.com`, `api.entur.io` and `api.cyclocity.fr` join
+ the proxy allowlist; the last two are kept to their GBFS paths. The proxy
+ now sends `Client-Identifier: gods-eye-view` on every request and
+ `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask,
+ and accepts Entur's extensionless feed paths. Attributed under NLOD and
+ Licence Ouverte. `get_bike_share` covers the six systems too.
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
list is one keyless GeoJSON request to the agency's OGC API view of its
diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md
index 07782fd5e..f7e283117 100644
--- a/DATA_SOURCES.md
+++ b/DATA_SOURCES.md
@@ -57,6 +57,8 @@ How to read this:
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
+| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
+| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" |
| **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) |
| **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) |
| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 8a25ccb8c..55056d2ea 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -963,7 +963,13 @@ streaming. The 12-second deadline includes reading the body, and rejected or
stalled downloads are cancelled. Development and preview use the same handler.
Every upstream request carries `Client-Identifier: gods-eye-view`, which Urban
Sharing asks of clients of the Oslo, Bergen and Trondheim Bysykkel feeds
-(`gbfs.urbansharing.com`); other operators ignore it.
+(`gbfs.urbansharing.com`); other operators ignore it. Requests to Entur
+(`api.entur.io`, Kolumbus and Farte Bysykkel) also carry
+`ET-Client-Name: gods-eye-view-bikeshare`. Entur serves its feeds without a
+`.json` suffix, so the endpoint check accepts `station_information` and
+`station_status` with or without it. Hosts that front more than GBFS are kept to
+their GBFS tree: `api.entur.io` to `/mobility/v2/gbfs/` and `api.cyclocity.fr`
+(Bysykkel Lillestrøm) to `/contracts/`.
## Remaining local service modules
@@ -2925,7 +2931,7 @@ its criteria cannot be silently ignored.
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
-| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
+| Bikeshare 🚲 | GBFS (Lyft + BCycle + Urban Sharing, Entur, JCDecaux NO) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js
index f55299992..990642c8b 100644
--- a/server/providers/gbfs.js
+++ b/server/providers/gbfs.js
@@ -3,6 +3,7 @@ import {
isAllowedGbfsHost,
isAllowedGbfsPath,
gbfsCacheControl,
+ gbfsUpstreamHeaders,
} from '../../src/data/gbfsSource.js';
// ---------------------------------------------------------------------------
@@ -66,6 +67,7 @@ export async function fetchGbfsUpstream(
// Urban Sharing (the Norwegian city-bike feeds) asks every client to
// name itself in this header; other operators ignore it.
'Client-Identifier': GBFS_CLIENT_IDENTIFIER,
+ ...gbfsUpstreamHeaders(new URL(url).hostname),
},
redirect: 'manual',
signal: controller.signal,
@@ -187,7 +189,7 @@ export function gbfsProxy() {
return;
}
- if (!isAllowedGbfsPath(upstreamUrl.pathname)) {
+ if (!isAllowedGbfsPath(upstreamUrl.pathname, upstreamUrl.hostname)) {
res.writeHead(400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js
index 87358dafc..195d2487e 100644
--- a/src/data/dataCredits.js
+++ b/src/data/dataCredits.js
@@ -258,6 +258,19 @@ export const DATA_CREDITS = [
'Norwegian licence for Open Government data (NLOD) ' +
'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel',
},
+ {
+ key: 'entur-mobility-gbfs',
+ html:
+ 'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' +
+ 'Norwegian licence for Open Government data (NLOD) ' +
+ 'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)',
+ },
+ {
+ key: 'jcdecaux-gbfs',
+ html:
+ 'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
+ 'Licence Ouverte',
+ },
{
key: 'osrm-routing',
// The service asks for its attribution to carry a "fix the map" link, so
diff --git a/src/data/gbfsProxy.test.mjs b/src/data/gbfsProxy.test.mjs
index af107b38e..c95256137 100644
--- a/src/data/gbfsProxy.test.mjs
+++ b/src/data/gbfsProxy.test.mjs
@@ -72,6 +72,26 @@ test('GBFS upstream fetch names the app in the Client-Identifier header', async
assert.equal(observedHeaders['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
});
+test('GBFS upstream fetch adds ET-Client-Name for Entur and only for Entur', async () => {
+ const headersFor = async (url) => {
+ let observed = null;
+ await fetchGbfsUpstream(url, {
+ fetchImpl: async (_url, options) => {
+ observed = options.headers;
+ return new Response(STATION_BODY, { status: 200 });
+ },
+ });
+ return observed;
+ };
+ const entur = await headersFor(
+ 'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
+ );
+ assert.equal(entur['ET-Client-Name'], 'gods-eye-view-bikeshare');
+ assert.equal(entur['Client-Identifier'], GBFS_CLIENT_IDENTIFIER);
+ const lyft = await headersFor(STATION_URL);
+ assert.equal(lyft['ET-Client-Name'], undefined);
+});
+
test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => {
const result = await fetchGbfsUpstream(STATION_URL, {
fetchImpl: async () => new Response(null, { status: 404 }),
diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js
index f06fa24f2..8d4980948 100644
--- a/src/data/gbfsSource.js
+++ b/src/data/gbfsSource.js
@@ -9,6 +9,30 @@ const GBFS_ALLOWED_HOSTS = new Set([
'hon.publicbikesystem.net',
'chat.publicbikesystem.net',
'gbfs.urbansharing.com',
+ 'api.entur.io',
+ 'api.cyclocity.fr',
+]);
+
+/**
+ * Hosts that serve more than GBFS keep the proxy to their GBFS tree: Entur's
+ * api.entur.io fronts every Entur API, and api.cyclocity.fr every JCDecaux
+ * contract endpoint.
+ */
+const GBFS_HOST_PATH_PREFIXES = new Map([
+ ['api.entur.io', '/mobility/v2/gbfs/'],
+ ['api.cyclocity.fr', '/contracts/'],
+]);
+
+/**
+ * Identifying headers an operator asks every GBFS client to send. Entur rate
+ * limits or blocks consumers that do not send ET-Client-Name, in the form
+ * "company-application".
+ */
+const GBFS_HOST_HEADERS = new Map([
+ [
+ 'api.entur.io',
+ Object.freeze({ 'ET-Client-Name': 'gods-eye-view-bikeshare' }),
+ ],
]);
/**
@@ -29,13 +53,40 @@ export function isAllowedGbfsHost(hostname) {
}
/**
- * Only allow station_information.json and station_status.json endpoints.
+ * Only allow station_information and station_status endpoints. The `.json`
+ * suffix is optional because Entur serves its feeds without one. When a
+ * hostname is given and that host serves more than GBFS, the path must also
+ * sit under the host's GBFS prefix.
*
* @param {string} pathname
+ * @param {string} [hostname]
* @returns {boolean}
*/
-export function isAllowedGbfsPath(pathname) {
- return /\/station_(information|status)\.json$/i.test(String(pathname || ''));
+export function isAllowedGbfsPath(pathname, hostname = '') {
+ const path = String(pathname || '');
+ if (!/\/station_(information|status)(\.json)?$/i.test(path)) return false;
+ const prefix = GBFS_HOST_PATH_PREFIXES.get(
+ String(hostname || '')
+ .trim()
+ .toLowerCase(),
+ );
+ return !prefix || path.startsWith(prefix);
+}
+
+/**
+ * Extra request headers an upstream GBFS host asks clients to send.
+ *
+ * @param {string} hostname
+ * @returns {Record}
+ */
+export function gbfsUpstreamHeaders(hostname) {
+ return (
+ GBFS_HOST_HEADERS.get(
+ String(hostname || '')
+ .trim()
+ .toLowerCase(),
+ ) || {}
+ );
}
/**
@@ -48,7 +99,7 @@ export function isAllowedGbfsPath(pathname) {
* @returns {string} Cache-Control header value.
*/
export function gbfsCacheControl(pathname) {
- if (/\/station_information\.json$/i.test(String(pathname || ''))) {
+ if (/\/station_information(\.json)?$/i.test(String(pathname || ''))) {
return 'public, max-age=300';
}
return 'no-store';
diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs
index 9158a6bdf..9bb04ced0 100644
--- a/src/layers/bikeshare/proxyRoute.test.mjs
+++ b/src/layers/bikeshare/proxyRoute.test.mjs
@@ -248,6 +248,58 @@ test('the Norwegian city-bike systems are served through the mounted route', asy
);
});
+test('every Norwegian city-bike system is registered and reachable through the mounted route', async () => {
+ const expected = {
+ 'stavanger-kolumbus':
+ 'https://api.entur.io/mobility/v2/gbfs/v2/kolumbusbysykkel/station_status',
+ 'skien-farte':
+ 'https://api.entur.io/mobility/v2/gbfs/v2/fartebysykkel/station_status',
+ 'lillestrom-bysykkel':
+ 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
+ };
+ const upstream = {};
+ for (const [id, statusUrl] of Object.entries(expected)) {
+ const system = CITY_BY_ID.get(id);
+ assert.ok(system, `${id} is registered`);
+ assert.equal(system.stationStatusUrl, statusUrl);
+ upstream[system.stationInformationUrl] = STATION_INFORMATION;
+ upstream[system.stationStatusUrl] = STATION_STATUS;
+ }
+
+ await withUpstream(upstream, async (requested) => {
+ const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
+ for (const id of Object.keys(expected)) {
+ const system = CITY_BY_ID.get(id);
+ const info = await source.getStations(system.stationInformationUrl);
+ const status = await source.getStations(system.stationStatusUrl);
+ assert.equal(info.data.stations.length, 1, `${id} information`);
+ assert.equal(status.data.stations.length, 1, `${id} status`);
+ }
+ assert.equal(requested.length, 6);
+
+ // Entur's extensionless station_information keeps its 5-minute cache.
+ const kolumbus = CITY_BY_ID.get('stavanger-kolumbus');
+ const response = await mountGbfsProxy()(
+ '/api/gbfs/' + encodeURIComponent(kolumbus.stationInformationUrl),
+ );
+ assert.equal(response.headers.get('cache-control'), 'public, max-age=300');
+ });
+});
+
+test('the mounted route keeps multi-API hosts to their GBFS tree', async () => {
+ await withUpstream({}, async (requested) => {
+ const mounted = mountGbfsProxy();
+ for (const target of [
+ 'https://api.entur.io/journey-planner/v3/station_status',
+ 'https://api.cyclocity.fr/other/station_status.json',
+ ]) {
+ const response = await mounted('/api/gbfs/' + encodeURIComponent(target));
+ assert.equal(response.status, 400, target);
+ }
+ assert.deepEqual(requested, []);
+ });
+});
+
test('the query-string shape the client used to send is refused by the mounted route', async () => {
await withUpstream(
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js
index 37b7d2c4e..89508d42e 100644
--- a/src/layers/bikeshare/registry.js
+++ b/src/layers/bikeshare/registry.js
@@ -43,6 +43,27 @@ function urbanSharingEntry({
stationStatusUrl: `https://gbfs.urbansharing.com/${systemId}/station_status.json`,
};
}
+function enturEntry({
+ id,
+ city,
+ centerLat,
+ centerLon,
+ systemId,
+ provider,
+ loadRadiusKm = 100,
+}) {
+ const base = `https://api.entur.io/mobility/v2/gbfs/v2/${systemId}`;
+ return {
+ id,
+ city,
+ centerLat,
+ centerLon,
+ loadRadiusKm,
+ provider,
+ stationInformationUrl: `${base}/station_information`,
+ stationStatusUrl: `${base}/station_status`,
+ };
+}
const RAW_GBFS_CITY_REGISTRY = [
{
id: 'nyc-citibike',
@@ -362,6 +383,35 @@ const RAW_GBFS_CITY_REGISTRY = [
systemId: 'trondheimbysykkel.no',
provider: 'Trondheim Bysykkel',
}),
+ enturEntry({
+ id: 'stavanger-kolumbus',
+ city: 'Stavanger, Norway',
+ centerLat: 58.9,
+ centerLon: 5.72,
+ systemId: 'kolumbusbysykkel',
+ provider: 'Kolumbus Bysykkel',
+ }),
+ enturEntry({
+ id: 'skien-farte',
+ city: 'Skien/Porsgrunn, Norway',
+ centerLat: 59.16,
+ centerLon: 9.64,
+ systemId: 'fartebysykkel',
+ provider: 'Farte Bysykkel',
+ loadRadiusKm: 40,
+ }),
+ {
+ id: 'lillestrom-bysykkel',
+ city: 'Lillestrøm, Norway',
+ centerLat: 59.9556,
+ centerLon: 11.0492,
+ loadRadiusKm: 40,
+ stationInformationUrl:
+ 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_information.json',
+ stationStatusUrl:
+ 'https://api.cyclocity.fr/contracts/lillestrom/gbfs/station_status.json',
+ provider: 'Bysykkel Lillestrøm',
+ },
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
@@ -390,11 +440,13 @@ function normalizeRegistryEntry(entry) {
) {
throw new Error(`GBFS entry "${id}" must use https URLs`);
}
- // Validate that URLs end with expected GBFS endpoint filenames
- if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
+ // Validate that URLs end with expected GBFS endpoint names (Entur omits .json)
+ if (
+ !/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname)
+ ) {
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
}
- if (!/\/station_status\.json$/i.test(stationStatusUrl.pathname)) {
+ if (!/\/station_status(\.json)?$/i.test(stationStatusUrl.pathname)) {
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
}
diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs
index 1626aa3dd..07482946b 100644
--- a/src/tools/queries/mobility.test.mjs
+++ b/src/tools/queries/mobility.test.mjs
@@ -429,7 +429,7 @@ test('bike share names the systems it did not search', async () => {
);
});
-test('bike share over Oslo searches Oslo Bysykkel only', async () => {
+test('bike share over Oslo searches only the nearby Norwegian systems', async () => {
const requested = [];
const result = await composeCatalog({
tools: coreTools,
@@ -445,15 +445,35 @@ test('bike share over Oslo searches Oslo Bysykkel only', async () => {
}).call('get_bike_share', {
area: { lat: 59.9139, lon: 10.7522, radius_km: 5 },
});
- assert.deepEqual(result.data.systems, ['Oslo, Norway']);
+ assert.deepEqual(result.data.systems.sort(), [
+ 'Lillestrøm, Norway',
+ 'Oslo, Norway',
+ ]);
assert.deepEqual(result.data.systems_not_searched, []);
assert.ok(
- requested.every((url) =>
- url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/'),
+ requested.every(
+ (url) =>
+ url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/') ||
+ url.startsWith('https://api.cyclocity.fr/contracts/lillestrom/'),
),
);
});
+test('bike share over Stavanger searches Kolumbus Bysykkel', async () => {
+ const result = await composeCatalog({
+ tools: coreTools,
+ services: {
+ bikeshare: {
+ systems: GBFS_CITY_REGISTRY,
+ getStations: async () => ({ data: { stations: [] } }),
+ },
+ },
+ }).call('get_bike_share', {
+ area: { lat: 58.97, lon: 5.7331, radius_km: 5 },
+ });
+ assert.deepEqual(result.data.systems, ['Stavanger, Norway']);
+});
+
test('traffic counts only roads inside a radius area and reports missing tiles', async () => {
const flow = (segments, partial = false) => ({
getStatus: async () => ({ hasKey: true }),