feat(bikeshare): add Oslo, Bergen and Trondheim Bysykkel

Add Norway's three Urban Sharing city-bike systems to the Bike Share
layer and get_bike_share. The feeds are keyless GBFS on
gbfs.urbansharing.com, which joins the proxy allowlist. The proxy now
sends the Client-Identifier header the operators ask clients to send.
Attributed under NLOD 2.0 in DATA_SOURCES.md and the data credits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Trygve HemminghyttandClaude Opus 5.5 committed 2026-10-05 22:13:53 +02:00
1 parent a08a53cf94
commit 42543c1fa1
10 files changed
+154 -1

No files matched your search

+7
View File
@@ -251,6 +251,13 @@ export const DATA_CREDITS = [
key: 'gbfs',
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: 'osrm-routing',
// The service asks for its attribution to carry a "fix the map" link, so
+16
View File
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GBFS_CLIENT_IDENTIFIER,
GBFS_MAX_BODY_BYTES,
fetchGbfsUpstream,
} from '../../server/providers/gbfs.js';
@@ -56,6 +57,21 @@ 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 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 }),
+1
View File
@@ -8,6 +8,7 @@ const GBFS_ALLOWED_HOSTS = new Set([
'austin.publicbikesystem.net',
'hon.publicbikesystem.net',
'chat.publicbikesystem.net',
'gbfs.urbansharing.com',
]);
/**
+44
View File
@@ -17,6 +17,7 @@ import { createModel } from './model.js';
import { CITY_BY_ID } from './registry.js';
const AUSTIN = CITY_BY_ID.get('austin-capmetro');
const OSLO = CITY_BY_ID.get('oslo-bysykkel');
const STATION_INFORMATION = {
data: {
@@ -204,6 +205,49 @@ 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('the query-string shape the client used to send is refused by the mounted route', async () => {
await withUpstream(
{ [AUSTIN.stationStatusUrl]: STATION_STATUS },
+44
View File
@@ -23,6 +23,26 @@ function bcycleEntry({
...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`,
};
}
const RAW_GBFS_CITY_REGISTRY = [
{
id: 'nyc-citibike',
@@ -318,6 +338,30 @@ const RAW_GBFS_CITY_REGISTRY = [
centerLon: -119.6982,
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',
}),
];
function normalizeRegistryEntry(entry) {
const id = String(entry?.id || '')
+25
View File
@@ -429,6 +429,31 @@ test('bike share names the systems it did not search', async () => {
);
});
test('bike share over Oslo searches Oslo Bysykkel only', 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, ['Oslo, Norway']);
assert.deepEqual(result.data.systems_not_searched, []);
assert.ok(
requested.every((url) =>
url.startsWith('https://gbfs.urbansharing.com/oslobysykkel.no/'),
),
);
});
test('traffic counts only roads inside a radius area and reports missing tiles', async () => {
const flow = (segments, partial = false) => ({
getStatus: async () => ({ hasKey: true }),