feat(bikeshare): add Kolumbus, Farte and Lillestrøm city bikes

Cover every Norwegian station-based city-bike system with an open GBFS
feed: Kolumbus Bysykkel (Stavanger) and Farte Bysykkel (Skien/Porsgrunn)
through Entur Mobility, and Bysykkel Lillestrøm through JCDecaux
Cyclocity.

api.entur.io and api.cyclocity.fr join the GBFS allowlist, each kept to
its GBFS path prefix since both hosts front other APIs. Entur serves its
feeds without a .json suffix, so the endpoint checks accept either form,
and requests to Entur carry the ET-Client-Name header it requires.
Attributed under NLOD (Entur) and Licence Ouverte (JCDecaux).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Trygve HemminghyttandClaude Opus 5.5 committed 2026-10-05 23:02:37 +02:00
1 parent 42543c1fa1
commit fa61955cf1
10 files changed
+241 -18

No files matched your search

+13
View File
@@ -258,6 +258,19 @@ export const DATA_CREDITS = [
'<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',
// The service asks for its attribution to carry a "fix the map" link, so
+20
View File
@@ -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 }),
+55 -4
View File
@@ -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<string, string>}
*/
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';
+52
View File
@@ -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 },
+55 -3
View File
@@ -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`);
}
+24 -4
View File
@@ -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 }),