├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .github ├── renovate.json └── workflows │ ├── docker-image.yml │ └── lint-test.yml ├── .gitignore ├── Dockerfile ├── api.js ├── architecture.svg ├── build ├── api-docs.js └── index.js ├── docs ├── getting-started.md └── readme.md ├── index.js ├── lib └── db-stations.js ├── license.md ├── package-lock.json ├── package.json ├── readme.md ├── routes ├── station.js └── stations.js └── test ├── index.js └── util.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | # Use tabs in JavaScript. 11 | [**.{js}] 12 | indent_style = tab 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es2022": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "ignorePatterns": [ 11 | "node_modules" 12 | ], 13 | "rules": { 14 | "no-unused-vars": "off" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "configMigration": true, 7 | "lockFileMaintenance": { 8 | "enabled": true, 9 | "automerge": true, 10 | "automergeType": "branch", 11 | "ignoreTests": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: build & publish Docker image 2 | on: 3 | push: 4 | branches: 5 | - 6 6 | jobs: 7 | lint-test: 8 | name: lint, build & test 9 | uses: './.github/workflows/lint-test.yml' 10 | 11 | build-and-publish: 12 | name: build & publish Docker image 13 | needs: [lint-test] 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: check out the repo 17 | uses: actions/checkout@v4 18 | - name: set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | - name: configure Docker to use buildx 21 | uses: docker/setup-buildx-action@v3 22 | - name: log in to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 27 | - name: build Docker image & push to Docker Hub 28 | uses: docker/build-push-action@v4 29 | with: 30 | context: . 31 | push: true 32 | platforms: linux/amd64,linux/arm64 33 | tags: | 34 | derhuerst/db-rest:6 35 | derhuerst/db-rest:latest 36 | # https://docs.docker.com/build/ci/github-actions/examples/#github-cache 37 | cache-from: type=gha 38 | cache-to: type=gha,mode=max,oci-mediatypes=true,compression=zstd 39 | 40 | # this is for the public-transport/infrastructure cluster 41 | - name: log in to GitHub Container Registry 42 | uses: docker/login-action@v3 43 | with: 44 | registry: ghcr.io 45 | username: ${{ github.repository_owner }} 46 | password: ${{ secrets.GITHUB_TOKEN }} 47 | - name: determine commit hash 48 | id: hash 49 | run: echo "::set-output name=hash::$(echo $GITHUB_SHA | head -c7)" 50 | - name: determine current date and time 51 | id: datetime 52 | run: echo "::set-output name=datetime::$(date -u +'%Y-%m-%dT%H.%M.%SZ')" 53 | - name: push Docker image to GitHub Registry 54 | uses: docker/build-push-action@v4 55 | with: 56 | context: . 57 | push: true 58 | platforms: linux/amd64,linux/arm64 59 | tags: | 60 | ghcr.io/${{github.repository}}:v6 61 | ghcr.io/${{github.repository}}:v6_${{steps.hash.outputs.hash}}_${{steps.datetime.outputs.datetime}} 62 | # https://docs.docker.com/build/ci/github-actions/examples/#github-cache 63 | cache-from: type=gha 64 | cache-to: type=gha,mode=max,oci-mediatypes=true,compression=zstd 65 | -------------------------------------------------------------------------------- /.github/workflows/lint-test.yml: -------------------------------------------------------------------------------- 1 | name: lint, build & test 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | pull_request: 7 | branches: 8 | - '*' 9 | workflow_call: 10 | 11 | jobs: 12 | lint-test: 13 | name: lint, build & test 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: 18 | - '18.x' 19 | - '20.x' 20 | - '22.x' 21 | 22 | steps: 23 | - name: checkout 24 | uses: actions/checkout@v4 25 | - name: setup Node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - run: npm ci 31 | 32 | - run: npm run lint 33 | - run: npm run build 34 | - run: npm test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | 4 | .nvm-version 5 | node_modules 6 | npm-debug.log 7 | 8 | /dump.rdb 9 | 10 | /docs/*.html 11 | /docs/syntax.css 12 | /docs/api.md 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine as builder 2 | WORKDIR /app 3 | 4 | # install dependencies 5 | RUN apk add --update git bash 6 | ADD package.json package-lock.json /app 7 | RUN npm ci 8 | 9 | # build documentation 10 | ADD . /app 11 | RUN npm run build 12 | 13 | # --- 14 | 15 | FROM node:18-alpine 16 | LABEL org.opencontainers.image.title="db-rest" 17 | LABEL org.opencontainers.image.description="A clean REST API wrapping around the Deutsche Bahn API." 18 | LABEL org.opencontainers.image.authors="Jannis R " 19 | LABEL org.opencontainers.image.documentation="https://github.com/derhuerst/db-rest/tree/6" 20 | LABEL org.opencontainers.image.source="https://github.com/derhuerst/db-rest" 21 | LABEL org.opencontainers.image.revision="6" 22 | LABEL org.opencontainers.image.licenses="ISC" 23 | WORKDIR /app 24 | 25 | # install dependencies 26 | ADD package.json /app 27 | RUN npm install --production && npm cache clean --force 28 | 29 | # add source code 30 | ADD . /app 31 | COPY --from=builder /app/docs ./docs 32 | 33 | EXPOSE 3000 34 | 35 | ENV HOSTNAME v6.db.transport.rest 36 | ENV PORT 3000 37 | 38 | CMD ["node", "index.js"] 39 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | // todo: use import assertions once they're supported by Node.js & ESLint 2 | // https://github.com/tc39/proposal-import-assertions 3 | import {createRequire} from 'node:module' 4 | const require = createRequire(import.meta.url) 5 | 6 | import {dirname, join as pathJoin} from 'node:path' 7 | import {fileURLToPath} from 'node:url' 8 | import {createClient, loadEnrichedStationData} from 'db-vendo-client' 9 | import {defaultProfile} from 'db-vendo-client/lib/default-profile.js' 10 | import {profile as dbProfile} from 'db-vendo-client/p/db/index.js' 11 | import {profile as dbnavProfile} from 'db-vendo-client/p/dbnav/index.js' 12 | import {profile as dbwebProfile} from 'db-vendo-client/p/dbweb/index.js' 13 | import {createWriteStream} from 'node:fs' 14 | import {createHafasRestApi} from 'hafas-rest-api' 15 | import createHealthCheck from 'hafas-client-health-check' 16 | import Redis from 'ioredis' 17 | import {createCachedHafasClient} from 'cached-hafas-client' 18 | import {createRedisStore} from 'cached-hafas-client/stores/redis.js' 19 | import serveStatic from 'serve-static' 20 | import {mapRouteParsers} from 'db-vendo-client/lib/api-parsers.js' 21 | import {route as stations} from './routes/stations.js' 22 | import {route as station} from './routes/station.js' 23 | import {parseString} from 'hafas-rest-api/lib/parse.js' 24 | import {enrichStation} from 'db-vendo-client/parse/location.js' 25 | 26 | const pkg = require('./package.json') 27 | 28 | const __dirname = dirname(fileURLToPath(import.meta.url)) 29 | const docsRoot = pathJoin(__dirname, 'docs') 30 | 31 | const berlinHbf = '8011160' 32 | 33 | const stationIndex = await loadEnrichedStationData(defaultProfile); 34 | const userAgent = process.env.USER_AGENT || process.env.HAFAS_USER_AGENT || pkg.name; 35 | const opt = { 36 | enrichStations: (ctx, stop) => enrichStation(ctx, stop, stationIndex) 37 | } 38 | const profileClients = { 39 | 'db': createClient(dbProfile, userAgent, opt), 40 | 'dbnav': createClient(dbnavProfile, userAgent, opt), 41 | 'dbweb': createClient(dbwebProfile, userAgent, opt), 42 | } 43 | 44 | const mapRouteParsersWithDynamicProfile = (route, parsers) => { 45 | return { 46 | ...mapRouteParsers(route, parsers), 47 | profile: { 48 | description: 'db-vendo-client profile to use for this request', 49 | type: 'string', 50 | default: 'dbnav', 51 | parse: parseString, 52 | }, 53 | } 54 | } 55 | 56 | const profileSwitchingEndpoint = (endpoint) => { 57 | return (...args) => { 58 | const opt = args[args.length - 1]; 59 | const p = profileClients[opt.profile] || profileClients.dbnav; 60 | if (!p.departuresGetPasslist && !opt.stopovers) { 61 | delete opt.stopovers; 62 | } 63 | return p[endpoint](...args); 64 | } 65 | } 66 | 67 | let profileSwitchingClient = { 68 | profile: { 69 | ...defaultProfile, 70 | locale: 'de-DE', 71 | timezone: 'Europe/Berlin', 72 | departuresGetPasslist: true, 73 | }, 74 | departures: profileSwitchingEndpoint('departures'), 75 | arrivals: profileSwitchingEndpoint('arrivals'), 76 | journeys: profileSwitchingEndpoint('journeys'), 77 | refreshJourney: profileSwitchingEndpoint('refreshJourney'), 78 | trip: profileSwitchingEndpoint('trip'), 79 | locations: profileSwitchingEndpoint('locations'), 80 | stop: profileSwitchingEndpoint('stop'), 81 | nearby: profileSwitchingEndpoint('nearby') 82 | } 83 | 84 | // todo: DRY env var check with localaddress-agent/random-from-env.js 85 | // Currently, this is impossible: localaddress-agent is an optional dependencies, so we rely on it to check the env var. 86 | if (process.env.RANDOM_LOCAL_ADDRESSES_RANGE) { 87 | const {randomLocalAddressAgent} = await import('localaddress-agent/random-from-env.js') 88 | Object.values(profileClients).forEach(c => c.profile.transformReq = (_, req) => { 89 | req.agent = randomLocalAddressAgent 90 | return req 91 | }) 92 | } 93 | 94 | if (process.env.HAFAS_REQ_RES_LOG_FILE) { 95 | const hafasLogPath = process.env.HAFAS_REQ_RES_LOG_FILE 96 | const hafasLog = createWriteStream(hafasLogPath, {flags: 'a'}) // append-only 97 | hafasLog.on('error', (err) => console.error('hafasLog error', err)) 98 | 99 | Object.keys(profileClients).forEach(name => { 100 | profileClients[name].profile.logRequest = (ctx, req, reqId) => { 101 | console.error(reqId + '_' + name, 'req', req.body + '') // todo: remove 102 | hafasLog.write(JSON.stringify([reqId + '_' + name, 'req', req.body + '']) + '\n') 103 | } 104 | profileClients[name].profile.logResponse = (ctx, res, body, reqId) => { 105 | console.error(reqId + '_' + name, 'res', body + '') // todo: remove 106 | hafasLog.write(JSON.stringify([reqId + '_' + name, 'res', body + '']) + '\n') 107 | } 108 | }) 109 | } 110 | 111 | let healthCheck = createHealthCheck(profileSwitchingClient, berlinHbf) 112 | 113 | if (process.env.REDIS_URL) { 114 | const redis = new Redis(process.env.REDIS_URL || null) 115 | profileSwitchingClient = createCachedHafasClient(profileSwitchingClient, createRedisStore(redis), { 116 | cachePeriods: { 117 | locations: 6 * 60 * 60 * 1000, // 6h 118 | }, 119 | }) 120 | 121 | const checkHafas = healthCheck 122 | const checkRedis = () => new Promise((resolve, reject) => { 123 | setTimeout(reject, 1000, new Error('didn\'t receive a PONG')) 124 | redis.ping().then( 125 | res => resolve(res === 'PONG'), 126 | reject, 127 | ) 128 | }) 129 | healthCheck = async () => ( 130 | (await checkHafas()) === true && 131 | (await checkRedis()) === true 132 | ) 133 | } 134 | 135 | const modifyRoutes = (routes, hafas, config) => { 136 | routes['/stations/:id'] = station 137 | routes['/stations'] = stations 138 | return routes 139 | } 140 | 141 | const config = { 142 | hostname: process.env.HOSTNAME || 'localhost', 143 | port: process.env.PORT ? parseInt(process.env.PORT) : 3000, 144 | name: pkg.name, 145 | description: pkg.description, 146 | homepage: pkg.homepage, 147 | version: pkg.version, 148 | docsLink: 'https://github.com/derhuerst/db-rest/blob/6/docs/readme.md', 149 | openapiSpec: true, 150 | logging: true, 151 | aboutPage: false, 152 | etags: 'strong', 153 | csp: `default-src 'none'; style-src 'self' 'unsafe-inline'; img-src https:`, 154 | healthCheck, 155 | mapRouteParsers: mapRouteParsersWithDynamicProfile, 156 | modifyRoutes, 157 | } 158 | 159 | const api = await createHafasRestApi(profileSwitchingClient, config, (api) => { 160 | api.use('/', serveStatic(docsRoot, { 161 | extensions: ['html', 'htm'], 162 | })) 163 | }) 164 | 165 | export { 166 | profileSwitchingClient as hafas, 167 | config, 168 | api, 169 | } 170 | -------------------------------------------------------------------------------- /architecture.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | db-rest architecture 4 | 32 | 41 | 42 | 43 | db-rest 44 | deployed at 45 | v6.db.transport.rest 46 | 47 | 48 | 49 | 50 | uses 51 | 52 | 53 | 54 | hafas-rest-api 55 | 56 | 57 | 58 | 59 | exposes 60 | 61 | 62 | 63 | db-vendo-client 64 | 65 | 66 | -------------------------------------------------------------------------------- /build/api-docs.js: -------------------------------------------------------------------------------- 1 | import {generateApiDocs} from 'hafas-rest-api/tools/generate-docs.js' 2 | import {api} from '../api.js' 3 | 4 | const HEAD = `\ 5 | # \`v6.db.transport.rest\` API documentation 6 | 7 | [\`v6.db.transport.rest\`](https://v6.db.transport.rest/) is a [REST API](https://restfulapi.net). Data is being returned as [JSON](https://www.json.org/). 8 | 9 | You can just use the API without authentication. There's a [rate limit](https://apisyouwonthate.com/blog/what-is-api-rate-limiting-all-about) of 100 request/minute (burst 200 requests/minute) set up. 10 | 11 | [OpenAPI playground](https://petstore.swagger.io/?url=https%3A%2F%2Fv6.db.transport.rest%2F.well-known%2Fservice-desc%0A) 12 | 13 | *Note:* The examples snippets in this documentation uses the \`url-encode\` CLI tool of the [\`url-decode-encode-cli\` package](https://www.npmjs.com/package/url-decode-encode-cli) for [URL-encoding](https://de.wikipedia.org/wiki/URL-Encoding). 14 | ` 15 | 16 | const order = [ 17 | '/locations', 18 | '/stops/nearby', 19 | '/stops/reachable-from', 20 | '/stops/:id', 21 | '/stops/:id/departures', 22 | '/stops/:id/arrivals', 23 | '/journeys', 24 | '/journeys/:ref', 25 | '/trips/:id', 26 | '/stations', 27 | '/stations/:id', 28 | '/radar', 29 | ] 30 | 31 | const descriptions = { 32 | '/locations': `\ 33 | Uses [\`hafasClient.locations()\`](https://github.com/public-transport/hafas-client/blob/6/docs/locations.md) to **find stops/stations, POIs and addresses matching \`query\`**. 34 | `, 35 | '/stops/nearby': `\ 36 | Uses [\`hafasClient.nearby()\`](https://github.com/public-transport/hafas-client/blob/6/docs/nearby.md) to **find stops/stations close to the given geolocation**. 37 | `, 38 | '/stops/reachable-from': `\ 39 | Uses [\`hafasClient.reachableFrom()\`](https://github.com/public-transport/hafas-client/blob/6/docs/reachable-from.md) to **find stops/stations reachable within a certain time from an address**. 40 | `, 41 | '/stops/:id': `\ 42 | Uses [\`hafasClient.stop()\`](https://github.com/public-transport/hafas-client/blob/6/docs/stop.md) to **find a stop/station by ID**. 43 | `, 44 | '/stops/:id/departures': `\ 45 | Uses [\`hafasClient.departures()\`](https://github.com/public-transport/hafas-client/blob/6/docs/departures.md) to **get departures at a stop/station**. 46 | `, 47 | '/stops/:id/arrivals': `\ 48 | Works like [\`/stops/:id/departures\`](#get-stopsiddepartures), except that it uses [\`hafasClient.arrivals()\`](https://github.com/public-transport/hafas-client/blob/6/docs/arrivals.md) to **arrivals at a stop/station**. 49 | `, 50 | '/stations': `\ 51 | If the \`query\` parameter is used, it will use [\`db-stations-autocomplete@2\`](https://github.com/derhuerst/db-stations-autocomplete/tree/2.2.0) to autocomplete *Deutsche Bahn*-operated stops/stations. Otherwise, it will filter the stops/stations in [\`db-stations@3\`](https://github.com/derhuerst/db-stations/tree/3.0.1). 52 | 53 | Instead of receiving a JSON response, you can request [newline-delimited JSON](http://ndjson.org) by sending \`Accept: application/x-ndjson\`. 54 | `, 55 | '/stations/:id': `\ 56 | Returns a stop/station from [\`db-stations\`](https://npmjs.com/package/db-stations). 57 | `, 58 | '/journeys': `\ 59 | Uses [\`hafasClient.journeys()\`](https://github.com/public-transport/hafas-client/blob/6/docs/journeys.md) to **find journeys from A (\`from\`) to B (\`to\`)**. 60 | 61 | \`from\` (A), \`to\` (B), and the optional \`via\` must each have one of these formats: 62 | 63 | - as stop/station ID (e.g. \`from=8010159\` for *Halle (Saale) Hbf*) 64 | - as a POI (e.g. \`from.id=991561765&from.latitude=51.48364&from.longitude=11.98084&from.name=Halle+(Saale),+Stadtpark+Halle+(Grünanlagen)\` for *Halle (Saale), Stadtpark Halle (Grünanlagen)*) 65 | - as an address (e.g. \`from.latitude=51.25639&from.longitude=7.46685&from.address=Hansestadt+Breckerfeld,+Hansering+3\` for *Hansestadt Breckerfeld, Hansering 3*) 66 | 67 | ### Pagination 68 | 69 | Given a response, you can also fetch more journeys matching the same criteria. Instead of \`from*\`, \`to*\` & \`departure\`/\`arrival\`, pass \`earlierRef\` from the first response as \`earlierThan\` to get journeys "before", or \`laterRef\` as \`laterThan\` to get journeys "after". 70 | 71 | *Pagination isn't fully functioning with the default [\`routingMode\`](#routing-mode).* 72 | 73 | Check the [\`hafasClient.journeys()\` docs](https://github.com/public-transport/hafas-client/blob/6/docs/journeys.md) for more details. 74 | 75 | ### Routing mode 76 | 77 | The \`routingMode\` parameter influences which data the system uses to compute the query results. The default is \`REALTIME\` and does *not* include canceled journeys. If you want canceled journeys to be included in the response, use \`HYBRID\` instead. 78 | 79 | Furthermore, \`REALTIME\` doesn't support [Pagination](#pagination) fully. If you need fully functioning pagination, use \`HYBRID\` instead. 80 | 81 | The \`hafas-client\` repository has [more details on the different routing modes](https://github.com/public-transport/hafas-client/blob/45610fc951bb834e1b6f09e363ee820c0b92b673/p/db/readme.md#using-the-routingmode-option). 82 | `, 83 | '/journeys/:ref': `\ 84 | Uses [\`hafasClient.refreshJourney()\`](https://github.com/public-transport/hafas-client/blob/6/docs/refresh-journey.md) to **"refresh" a journey, using its \`refreshToken\`**. 85 | 86 | The journey will be the same (equal \`from\`, \`to\`, \`via\`, date/time & vehicles used), but you can get up-to-date realtime data, like delays & cancellations. 87 | `, 88 | '/trips/:id': `\ 89 | Uses [\`hafasClient.trip()\`](https://github.com/public-transport/hafas-client/blob/6/docs/trip.md) to **fetch a trip by ID**. 90 | 91 | A trip is a specific vehicle, stopping at a series of stops at specific points in time. Departures, arrivals & journey legs reference trips by their ID. 92 | `, 93 | '/radar': `\ 94 | Uses [\`hafasClient.radar()\`](https://github.com/public-transport/hafas-client/blob/6/docs/radar.md) to **find all vehicles currently in an area**, as well as their movements. 95 | `, 96 | } 97 | 98 | const examples = { 99 | '/locations': `\ 100 | ### Example 101 | 102 | \`\`\`shell 103 | curl 'https://v6.db.transport.rest/locations?query=halle&results=1' -s | jq 104 | \`\`\` 105 | 106 | \`\`\`js 107 | [ 108 | { 109 | "type": "stop", 110 | "id": "8010159", 111 | "name": "Halle (Saale) Hbf", 112 | "location": { 113 | "type": "location", 114 | "id": "8010159", 115 | "latitude": 51.477079, 116 | "longitude": 11.98699 117 | }, 118 | "products": { 119 | "nationalExpress": true, 120 | "national": true, 121 | // … 122 | } 123 | } 124 | ] 125 | \`\`\` 126 | `, 127 | '/stops/nearby': `\ 128 | ### Example 129 | 130 | \`\`\`shell 131 | curl 'https://v6.db.transport.rest/stops/nearby?latitude=53.5711&longitude=10.0015' -s | jq 132 | \`\`\` 133 | 134 | \`\`\`js 135 | [ 136 | { 137 | "type": "stop", 138 | "id": "694800", 139 | "name": "Böttgerstraße, Hamburg", 140 | "location": { 141 | "type": "location", 142 | "id": "694800", 143 | "latitude": 53.568356, 144 | "longitude": 9.995528 145 | }, 146 | "products": { /* … */ }, 147 | "distance": 498, 148 | }, 149 | // … 150 | { 151 | "type": "stop", 152 | "id": "694802", 153 | "name": "Bahnhof Dammtor, Hamburg", 154 | "location": { 155 | "type": "location", 156 | "id": "694802", 157 | "latitude": 53.561048, 158 | "longitude": 9.990315 159 | }, 160 | "products": { /* … */ }, 161 | "distance": 1340, 162 | }, 163 | // … 164 | ] 165 | \`\`\` 166 | `, 167 | '/stops/reachable-from': `\ 168 | ### Example 169 | 170 | \`\`\`shell 171 | curl 'https://v6.db.transport.rest/stops/reachable-from?latitude=53.553766&longitude=9.977514&address=Hamburg,+Holstenwall+9' -s | jq 172 | \`\`\` 173 | 174 | \`\`\`js 175 | [ 176 | { 177 | "duration": 1, 178 | "stations": [ 179 | { 180 | "type": "stop", 181 | "id": "694815", 182 | "name": "Handwerkskammer, Hamburg", 183 | "location": { /* … */ }, 184 | "products": { /* … */ }, 185 | }, 186 | ] 187 | }, 188 | // … 189 | { 190 | "duration": 5, 191 | "stations": [ 192 | { 193 | "type": "stop", 194 | "id": "694807", 195 | "name": "Feldstraße (U), Hamburg", 196 | "location": { /* … */ }, 197 | "products": { /* … */ }, 198 | // … 199 | }, 200 | // … 201 | ] 202 | }, 203 | // … 204 | ] 205 | \`\`\` 206 | `, 207 | '/stops/:id': `\ 208 | ### Example 209 | 210 | \`\`\`shell 211 | curl 'https://v6.db.transport.rest/stops/8010159' -s | jq 212 | \`\`\` 213 | 214 | \`\`\`js 215 | { 216 | "type": "stop", 217 | "id": "8010159", 218 | "ids": { 219 | "dhid": "de:15002:8010159", 220 | "MDV": "8010159", 221 | "NASA": "8010159" 222 | }, 223 | "name": "Halle (Saale) Hbf", 224 | "location": { 225 | "type": "location", 226 | "id": "8010159", 227 | "latitude": 51.477079, 228 | "longitude": 11.98699 229 | }, 230 | "products": { /* … */ }, 231 | // … 232 | } 233 | \`\`\` 234 | `, 235 | '/stations': `\ 236 | ### Examples 237 | 238 | \`\`\`shell 239 | # autocomplete using db-stations-autocomplete 240 | curl 'https://v6.db.transport.rest/stations?query=dammt' -s | jq 241 | \`\`\` 242 | 243 | \`\`\`js 244 | { 245 | "8002548": { 246 | "id": "8002548", 247 | "relevance": 0.8572361756428573, 248 | "score": 9.175313823998414, 249 | "weight": 1212, 250 | "type": "station", 251 | "ril100": "ADF", 252 | "name": "Hamburg Dammtor", 253 | "location": { 254 | "type": "location", 255 | "latitude": 53.560751, 256 | "longitude": 9.989566 257 | }, 258 | "operator": { 259 | "type": "operator", 260 | "id": "hamburger-verkehrsverbund-gmbh", 261 | "name": "BWVI" 262 | }, 263 | "address": { 264 | "city": "Hamburg", 265 | "zipcode": "20354", 266 | "street": "Dag-Hammarskjöld-Platz 15" 267 | }, 268 | // … 269 | }, 270 | // … 271 | } 272 | \`\`\` 273 | 274 | \`\`\`shell 275 | # filter db-stations by \`hasParking\` property 276 | curl 'https://v6.db.transport.rest/stations?hasParking=true' -s | jq 277 | \`\`\` 278 | 279 | \`\`\`js 280 | { 281 | "8000001": { 282 | "type": "station", 283 | "id": "8000001", 284 | "ril100": "KA", 285 | "name": "Aachen Hbf", 286 | "weight": 653.75, 287 | "location": { /* … */ }, 288 | "operator": { /* … */ }, 289 | "address": { /* … */ }, 290 | // … 291 | }, 292 | // … 293 | } 294 | \`\`\` 295 | 296 | \`\`\`shell 297 | # filter db-stations by \`hasDBLounge\` property, get newline-delimited JSON 298 | curl 'https://v6.db.transport.rest/stations?hasDBLounge=true' -H 'accept: application/x-ndjson' -s | jq 299 | \`\`\` 300 | `, 301 | '/stations/:id': `\ 302 | ### Example 303 | 304 | \`\`\`shell 305 | # lookup Halle (Saale) Hbf 306 | curl 'https://v6.db.transport.rest/stations/8010159' -s | jq 307 | curl 'https://v6.db.transport.rest/stations/LH' -s | jq # RIL100/DS100 308 | curl 'https://v6.db.transport.rest/stations/LHG' -s | jq # RIL100/DS100 309 | \`\`\` 310 | 311 | \`\`\`js 312 | { 313 | "type": "station", 314 | "id": "8010159", 315 | "additionalIds": ["8098159"], 316 | "ril100": "LH", 317 | "nr": 2498, 318 | "name": "Halle (Saale) Hbf", 319 | "weight": 815.6, 320 | "location": { /* … */ }, 321 | "operator": { /* … */ }, 322 | "address": { /* … */ }, 323 | "ril100Identifiers": [ 324 | { 325 | "rilIdentifier": "LH", 326 | // … 327 | }, 328 | // … 329 | ], 330 | // … 331 | } 332 | \`\`\` 333 | `, 334 | '/stops/:id/departures': `\ 335 | ### Example 336 | 337 | \`\`\`shell 338 | # at Halle (Saale) Hbf, in direction Berlin Südkreuz 339 | curl 'https://v6.db.transport.rest/stops/8010159/departures?direction=8011113&duration=120' -s | jq 340 | \`\`\` 341 | 342 | \`\`\`js 343 | [ 344 | { 345 | "tripId": "1|317591|0|80|1052020", 346 | "direction": "Berlin Hbf (tief)", 347 | "line": { 348 | "type": "line", 349 | "id": "ice-702", 350 | "name": "ICE 702", 351 | "mode": "train", 352 | "product": "nationalExpress", 353 | // … 354 | }, 355 | 356 | "when": "2020-05-01T21:06:00+02:00", 357 | "plannedWhen": "2020-05-01T21:06:00+02:00", 358 | "delay": 0, 359 | "platform": "8", 360 | "plannedPlatform": "8", 361 | 362 | "stop": { 363 | "type": "stop", 364 | "id": "8010159", 365 | "name": "Halle (Saale) Hbf", 366 | "location": { /* … */ }, 367 | "products": { /* … */ }, 368 | }, 369 | 370 | "remarks": [], 371 | // … 372 | } 373 | ] 374 | \`\`\` 375 | `, 376 | '/stops/:id/arrivals': `\ 377 | ### Example 378 | 379 | \`\`\`shell 380 | # at Halle (Saale) Hbf, 10 minutes 381 | curl 'https://v6.db.transport.rest/stops/8010159/arrivals?duration=10' -s | jq 382 | \`\`\` 383 | `, 384 | '/journeys': `\ 385 | ### Examples 386 | 387 | \`\`\`shell 388 | # stop/station to POI 389 | curl 'https://v6.db.transport.rest/journeys?from=8010159&to.id=991561765&to.latitude=51.483641&to.longitude=11.980841' -s | jq 390 | # without buses, with ticket info 391 | curl 'https://v6.db.transport.rest/journeys?from=…&to=…&bus=false&tickets=true' -s | jq 392 | \`\`\` 393 | `, 394 | '/journeys/:ref': `\ 395 | ### Example 396 | 397 | \`\`\`shell 398 | # get the refreshToken of a journey 399 | journey=$(curl 'https://v6.db.transport.rest/journeys?from=…&to=…&results=1' -s | jq '.journeys[0]') 400 | refresh_token=$(echo $journey | jq -r '.refreshToken') 401 | 402 | # refresh the journey 403 | curl "https://v6.db.transport.rest/journeys/$(echo $refresh_token | url-encode)" -s | jq 404 | \`\`\` 405 | `, 406 | '/trips/:id': `\ 407 | ### Example 408 | 409 | \`\`\`shell 410 | # get the trip ID of a journey leg 411 | journey=$(curl 'https://v6.db.transport.rest/journeys?from=…&to=…&results=1' -s | jq '.journeys[0]') 412 | journey_leg=$(echo $journey | jq -r '.legs[0]') 413 | trip_id=$(echo $journey_leg | jq -r '.tripId') 414 | 415 | # fetch the trip 416 | curl "https://v6.db.transport.rest/trips/$(echo $trip_id | url-encode)" -s | jq 417 | \`\`\` 418 | `, 419 | '/radar': `\ 420 | ### Example 421 | 422 | \`\`\`shell 423 | bbox='north=53.555&west=9.989&south=53.55&east=10.001' 424 | curl "https://v6.db.transport.rest/radar?$bbox&results=10" -s | jq 425 | \`\`\` 426 | `, 427 | } 428 | 429 | const generateMarkdownApiDocs = async function* () { 430 | const { 431 | listOfRoutes, 432 | routes, 433 | tail, 434 | } = generateApiDocs(api.routes) 435 | 436 | const sortedRoutes = Object.entries(routes) 437 | .sort(([routeA], [routeB]) => { 438 | const iA = order.indexOf(routeA) 439 | const iB = order.indexOf(routeB) 440 | if (iA >= 0 && iB >= 0) return iA - iB 441 | if (iA < 0 && iB >= 0) return 1 442 | if (iB < 0 && iA >= 0) return -1 443 | return 0 444 | }) 445 | 446 | yield HEAD 447 | yield `\n\n` 448 | 449 | yield listOfRoutes 450 | yield `\n\n` 451 | 452 | for (const [route, params] of sortedRoutes) { 453 | yield `## \`GET ${route}\`\n\n` 454 | yield descriptions[route] || '' 455 | yield `\n### Query Parameters\n` 456 | yield params 457 | if (examples[route]) { 458 | yield '\n' + examples[route] 459 | } 460 | yield `\n\n` 461 | } 462 | yield tail 463 | } 464 | 465 | export { 466 | generateMarkdownApiDocs, 467 | } -------------------------------------------------------------------------------- /build/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // todo: use import assertions once they're supported by Node.js & ESLint 4 | // https://github.com/tc39/proposal-import-assertions 5 | import {createRequire} from 'module' 6 | const require = createRequire(import.meta.url) 7 | 8 | import {dirname, join} from 'node:path' 9 | import {pipeline} from 'node:stream/promises' 10 | import {createReadStream, createWriteStream} from 'node:fs' 11 | import {copyFile} from 'node:fs/promises' 12 | import _technicalDocsCli from '@derhuerst/technical-docs-cli' 13 | import {config} from '../api.js' 14 | const { 15 | createMarkdownRenderer, 16 | determineSyntaxStylesheetPath, 17 | } = _technicalDocsCli 18 | import {generateMarkdownApiDocs} from './api-docs.js' 19 | 20 | const pkg = require('../package.json') 21 | 22 | const BASE_URL = new URL('..', import.meta.url).href 23 | const API_DOCS_DEST = 'docs/api.md' 24 | const DOCS_TO_RENDER = [ 25 | ['docs/readme.md', 'docs/index.html'], 26 | ['docs/getting-started.md', 'docs/getting-started.html'], 27 | ['docs/api.md', 'docs/api.html'], 28 | ] 29 | const SYNTAX_STYLESHEET_URL = '/syntax.css' 30 | const SYNTAX_STYLESHEET_SRC = determineSyntaxStylesheetPath('github') 31 | const SYNTAX_STYLESHEET_DEST = 'docs/syntax.css' 32 | 33 | { 34 | console.info('writing Markdown API docs to ' + API_DOCS_DEST) 35 | 36 | const destPath = new URL(API_DOCS_DEST, BASE_URL).pathname 37 | await pipeline( 38 | generateMarkdownApiDocs(), 39 | createWriteStream(destPath), 40 | ) 41 | } 42 | 43 | const markdownRenderingCfg = { 44 | syntaxStylesheetUrl: SYNTAX_STYLESHEET_URL, 45 | additionalHeadChildren: (h) => { 46 | if (!pkg.goatCounterUrl) return [] 47 | return [ 48 | // https://72afc0822cce0642af90.goatcounter.com/help/skip-dev#skip-loading-staging-beta-sites-312 49 | h('script', ` 50 | if (window.location.host !== ${JSON.stringify(config.hostname)}) { 51 | window.goatcounter = {no_onload: true} 52 | } 53 | `), 54 | // https://72afc0822cce0642af90.goatcounter.com/help/start 55 | h('script', { 56 | src: '//gc.zgo.at/count.js', 57 | async: true, 58 | 'data-goatcounter': pkg.goatCounterUrl, 59 | }), 60 | ] 61 | }, 62 | } 63 | for (const [src, dest] of DOCS_TO_RENDER) { 64 | console.info(`rendering Markdown file ${src} to HTML file ${dest}`) 65 | 66 | const srcPath = new URL(src, BASE_URL).pathname 67 | const destPath = new URL(dest, BASE_URL).pathname 68 | // unfortunately, we can't use stream.pipeline right now 69 | // see https://github.com/unifiedjs/unified-stream/issues/1 70 | await new Promise((resolve, reject) => { 71 | createReadStream(srcPath) 72 | .once('error', reject) 73 | .pipe(createMarkdownRenderer(markdownRenderingCfg)) 74 | .once('error', reject) 75 | .pipe(createWriteStream(destPath)) 76 | .once('error', reject) 77 | .once('finish', resolve) 78 | }) 79 | } 80 | 81 | { 82 | const srcPath = SYNTAX_STYLESHEET_SRC 83 | const destPath = new URL(SYNTAX_STYLESHEET_DEST, BASE_URL).pathname 84 | console.info(`copying syntax stylesheet from ${srcPath} to ${destPath}`) 85 | await copyFile(srcPath, destPath) 86 | } 87 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started with `v6.db.transport.rest` 2 | 3 | Let's walk through the **requests that are necessary to implement a typical basic transit app**. 4 | 5 | *Note:* To properly & securely handle user input containing URL-unsafe characters, always [URL-encode](https://en.wikipedia.org/wiki/Percent-encoding) your query parameters! 6 | 7 | The following code snippets use [`curl`](https://curl.haxx.se) (a versatile command line HTTP tool) and [`jq`](https://stedolan.github.io/jq/) (the command line swiss army knife for processing JSON). 8 | 9 | ### 1. search for stops 10 | 11 | The `/locations?query=…` route allows you to query stops, points of interest (POIs) & addresses. We're only interested in stops though, so we filter using `poi=false&addresses=false`: 12 | 13 | ```shell 14 | curl 'https://v6.db.transport.rest/locations?poi=false&addresses=false&query=südkreuz' -s | jq 15 | ``` 16 | 17 | ```js 18 | [ 19 | { 20 | "type": "stop", 21 | "id": "8011113", 22 | "name": "Berlin Südkreuz", 23 | "location": { 24 | "type": "location", 25 | "id": "8011113", 26 | "latitude": 52.47623, 27 | "longitude": 13.365863 28 | }, 29 | "products": { 30 | "nationalExpress": true, 31 | "national": true, 32 | // … 33 | } 34 | }, 35 | { 36 | "type": "stop", 37 | "id": "731654", 38 | "name": "Südkreuz Bahnhof (S), Berlin", 39 | "location": { 40 | "type": "location", 41 | "id": "731654", 42 | "latitude": 52.476265, 43 | "longitude": 13.3642 44 | }, 45 | "products": { 46 | "nationalExpress": true, 47 | "national": true, 48 | "regionalExp": true, 49 | "regional": true, 50 | "suburban": true, 51 | "bus": true, 52 | "ferry": false, 53 | "subway": false, 54 | "tram": false, 55 | "taxi": false 56 | } 57 | }, 58 | { 59 | "type": "stop", 60 | "id": "727256", 61 | "name": "Südkreuz Bahnhof (S)/Ostseite, Berlin", 62 | "location": { 63 | "type": "location", 64 | "id": "727256", 65 | "latitude": 52.47436, 66 | "longitude": 13.366843 67 | }, 68 | "products": { 69 | // … 70 | } 71 | }, 72 | // … 73 | ] 74 | ``` 75 | 76 | ### 2. fetch departures at a stop 77 | 78 | Let's fetch 5 of the next departures at *Berlin Südkreuz* (which has the ID `8011113`): 79 | 80 | ```shell 81 | curl 'https://v6.db.transport.rest/stops/8011113/departures?results=5' -s | jq 82 | ``` 83 | 84 | ```js 85 | [ 86 | { 87 | "tripId": "1|1168945|24|80|1052020", 88 | "direction": "Schöneberg, Reichartstr.", 89 | "line": { 90 | "type": "line", 91 | "id": "5-vbbbvb-248", 92 | "name": "Bus 248", 93 | "mode": "bus", 94 | "product": "bus", 95 | // … 96 | }, 97 | 98 | "when": "2020-05-01T18:39:00+02:00", 99 | "plannedWhen": "2020-05-01T18:38:00+02:00", 100 | "delay": 60, 101 | "platform": null, 102 | "plannedPlatform": null, 103 | 104 | "stop": { 105 | "type": "stop", 106 | "id": "727256", 107 | "name": "Südkreuz Bahnhof (S)/Ostseite, Berlin", 108 | "location": { /* … */ }, 109 | "products": { /* … */ }, 110 | "station": { 111 | "type": "station", 112 | "id": "8011113", 113 | "name": "Berlin Südkreuz", 114 | "location": { /* … */ }, 115 | "products": { /* … */ }, 116 | } 117 | }, 118 | 119 | "remarks": [], 120 | }, 121 | // … 122 | { 123 | "tripId": "1|322308|0|80|1052020", 124 | "direction": "Lutherstadt Wittenberg Hbf", 125 | "line": { 126 | "type": "line", 127 | "id": "re-3", 128 | "name": "RE 3", 129 | "mode": "train", 130 | "product": "regional", 131 | // … 132 | }, 133 | 134 | "when": "2020-05-01T18:40:00+02:00", 135 | "plannedWhen": "2020-05-01T18:41:00+02:00", 136 | "delay": -60, 137 | "platform": "6", 138 | "plannedPlatform": "6", 139 | 140 | "stop": { 141 | "type": "stop", 142 | "id": "8011113", 143 | "name": "Berlin Südkreuz", 144 | "location": { /* … */ }, 145 | "products": { /* … */ }, 146 | }, 147 | 148 | "remarks": [ /* … */ ], 149 | }, 150 | // … 151 | ] 152 | ``` 153 | 154 | Note that `when` includes the `delay`, and `plannedWhen` does not. 155 | 156 | ### 3. fetch journeys from A to B 157 | 158 | We call a connection from A to B – at a specific date & time, made up of sections on specific *trips* – `journey`. 159 | 160 | Let's fetch 2 journeys from `8011113` (*Berlin Südkreuz*) to `8010159` (*Halle (Saale)Hbf*), departing tomorrow at 2pm (at the time of writing this). 161 | 162 | ```shell 163 | curl 'https://v6.db.transport.rest/journeys?from=8011113&to=8010159&departure=tomorrow+2pm&results=2' -s | jq 164 | ``` 165 | 166 | ```js 167 | { 168 | "journeys": [{ 169 | // 1st journey 170 | "type": "journey", 171 | "legs": [{ 172 | // 1st leg 173 | "tripId": "1|310315|0|80|2052020", 174 | "direction": "München Hbf", 175 | "line": { 176 | "type": "line", 177 | "id": "ice-1601", 178 | "name": "ICE 1601", 179 | "mode": "train", 180 | "product": "nationalExpress", 181 | // … 182 | }, 183 | 184 | "origin": { 185 | "type": "stop", 186 | "id": "8011113", 187 | "name": "Berlin Südkreuz", 188 | "location": { /* … */ }, 189 | "products": { /* … */ }, 190 | }, 191 | "departure": "2020-05-02T14:37:00+02:00", 192 | "plannedDeparture": "2020-05-02T14:37:00+02:00", 193 | "departureDelay": null, 194 | "departurePlatform": "3", 195 | "plannedDeparturePlatform": "3" 196 | 197 | "destination": { 198 | "type": "stop", 199 | "id": "8010205", 200 | "name": "Leipzig Hbf", 201 | "location": { /* … */ }, 202 | "products": { /* … */ }, 203 | }, 204 | "arrival": "2020-05-02T15:42:00+02:00", 205 | "plannedArrival": "2020-05-02T15:42:00+02:00", 206 | "arrivalDelay": null, 207 | "arrivalPlatform": "11", 208 | "plannedArrivalPlatform": "11", 209 | // … 210 | }, { 211 | // 2nd leg 212 | "walking": true, 213 | "distance": 116, 214 | 215 | "origin": { 216 | "type": "stop", 217 | "id": "8010205", 218 | "name": "Leipzig Hbf", 219 | "location": { /* … */ }, 220 | "products": { /* … */ }, 221 | }, 222 | "departure": "2020-05-02T15:42:00+02:00", 223 | "plannedDeparture": "2020-05-02T15:42:00+02:00", 224 | "departureDelay": null, 225 | 226 | "destination": { 227 | "type": "stop", 228 | "id": "8098205", 229 | "name": "Leipzig Hbf (tief)", 230 | "location": { /* … */ }, 231 | "products": { /* … */ },, 232 | "station": { 233 | "type": "station", 234 | "id": "8010205", 235 | "name": "Leipzig Hbf", 236 | "location": { /* … */ }, 237 | "products": { /* … */ }, 238 | } 239 | }, 240 | "arrival": "2020-05-02T15:51:00+02:00", 241 | "plannedArrival": "2020-05-02T15:51:00+02:00", 242 | "arrivalDelay": null, 243 | // … 244 | }, { 245 | // 3rd leg 246 | "tripId": "1|334376|4|80|2052020", 247 | "direction": "Halle(Saale)Hbf", 248 | "line": { 249 | "type": "line", 250 | "id": "4-800486-5", 251 | "name": "S 5", 252 | "mode": "train", 253 | "product": "suburban", 254 | // … 255 | }, 256 | 257 | "origin": { 258 | "type": "stop", 259 | "id": "8098205", 260 | "name": "Leipzig Hbf (tief)", 261 | "location": { /* … */ }, 262 | "products": { /* … */ },, 263 | "station": { 264 | "type": "station", 265 | "id": "8010205", 266 | "name": "Leipzig Hbf", 267 | "location": { /* … */ }, 268 | "products": { /* … */ }, 269 | } 270 | }, 271 | "departure": "2020-05-02T15:53:00+02:00", 272 | "plannedDeparture": "2020-05-02T15:53:00+02:00", 273 | "departureDelay": null, 274 | "departurePlatform": "2", 275 | "plannedDeparturePlatform": "2", 276 | 277 | "destination": { 278 | "type": "stop", 279 | "id": "8010159", 280 | "name": "Halle(Saale)Hbf", 281 | "location": { /* … */ }, 282 | "products": { /* … */ }, 283 | }, 284 | "arrival": "2020-05-02T16:19:00+02:00", 285 | "plannedArrival": "2020-05-02T16:19:00+02:00", 286 | "arrivalDelay": null, 287 | "arrivalPlatform": "13", 288 | "plannedArrivalPlatform": "13", 289 | 290 | "cycle": {"min": 600, "max": 1800, "nr": 7}, 291 | "alternatives": [ 292 | { 293 | "tripId": "1|333647|0|80|2052020", 294 | "direction": "Halle(Saale)Hbf", 295 | "line": { /* … */ }, 296 | "when": "2020-05-02T16:03:00+02:00", 297 | "plannedWhen": "2020-05-02T16:03:00+02:00", 298 | "delay": null, 299 | }, 300 | // … 301 | ], 302 | // … 303 | }], 304 | }, { 305 | // 2nd journey 306 | "type": "journey", 307 | "legs": [ /* … */ ], 308 | // … 309 | }], 310 | 311 | // … 312 | } 313 | ``` 314 | 315 | Note that `departure` includes the `departureDelay`, and `arrival` includes the `arrivalDelay`. `plannedDeparture` and `plannedArrival` do not. 316 | 317 | ### 4. more features 318 | 319 | These are the basics. Check the full [API docs](api.md) for all features or use the [OpenAPI playground](https://petstore.swagger.io/?url=https%3A%2F%2Fv6.db.transport.rest%2F.well-known%2Fservice-desc%0A) or explore the API! 320 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | # `v6.db.transport.rest` documentation 2 | 3 | [`v6.db.transport.rest`](https://v6.db.transport.rest/) is a [REST API](https://restfulapi.net) for the public transportation system in Germany. 4 | 5 | [![API status](https://badgen.net/uptime-robot/status/m793274556-25c5e9bbab0297d91cda7134)](https://stats.uptimerobot.com/57wNLs39M/793274556) 6 | 7 | The [DB HAFAS API is currently not available](https://github.com/public-transport/hafas-client/issues/331), and it seems like it has been shut off permanently. 8 | 9 | **This wrapper API now uses [`db-vendo-client`](https://github.com/public-transport/db-vendo-client) as a backend, which covers most of the use cases**, notably except for `/stops/reachable-from` and `/radar`. Please also note some further limitations and caveats in the readme and documentation of [`db-vendo-client`](https://github.com/public-transport/db-vendo-client). 10 | 11 | Also, the new [underlying APIs seem to have a **much lower rate limit** than the old HAFAS API](https://github.com/public-transport/db-vendo-client/issues/10). ⚠️ Hence, please check if you [can obtain the data needed for your use case in a more efficient manner](#why-not-to-use-this-api), e.g. by using the available GTFS feeds. 12 | 13 | ## How it works 14 | 15 | Because it wraps [APIs](https://github.com/public-transport/db-vendo-client/blob/main/docs/db-apis.md) of [Deutsche Bahn](https://de.wikipedia.org/wiki/Deutsche_Bahn), it **includes most of the long-distance and regional traffic, as well as some international trains and local buses**. Essentially, it returns whatever data the [*DB Navigator* app](https://www.bahn.de/p/view/service/mobile/db-navigator.shtml) shows*, **including realtime delays and disruptions**. 16 | 17 | *When comparing results from this API to what the DB Navigator app shows there might be occasional differences due to them utilizing different, not 100% identical backends.* 18 | 19 | - [Getting Started](getting-started.md) 20 | - [API documentation](api.md) (run `npm run build` to generate) 21 | - [OpenAPI playground with API documentation](https://petstore.swagger.io/?url=https%3A%2F%2Fv6.db.transport.rest%2F.well-known%2Fservice-desc%0A) 22 | 23 | By default, the `dbnav` profile of [db-vendo-client](https://github.com/public-transport/db-vendo-client) will be used. On all endpoints, you can use the `profile` URL parameter to change to a different profile, e.g. `profile=db` or `profile=dbweb`. As per [db-vendo-client](https://github.com/public-transport/db-vendo-client)'s documentation, this will have an impact on returned details, parameter limits and quotas. 24 | 25 | ## Why not to use this API? 26 | 27 | ### Low rate limits 28 | 29 | [The underlying APIs seem to have a **much lower rate limit**](https://github.com/public-transport/db-vendo-client/issues/10) than before, i.e. you should not use this service to send many requests in an automated manner. Instead, you may: 30 | 31 | ### Use GTFS and GTFS-RT 32 | 33 | [GTFS](https://gtfs.org) and [GTFS-RT](https://gtfs.org/documentation/realtime/reference/) are file format/feed specifications for scheduled timetables and realtime updates like delays, respectively. They enable you to download datasets for entire transport associations, regions or even countries in one go, for local querying (e.g. with [MOTIS](https://github.com/motis-project/motis) or [OTP](https://github.com/opentripplanner/OpenTripPlanner/)) and analysis (e.g. with [gtfs-via-postgres](https://github.com/public-transport/gtfs-via-postgres) and [print-gtfs-rt-cli](https://github.com/derhuerst/print-gtfs-rt-cli)). For Germany, [GTFS](https://www.opendata-oepnv.de/ht/de/organisation/delfi/startseite?tx_vrrkit_view%5Baction%5D=details&tx_vrrkit_view%5Bcontroller%5D=View&tx_vrrkit_view%5Bdataset_name%5D=deutschlandweite-sollfahrplandaten-gtfs&cHash=af4be4c0a9de59953fb9ee2325ef818f) and [GTFS-RT](https://mobilithek.info/offers/755009281410899968) feeds are provided by [DELFI e.V.](https://www.delfi.de/) Refined, publicly available feeds based on these and other sources can be obtained from [gtfs.de](https://gtfs.de) and [stc.traines.eu](https://stc.traines.eu/mirror/). I.e. by using these you do not need to make any excessive API requests, however, the data quality and coverage of these feeds will currently in many cases still be inferior to what is provided by Deutsche Bahn. 34 | 35 | ### Use other APIs 36 | 37 | [transitous.org](https://transitous.org) may be another option, even with worldwide coverage based on GTFS/RT-feeds, however, as with any other APIs, it should not be flooded with requests. If only data on trains is needed (no buses, trams etc.), [DB (I)RIS-based APIs](https://developers.deutschebahn.com/db-api-marketplace/apis/product/timetables) may also be of interest. 38 | 39 | ### Run your own instance of this API 40 | 41 | It may help to distribute the load by running your own instance of `db-rest`/[`db-vendo-client`](https://github.com/public-transport/db-vendo-client/). Both provide Dockerfiles/Docker images for easy setup of the REST API server. `db-rest` additionally includes a caching layer (if configured correctly), serves the OpenAPI specification, and does some more plumbing. 42 | 43 | ## Why use this API? 44 | 45 | ### Realtime Data 46 | 47 | This API returns realtime data whenever it is available upstream, as the API for DB's mobile app provides it. 48 | 49 | *Note: Different endpoints might remove realtime data like delays and cancellations at different times, i.e. after a journey's arrival.* 50 | 51 | ### No API Key 52 | 53 | You can just use the API without authentication. There's a [rate limit](https://apisyouwonthate.com/blog/what-is-api-rate-limiting-all-about) of 100 requests/minute set up. 54 | 55 | ### CORS 56 | 57 | This API has [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) enabled, so you can query it from any webpage. 58 | 59 | ### Caching-friendly 60 | 61 | This API sends [`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) & [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) headers, allowing clients cache responses properly. 62 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {api, config} from './api.js' 2 | 3 | api.listen(config.port, (err) => { 4 | const {logger} = api.locals 5 | if (err) { 6 | logger.error(err) 7 | process.exit(1) 8 | } else { 9 | logger.info(`listening on ${config.port} (${config.hostname}).`) 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /lib/db-stations.js: -------------------------------------------------------------------------------- 1 | import {createRequire} from 'node:module' 2 | const require = createRequire(import.meta.url) 3 | 4 | import {statSync} from 'node:fs' 5 | import {readFullStations} from 'db-stations' 6 | 7 | // We don't have access to the publish date+time of the npm package, 8 | // so we use the ctime of db-stations/full.ndjson as an approximation. 9 | // Also require() doesn't make much sense in ES Modules. 10 | // todo: this is brittle, find a better way, e.g. a build script 11 | const timeModified = statSync(require.resolve('db-stations/full.ndjson')).ctime 12 | 13 | const pStations = new Promise((resolve, reject) => { 14 | let raw = readFullStations() 15 | raw.once('error', reject) 16 | 17 | let data = Object.create(null) 18 | raw.on('data', (station) => { 19 | data[station.id] = station 20 | if (Array.isArray(station.ril100Identifiers)) { 21 | for (const ril100 of station.ril100Identifiers) { 22 | data[ril100.rilIdentifier] = station 23 | } 24 | } 25 | if (Array.isArray(station.additionalIds)) { 26 | for (const addId of station.additionalIds) { 27 | data[addId] = station 28 | } 29 | } 30 | }) 31 | raw.once('end', () => { 32 | raw = null 33 | resolve({data, timeModified}) 34 | }) 35 | }) 36 | 37 | pStations.catch((err) => { 38 | console.error(err) 39 | process.exit(1) // todo: is this appropriate? 40 | }) 41 | 42 | export { 43 | pStations, 44 | } 45 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025, Jannis R 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "db-rest", 4 | "description": "A clean REST API wrapping around the Deutsche Bahn API.", 5 | "version": "6.1.0", 6 | "type": "module", 7 | "bin": { 8 | "db-rest": "./index.js" 9 | }, 10 | "main": "index.js", 11 | "files": [ 12 | "docs", 13 | "lib", 14 | "routes", 15 | "api.js", 16 | "index.js" 17 | ], 18 | "author": "Jannis R ", 19 | "homepage": "https://github.com/derhuerst/db-rest/tree/6", 20 | "repository": "derhuerst/db-rest", 21 | "bugs": "https://github.com/derhuerst/db-rest/issues", 22 | "license": "ISC", 23 | "keywords": [ 24 | "public", 25 | "transport", 26 | "api", 27 | "http", 28 | "rest", 29 | "deutsche bahn", 30 | "db" 31 | ], 32 | "engines": { 33 | "node": ">=18" 34 | }, 35 | "dependencies": { 36 | "cached-hafas-client": "^5.0.1", 37 | "cli-native": "^1.0.0", 38 | "db-stations": "^5.0.0", 39 | "db-stations-autocomplete": "^4.0.0", 40 | "db-vendo-client": "^6.6.2", 41 | "etag": "^1.8.1", 42 | "hafas-client-health-check": "^2.1.1", 43 | "hafas-rest-api": "^5.1.0", 44 | "ioredis": "^5.0.6", 45 | "serve-buffer": "^3.0.3", 46 | "serve-static": "^1.14.1" 47 | }, 48 | "optionalDependencies": { 49 | "localaddress-agent": "^2.1.1" 50 | }, 51 | "devDependencies": { 52 | "@derhuerst/technical-docs-cli": "^1.5.0", 53 | "axios": "^1.6.1", 54 | "eslint": "^8.12.0", 55 | "get-port": "^6.1.2", 56 | "ndjson": "^2.0.0", 57 | "pino-pretty": "^9.1.1", 58 | "tap-min": "^3.0.0", 59 | "tape": "^5.5.2" 60 | }, 61 | "scripts": { 62 | "build": "REDIS_URL='' ./build/index.js", 63 | "start": "node index.js", 64 | "lint": "eslint .", 65 | "test": "node test/index.js | tap-min" 66 | }, 67 | "goatCounterUrl": "https://37462fdee48390778258.goatcounter.com/count" 68 | } 69 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # db-rest 2 | 3 | **A clean REST API wrapping around the [Deutsche Bahn HAFAS API](https://github.com/public-transport/db-hafas#db-hafas).** It is deployed at [`v6.db.transport.rest`](https://v6.db.transport.rest/). 4 | 5 | [**API Documentation**](docs/readme.md) 6 | 7 | > [!IMPORTANT] 8 | > The [DB HAFAS API is currently not available](https://github.com/public-transport/hafas-client/issues/331), and it seems like it has been shut off permanently. 9 | > 10 | > **This wrapper API now uses [`db-vendo-client`](https://github.com/public-transport/db-vendo-client) as a backend, which covers most of the use cases**, notably except for `/stops/reachable-from` and `/radar`. Please also note some further limitations and caveats in the readme and documentation of [`db-vendo-client`](https://github.com/public-transport/db-vendo-client). 11 | > 12 | > Also, the new [underlying APIs seem to have a **much lower rate limit** than the old HAFAS API](https://github.com/public-transport/db-vendo-client/issues/10). ⚠️ Hence, please check if you [can obtain the data needed for your use case in a more efficient manner](docs/readme.md#why-not-to-use-this-api), e.g. by using the available GTFS feeds. 13 | 14 | ![db-rest architecture diagram](architecture.svg) 15 | 16 | [![API status](https://badgen.net/uptime-robot/status/m793274556-25c5e9bbab0297d91cda7134)](https://stats.uptimerobot.com/57wNLs39M/793274556) 17 | [![dependency status](https://img.shields.io/david/derhuerst/db-rest.svg)](https://david-dm.org/derhuerst/db-rest) 18 | ![ISC-licensed](https://img.shields.io/github/license/derhuerst/db-rest.svg) 19 | [![support me via GitHub Sponsors](https://img.shields.io/badge/support%20me-donate-fa7664.svg)](https://github.com/sponsors/derhuerst) 20 | [![chat with me on Twitter](https://img.shields.io/badge/chat%20with%20me-on%20Twitter-1da1f2.svg)](https://twitter.com/derhuerst) 21 | 22 | 23 | ## installing & running 24 | 25 | ### access to Redis 26 | 27 | It is recommended that you let `bvg-rest` cache HAFAS responses within a [Redis](https://redis.io/) cache. To use this feature, set `$REDIS_URL` (e.g. to `redis://localhost:6379/1` when running Redis locally). 28 | 29 | ### via Docker 30 | 31 | A Docker image [is available as `docker.io/derhuerst/db-rest:6`](https://hub.docker.com/r/docker.io/derhuerst/db-rest:6). 32 | 33 | ```shell 34 | docker run -d -p 3000:3000 docker.io/derhuerst/db-rest:6 35 | ``` 36 | 37 | *Note:* The Docker image does not contain the Redis server. 38 | 39 | ### manually 40 | 41 | ```shell 42 | git clone https://github.com/derhuerst/db-rest.git 43 | cd db-rest 44 | git checkout 6 45 | npm install 46 | 47 | export HOSTNAME='my-vbb-rest-api.example.org' 48 | npm run build 49 | 50 | redis-server & 51 | npm start 52 | ``` 53 | 54 | To keep the API running permanently, use tools like [`forever`](https://github.com/foreverjs/forever#forever) or [`systemd`](https://wiki.debian.org/systemd). 55 | 56 | 57 | ## Related Projects 58 | 59 | - [`DB-Adapter-v6`](https://github.com/olech2412/DB-Adapter-v6) – A Java API client for `db-rest`. 60 | - [`vbb-rest`](https://github.com/derhuerst/vbb-rest) – A clean REST API wrapping around the VBB API. 61 | - [`bvg-rest`](https://github.com/derhuerst/bvg-rest) – A clean REST API wrapping around the BVG API. 62 | - [`hvv-rest`](https://github.com/derhuerst/hvv-rest) – A clean REST API wrapping around the HVV API. 63 | - [`hafas-rest-api`](https://github.com/public-transport/hafas-rest-api) – Expose a HAFAS client via an HTTP REST API. 64 | - [`hafas-client`](https://github.com/public-transport/hafas-client) – JavaScript client for HAFAS public transport APIs. 65 | 66 | 67 | ## Contributing 68 | 69 | If you **have a question**, **found a bug** or want to **propose a feature**, have a look at [the issues page](https://github.com/derhuerst/db-rest/issues). 70 | -------------------------------------------------------------------------------- /routes/station.js: -------------------------------------------------------------------------------- 1 | import {pStations} from '../lib/db-stations.js' 2 | 3 | const err404 = (msg) => { 4 | const err = new Error(msg) 5 | err.statusCode = 404 6 | return err 7 | } 8 | 9 | const stationRoute = (req, res, next) => { 10 | const id = req.params.id.trim() 11 | 12 | pStations 13 | .then(({data, timeModified}) => { 14 | const station = data[id] 15 | if (!station) { 16 | next(err404('Station not found.')) 17 | return; 18 | } 19 | 20 | res.setHeader('Last-Modified', timeModified.toUTCString()) 21 | res.json(station) 22 | }) 23 | .catch(next) 24 | } 25 | 26 | stationRoute.openapiPaths = { 27 | '/stations/{id}': { 28 | get: { 29 | summary: 'Returns a stop/station from `db-stations`.', 30 | description: `\ 31 | Returns a stop/station from [\`db-stations@3\`](https://github.com/derhuerst/db-stations/tree/3.0.1).`, 32 | parameters: [{ 33 | name: 'id', 34 | in: 'path', 35 | description: 'Stop/station ID.', 36 | required: true, 37 | schema: { 38 | type: 'string', 39 | }, 40 | }], 41 | responses: { 42 | '2XX': { 43 | description: 'A stop/station, in the [`db-stations@3` format](https://github.com/derhuerst/db-stations/blob/3.0.1/readme.md).', 44 | content: { 45 | 'application/json': { 46 | schema: { 47 | type: 'object', // todo 48 | }, 49 | // todo: example(s) 50 | }, 51 | }, 52 | }, 53 | // todo: non-2xx response 54 | }, 55 | }, 56 | }, 57 | } 58 | 59 | export { 60 | stationRoute as route, 61 | } 62 | -------------------------------------------------------------------------------- /routes/stations.js: -------------------------------------------------------------------------------- 1 | import computeEtag from 'etag' 2 | import serveBuffer from 'serve-buffer' 3 | import {autocomplete} from 'db-stations-autocomplete' 4 | import _cliNative from 'cli-native' 5 | const {to: parse} = _cliNative 6 | import {createFilter} from 'db-stations/create-filter.js' 7 | import {pStations} from '../lib/db-stations.js' 8 | 9 | const JSON_MIME = 'application/json' 10 | const NDJSON_MIME = 'application/x-ndjson' 11 | 12 | // todo: DRY with vbb-rest/routes/stations.js? 13 | 14 | const toNdjsonBuf = (data) => { 15 | const chunks = [] 16 | let i = 0, bytes = 0 17 | for (const id in data) { 18 | const sep = i++ === 0 ? '' : '\n' 19 | const buf = Buffer.from(sep + JSON.stringify(data[id]), 'utf8') 20 | chunks.push(buf) 21 | bytes += buf.length 22 | } 23 | return Buffer.concat(chunks, bytes) 24 | } 25 | 26 | const pAllStations = pStations.then(({data, timeModified}) => { 27 | const asJson = Buffer.from(JSON.stringify(data), 'utf8') 28 | const asNdjson = toNdjsonBuf(data) 29 | return { 30 | stations: data, 31 | timeModified, 32 | asJson: {data: asJson, etag: computeEtag(asJson)}, 33 | asNdjson: {data: asNdjson, etag: computeEtag(asNdjson)}, 34 | } 35 | }) 36 | .catch((err) => { 37 | console.error(err) 38 | process.exit(1) 39 | }) 40 | 41 | const err = (msg, statusCode = 500) => { 42 | const err = new Error(msg) 43 | err.statusCode = statusCode 44 | return err 45 | } 46 | 47 | const complete = (req, res, next, q, allStations, onStation, onEnd) => { 48 | const limit = q.results && parseInt(q.results) || 3 49 | const fuzzy = parse(q.fuzzy) === true 50 | const completion = parse(q.completion) !== false 51 | const results = autocomplete(q.query, limit, fuzzy, completion) 52 | 53 | for (const result of results) { 54 | const station = allStations[result.id] 55 | if (!station) continue 56 | 57 | Object.assign(result, station) 58 | onStation(result) 59 | } 60 | onEnd() 61 | } 62 | 63 | const filter = (req, res, next, q, allStations, onStation, onEnd) => { 64 | const selector = Object.create(null) 65 | for (const prop in q) selector[prop] = parse(q[prop]) 66 | const filter = createFilter(selector) 67 | 68 | for (const id in allStations) { 69 | const station = allStations[id] 70 | if (filter(station)) onStation(station) 71 | } 72 | onEnd() 73 | } 74 | 75 | const stationsRoute = (req, res, next) => { 76 | const t = req.accepts([JSON_MIME, NDJSON_MIME]) 77 | if (t !== JSON_MIME && t !== NDJSON_MIME) { 78 | return next(err(JSON + ' or ' + NDJSON_MIME, 406)) 79 | } 80 | 81 | const head = t === JSON_MIME ? '{\n' : '' 82 | const sep = t === JSON_MIME ? ',\n' : '\n' 83 | const tail = t === JSON_MIME ? '\n}\n' : '\n' 84 | let i = 0 85 | const onStation = (s) => { 86 | const j = JSON.stringify(s) 87 | const field = t === JSON_MIME ? `"${s.id}":` : '' 88 | res.write(`${i++ === 0 ? head : sep}${field}${j}`) 89 | } 90 | const onEnd = () => { 91 | if (i > 0) res.end(tail) 92 | else res.end(head + tail) 93 | } 94 | 95 | const q = req.query 96 | pAllStations 97 | .then(({stations, timeModified, asJson, asNdjson}) => { 98 | res.setHeader('Last-Modified', timeModified.toUTCString()) 99 | res.setHeader('Content-Type', t) 100 | if (Object.keys(q).length === 0) { 101 | const data = t === JSON_MIME ? asJson.data : asNdjson.data 102 | const etag = t === JSON_MIME ? asJson.etag : asNdjson.etag 103 | serveBuffer(req, res, data, { 104 | timeModified, 105 | etag, 106 | contentType: t, 107 | }) 108 | } else if (q.query) { 109 | complete(req, res, next, q, stations, onStation, onEnd) 110 | } else { 111 | filter(req, res, next, q, stations, onStation, onEnd) 112 | } 113 | }) 114 | .catch(next) 115 | } 116 | 117 | stationsRoute.openapiPaths = { 118 | '/stations': { 119 | get: { 120 | summary: 'Autocompletes stops/stations by name or filters stops/stations.', 121 | description: `\ 122 | If the \`query\` parameter is used, it will use [\`db-stations-autocomplete@2\`](https://github.com/derhuerst/db-stations-autocomplete/tree/2.2.0) to autocomplete *Deutsche Bahn*-operated stops/stations by name. Otherwise, it will filter the stops/stations in [\`db-stations@3\`](https://github.com/derhuerst/db-stations/tree/3.0.1). 123 | 124 | Instead of receiving a JSON response, you can request [newline-delimited JSON](http://ndjson.org) by sending \`Accept: application/x-ndjson\`.`, 125 | parameters: [{ 126 | name: 'query', 127 | in: 'query', 128 | description: 'Find stations by name using [`db-stations-autocomplete@2`](https://github.com/derhuerst/db-stations-autocomplete/tree/2.2.0).', 129 | schema: { 130 | type: 'string', 131 | }, 132 | }, { 133 | name: 'limit', 134 | in: 'query', 135 | description: '*If `query` is used:* Return at most `n` stations.', 136 | schema: { 137 | type: 'integer', 138 | default: 3, 139 | }, 140 | }, { 141 | name: 'fuzzy', 142 | in: 'query', 143 | description: '*If `query` is used:* Find stations despite typos.', 144 | schema: { 145 | type: 'boolean', 146 | default: false, 147 | }, 148 | }, { 149 | name: 'completion', 150 | in: 'query', 151 | description: '*If `query` is used:* Autocomplete stations.', 152 | schema: { 153 | type: 'boolean', 154 | default: true, 155 | }, 156 | }], 157 | responses: { 158 | '2XX': { 159 | description: 'An object of stops/stations in the [`db-stations@3` format](https://github.com/derhuerst/db-stations/blob/3.0.1/readme.md), with their IDs as the keys.', 160 | content: { 161 | 'application/json': { 162 | schema: { 163 | // todo 164 | type: 'object', 165 | }, 166 | // todo: example(s) 167 | }, 168 | 'application/x-ndjson': { 169 | schema: { 170 | type: 'string', 171 | }, 172 | }, 173 | }, 174 | }, 175 | // todo: non-2xx response 176 | }, 177 | }, 178 | }, 179 | } 180 | 181 | stationsRoute.queryParameters = { 182 | query: { 183 | description: 'Find stations by name using [`db-stations-autocomplete@2`](https://github.com/derhuerst/db-stations-autocomplete/tree/2.2.0).', 184 | type: 'string', 185 | defaultStr: '–', 186 | }, 187 | limit: { 188 | description: '*If `query` is used:* Return at most `n` stations.', 189 | type: 'number', 190 | default: 3, 191 | }, 192 | fuzzy: { 193 | description: '*If `query` is used:* Find stations despite typos.', 194 | type: 'boolean', 195 | default: false, 196 | }, 197 | completion: { 198 | description: '*If `query` is used:* Autocomplete stations.', 199 | type: 'boolean', 200 | default: true, 201 | }, 202 | } 203 | 204 | export { 205 | stationsRoute as route, 206 | } 207 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import _ndjson from 'ndjson' 3 | const {parse: ndjsonParser} = _ndjson 4 | import {data as loyaltyCards} from 'db-vendo-client/format/loyalty-cards.js' 5 | import {fetchWithTestApi} from './util.js' 6 | import {pStations as pAllStations} from '../lib/db-stations.js' 7 | 8 | const NO_JOURNEYS = { 9 | // todo? 10 | journeys: [], 11 | } 12 | 13 | tape.test('/journeys?firstClass works', async (t) => { 14 | await fetchWithTestApi({ 15 | journeys: async (from, to, opt = {}) => { 16 | t.equal(opt.firstClass, true, 'journeys() called with invalid opt.firstClass') 17 | return NO_JOURNEYS 18 | } 19 | }, {}, '/journeys?from=123&to=234&firstClass=true') 20 | }) 21 | 22 | tape.test('/journeys?loyaltyCard works', async (t) => { 23 | await fetchWithTestApi({ 24 | journeys: async (from, to, opt = {}) => { 25 | t.deepEqual(opt.loyaltyCard, { 26 | type: loyaltyCards.SHCARD, 27 | }, 'journeys() called with invalid opt.loyaltyCard') 28 | return NO_JOURNEYS 29 | } 30 | }, {}, '/journeys?from=123&to=234&loyaltyCard=shcard') 31 | 32 | await fetchWithTestApi({ 33 | journeys: async (from, to, opt = {}) => { 34 | t.deepEqual(opt.loyaltyCard, { 35 | type: loyaltyCards.BAHNCARD, 36 | discount: 50, 37 | class: 2, 38 | }, 'journeys() called with invalid opt.loyaltyCard') 39 | return NO_JOURNEYS 40 | } 41 | }, {}, '/journeys?from=123&to=234&loyaltyCard=bahncard-2nd-50') 42 | }) 43 | 44 | tape.test('/stations works', async (t) => { 45 | const {data: allStations} = await pAllStations 46 | const someStationId = Object.keys(allStations)[0] 47 | 48 | { 49 | const {headers, data} = await fetchWithTestApi({}, {}, '/stations', { 50 | headers: { 51 | 'accept': 'application/json', 52 | }, 53 | }) 54 | t.equal(headers['content-type'], 'application/json') 55 | t.equal(typeof data, 'object') 56 | t.ok(data) 57 | t.ok(data[someStationId]) 58 | t.equal(Object.keys(data).length, Object.keys(allStations).length) 59 | } 60 | 61 | { 62 | const {headers, data} = await fetchWithTestApi({}, {}, '/stations', { 63 | headers: { 64 | 'accept': 'application/x-ndjson', 65 | }, 66 | }) 67 | t.equal(headers['content-type'], 'application/x-ndjson') 68 | 69 | let nrOfStations = 0 70 | const parser = ndjsonParser() 71 | parser.end(data) 72 | for await (const station of parser) nrOfStations++ 73 | 74 | t.equal(nrOfStations, Object.keys(allStations).length) 75 | } 76 | }) 77 | 78 | tape.test('/stations?query=frankfurt%20ha works', async (t) => { 79 | const FRANKFURT_MAIN_HBF = '8000105' 80 | 81 | { 82 | const {headers, data} = await fetchWithTestApi({}, {}, '/stations?query=frankfurt%20ha', { 83 | headers: { 84 | 'accept': 'application/json', 85 | }, 86 | }) 87 | t.equal(headers['content-type'], 'application/json') 88 | t.equal(typeof data, 'object') 89 | t.ok(data) 90 | t.ok(data[FRANKFURT_MAIN_HBF]) 91 | t.ok(Object.keys(data).length > 0) 92 | } 93 | 94 | { 95 | const {headers, data} = await fetchWithTestApi({}, {}, '/stations?query=frankfurt%20ha', { 96 | headers: { 97 | 'accept': 'application/x-ndjson', 98 | }, 99 | }) 100 | t.equal(headers['content-type'], 'application/x-ndjson') 101 | 102 | const stations = [] 103 | const parser = ndjsonParser() 104 | parser.end(data) 105 | for await (const station of parser) stations.push(station) 106 | 107 | t.ok(stations.find(s => s.id === FRANKFURT_MAIN_HBF)) 108 | t.ok(stations.length > 0) 109 | } 110 | }) 111 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import {createHafasRestApi} from 'hafas-rest-api' 2 | import getPort from 'get-port' 3 | import {createServer} from 'node:http' 4 | import {promisify} from 'node:util' 5 | import axios from 'axios' 6 | import {config, hafas as unmockedHafas} from '../api.js' 7 | 8 | // adapted from https://github.com/public-transport/hafas-rest-api/blob/60335eacd8332d7f448da875a7498dd97934e360/test/util.js#L40-L77 9 | const createTestApi = async (mocks, cfg) => { 10 | const mockedHafas = Object.assign(Object.create(unmockedHafas), mocks) 11 | 12 | cfg = { 13 | ...config, 14 | hostname: 'localhost', 15 | name: 'test', 16 | version: '1.2.3a', 17 | homepage: 'http://example.org', 18 | description: 'test API', 19 | docsLink: 'https://example.org', 20 | logging: false, 21 | ...cfg, 22 | } 23 | 24 | const api = await createHafasRestApi(mockedHafas, cfg, () => {}) 25 | const server = createServer(api) 26 | 27 | const port = await getPort() 28 | await promisify(server.listen.bind(server))(port) 29 | 30 | const stop = () => promisify(server.close.bind(server))() 31 | const fetch = (path, opt = {}) => { 32 | opt = Object.assign({ 33 | method: 'get', 34 | baseURL: `http://localhost:${port}/`, 35 | url: path, 36 | timeout: 5000 37 | }, opt) 38 | return axios(opt) 39 | } 40 | return {stop, fetch} 41 | } 42 | 43 | const fetchWithTestApi = async (mocks, cfg, path, opt = {}) => { 44 | const {fetch, stop} = await createTestApi(mocks, cfg) 45 | const res = await fetch(path, opt) 46 | await stop() 47 | return res 48 | } 49 | 50 | export { 51 | createTestApi, 52 | fetchWithTestApi, 53 | } 54 | --------------------------------------------------------------------------------