mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #946 from trygvehhh/feat/bikeshare-norway
feat(bikeshare): add Norway's city bikes (Oslo, Bergen, Trondheim, Stavanger, Skien/Porsgrunn, Lillestrøm)
This commit is contained in:
10 files changed
+385
-9
No files matched your search
@@ -237,6 +237,17 @@
|
|||||||
panel, resize it from any edge or corner, and double-click the header to dock
|
panel, resize it from any edge or corner, and double-click the header to dock
|
||||||
it again. The position persists across reloads.
|
it again. The position persists across reloads.
|
||||||
|
|
||||||
|
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
|
||||||
|
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
|
- 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
|
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||||
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ 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" |
|
| **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" |
|
||||||
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
|
||||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **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) |
|
||||||
|
|||||||
+10
-1
@@ -1182,6 +1182,15 @@ The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
|
|||||||
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
|
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
|
||||||
streaming. The 12-second deadline includes reading the body, and rejected or
|
streaming. The 12-second deadline includes reading the body, and rejected or
|
||||||
stalled downloads are cancelled. Development and preview use the same handler.
|
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. 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
|
## Remaining local service modules
|
||||||
|
|
||||||
@@ -3163,7 +3172,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) + NYC DOT TMC (opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
| 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) + NYC DOT TMC (opt-in) + 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 |
|
| 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) |
|
| 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) | `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 |
|
| 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 |
|
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
isAllowedGbfsHost,
|
isAllowedGbfsHost,
|
||||||
isAllowedGbfsPath,
|
isAllowedGbfsPath,
|
||||||
gbfsCacheControl,
|
gbfsCacheControl,
|
||||||
|
gbfsUpstreamHeaders,
|
||||||
} from '../../src/data/gbfsSource.js';
|
} from '../../src/data/gbfsSource.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -11,6 +12,9 @@ import {
|
|||||||
/** Upstream fetch timeout for GBFS requests (ms). */
|
/** Upstream fetch timeout for GBFS requests (ms). */
|
||||||
const GBFS_PROXY_TIMEOUT_MS = 12000;
|
const GBFS_PROXY_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
|
/** Value of the Client-Identifier header sent with every upstream request. */
|
||||||
|
export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view';
|
||||||
|
|
||||||
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||||
|
|
||||||
function gbfsRedirectHost(location, requestUrl) {
|
function gbfsRedirectHost(location, requestUrl) {
|
||||||
@@ -60,6 +64,10 @@ export async function fetchGbfsUpstream(
|
|||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
|
'User-Agent': 'gods-eye-view-gbfs-proxy/1.0',
|
||||||
|
// 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',
|
redirect: 'manual',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -181,7 +189,7 @@ export function gbfsProxy() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAllowedGbfsPath(upstreamUrl.pathname)) {
|
if (!isAllowedGbfsPath(upstreamUrl.pathname, upstreamUrl.hostname)) {
|
||||||
res.writeHead(400, {
|
res.writeHead(400, {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
|
|||||||
@@ -265,6 +265,26 @@ export const DATA_CREDITS = [
|
|||||||
key: 'gbfs',
|
key: 'gbfs',
|
||||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'urbansharing-gbfs',
|
||||||
|
html:
|
||||||
|
'City bikes (Norway): contains data under the ' +
|
||||||
|
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||||
|
'made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entur-mobility-gbfs',
|
||||||
|
html:
|
||||||
|
'City bikes (Stavanger, Skien/Porsgrunn): contains data under the ' +
|
||||||
|
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||||
|
'made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jcdecaux-gbfs',
|
||||||
|
html:
|
||||||
|
'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' +
|
||||||
|
'<a href="https://developer.jcdecaux.com/files/Open-Licence-fr.pdf" target="_blank" rel="noopener">Licence Ouverte</a>',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'osrm-routing',
|
key: 'osrm-routing',
|
||||||
// The service asks for its attribution to carry a "fix the map" link, so
|
// The service asks for its attribution to carry a "fix the map" link, so
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
GBFS_CLIENT_IDENTIFIER,
|
||||||
GBFS_MAX_BODY_BYTES,
|
GBFS_MAX_BODY_BYTES,
|
||||||
fetchGbfsUpstream,
|
fetchGbfsUpstream,
|
||||||
} from '../../server/providers/gbfs.js';
|
} from '../../server/providers/gbfs.js';
|
||||||
@@ -56,6 +57,41 @@ test('GBFS upstream fetch passes a direct 200 through unchanged', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GBFS upstream fetch names the app in the Client-Identifier header', async () => {
|
||||||
|
let observedHeaders = null;
|
||||||
|
await fetchGbfsUpstream(
|
||||||
|
'https://gbfs.urbansharing.com/oslobysykkel.no/station_status.json',
|
||||||
|
{
|
||||||
|
fetchImpl: async (_url, options) => {
|
||||||
|
observedHeaders = options.headers;
|
||||||
|
return new Response(STATION_BODY, { status: 200 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(GBFS_CLIENT_IDENTIFIER, 'gods-eye-view');
|
||||||
|
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 () => {
|
test('GBFS upstream fetch forwards a non-redirect error status for the middleware to relay', async () => {
|
||||||
const result = await fetchGbfsUpstream(STATION_URL, {
|
const result = await fetchGbfsUpstream(STATION_URL, {
|
||||||
fetchImpl: async () => new Response(null, { status: 404 }),
|
fetchImpl: async () => new Response(null, { status: 404 }),
|
||||||
|
|||||||
+56
-4
@@ -8,6 +8,31 @@ const GBFS_ALLOWED_HOSTS = new Set([
|
|||||||
'austin.publicbikesystem.net',
|
'austin.publicbikesystem.net',
|
||||||
'hon.publicbikesystem.net',
|
'hon.publicbikesystem.net',
|
||||||
'chat.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' }),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,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} pathname
|
||||||
|
* @param {string} [hostname]
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function isAllowedGbfsPath(pathname) {
|
export function isAllowedGbfsPath(pathname, hostname = '') {
|
||||||
return /\/station_(information|status)\.json$/i.test(String(pathname || ''));
|
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<string, string>}
|
||||||
|
*/
|
||||||
|
export function gbfsUpstreamHeaders(hostname) {
|
||||||
|
return (
|
||||||
|
GBFS_HOST_HEADERS.get(
|
||||||
|
String(hostname || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
) || {}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +99,7 @@ export function isAllowedGbfsPath(pathname) {
|
|||||||
* @returns {string} Cache-Control header value.
|
* @returns {string} Cache-Control header value.
|
||||||
*/
|
*/
|
||||||
export function gbfsCacheControl(pathname) {
|
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 'public, max-age=300';
|
||||||
}
|
}
|
||||||
return 'no-store';
|
return 'no-store';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createModel } from './model.js';
|
|||||||
import { CITY_BY_ID } from './registry.js';
|
import { CITY_BY_ID } from './registry.js';
|
||||||
|
|
||||||
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
|
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
|
||||||
|
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
|
||||||
|
|
||||||
const STATION_INFORMATION = {
|
const STATION_INFORMATION = {
|
||||||
data: {
|
data: {
|
||||||
@@ -204,6 +205,101 @@ test('the proxy relays its cache policy and upstream host through the mounted ro
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the Norwegian city-bike systems are served through the mounted route', async () => {
|
||||||
|
for (const [id, systemId] of [
|
||||||
|
['oslo-bysykkel', 'oslobysykkel.no'],
|
||||||
|
['bergen-bysykkel', 'bergenbysykkel.no'],
|
||||||
|
['trondheim-bysykkel', 'trondheimbysykkel.no'],
|
||||||
|
]) {
|
||||||
|
const system = CITY_BY_ID.get(id);
|
||||||
|
assert.ok(system, `${id} is registered`);
|
||||||
|
assert.deepEqual(system.hosts, ['gbfs.urbansharing.com']);
|
||||||
|
assert.equal(
|
||||||
|
system.stationStatusUrl,
|
||||||
|
`https://gbfs.urbansharing.com/${systemId}/station_status.json`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await withUpstream(
|
||||||
|
{
|
||||||
|
[OSLO.stationInformationUrl]: STATION_INFORMATION,
|
||||||
|
[OSLO.stationStatusUrl]: STATION_STATUS,
|
||||||
|
},
|
||||||
|
async (requested) => {
|
||||||
|
const source = createBikeshareSource({ fetchImpl: mountGbfsProxy() });
|
||||||
|
const info = await source.getStations(OSLO.stationInformationUrl);
|
||||||
|
const status = await source.getStations(OSLO.stationStatusUrl);
|
||||||
|
assert.deepEqual(requested, [
|
||||||
|
OSLO.stationInformationUrl,
|
||||||
|
OSLO.stationStatusUrl,
|
||||||
|
]);
|
||||||
|
assert.equal(info.data.stations.length, 1);
|
||||||
|
assert.equal(status.data.stations.length, 1);
|
||||||
|
|
||||||
|
const response = await mountGbfsProxy()(
|
||||||
|
'/api/gbfs/' + encodeURIComponent(OSLO.stationStatusUrl),
|
||||||
|
);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
response.headers.get('x-gbfs-upstream'),
|
||||||
|
'gbfs.urbansharing.com',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test('the query-string shape the client used to send is refused by the mounted route', async () => {
|
||||||
await withUpstream(
|
await withUpstream(
|
||||||
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
|
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
|
||||||
|
|||||||
@@ -23,6 +23,47 @@ function bcycleEntry({
|
|||||||
...buildBcycleUrls(systemId),
|
...buildBcycleUrls(systemId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function urbanSharingEntry({
|
||||||
|
id,
|
||||||
|
city,
|
||||||
|
centerLat,
|
||||||
|
centerLon,
|
||||||
|
systemId,
|
||||||
|
provider,
|
||||||
|
loadRadiusKm = 100,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
city,
|
||||||
|
centerLat,
|
||||||
|
centerLon,
|
||||||
|
loadRadiusKm,
|
||||||
|
provider,
|
||||||
|
stationInformationUrl: `https://gbfs.urbansharing.com/${systemId}/station_information.json`,
|
||||||
|
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 = [
|
const RAW_GBFS_CITY_REGISTRY = [
|
||||||
{
|
{
|
||||||
id: 'nyc-citibike',
|
id: 'nyc-citibike',
|
||||||
@@ -368,6 +409,59 @@ const RAW_GBFS_CITY_REGISTRY = [
|
|||||||
centerLon: -119.6982,
|
centerLon: -119.6982,
|
||||||
systemId: 'bcycle_santabarbara',
|
systemId: 'bcycle_santabarbara',
|
||||||
}),
|
}),
|
||||||
|
urbanSharingEntry({
|
||||||
|
id: 'oslo-bysykkel',
|
||||||
|
city: 'Oslo, Norway',
|
||||||
|
centerLat: 59.9139,
|
||||||
|
centerLon: 10.7522,
|
||||||
|
systemId: 'oslobysykkel.no',
|
||||||
|
provider: 'Oslo Bysykkel',
|
||||||
|
}),
|
||||||
|
urbanSharingEntry({
|
||||||
|
id: 'bergen-bysykkel',
|
||||||
|
city: 'Bergen, Norway',
|
||||||
|
centerLat: 60.3913,
|
||||||
|
centerLon: 5.3221,
|
||||||
|
systemId: 'bergenbysykkel.no',
|
||||||
|
provider: 'Bergen Bysykkel',
|
||||||
|
}),
|
||||||
|
urbanSharingEntry({
|
||||||
|
id: 'trondheim-bysykkel',
|
||||||
|
city: 'Trondheim, Norway',
|
||||||
|
centerLat: 63.4305,
|
||||||
|
centerLon: 10.3951,
|
||||||
|
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) {
|
function normalizeRegistryEntry(entry) {
|
||||||
const id = String(entry?.id || '')
|
const id = String(entry?.id || '')
|
||||||
@@ -396,11 +490,13 @@ function normalizeRegistryEntry(entry) {
|
|||||||
) {
|
) {
|
||||||
throw new Error(`GBFS entry "${id}" must use https URLs`);
|
throw new Error(`GBFS entry "${id}" must use https URLs`);
|
||||||
}
|
}
|
||||||
// Validate that URLs end with expected GBFS endpoint filenames
|
// Validate that URLs end with expected GBFS endpoint names (Entur omits .json)
|
||||||
if (!/\/station_information\.json$/i.test(stationInformationUrl.pathname)) {
|
if (
|
||||||
|
!/\/station_information(\.json)?$/i.test(stationInformationUrl.pathname)
|
||||||
|
) {
|
||||||
throw new Error(`GBFS entry "${id}" station_information URL is invalid`);
|
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`);
|
throw new Error(`GBFS entry "${id}" station_status URL is invalid`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -457,6 +457,51 @@ test('bike share names the systems it did not search', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bike share over Oslo searches only the nearby Norwegian systems', async () => {
|
||||||
|
const requested = [];
|
||||||
|
const result = await composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: {
|
||||||
|
bikeshare: {
|
||||||
|
systems: GBFS_CITY_REGISTRY,
|
||||||
|
getStations: async (url) => {
|
||||||
|
requested.push(url);
|
||||||
|
return { data: { stations: [] } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).call('get_bike_share', {
|
||||||
|
area: { lat: 59.9139, lon: 10.7522, radius_km: 5 },
|
||||||
|
});
|
||||||
|
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/') ||
|
||||||
|
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 () => {
|
test('traffic counts only roads inside a radius area and reports missing tiles', async () => {
|
||||||
const flow = (segments, partial = false) => ({
|
const flow = (segments, partial = false) => ({
|
||||||
getStatus: async () => ({ hasKey: true }),
|
getStatus: async () => ({ hasKey: true }),
|
||||||
|
|||||||
Reference in new issue
Block a user