├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── about-page.js ├── docs.js ├── examples ├── db.js └── vbb.js ├── handle-errors.js ├── index.js ├── lib ├── format-parsers-as-openapi.js ├── format-product-parameters.js ├── format.js ├── json-pretty-printing.js ├── link-header.js ├── openapi-spec.js ├── parse.js ├── route-uri-template.js └── server-timing.js ├── license.md ├── logging.js ├── package.json ├── readme.md ├── routes ├── arrivals.js ├── departures.js ├── index.js ├── journeys.js ├── locations.js ├── nearby.js ├── radar.js ├── reachable-from.js ├── refresh-journey.js ├── stop.js ├── trip.js └── trips.js ├── test ├── index.js └── util.js └── tools └── generate-docs.js /.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/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: ['16', '18'] 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | - name: setup Node v${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | 27 | - run: npm run lint 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | 4 | .nvm-version 5 | node_modules 6 | npm-debug.log 7 | 8 | package-lock.json 9 | shrinkwrap.yaml 10 | -------------------------------------------------------------------------------- /about-page.js: -------------------------------------------------------------------------------- 1 | import {stringifyEntitiesLight as escape} from 'stringify-entities' 2 | 3 | const createAboutPageRoute = (name, description, docsLink) => { 4 | if ('string' !== typeof name || !name) { 5 | throw new Error('name must be a string.') 6 | } 7 | if ('string' !== typeof description || !description) { 8 | throw new Error('description must be a string.') 9 | } 10 | if ('string' !== typeof docsLink || !docsLink) { 11 | throw new Error('docsLink must be a string.') 12 | } 13 | 14 | const msg = `\ 15 |

${escape(name)}

16 |

${escape(description)}

17 |

documentation

` 18 | 19 | const about = (req, res, next) => { 20 | if (!req.accepts('html')) return next() 21 | 22 | res.set('content-type', 'text/html') 23 | res.send(msg) 24 | next() 25 | } 26 | return about 27 | } 28 | 29 | export { 30 | createAboutPageRoute, 31 | } 32 | -------------------------------------------------------------------------------- /docs.js: -------------------------------------------------------------------------------- 1 | import MarkdownRender from 'markdown-it' 2 | 3 | const md = new MarkdownRender() 4 | // todo: https://github.com/markdown-it/markdown-it/issues/28 5 | 6 | const createDocsRoute = (cfg) => { 7 | if ('string' !== typeof cfg.docsAsMarkdown) { 8 | throw new Error('cfg.docsAsMarkdown must be a string.') 9 | } 10 | const docsAsHtml = md.render(cfg.docsAsMarkdown) 11 | 12 | const docs = (req, res, next) => { 13 | res.set('content-type', 'text/html') 14 | res.send(docsAsHtml) 15 | next() 16 | } 17 | return docs 18 | } 19 | 20 | export { 21 | createDocsRoute, 22 | } 23 | -------------------------------------------------------------------------------- /examples/db.js: -------------------------------------------------------------------------------- 1 | import {createClient as createHafas} from 'hafas-client' 2 | import {profile as dbProfile} from 'hafas-client/p/db/index.js' 3 | 4 | import {parseBoolean} from '../lib/parse.js' 5 | import {createHafasRestApi} from '../index.js' 6 | 7 | const fooRoute = (req, res) => { 8 | res.json(req.query.bar === 'true' ? 'bar' : 'foo') 9 | } 10 | fooRoute.queryParameters = { 11 | bar: { 12 | description: 'Return "bar"?', 13 | type: 'boolean', 14 | default: false, 15 | parse: parseBoolean, 16 | }, 17 | } 18 | 19 | // pro tip: pipe this script into `pino-pretty` to get nice logs 20 | 21 | const config = { 22 | hostname: process.env.HOSTNAME || 'v5.db.transport.rest', 23 | name: 'db-rest', 24 | version: '5.0.0', 25 | description: 'An HTTP API for Deutsche Bahn.', 26 | homepage: 'http://example.org/', 27 | docsLink: 'http://example.org/docs', 28 | logging: true, 29 | healthCheck: async () => { 30 | const stop = await hafas.stop('8011306') 31 | return !!stop 32 | }, 33 | modifyRoutes: (routes) => ({ 34 | ...routes, 35 | '/foo': fooRoute, 36 | }), 37 | } 38 | 39 | const hafas = createHafas(dbProfile, 'hafas-rest-api-example') 40 | 41 | const api = await createHafasRestApi(hafas, config, () => {}) 42 | 43 | const {logger} = api.locals 44 | const port = process.env.PORT || 3000 45 | api.listen(port, (err) => { 46 | if (err) { 47 | logger.error(err) 48 | process.exitCode = 1 49 | } else { 50 | logger.info(`listening on ${port} (${config.hostname}).`) 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /examples/vbb.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import {createClient as createHafas} from 'hafas-client' 3 | import {profile as vbbProfile} from 'hafas-client/p/vbb/index.js' 4 | import Redis from 'ioredis' 5 | import {createCachedHafasClient as withCaching} from 'cached-hafas-client' 6 | import {createRedisStore} from 'cached-hafas-client/stores/redis.js' 7 | 8 | import {createHafasRestApi} from '../index.js' 9 | 10 | // pro tip: pipe this script into `pino-pretty` to get nice logs 11 | 12 | const config = { 13 | hostname: process.env.HOSTNAME || 'v5.vbb.transport.rest', 14 | name: 'vbb-rest', 15 | version: '5.0.0', 16 | description: 'An HTTP API for Berlin & Brandenburg public transport.', 17 | homepage: 'http://example.org/', 18 | docsLink: 'http://example.org/docs', 19 | openapiSpec: true, 20 | logging: true, 21 | aboutPage: true, 22 | healthCheck: async () => { 23 | const stop = await hafas.stop('900000100001') 24 | return !!stop 25 | } 26 | } 27 | 28 | const rawHafas = createHafas(vbbProfile, 'hafas-rest-api-example') 29 | 30 | let hafas = rawHafas 31 | if (process.env.REDIS_URL) { 32 | const opts = {} 33 | const url = new URL(process.env.REDIS_URL) 34 | opts.host = url.hostname || 'localhost' 35 | opts.port = url.port || '6379' 36 | if (url.password) opts.password = url.password 37 | if (url.pathname && url.pathname.length > 1) { 38 | opts.db = parseInt(url.pathname.slice(1)) 39 | } 40 | const redis = new Redis(opts) 41 | hafas = withCaching(rawHafas, createRedisStore(redis)) 42 | } 43 | 44 | const api = await createHafasRestApi(hafas, config, () => {}) 45 | 46 | const {logger} = api.locals 47 | const port = process.env.PORT || 3000 48 | api.listen(port, (err) => { 49 | if (err) { 50 | logger.error(err) 51 | process.exitCode = 1 52 | } else { 53 | logger.info(`listening on ${port} (${config.hostname}).`) 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /handle-errors.js: -------------------------------------------------------------------------------- 1 | import { 2 | HafasError, 3 | HafasInvalidRequestError, 4 | HafasNotFoundError, 5 | } from 'hafas-client/lib/errors.js' 6 | 7 | const createErrorHandler = (logger) => { 8 | const handleErrors = (err, req, res, next) => { 9 | logger.error(err) 10 | if (res.headersSent) return next() 11 | 12 | let msg = err.message, code = err.statusCode || null 13 | if (err instanceof HafasError) { 14 | msg = 'HAFAS error: ' + msg 15 | if (err instanceof HafasInvalidRequestError) { 16 | code = 400 17 | } else if (err instanceof HafasNotFoundError) { 18 | code = 404 19 | } else { 20 | code = 502 21 | } 22 | } 23 | 24 | res.status(code || 500).json({ 25 | message: err.message || null, 26 | ...err, 27 | request: undefined, 28 | response: undefined, 29 | }) 30 | next() 31 | } 32 | return handleErrors 33 | } 34 | 35 | export { 36 | createErrorHandler, 37 | } 38 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {hostname as osHostname} from 'node:os' 2 | import express from 'express' 3 | import compression from 'compression' 4 | import hsts from 'hsts' 5 | import pino from 'pino' 6 | import createCors from 'cors' 7 | import onHeaders from 'on-headers' 8 | import {getAllRoutes as getRoutes} from './routes/index.js' 9 | import {routeUriTemplate} from './lib/route-uri-template.js' 10 | import {formatLinkHeader as linkHeader} from './lib/link-header.js' 11 | import {setOpenapiLink, serveOpenapiSpec} from './lib/openapi-spec.js' 12 | 13 | const REQ_START_TIME = Symbol.for('request-start-time') 14 | 15 | const defaultConfig = { 16 | hostname: osHostname(), 17 | cors: true, 18 | etags: 'weak', 19 | csp: `default-src 'none'`, 20 | handleErrors: true, 21 | openapiSpec: false, 22 | aboutPage: true, 23 | logging: false, 24 | healthCheck: null, 25 | mapRouteParsers: (route, parsers) => parsers, 26 | mapRouteOpenapiPaths: (route, openapiPaths) => openapiPaths, 27 | addHafasOpts: () => {}, 28 | modifyRoutes: routes => routes, 29 | } 30 | 31 | const assertNonEmptyString = (cfg, key) => { 32 | if ('string' !== typeof cfg[key]) { 33 | throw new Error(`config.${key} must be a string`) 34 | } 35 | if (!cfg[key]) throw new Error(`config.${key} must not be empty`) 36 | } 37 | const assertBoolean = (cfg, key) => { 38 | if ('boolean' !== typeof cfg[key]) { 39 | throw new Error(`config.${key} must be a boolean`) 40 | } 41 | } 42 | 43 | const createHafasRestApi = async (hafas, config, attachMiddleware) => { 44 | config = Object.assign({}, defaultConfig, config) 45 | // mandatory 46 | assertNonEmptyString(config, 'hostname') 47 | assertNonEmptyString(config, 'name') 48 | // optional 49 | if ('cors' in config) assertBoolean(config, 'cors') 50 | if ('handleErrors' in config) assertBoolean(config, 'handleErrors') 51 | if ('logging' in config) assertBoolean(config, 'logging') 52 | if (config.healthCheck !== null && 'function' !== typeof config.healthCheck) { 53 | throw new Error('cfg.healthCheck must be a function') 54 | } 55 | if ('version' in config) assertNonEmptyString(config, 'version') 56 | if ('homepage' in config) assertNonEmptyString(config, 'homepage') 57 | if ('aboutPage' in config) assertBoolean(config, 'aboutPage') 58 | if ('description' in config) assertNonEmptyString(config, 'description') 59 | if ('docsLink' in config) assertNonEmptyString(config, 'docsLink') 60 | if ('function' !== typeof config.mapRouteParsers) { 61 | throw new Error('cfg.mapRouteParsers must be a function') 62 | } 63 | if ('function' !== typeof config.mapRouteOpenapiPaths) { 64 | throw new Error('cfg.mapRouteOpenapiPaths must be a function') 65 | } 66 | 67 | const api = express() 68 | api.locals.config = config 69 | api.locals.logger = pino({ 70 | redact: { 71 | paths: [ 72 | 'err.request', 'err.response', 73 | ], 74 | remove: true, 75 | }, 76 | }) 77 | 78 | if (config.cors) { 79 | const cors = createCors({ 80 | exposedHeaders: '*', 81 | maxAge: 24 * 60 * 60, // 1 day 82 | }) 83 | api.options('*', cors) 84 | api.use(cors) 85 | } 86 | api.set('etag', config.etags) 87 | if (config.logging) { 88 | const { 89 | createLoggingMiddleware: createLogging, 90 | } = await import('./logging.js') 91 | api.use(createLogging(api.locals.logger)) 92 | } 93 | api.use(compression()) 94 | api.use(hsts({ 95 | maxAge: 10 * 24 * 60 * 60 96 | })) 97 | api.use((req, res, next) => { 98 | res.setLinkHeader = (linkSpec) => { 99 | const link = linkHeader(res.getHeader('Link'), linkSpec) 100 | res.setHeader('Link', link) 101 | } 102 | req.searchWithNewParams = (newParams) => { 103 | const u = new URL(req.url, 'http://example.org') 104 | for (const [name, val] of Object.entries(newParams)) { 105 | if (val === null) u.searchParams.delete(name) 106 | else u.searchParams.set(name, val) 107 | } 108 | return u.search 109 | } 110 | res.allowCachingFor = (sec) => { 111 | if (!Number.isInteger(sec)) { 112 | throw new Error('sec is invalid') 113 | } 114 | 115 | // Allow clients to use the cache when re-fetching fails 116 | // for another `sec` seconds after expiry. 117 | res.setHeader('cache-control', `public, max-age: ${sec}, s-maxage: ${sec}, stale-if-error=${sec}`) 118 | // Allow CDNs to cache for another `sec` seconds while 119 | // they're re-fetching the latest copy. 120 | res.setHeader('surrogate-control', `stale-while-revalidate=${sec}`) 121 | } 122 | 123 | res.serverTiming = Object.create(null) 124 | res[REQ_START_TIME] = process.hrtime() 125 | onHeaders(res, () => { 126 | const t = Object.entries(res.serverTiming) 127 | const dt = process.hrtime(res[REQ_START_TIME]) 128 | t.push(['total', Math.round(dt[0] * 1e3 + dt[1] / 1e6)]) 129 | const h = t.map(([name, dur]) => name + ';dur=' + dur).join(', ') 130 | res.setHeader('server-timing', h) 131 | }) 132 | 133 | if (!res.headersSent) { 134 | // https://helmetjs.github.io/docs/dont-sniff-mimetype/ 135 | res.setHeader('X-Content-Type-Options', 'nosniff') 136 | res.setHeader('content-security-policy', config.csp) 137 | res.setHeader('X-Powered-By', [ 138 | config.name, config.version, config.homepage 139 | ].filter(str => !!str).join(' ')) 140 | if (config.version) res.setHeader('X-API-Version', config.version) 141 | 142 | if (config.openapiSpec) setOpenapiLink(res) 143 | } 144 | next() 145 | }) 146 | 147 | if (attachMiddleware) attachMiddleware(api) 148 | 149 | if (config.healthCheck) { 150 | api.get('/health', (req, res, next) => { 151 | res.setHeader('cache-control', 'no-store') 152 | res.setHeader('expires', '0') 153 | try { 154 | config.healthCheck() 155 | .then((isHealthy) => { 156 | if (isHealthy === true) { 157 | res.status(200) 158 | res.json({ok: true}) 159 | } else { 160 | res.status(502) 161 | res.json({ok: false}) 162 | } 163 | }, next) 164 | } catch (err) { 165 | next(err) 166 | } 167 | }) 168 | } 169 | 170 | if (config.aboutPage) { 171 | const { 172 | createAboutPageRoute: aboutPage, 173 | } = await import('./about-page.js') 174 | api.get('/', aboutPage(config.name, config.description, config.docsLink)) 175 | } 176 | if (config.docsAsMarkdown) { 177 | const { 178 | createDocsRoute: docs, 179 | } = await import('./docs.js') 180 | api.get('/docs', docs(config)) 181 | } 182 | 183 | const _routes = await getRoutes(hafas, config) 184 | const routes = config.modifyRoutes(_routes, hafas, config) 185 | api.routes = routes 186 | for (const [path, route] of Object.entries(routes)) { 187 | api.get(path, route) 188 | } 189 | 190 | if (config.openapiSpec) serveOpenapiSpec(api) 191 | 192 | const rootLinks = {} 193 | for (const [path, route] of Object.entries(routes)) { 194 | rootLinks[route.name + 'Url'] = routeUriTemplate(path, route) 195 | } 196 | api.get('/', (req, res, next) => { 197 | if (!req.accepts('json')) return next() 198 | if (res.headersSent) return next() 199 | res.json(rootLinks) 200 | }) 201 | 202 | if (config.handleErrors) { 203 | const { 204 | createErrorHandler: handleErrors, 205 | } = await import('./handle-errors.js') 206 | api.use(handleErrors(api.locals.logger)) 207 | } 208 | 209 | return api 210 | } 211 | 212 | export { 213 | createHafasRestApi, 214 | } 215 | -------------------------------------------------------------------------------- /lib/format-parsers-as-openapi.js: -------------------------------------------------------------------------------- 1 | const formatParameterParsersAsOpenapiParameters = (paramsParsers) => { 2 | return Object.entries(paramsParsers) 3 | .map(([name, _]) => { 4 | const res = { 5 | name, 6 | in: 'query', 7 | description: _.description, 8 | } 9 | if (_.type) { 10 | res.schema = { 11 | type: _.type, 12 | } 13 | if (_.type === 'date+time') { 14 | res.schema.type = 'string' 15 | res.schema.format = 'date-time' 16 | } 17 | if ('default' in _) res.schema.default = _.default 18 | if (_.enum) res.schema.enum = _.enum 19 | } 20 | if (!('default' in _) && _.defaultStr) { 21 | res.description += ` – Default: ${_.defaultStr}` 22 | } 23 | return res 24 | }) 25 | } 26 | 27 | export { 28 | formatParameterParsersAsOpenapiParameters as formatParsersAsOpenapiParams, 29 | } 30 | -------------------------------------------------------------------------------- /lib/format-product-parameters.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseBoolean, 3 | } from '../lib/parse.js' 4 | 5 | const formatProductParams = (products) => { 6 | const params = Object.create(null) 7 | for (const p of products) { 8 | let name = p.name 9 | if (p.short && p.short !== p.name) name += ` (${p.short})` 10 | params[p.id] = { 11 | description: `Include ${name}?`, 12 | type: 'boolean', 13 | default: p.default === true, 14 | parse: parseBoolean, 15 | } 16 | } 17 | return params 18 | } 19 | 20 | export { 21 | formatProductParams, 22 | } 23 | -------------------------------------------------------------------------------- /lib/format.js: -------------------------------------------------------------------------------- 1 | import {DateTime} from 'luxon' 2 | 3 | const formatWhen = (t, tz = null) => { 4 | const dt = DateTime.fromMillis(t, {zone: tz}) 5 | return dt.toISO({ 6 | suppressMilliseconds: true, 7 | suppressSeconds: true, 8 | }) 9 | } 10 | 11 | export { 12 | formatWhen, 13 | } 14 | -------------------------------------------------------------------------------- /lib/json-pretty-printing.js: -------------------------------------------------------------------------------- 1 | import {parseBoolean} from './parse.js' 2 | 3 | const configureJSONPrettyPrinting = (req, res) => { 4 | const spaces = req.query.pretty === 'false' ? undefined : '\t' 5 | req.app.set('json spaces', spaces) 6 | } 7 | 8 | const jsonPrettyPrintingOpenapiParam = { 9 | name: 'pretty', 10 | in: 'path', 11 | description: 'Pretty-print JSON responses?', 12 | schema: {type: 'boolean'}, 13 | } 14 | 15 | const jsonPrettyPrintingParam = { 16 | description: 'Pretty-print JSON responses?', 17 | type: 'boolean', 18 | default: true, 19 | parse: parseBoolean, 20 | } 21 | 22 | export { 23 | configureJSONPrettyPrinting, 24 | jsonPrettyPrintingOpenapiParam, 25 | jsonPrettyPrintingParam, 26 | } 27 | -------------------------------------------------------------------------------- /lib/link-header.js: -------------------------------------------------------------------------------- 1 | import LinkHeader from 'http-link-header' 2 | 3 | const formatLinkHeader = (existingLink, {prev, next, first, last}) => { 4 | const header = existingLink 5 | ? LinkHeader.parse(existingLink) 6 | : new LinkHeader() 7 | 8 | if (first) header.set({rel: 'first', uri: first}) 9 | if (prev) header.set({rel: 'prev', uri: prev}) 10 | if (next) header.set({rel: 'next', uri: next}) 11 | if (last) header.set({rel: 'last', uri: last}) 12 | return header.toString() 13 | } 14 | 15 | export { 16 | formatLinkHeader, 17 | } 18 | -------------------------------------------------------------------------------- /lib/openapi-spec.js: -------------------------------------------------------------------------------- 1 | import LinkHeader from 'http-link-header' 2 | 3 | const openapiContentType = 'application/vnd.oai.openapi;version=3.0.3' 4 | 5 | const generateSpec = (config, routes, logger = console) => { 6 | const spec = { 7 | openapi: '3.0.3', 8 | info: { 9 | title: config.name, 10 | description: config.description, 11 | contact: { 12 | url: config.homepage, 13 | }, 14 | version: config.version, 15 | }, 16 | paths: {}, 17 | } 18 | if (config.docsLink) { 19 | spec.externalDocs = { 20 | description: 'human-readable docs', 21 | url: config.docsLink, 22 | } 23 | } 24 | 25 | for (const [path, route] of Object.entries(routes)) { 26 | if (!route.openapiPaths) { 27 | console.warn(`${path} does not expose \`route.openapiPaths\``) 28 | continue 29 | } 30 | // todo: detect conflicts? 31 | Object.assign(spec.paths, route.openapiPaths) 32 | } 33 | 34 | return JSON.stringify(spec) 35 | } 36 | 37 | // This follows the [.well-known service description draft](https://ioggstream.github.io/draft-polli-service-description-well-known-uri/draft-polli-service-description-well-known-uri.html). 38 | const wellKnownPath = '/.well-known/service-desc' 39 | 40 | const setOpenapiLink = (res) => { 41 | const existingLink = res.getHeader('Link') 42 | const header = existingLink ? LinkHeader.parse(existingLink) : new LinkHeader() 43 | header.set({ 44 | rel: 'service-desc', 45 | uri: wellKnownPath, 46 | type: openapiContentType, 47 | }) 48 | res.setHeader('Link', header.toString()) 49 | } 50 | 51 | const serveOpenapiSpec = (api) => { 52 | const {config, logger} = api.locals 53 | const spec = generateSpec(config, api.routes, logger) 54 | 55 | api.get([ 56 | wellKnownPath, 57 | '/openapi.json', 58 | '/swagger.json', 59 | ], (req, res, next) => { 60 | res.set('content-type', openapiContentType) 61 | res.send(spec) 62 | next() 63 | }) 64 | } 65 | 66 | export { 67 | setOpenapiLink, 68 | serveOpenapiSpec, 69 | } 70 | -------------------------------------------------------------------------------- /lib/parse.js: -------------------------------------------------------------------------------- 1 | import {DateTime} from 'luxon' 2 | import _parseHumanRelativeTime from 'parse-human-relative-time' 3 | const parseHumanRelativeTime = _parseHumanRelativeTime(DateTime) 4 | 5 | const isNumber = /^\d+$/ 6 | const parseWhen = (tz = null) => (key, val) => { 7 | if (isNumber.test(val)) return new Date(val * 1000) 8 | const d = new Date(val) 9 | if (Number.isInteger(+d)) return d 10 | 11 | const dt = DateTime.fromMillis(Date.now(), {zone: tz}) 12 | return parseHumanRelativeTime(val, dt).toJSDate() 13 | } 14 | 15 | const parseNumber = (key, val) => { 16 | const res = +val 17 | if (Number.isNaN(res)) throw new Error(key + ' must be a number') 18 | return res 19 | } 20 | const parseInteger = (key, val) => { 21 | const res = parseInt(val, 10) 22 | if (Number.isNaN(res)) throw new Error(key + ' must be a number') 23 | return res 24 | } 25 | 26 | const parseString = (key, val) => { 27 | if ('string' !== typeof val) throw new Error(key + ' must be a string') 28 | return val.trim() 29 | } 30 | 31 | const parseArrayOfStrings = (key, val) => { 32 | if ('string' !== typeof val) throw new Error(key + ' must be a string') 33 | return val.split(',').map((str, i) => parseString(`${key}[${i}]`, str)) 34 | } 35 | 36 | const parseBoolean = (key, val) => { 37 | val = val && val.toLowerCase() 38 | if (val === 'true') return true 39 | if (val === 'false') return false 40 | throw new Error(key + ' must be a boolean') 41 | } 42 | 43 | const parseProducts = (products, query) => { 44 | const res = Object.create(null) 45 | 46 | for (let info of products) { 47 | const p = info.id 48 | if (Object.hasOwnProperty.call(query, p)) { 49 | res[p] = parseBoolean(p, query[p]) 50 | } 51 | } 52 | 53 | return res 54 | } 55 | 56 | const IBNR = /^\d{2,}$/ 57 | const parseStop = (key, val) => { 58 | if (!IBNR.test(val)) throw new Error(key + ' must be an IBNR') 59 | return val 60 | } 61 | 62 | const parseLocation = (q, key) => { 63 | if (q[key]) return parseStop(key, q[key]) 64 | if (q[key + '.latitude'] && q[key + '.longitude']) { 65 | const l = { 66 | type: 'location', 67 | latitude: +q[key + `.latitude`], 68 | longitude: +q[key + `.longitude`] 69 | } 70 | if (q[key + '.name']) l.name = q[key + '.name'] 71 | if (q[key + '.id']) { 72 | l.id = q[key + '.id'] 73 | l.poi = true 74 | } else if (q[key + '.address']) { 75 | l.address = q[key + '.address'] 76 | } 77 | return l 78 | } 79 | return null 80 | } 81 | 82 | const parseQuery = (params, query) => { 83 | const res = Object.create(null) 84 | 85 | for (const [key, param] of Object.entries(params)) { 86 | if ('default' in param) res[key] = param.default 87 | } 88 | for (const key of Object.keys(query)) { 89 | if (!Object.hasOwnProperty.call(params, key)) continue 90 | const {parse} = params[key] 91 | res[key] = parse(key, query[key]) 92 | } 93 | 94 | return res 95 | } 96 | 97 | export { 98 | parseWhen, 99 | parseStop, 100 | parseNumber, 101 | parseInteger, 102 | parseString, 103 | parseArrayOfStrings, 104 | parseBoolean, 105 | parseProducts, 106 | parseLocation, 107 | parseQuery 108 | } 109 | -------------------------------------------------------------------------------- /lib/route-uri-template.js: -------------------------------------------------------------------------------- 1 | import {strictEqual} from 'assert' 2 | 3 | const routeUriTemplate = (basePath, route) => { 4 | let tpl = basePath 5 | if (route.pathParameters) { 6 | for (const p of Object.keys(route.pathParameters)) { 7 | tpl = tpl.replace('/:' + p, `{/${p}}`) 8 | } 9 | } 10 | if ( 11 | route.queryParameters 12 | ) { 13 | const ps = Object.keys(route.queryParameters) 14 | .map(p => encodeURIComponent(p)) 15 | if (ps.length > 0) tpl += `{?${ps.join(',')}}` 16 | } 17 | return tpl 18 | } 19 | 20 | strictEqual(routeUriTemplate('/stops/:id', { 21 | pathParameters: { 22 | 'id': {description: 'id'}, 23 | }, 24 | queryParameters: { 25 | 'foo': {description: 'foo'}, 26 | 'bar': {description: 'bar'}, 27 | } 28 | }), '/stops{/id}{?foo,bar}') 29 | 30 | export { 31 | routeUriTemplate, 32 | } 33 | -------------------------------------------------------------------------------- /lib/server-timing.js: -------------------------------------------------------------------------------- 1 | const CACHED = Symbol.for('cached-hafas-client:cached') 2 | // cached-hafas-client + hafas-client! 3 | const TOTAL_CACHE_TIME = Symbol.for('cached-hafas-client:time') 4 | 5 | const sendServerTiming = (res, hafasResponse) => { 6 | const cached = hafasResponse[CACHED] === true 7 | const cacheTime = hafasResponse[TOTAL_CACHE_TIME] 8 | if (Number.isInteger(cacheTime)) { 9 | res.serverTiming[cached ? 'cache' : 'hafas'] = cacheTime 10 | } 11 | // todo: alternatively, use hafas-client's reponse time value 12 | res.setHeader('X-Cache', cached ? 'HIT' : 'MISS') 13 | } 14 | 15 | export { 16 | sendServerTiming, 17 | } 18 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, 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 | -------------------------------------------------------------------------------- /logging.js: -------------------------------------------------------------------------------- 1 | import {stdSerializers} from 'pino' 2 | import _shorthash from 'shorthash' 3 | const {unique: shorthash} = _shorthash 4 | import pinoHttp from 'pino-http' 5 | 6 | const reqSerializer = stdSerializers.req 7 | const withoutRemoteAddress = (req) => { 8 | const log = reqSerializer(req) 9 | if (req.headers['x-identifier']) log.remoteAddress = req.headers['x-identifier'] 10 | else if (log.remoteAddress) log.remoteAddress = shorthash(log.remoteAddress) 11 | return log 12 | } 13 | 14 | const serializers = Object.assign({}, stdSerializers, {req: withoutRemoteAddress}) 15 | 16 | const createLoggingMiddleware = logger => pinoHttp({logger, serializers}) 17 | 18 | export { 19 | createLoggingMiddleware, 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hafas-rest-api", 3 | "description": "Expose a HAFAS client via an HTTP REST API.", 4 | "version": "5.1.3", 5 | "main": "index.js", 6 | "type": "module", 7 | "files": [ 8 | "index.js", 9 | "routes", 10 | "lib", 11 | "tools", 12 | "logging.js", 13 | "handle-errors.js", 14 | "about-page.js", 15 | "docs.js" 16 | ], 17 | "keywords": [ 18 | "hafas", 19 | "hafas-client", 20 | "public transport", 21 | "transit", 22 | "http", 23 | "fptf" 24 | ], 25 | "author": "Jannis R ", 26 | "homepage": "https://github.com/public-transport/hafas-rest-api", 27 | "repository": "public-transport/hafas-rest-api", 28 | "bugs": "https://github.com/public-transport/hafas-rest-api/issues", 29 | "license": "ISC", 30 | "engines": { 31 | "node": ">=18" 32 | }, 33 | "dependencies": { 34 | "compression": "^1.7.2", 35 | "cors": "^2.8.4", 36 | "date-fns": "^2.12.0", 37 | "express": "^4.16.2", 38 | "github-slugger": "^2.0.0", 39 | "hsts": "^2.1.0", 40 | "http-link-header": "^1.0.2", 41 | "lodash": "^4.17.15", 42 | "luxon": "^3.1.1", 43 | "markdown-it": "^13.0.1", 44 | "on-headers": "^1.0.2", 45 | "parse-human-relative-time": "^3.0.0", 46 | "pino": "^8.8.0", 47 | "pino-http": "^8.2.1", 48 | "shorthash": "0.0.2", 49 | "stringify-entities": "^4.0.3" 50 | }, 51 | "devDependencies": { 52 | "axios": "^1.4.0", 53 | "cached-hafas-client": "^5.0.0", 54 | "eslint": "^8.0.1", 55 | "get-port": "^7.0.0", 56 | "hafas-client": "^6.0.0", 57 | "ioredis": "^5.2.4", 58 | "pino-pretty": "^10.0.1", 59 | "tap-min": "^2.0.0", 60 | "tape": "^5.1.1", 61 | "tape-promise": "^4.0.0" 62 | }, 63 | "peerDependencies": { 64 | "hafas-client": "^6" 65 | }, 66 | "scripts": { 67 | "test": "node test/index.js | tap-min", 68 | "lint": "eslint .", 69 | "prepublishOnly": "npm test && npm run lint" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # hafas-rest-api 2 | 3 | **Expose a [`hafas-client@6`](https://github.com/public-transport/hafas-client/tree/6) instance as an HTTP REST API.** 4 | 5 | [![npm version](https://img.shields.io/npm/v/hafas-rest-api.svg)](https://www.npmjs.com/package/hafas-rest-api) 6 | ![ISC-licensed](https://img.shields.io/github/license/public-transport/hafas-rest-api.svg) 7 | [![support Jannis via GitHub Sponsors](https://img.shields.io/badge/support%20Jannis-donate-fa7664.svg)](https://github.com/sponsors/derhuerst) 8 | [![chat with Jannis on Twitter](https://img.shields.io/badge/chat%20with%20Jannis-on%20Twitter-1da1f2.svg)](https://twitter.com/derhuerst) 9 | 10 | 11 | ## Installing 12 | 13 | ```shell 14 | npm install hafas-rest-api 15 | ``` 16 | 17 | 18 | ## Usage 19 | 20 | ```js 21 | import {createClient as createHafas} from 'hafas-client' 22 | import {profile as dbProfile} from 'hafas-client/p/db/index.js' 23 | import {createHafasRestApi as createApi} from 'hafas-rest-api' 24 | 25 | const config = { 26 | hostname: 'example.org', 27 | name: 'my-hafas-rest-api', 28 | homepage: 'https://github.com/someone/my-hafas-rest-api', 29 | version: '1.0.0', 30 | aboutPage: false 31 | } 32 | 33 | const hafas = createHafas(dbProfile, 'my-hafas-rest-api') 34 | const api = await createApi(hafas, config) 35 | 36 | api.listen(3000, (err) => { 37 | if (err) console.error(err) 38 | }) 39 | ``` 40 | 41 | ### `config` keys 42 | 43 | key | description | mandatory? | default value 44 | ----|-------------|------------|-------------- 45 | `hostname` | The public hostname of the API. | ✔︎ | – 46 | `name` | The name of the API. Used for the `X-Powered-By` header and the about page. | ✔︎ | – 47 | `description` | Used for the about page. | ✔︎ (with `aboutPage: true`) | – 48 | `docsLink` | Used for the about page. | ✔︎ (with `aboutPage: true`) | – 49 | `cors` | Enable [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)? | ✗ | `true` 50 | `etags` | [Express config](https://expressjs.com/en/4x/api.html#etag.options.table) for [`ETag` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) | ✗ | `weak` 51 | `handleErrors` | Handle errors by sending `5**` codes and JSON. | ✗ | `true` 52 | `logging` | Log requests using [`pino`](https://npmjs.com/package/pino)? | ✗ | `false` 53 | `healthCheck` | A function that returning [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/promise) that resolve with `true` (for healthy) or `false`. | ✗ | – 54 | `version` | Used for the `X-Powered-By` and `X-API-Version` headers. | ✗ | – 55 | `homepage` | Used for the `X-Powered-By` header. | ✗ | – 56 | `aboutPage` | Enable the about page on `GET /`? | ✗ | `true` 57 | `openapiSpec` | Generate and serve an [OpenAPI spec](https://en.wikipedia.org/wiki/OpenAPI_Specification) of the API? | ✗ | `false` 58 | `addHafasOpts` | Computes additional `hafas-client` opts. `(opt, hafasClientMethod, httpReq) => additionaOpts` | ✗ | – 59 | `modifyRoutes` | Extend or modify the [default routes](routes/index.js). | ✗ | `routes => routes` 60 | 61 | *Pro Tip:* Use [`hafas-client-health-check`](https://github.com/public-transport/hafas-client-health-check) for `config.healthCheck`. 62 | 63 | 64 | ## Contributing 65 | 66 | If you have a question or have difficulties using `hafas-rest-api`, please double-check your code and setup first. If you think you have found a bug or want to propose a feature, refer to [the issues page](https://github.com/public-transport/hafas-rest-api/issues). 67 | -------------------------------------------------------------------------------- /routes/arrivals.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseWhen, 3 | parseStop, 4 | parseInteger, 5 | parseBoolean, 6 | parseString, 7 | parseQuery, 8 | parseProducts 9 | } from '../lib/parse.js' 10 | import { 11 | formatWhen, 12 | } from '../lib/format.js' 13 | import {sendServerTiming} from '../lib/server-timing.js' 14 | import { 15 | configureJSONPrettyPrinting, 16 | jsonPrettyPrintingOpenapiParam, 17 | jsonPrettyPrintingParam, 18 | } from '../lib/json-pretty-printing.js' 19 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 20 | import {formatProductParams} from '../lib/format-product-parameters.js' 21 | 22 | const err400 = (msg) => { 23 | const err = new Error(msg) 24 | err.statusCode = 400 25 | return err 26 | } 27 | 28 | // todo: DRY with routes/departures.js 29 | const createArrivalsRoute = (hafas, config) => { 30 | // todo: move to `hafas-client` 31 | const _parsers = { 32 | when: { 33 | description: 'Date & time to get departures for.', 34 | type: 'date+time', 35 | defaultStr: '*now*', 36 | parse: parseWhen(hafas.profile.timezone), 37 | }, 38 | direction: { 39 | description: 'Filter departures by direction.', 40 | type: 'string', 41 | parse: parseStop, 42 | }, 43 | duration: { 44 | description: 'Show departures for how many minutes?', 45 | type: 'integer', 46 | default: 10, 47 | parse: parseInteger, 48 | }, 49 | results: { 50 | description: 'Max. number of departures.', 51 | type: 'integer', 52 | defaultStr: '*whatever HAFAS wants*', 53 | parse: parseInteger, 54 | }, 55 | linesOfStops: { 56 | description: 'Parse & return lines of each stop/station?', 57 | type: 'boolean', 58 | default: false, 59 | parse: parseBoolean, 60 | }, 61 | remarks: { 62 | description: 'Parse & return hints & warnings?', 63 | type: 'boolean', 64 | default: true, 65 | parse: parseBoolean, 66 | }, 67 | language: { 68 | description: 'Language of the results.', 69 | type: 'string', 70 | default: 'en', 71 | parse: parseString, 72 | }, 73 | } 74 | if (hafas.profile.departuresStbFltrEquiv !== false) { 75 | _parsers.includeRelatedStations = { 76 | description: 'Fetch departures at related stops, e.g. those that belong together on the metro map?', 77 | type: 'boolean', 78 | default: true, 79 | parse: parseBoolean, 80 | } 81 | } 82 | if (hafas.profile.departuresGetPasslist !== false) { 83 | _parsers.stopovers = { 84 | description: 'Fetch & parse next stopovers of each departure?', 85 | type: 'boolean', 86 | default: false, 87 | parse: parseBoolean, 88 | } 89 | } 90 | const parsers = config.mapRouteParsers('arrivals', _parsers) 91 | 92 | const linkHeader = (req, opt, arrivals) => { 93 | if (!opt.when || !Number.isInteger(opt.duration)) return {} 94 | const tNext = Date.parse(opt.when) - opt.duration * 60 * 1000 95 | const next = req.searchWithNewParams({ 96 | when: formatWhen(tNext, hafas.profile.timezone), 97 | }) 98 | return {next} 99 | } 100 | 101 | const arrivals = (req, res, next) => { 102 | const id = parseStop('id', req.params.id) 103 | 104 | const opt = parseQuery(parsers, req.query) 105 | opt.products = parseProducts(hafas.profile.products, req.query) 106 | config.addHafasOpts(opt, 'arrivals', req) 107 | 108 | hafas.arrivals(id, opt) 109 | .then((arrsRes) => { 110 | sendServerTiming(res, arrsRes) 111 | res.setLinkHeader(linkHeader(req, opt, arrsRes.arrivals)) 112 | // todo: send res.realtimeDataUpdatedAt as Last-Modified? 113 | res.allowCachingFor(30) // 30 seconds 114 | configureJSONPrettyPrinting(req, res) 115 | res.json(arrsRes) 116 | next() 117 | }) 118 | .catch(next) 119 | } 120 | 121 | arrivals.openapiPaths = config.mapRouteOpenapiPaths('arrivals', { 122 | '/stops/{id}/arrivals': { 123 | get: { 124 | summary: 'Fetches arrivals at a stop/station.', 125 | description: `\ 126 | Works like \`/stops/{id}/departures\`, except that it uses [\`hafasClient.arrivals()\`](https://github.com/public-transport/hafas-client/blob/6/docs/arrivals.md) to **query arrivals at a stop/station**.`, 127 | externalDocs: { 128 | description: '`hafasClient.arrivals()` documentation', 129 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/arrivals.md', 130 | }, 131 | parameters: [ 132 | { 133 | name: 'id', 134 | in: 'path', 135 | description: 'stop/station ID to show arrivals for', 136 | required: true, 137 | schema: {type: 'string'}, 138 | // todo: examples? 139 | }, 140 | ...formatParsersAsOpenapiParams(parsers), 141 | jsonPrettyPrintingOpenapiParam, 142 | ], 143 | responses: { 144 | '2XX': { 145 | description: 'An object with an array of arrivals, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/arrivals.md).', 146 | content: { 147 | 'application/json': { 148 | schema: { 149 | type: 'object', 150 | properties: { 151 | arrivals: { 152 | type: 'array', 153 | items: {type: 'object'}, // todo 154 | }, 155 | realtimeDataUpdatedAt: { 156 | type: 'integer', 157 | }, 158 | }, 159 | required: [ 160 | 'arrivals', 161 | ], 162 | }, 163 | // todo: example(s) 164 | }, 165 | }, 166 | // todo: links 167 | }, 168 | // todo: non-2xx response 169 | }, 170 | }, 171 | }, 172 | }) 173 | 174 | arrivals.pathParameters = { 175 | 'id': { 176 | description: 'stop/station ID to show arrivals for', 177 | type: 'number', 178 | }, 179 | } 180 | arrivals.queryParameters = { 181 | ...parsers, 182 | ...formatProductParams(hafas.profile.products), 183 | 'pretty': jsonPrettyPrintingParam, 184 | } 185 | return arrivals 186 | } 187 | 188 | export { 189 | createArrivalsRoute, 190 | } 191 | -------------------------------------------------------------------------------- /routes/departures.js: -------------------------------------------------------------------------------- 1 | import max from 'lodash/max.js' 2 | import { 3 | parseWhen, 4 | parseStop, 5 | parseInteger, 6 | parseBoolean, 7 | parseString, 8 | parseQuery, 9 | parseProducts 10 | } from '../lib/parse.js' 11 | import { 12 | formatWhen, 13 | } from '../lib/format.js' 14 | import {sendServerTiming} from '../lib/server-timing.js' 15 | import { 16 | configureJSONPrettyPrinting, 17 | jsonPrettyPrintingOpenapiParam, 18 | jsonPrettyPrintingParam, 19 | } from '../lib/json-pretty-printing.js' 20 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 21 | import {formatProductParams} from '../lib/format-product-parameters.js' 22 | 23 | const MINUTE = 60 * 1000 24 | 25 | const err400 = (msg) => { 26 | const err = new Error(msg) 27 | err.statusCode = 400 28 | return err 29 | } 30 | 31 | // todo: DRY with routes/arrivals.js 32 | const createDeparturesRoute = (hafas, config) => { 33 | // todo: move to `hafas-client` 34 | const _parsers = { 35 | when: { 36 | description: 'Date & time to get departures for.', 37 | type: 'date+time', 38 | defaultStr: '*now*', 39 | parse: parseWhen(hafas.profile.timezone), 40 | }, 41 | direction: { 42 | description: 'Filter departures by direction.', 43 | type: 'string', 44 | parse: parseStop, 45 | }, 46 | duration: { 47 | description: 'Show departures for how many minutes?', 48 | type: 'integer', 49 | default: 10, 50 | parse: parseInteger, 51 | }, 52 | results: { 53 | description: 'Max. number of departures.', 54 | type: 'integer', 55 | defaultStr: '*whatever HAFAS wants', 56 | parse: parseInteger, 57 | }, 58 | linesOfStops: { 59 | description: 'Parse & return lines of each stop/station?', 60 | type: 'boolean', 61 | default: false, 62 | parse: parseBoolean, 63 | }, 64 | remarks: { 65 | description: 'Parse & return hints & warnings?', 66 | type: 'boolean', 67 | default: true, 68 | parse: parseBoolean, 69 | }, 70 | language: { 71 | description: 'Language of the results.', 72 | type: 'string', 73 | default: 'en', 74 | parse: parseString, 75 | }, 76 | } 77 | if (hafas.profile.departuresStbFltrEquiv !== false) { 78 | _parsers.includeRelatedStations = { 79 | description: 'Fetch departures at related stops, e.g. those that belong together on the metro map?', 80 | type: 'boolean', 81 | default: true, 82 | parse: parseBoolean, 83 | } 84 | } 85 | if (hafas.profile.departuresGetPasslist !== false) { 86 | _parsers.stopovers = { 87 | description: 'Fetch & parse next stopovers of each departure?', 88 | type: 'boolean', 89 | default: false, 90 | parse: parseBoolean, 91 | } 92 | } 93 | const parsers = config.mapRouteParsers('departures', _parsers) 94 | 95 | const linkHeader = (req, opt, departures) => { 96 | let tNext = null 97 | if (opt.when && Number.isInteger(opt.duration)) { 98 | tNext = Date.parse(opt.when) + opt.duration * MINUTE + MINUTE 99 | } else { 100 | const ts = departures 101 | .map(dep => Date.parse(dep.when || dep.plannedWhen)) 102 | .filter(t => Number.isInteger(t)) 103 | tNext = max(ts) + MINUTE 104 | } 105 | 106 | if (tNext === null) return {} 107 | const next = req.searchWithNewParams({ 108 | when: formatWhen(tNext, hafas.profile.timezone), 109 | }) 110 | return {next} 111 | } 112 | 113 | const departures = (req, res, next) => { 114 | const id = parseStop('id', req.params.id) 115 | 116 | const opt = parseQuery(parsers, req.query) 117 | opt.products = parseProducts(hafas.profile.products, req.query) 118 | config.addHafasOpts(opt, 'departures', req) 119 | 120 | hafas.departures(id, opt) 121 | .then((depsRes) => { 122 | sendServerTiming(res, depsRes) 123 | res.setLinkHeader(linkHeader(req, opt, depsRes.departures)) 124 | // todo: send res.realtimeDataUpdatedAt as Last-Modified? 125 | res.allowCachingFor(30) // 30 seconds 126 | configureJSONPrettyPrinting(req, res) 127 | res.json(depsRes) 128 | next() 129 | }) 130 | .catch(next) 131 | } 132 | 133 | departures.openapiPaths = config.mapRouteOpenapiPaths('departures', { 134 | '/stops/{id}/departures': { 135 | get: { 136 | summary: 'Fetches departures at a stop/station.', 137 | description: `\ 138 | Uses [\`hafasClient.departures()\`](https://github.com/public-transport/hafas-client/blob/6/docs/departures.md) to **query departures at a stop/station**.`, 139 | externalDocs: { 140 | description: '`hafasClient.departures()` documentation', 141 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/departures.md', 142 | }, 143 | parameters: [ 144 | { 145 | name: 'id', 146 | in: 'path', 147 | description: 'stop/station ID to show departures for', 148 | required: true, 149 | schema: {type: 'string'}, 150 | // todo: examples? 151 | }, 152 | ...formatParsersAsOpenapiParams(parsers), 153 | jsonPrettyPrintingOpenapiParam, 154 | ], 155 | responses: { 156 | '2XX': { 157 | description: 'An object with an array of departures, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/departures.md).', 158 | content: { 159 | 'application/json': { 160 | schema: { 161 | type: 'object', 162 | properties: { 163 | departures: { 164 | type: 'array', 165 | items: {type: 'object'}, // todo 166 | }, 167 | realtimeDataUpdatedAt: { 168 | type: 'integer', 169 | }, 170 | }, 171 | required: [ 172 | 'departures', 173 | ], 174 | }, 175 | // todo: example(s) 176 | }, 177 | }, 178 | // todo: links 179 | }, 180 | // todo: non-2xx response 181 | }, 182 | }, 183 | }, 184 | }) 185 | 186 | departures.pathParameters = { 187 | 'id': { 188 | description: 'stop/station ID to show departures for', 189 | type: 'number', 190 | }, 191 | } 192 | departures.queryParameters = { 193 | ...parsers, 194 | ...formatProductParams(hafas.profile.products), 195 | 'pretty': jsonPrettyPrintingParam, 196 | } 197 | return departures 198 | } 199 | 200 | export { 201 | createDeparturesRoute, 202 | } 203 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | import {createNearbyRoute as nearby} from './nearby.js' 2 | import {createStopRoute as stop} from './stop.js' 3 | import {createDeparturesRoute as departures} from './departures.js' 4 | import {createArrivalsRoute as arrivals} from './arrivals.js' 5 | import {createJourneysRoute as journeys} from './journeys.js' 6 | import {createLocationsRoute as locations} from './locations.js' 7 | 8 | const getAllRoutes = async (hafas, config) => { 9 | const routes = Object.create(null) 10 | 11 | if (hafas.reachableFrom) { 12 | const { 13 | createReachableFromRoute: reachableFrom, 14 | } = await import('./reachable-from.js') 15 | routes['/stops/reachable-from'] = reachableFrom(hafas, config) 16 | } 17 | routes['/stops/:id'] = stop(hafas, config) 18 | routes['/stops/:id/departures'] = departures(hafas, config) 19 | routes['/stops/:id/arrivals'] = arrivals(hafas, config) 20 | routes['/journeys'] = journeys(hafas, config) 21 | if (hafas.trip) { 22 | const { 23 | createTripRoute: trip, 24 | } = await import('./trip.js') 25 | routes['/trips/:id'] = trip(hafas, config) 26 | } 27 | if (hafas.tripsByName) { 28 | const { 29 | createTripsRoute: trips, 30 | } = await import('./trips.js') 31 | routes['/trips'] = trips(hafas, config) 32 | } 33 | routes['/locations/nearby'] = nearby(hafas, config) 34 | routes['/locations'] = locations(hafas, config) 35 | if (hafas.radar) { 36 | const { 37 | createRadarRoute: radar, 38 | } = await import('./radar.js') 39 | routes['/radar'] = radar(hafas, config) 40 | } 41 | if (hafas.refreshJourney) { 42 | const { 43 | createRefreshJourneyRoute: refreshJourney, 44 | } = await import('./refresh-journey.js') 45 | routes['/journeys/:ref'] = refreshJourney(hafas, config) 46 | } 47 | 48 | return routes 49 | } 50 | 51 | export { 52 | getAllRoutes, 53 | } 54 | -------------------------------------------------------------------------------- /routes/journeys.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseWhen, 3 | parseInteger, 4 | parseNumber, 5 | parseString, 6 | parseBoolean, 7 | parseQuery, 8 | parseProducts, 9 | parseLocation 10 | } from '../lib/parse.js' 11 | import {sendServerTiming} from '../lib/server-timing.js' 12 | import { 13 | configureJSONPrettyPrinting, 14 | jsonPrettyPrintingOpenapiParam, 15 | jsonPrettyPrintingParam, 16 | } from '../lib/json-pretty-printing.js' 17 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 18 | import {formatProductParams} from '../lib/format-product-parameters.js' 19 | 20 | const WITHOUT_FROM_TO = { 21 | from: null, 22 | 'from.id': null, 23 | 'from.name': null, 24 | 'from.latitude': null, 25 | 'from.longitude': null, 26 | to: null, 27 | 'to.id': null, 28 | 'to.name': null, 29 | 'to.latitude': null, 30 | 'to.longitude': null, 31 | } 32 | 33 | const err400 = (msg) => { 34 | const err = new Error(msg) 35 | err.statusCode = 400 36 | return err 37 | } 38 | 39 | const parseWalkingSpeed = (key, val) => { 40 | if (!['slow', 'normal', 'fast'].includes(val)) { 41 | throw new Error(key + ' must be `slow`, `normal`, or `fast`') 42 | } 43 | return val 44 | } 45 | 46 | const createJourneysRoute = (hafas, config) => { 47 | const parsers = config.mapRouteParsers('journeys', { 48 | departure: { 49 | description: 'Compute journeys departing at this date/time. Mutually exclusive with `arrival`.', 50 | type: 'date+time', 51 | defaultStr: '*now*', 52 | parse: parseWhen(hafas.profile.timezone), 53 | }, 54 | arrival: { 55 | description: 'Compute journeys arriving at this date/time. Mutually exclusive with `departure`.', 56 | type: 'date+time', 57 | defaultStr: '*now*', 58 | parse: parseWhen(hafas.profile.timezone), 59 | }, 60 | earlierThan: { 61 | description: 'Compute journeys "before" an `ealierRef`.', 62 | type: 'string', 63 | parse: parseString, 64 | }, 65 | laterThan: { 66 | description: 'Compute journeys "after" an `laterRef`.', 67 | type: 'string', 68 | parse: parseString, 69 | }, 70 | 71 | results: { 72 | description: 'Max. number of journeys.', 73 | type: 'integer', 74 | default: 3, 75 | parse: parseInteger, 76 | }, 77 | stopovers: { 78 | description: 'Fetch & parse stopovers on the way?', 79 | type: 'boolean', 80 | default: false, 81 | parse: parseBoolean, 82 | }, 83 | transfers: { 84 | description: 'Maximum number of transfers.', 85 | type: 'integer', 86 | defaultStr: '*let HAFAS decide*', 87 | parse: parseInteger, 88 | }, 89 | transferTime: { 90 | description: 'Minimum time in minutes for a single transfer.', 91 | type: 'integer', 92 | default: 0, 93 | parse: parseNumber, 94 | }, 95 | accessibility: { 96 | description: '`partial` or `complete`.', 97 | type: 'string', 98 | defaultStr: '*not accessible*', 99 | parse: parseString, 100 | }, 101 | bike: { 102 | description: 'Compute only bike-friendly journeys?', 103 | type: 'boolean', 104 | default: false, 105 | parse: parseBoolean, 106 | }, 107 | startWithWalking: { 108 | description: 'Consider walking to nearby stations at the beginning of a journey?', 109 | type: 'boolean', 110 | default: true, 111 | parse: parseBoolean, 112 | }, 113 | walkingSpeed: { 114 | description: '`slow`, `normal` or `fast`.', 115 | type: 'string', 116 | enum: ['slow', 'normal', 'fast'], 117 | default: 'normal', 118 | parse: parseWalkingSpeed, 119 | }, 120 | tickets: { 121 | description: 'Return information about available tickets?', 122 | type: 'boolean', 123 | default: false, 124 | parse: parseBoolean, 125 | }, 126 | polylines: { 127 | description: 'Fetch & parse a shape for each journey leg?', 128 | type: 'boolean', 129 | default: false, 130 | parse: parseBoolean, 131 | }, 132 | subStops: { 133 | description: 'Parse & return sub-stops of stations?', 134 | type: 'boolean', 135 | default: true, 136 | parse: parseBoolean, 137 | }, 138 | entrances: { 139 | description: 'Parse & return entrances of stops/stations?', 140 | type: 'boolean', 141 | default: true, 142 | parse: parseBoolean, 143 | }, 144 | remarks: { 145 | description: 'Parse & return hints & warnings?', 146 | type: 'boolean', 147 | default: true, 148 | parse: parseBoolean, 149 | }, 150 | scheduledDays: { 151 | description: 'Parse & return dates each journey is valid on?', 152 | type: 'boolean', 153 | default: false, 154 | parse: parseBoolean, 155 | }, 156 | language: { 157 | description: 'Language of the results.', 158 | type: 'string', 159 | default: 'en', 160 | parse: parseString, 161 | }, 162 | }) 163 | 164 | const journeys = (req, res, next) => { 165 | const from = parseLocation(req.query, 'from') 166 | if (!from) return next(err400('Missing origin.')) 167 | const to = parseLocation(req.query, 'to') 168 | if (!to) return next(err400('Missing destination.')) 169 | 170 | const opt = parseQuery(parsers, req.query) 171 | const via = parseLocation(req.query, 'via') 172 | if (via) opt.via = via 173 | opt.products = parseProducts(hafas.profile.products, req.query) 174 | config.addHafasOpts(opt, 'journeys', req) 175 | 176 | hafas.journeys(from, to, opt) 177 | .then((data) => { 178 | sendServerTiming(res, data) 179 | res.setLinkHeader({ 180 | prev: (data.earlierRef 181 | ? req.searchWithNewParams({ 182 | ...WITHOUT_FROM_TO, 183 | departure: null, arrival: null, 184 | earlierThan: data.earlierRef, 185 | }) 186 | : null 187 | ), 188 | next: (data.laterRef 189 | ? req.searchWithNewParams({ 190 | ...WITHOUT_FROM_TO, 191 | departure: null, arrival: null, 192 | laterThan: data.laterRef, 193 | }) 194 | : null 195 | ), 196 | }) 197 | 198 | res.allowCachingFor(60) // 1 minute 199 | configureJSONPrettyPrinting(req, res) 200 | res.json(data) 201 | next() 202 | }) 203 | .catch(next) 204 | } 205 | 206 | journeys.openapiPaths = config.mapRouteOpenapiPaths('journeys', { 207 | '/journeys': { 208 | get: { 209 | summary: 'Finds journeys from A to B.', 210 | description: `\ 211 | Uses [\`hafasClient.journeys()\`](https://github.com/public-transport/hafas-client/blob/6/docs/journeys.md) to **find journeys from A (\`from\`) to B (\`to\`)**.`, 212 | externalDocs: { 213 | description: '`hafasClient.journeys()` documentation', 214 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/journeys.md', 215 | }, 216 | parameters: [ 217 | ...formatParsersAsOpenapiParams(parsers), 218 | jsonPrettyPrintingOpenapiParam, 219 | ], 220 | responses: { 221 | '2XX': { 222 | description: 'An object with an array of journeys, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/journeys.md).', 223 | content: { 224 | 'application/json': { 225 | schema: { 226 | type: 'object', 227 | properties: { 228 | journeys: { 229 | type: 'array', 230 | items: {type: 'object'}, // todo 231 | }, 232 | realtimeDataUpdatedAt: { 233 | type: 'integer', 234 | }, 235 | earlierRef: { 236 | type: 'string', 237 | }, 238 | laterRef: { 239 | type: 'string', 240 | }, 241 | }, 242 | required: [ 243 | 'journeys', 244 | ], 245 | }, 246 | // todo: example(s) 247 | }, 248 | }, 249 | // todo: links 250 | }, 251 | // todo: non-2xx response 252 | }, 253 | }, 254 | }, 255 | }) 256 | 257 | journeys.queryParameters = { 258 | 'from': {docs: false}, 259 | 'from.id': {docs: false}, 260 | 'from.latitude': {docs: false}, 'from.longitude': {docs: false}, 261 | 'from.address': {docs: false}, 262 | 'from.name': {docs: false}, 263 | 264 | 'via': {docs: false}, 265 | 'via.id': {docs: false}, 266 | 'via.latitude': {docs: false}, 'via.longitude': {docs: false}, 267 | 'via.address': {docs: false}, 268 | 'via.name': {docs: false}, 269 | 270 | 'to': {docs: false}, 271 | 'to.id': {docs: false}, 272 | 'to.latitude': {docs: false}, 'to.longitude': {docs: false}, 273 | 'to.address': {docs: false}, 274 | 'to.name': {docs: false}, 275 | 276 | ...parsers, 277 | ...formatProductParams(hafas.profile.products), 278 | 'pretty': jsonPrettyPrintingParam, 279 | } 280 | return journeys 281 | } 282 | 283 | export { 284 | createJourneysRoute, 285 | } 286 | -------------------------------------------------------------------------------- /routes/locations.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseInteger, 3 | parseBoolean, 4 | parseString, 5 | parseQuery 6 | } from '../lib/parse.js' 7 | import {sendServerTiming} from '../lib/server-timing.js' 8 | import { 9 | configureJSONPrettyPrinting, 10 | jsonPrettyPrintingOpenapiParam, 11 | jsonPrettyPrintingParam, 12 | } from '../lib/json-pretty-printing.js' 13 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 14 | 15 | const err400 = (msg) => { 16 | const err = new Error(msg) 17 | err.statusCode = 400 18 | return err 19 | } 20 | 21 | const createLocationsRoute = (hafas, config) => { 22 | const parsers = config.mapRouteParsers('locations', { 23 | fuzzy: { 24 | description: 'Find more than exact matches?', 25 | type: 'boolean', 26 | default: true, 27 | parse: parseBoolean, 28 | }, 29 | results: { 30 | description: 'How many stations shall be shown?', 31 | type: 'integer', 32 | default: 10, 33 | parse: parseInteger, 34 | }, 35 | stops: { 36 | description: 'Show stops/stations?', 37 | type: 'boolean', 38 | default: true, 39 | parse: parseBoolean, 40 | }, 41 | addresses: { 42 | description: 'Show addresses?', 43 | type: 'boolean', 44 | default: true, 45 | parse: parseBoolean, 46 | }, 47 | poi: { 48 | description: 'Show points of interest?', 49 | type: 'boolean', 50 | default: true, 51 | parse: parseBoolean, 52 | }, 53 | linesOfStops: { 54 | description: 'Parse & return lines of each stop/station?', 55 | type: 'boolean', 56 | default: false, 57 | parse: parseBoolean, 58 | }, 59 | language: { 60 | description: 'Language of the results.', 61 | type: 'string', 62 | default: 'en', 63 | parse: parseString, 64 | }, 65 | }) 66 | 67 | const locations = (req, res, next) => { 68 | if (!req.query.query) return next(err400('Missing query.')) 69 | 70 | const opt = parseQuery(parsers, req.query) 71 | config.addHafasOpts(opt, 'locations', req) 72 | 73 | hafas.locations(req.query.query, opt) 74 | .then((locations) => { 75 | sendServerTiming(res, locations) 76 | res.allowCachingFor(5 * 60) // 5 minutes 77 | configureJSONPrettyPrinting(req, res) 78 | res.json(locations) 79 | next() 80 | }) 81 | .catch(next) 82 | } 83 | 84 | locations.openapiPaths = config.mapRouteOpenapiPaths('locations', { 85 | '/locations': { 86 | get: { 87 | summary: 'Finds stops/stations, POIs and addresses matching a query.', 88 | description: `\ 89 | Uses [\`hafasClient.locations()\`](https://github.com/public-transport/hafas-client/blob/6/docs/locations.md) to **find stops/stations, POIs and addresses matching \`query\`**.`, 90 | externalDocs: { 91 | description: '`hafasClient.locations()` documentation', 92 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/locations.md', 93 | }, 94 | parameters: [ 95 | { 96 | name: 'query', 97 | in: 'query', 98 | description: 'The term to search for.', 99 | required: true, 100 | schema: {type: 'string'}, 101 | // todo: examples? 102 | }, 103 | ...formatParsersAsOpenapiParams(parsers), 104 | jsonPrettyPrintingOpenapiParam, 105 | ], 106 | responses: { 107 | '2XX': { 108 | description: 'An array of locations, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/locations.md).', 109 | content: { 110 | 'application/json': { 111 | schema: { 112 | type: 'array', 113 | items: {type: 'object'}, // todo 114 | }, 115 | // todo: example(s) 116 | }, 117 | }, 118 | // todo: links 119 | }, 120 | // todo: non-2xx response 121 | }, 122 | }, 123 | }, 124 | }) 125 | 126 | locations.queryParameters = { 127 | 'query': { 128 | required: true, 129 | type: 'string', 130 | defaultStr: '–', 131 | }, 132 | ...parsers, 133 | 'pretty': jsonPrettyPrintingParam, 134 | } 135 | return locations 136 | } 137 | 138 | export { 139 | createLocationsRoute, 140 | } 141 | -------------------------------------------------------------------------------- /routes/nearby.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseInteger, 3 | parseNumber, 4 | parseBoolean, 5 | parseString, 6 | parseQuery 7 | } from '../lib/parse.js' 8 | import {sendServerTiming} from '../lib/server-timing.js' 9 | import { 10 | configureJSONPrettyPrinting, 11 | jsonPrettyPrintingOpenapiParam, 12 | jsonPrettyPrintingParam, 13 | } from '../lib/json-pretty-printing.js' 14 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 15 | 16 | const err400 = (msg) => { 17 | const err = new Error(msg) 18 | err.statusCode = 400 19 | return err 20 | } 21 | 22 | const createNearbyRoute = (hafas, config) => { 23 | const parsers = config.mapRouteParsers('nearby', { 24 | results: { 25 | description: 'maximum number of results', 26 | type: 'integer', 27 | default: 8, 28 | parse: parseInteger, 29 | }, 30 | distance: { 31 | description: 'maximum walking distance in meters', 32 | type: 'integer', 33 | defaultStr: '–', 34 | parse: parseNumber, 35 | }, 36 | stops: { 37 | description: 'Return stops/stations?', 38 | type: 'boolean', 39 | default: true, 40 | parse: parseBoolean, 41 | }, 42 | poi: { 43 | description: 'Return points of interest?', 44 | type: 'boolean', 45 | default: false, 46 | parse: parseBoolean, 47 | }, 48 | linesOfStops: { 49 | description: 'Parse & expose lines at each stop/station?', 50 | type: 'boolean', 51 | default: false, 52 | parse: parseBoolean, 53 | }, 54 | language: { 55 | description: 'Language of the results.', 56 | type: 'string', 57 | default: 'en', 58 | parse: parseString, 59 | }, 60 | }) 61 | 62 | const nearby = (req, res, next) => { 63 | if (!req.query.latitude) return next(err400('Missing latitude.')) 64 | if (!req.query.longitude) return next(err400('Missing longitude.')) 65 | 66 | const opt = parseQuery(parsers, req.query) 67 | config.addHafasOpts(opt, 'nearby', req) 68 | 69 | hafas.nearby({ 70 | type: 'location', 71 | latitude: +req.query.latitude, 72 | longitude: +req.query.longitude 73 | }, opt) 74 | .then((nearby) => { 75 | sendServerTiming(res, nearby) 76 | res.allowCachingFor(5 * 60) // 5 minutes 77 | configureJSONPrettyPrinting(req, res) 78 | res.json(nearby) 79 | next() 80 | }) 81 | .catch(next) 82 | } 83 | 84 | nearby.openapiPaths = config.mapRouteOpenapiPaths('nearby', { 85 | '/locations/nearby': { 86 | get: { 87 | summary: 'Finds stops/stations & POIs close to a geolocation.', 88 | description: `\ 89 | Uses [\`hafasClient.nearby()\`](https://github.com/public-transport/hafas-client/blob/6/docs/nearby.md) to **find stops/stations & POIs close to the given geolocation**.`, 90 | externalDocs: { 91 | description: '`hafasClient.nearby()` documentation', 92 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/nearby.md', 93 | }, 94 | parameters: [ 95 | ...formatParsersAsOpenapiParams(parsers), 96 | jsonPrettyPrintingOpenapiParam, 97 | ], 98 | responses: { 99 | '2XX': { 100 | description: 'An array of locations, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/nearby.md).', 101 | content: { 102 | 'application/json': { 103 | schema: { 104 | type: 'array', 105 | items: {type: 'object'}, // todo 106 | }, 107 | // todo: example(s) 108 | }, 109 | }, 110 | // todo: links 111 | }, 112 | // todo: non-2xx response 113 | }, 114 | }, 115 | }, 116 | }) 117 | 118 | nearby.queryParameters = { 119 | 'latitude': { 120 | required: true, 121 | type: 'number', 122 | defaultStr: '–', 123 | }, 124 | 'longitude': { 125 | required: true, 126 | type: 'number', 127 | defaultStr: '–', 128 | }, 129 | ...parsers, 130 | 'pretty': jsonPrettyPrintingParam, 131 | } 132 | return nearby 133 | } 134 | 135 | export { 136 | createNearbyRoute, 137 | } 138 | -------------------------------------------------------------------------------- /routes/radar.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseInteger, 3 | parseBoolean, 4 | parseString, 5 | parseQuery 6 | } from '../lib/parse.js' 7 | import {sendServerTiming} from '../lib/server-timing.js' 8 | import { 9 | configureJSONPrettyPrinting, 10 | jsonPrettyPrintingOpenapiParam, 11 | jsonPrettyPrintingParam, 12 | } from '../lib/json-pretty-printing.js' 13 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 14 | 15 | const err400 = (msg) => { 16 | const err = new Error(msg) 17 | err.statusCode = 400 18 | return err 19 | } 20 | 21 | const createRadarRoute = (hafas, config) => { 22 | const parsers = config.mapRouteParsers('radar', { 23 | results: { 24 | description: 'Max. number of vehicles.', 25 | type: 'integer', 26 | default: 256, 27 | parse: parseInteger, 28 | }, 29 | duration: { 30 | description: 'Compute frames for the next `n` seconds.', 31 | type: 'integer', 32 | default: 30, 33 | parse: parseInteger, 34 | }, 35 | frames: { 36 | description: 'Number of frames to compute.', 37 | type: 'integer', 38 | default: 3, 39 | parse: parseInteger, 40 | }, 41 | polylines: { 42 | description: 'Fetch & parse a geographic shape for the movement of each vehicle?', 43 | type: 'boolean', 44 | default: true, 45 | parse: parseBoolean, 46 | }, 47 | language: { 48 | description: 'Language of the results.', 49 | type: 'string', 50 | default: 'en', 51 | parse: parseString, 52 | }, 53 | }) 54 | 55 | const radar = (req, res, next) => { 56 | const q = req.query 57 | 58 | if (!q.north) return next(err400('Missing north latitude.')) 59 | if (!q.west) return next(err400('Missing west longitude.')) 60 | if (!q.south) return next(err400('Missing south latitude.')) 61 | if (!q.east) return next(err400('Missing east longitude.')) 62 | 63 | const opt = parseQuery(parsers, q) 64 | config.addHafasOpts(opt, 'radar', req) 65 | hafas.radar({north: +q.north, west: +q.west, south: +q.south, east: +q.east}, opt) 66 | .then((movements) => { 67 | sendServerTiming(res, movements) 68 | res.allowCachingFor(30) // 30 seconds 69 | configureJSONPrettyPrinting(req, res) 70 | res.json(movements) 71 | next() 72 | }) 73 | .catch(next) 74 | } 75 | 76 | radar.openapiPaths = config.mapRouteOpenapiPaths('radar', { 77 | '/radar': { 78 | get: { 79 | summary: 'Finds all vehicles currently in an area.', 80 | description: `\ 81 | 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.`, 82 | externalDocs: { 83 | description: '`hafasClient.radar()` documentation', 84 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/radar.md', 85 | }, 86 | parameters: [ 87 | ...formatParsersAsOpenapiParams(parsers), 88 | jsonPrettyPrintingOpenapiParam, 89 | ], 90 | responses: { 91 | '2XX': { 92 | description: 'An object with an array of movements, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/radar.md).', 93 | content: { 94 | 'application/json': { 95 | schema: { 96 | type: 'object', 97 | properties: { 98 | movements: { 99 | type: 'array', 100 | items: {type: 'object'}, // todo 101 | }, 102 | realtimeDataUpdatedAt: { 103 | type: 'integer', 104 | }, 105 | }, 106 | required: [ 107 | 'movements', 108 | ], 109 | }, 110 | // todo: example(s) 111 | }, 112 | }, 113 | // todo: links 114 | }, 115 | // todo: non-2xx response 116 | }, 117 | }, 118 | }, 119 | }) 120 | 121 | radar.queryParameters = { 122 | 'north': { 123 | required: true, 124 | description: 'Northern latitude.', 125 | type: 'number', 126 | defaultStr: '–', 127 | }, 128 | 'west': { 129 | required: true, 130 | description: 'Western longitude.', 131 | type: 'number', 132 | defaultStr: '–', 133 | }, 134 | 'south': { 135 | required: true, 136 | description: 'Southern latitude.', 137 | type: 'number', 138 | defaultStr: '–', 139 | }, 140 | 'east': { 141 | required: true, 142 | description: 'Eastern longitude.', 143 | type: 'number', 144 | defaultStr: '–', 145 | }, 146 | ...parsers, 147 | 'pretty': jsonPrettyPrintingParam, 148 | } 149 | return radar 150 | } 151 | 152 | export { 153 | createRadarRoute, 154 | } 155 | -------------------------------------------------------------------------------- /routes/reachable-from.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseWhen, 3 | parseInteger, 4 | parseString, 5 | parseQuery, 6 | parseProducts 7 | } from '../lib/parse.js' 8 | import {sendServerTiming} from '../lib/server-timing.js' 9 | import { 10 | configureJSONPrettyPrinting, 11 | jsonPrettyPrintingOpenapiParam, 12 | jsonPrettyPrintingParam, 13 | } from '../lib/json-pretty-printing.js' 14 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 15 | import {formatProductParams} from '../lib/format-product-parameters.js' 16 | 17 | const err400 = (msg) => { 18 | const err = new Error(msg) 19 | err.statusCode = 400 20 | return err 21 | } 22 | 23 | const createReachableFromRoute = (hafas, config) => { 24 | const parsers = config.mapRouteParsers('reachable-from', { 25 | when: { 26 | description: 'Date & time to compute the reachability for.', 27 | type: 'date+time', 28 | defaultStr: '*now*', 29 | parse: parseWhen(hafas.profile.timezone), 30 | }, 31 | maxTransfers: { 32 | description: 'Maximum number of transfers.', 33 | type: 'integer', 34 | default: 5, 35 | parse: parseInteger, 36 | }, 37 | maxDuration: { 38 | description: 'Maximum travel duration, in minutes.', 39 | type: 'integer', 40 | defaultStr: '*infinite*', 41 | parse: parseInteger, 42 | }, 43 | language: { 44 | description: 'Language of the results.', 45 | type: 'string', 46 | default: 'en', 47 | parse: parseString, 48 | }, 49 | }) 50 | 51 | const reachableFrom = (req, res, next) => { 52 | if (!req.query.latitude) return next(err400('Missing latitude.')) 53 | if (!req.query.longitude) return next(err400('Missing longitude.')) 54 | if (!req.query.address) return next(err400('Missing address.')) 55 | 56 | const opt = parseQuery(parsers, req.query) 57 | opt.products = parseProducts(hafas.profile.products, req.query) 58 | config.addHafasOpts(opt, 'reachableFrom', req) 59 | 60 | hafas.reachableFrom({ 61 | type: 'location', 62 | latitude: +req.query.latitude, 63 | longitude: +req.query.longitude, 64 | address: req.query.address, 65 | }, opt) 66 | .then((reachable) => { 67 | sendServerTiming(res, reachable) 68 | res.allowCachingFor(60) // 1 minute 69 | configureJSONPrettyPrinting(req, res) 70 | res.json(reachable) 71 | next() 72 | }) 73 | .catch(next) 74 | } 75 | 76 | reachableFrom.openapiPaths = config.mapRouteOpenapiPaths('reachable-from', { 77 | '/stops/reachable-from': { 78 | get: { 79 | summary: 'Finds stops/stations reachable within a certain time from an address.', 80 | description: `\ 81 | 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**.`, 82 | externalDocs: { 83 | description: '`hafasClient.reachableFrom()` documentation', 84 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/reachable-from.md', 85 | }, 86 | parameters: [ 87 | ...formatParsersAsOpenapiParams(parsers), 88 | jsonPrettyPrintingOpenapiParam, 89 | ], 90 | responses: { 91 | '2XX': { 92 | description: 'An object with an array of stops/stations, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/reachable-from.md).', 93 | content: { 94 | 'application/json': { 95 | schema: { 96 | type: 'object', 97 | properties: { 98 | reachable: { 99 | type: 'array', 100 | items: {type: 'object'}, // todo 101 | }, 102 | realtimeDataUpdatedAt: { 103 | type: 'integer', 104 | }, 105 | }, 106 | required: [ 107 | 'reachable', 108 | ], 109 | }, 110 | // todo: example(s) 111 | }, 112 | }, 113 | // todo: links 114 | }, 115 | // todo: non-2xx response 116 | }, 117 | }, 118 | }, 119 | }) 120 | 121 | reachableFrom.queryParameters = { 122 | 'latitude': { 123 | required: true, 124 | type: 'number', 125 | defaultStr: '–', 126 | }, 127 | 'longitude': { 128 | required: true, 129 | type: 'number', 130 | defaultStr: '–', 131 | }, 132 | 'address': { 133 | required: true, 134 | type: 'string', 135 | defaultStr: '–', 136 | }, 137 | ...parsers, 138 | ...formatProductParams(hafas.profile.products), 139 | 'pretty': jsonPrettyPrintingParam, 140 | } 141 | return reachableFrom 142 | } 143 | 144 | export { 145 | createReachableFromRoute, 146 | } 147 | -------------------------------------------------------------------------------- /routes/refresh-journey.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseBoolean, 3 | parseString, 4 | parseQuery 5 | } from '../lib/parse.js' 6 | import {sendServerTiming} from '../lib/server-timing.js' 7 | import { 8 | configureJSONPrettyPrinting, 9 | jsonPrettyPrintingOpenapiParam, 10 | jsonPrettyPrintingParam, 11 | } from '../lib/json-pretty-printing.js' 12 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 13 | 14 | const err400 = (msg) => { 15 | const err = new Error(msg) 16 | err.statusCode = 400 17 | return err 18 | } 19 | 20 | const createRefreshJourneyRoute = (hafas, config) => { 21 | const parsers = config.mapRouteParsers('refresh-journey', { 22 | stopovers: { 23 | description: 'Fetch & parse stopovers on the way?', 24 | type: 'boolean', 25 | default: false, 26 | parse: parseBoolean, 27 | }, 28 | tickets: { 29 | description: 'Return information about available tickets?', 30 | type: 'boolean', 31 | default: false, 32 | parse: parseBoolean, 33 | }, 34 | polylines: { 35 | description: 'Fetch & parse a shape for each journey leg?', 36 | type: 'boolean', 37 | default: false, 38 | parse: parseBoolean, 39 | }, 40 | subStops: { 41 | description: 'Parse & return sub-stops of stations?', 42 | type: 'boolean', 43 | default: true, 44 | parse: parseBoolean, 45 | }, 46 | entrances: { 47 | description: 'Parse & return entrances of stops/stations?', 48 | type: 'boolean', 49 | default: true, 50 | parse: parseBoolean, 51 | }, 52 | remarks: { 53 | description: 'Parse & return hints & warnings?', 54 | type: 'boolean', 55 | default: true, 56 | parse: parseBoolean, 57 | }, 58 | scheduledDays: { 59 | description: 'Parse & return dates the journey is valid on?', 60 | type: 'boolean', 61 | default: false, 62 | parse: parseBoolean, 63 | }, 64 | language: { 65 | description: 'Language of the results.', 66 | type: 'string', 67 | default: 'en', 68 | parse: parseString, 69 | }, 70 | }) 71 | 72 | const refreshJourney = (req, res, next) => { 73 | const ref = req.params.ref.trim() 74 | 75 | const opt = parseQuery(parsers, req.query) 76 | config.addHafasOpts(opt, 'refreshJourney', req) 77 | 78 | hafas.refreshJourney(ref, opt) 79 | .then((journeyRes) => { 80 | sendServerTiming(res, journeyRes) 81 | res.allowCachingFor(60) // 1 minute 82 | configureJSONPrettyPrinting(req, res) 83 | res.json(journeyRes) 84 | next() 85 | }) 86 | .catch(next) 87 | } 88 | 89 | refreshJourney.openapiPaths = config.mapRouteOpenapiPaths('refresh-journey', { 90 | '/journeys/{ref}': { 91 | get: { 92 | summary: 'Fetches up-to-date realtime data for a journey computed before.', 93 | description: `\ 94 | Uses [\`hafasClient.refreshJourney()\`](https://github.com/public-transport/hafas-client/blob/6/docs/refresh-journey.md) to **"refresh" a journey, using its \`refreshToken\`**. 95 | 96 | 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.`, 97 | externalDocs: { 98 | description: '`hafasClient.refreshJourney()` documentation', 99 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/refresh-journey.md', 100 | }, 101 | parameters: [ 102 | { 103 | name: 'ref', 104 | in: 'path', 105 | description: 'The journey\'s `refreshToken`.', 106 | required: true, 107 | schema: {type: 'string'}, 108 | // todo: examples? 109 | }, 110 | ...formatParsersAsOpenapiParams(parsers), 111 | jsonPrettyPrintingOpenapiParam, 112 | ], 113 | responses: { 114 | '2XX': { 115 | description: 'An object with the up-to-date journey, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/refresh-journey.md).', 116 | content: { 117 | 'application/json': { 118 | schema: { 119 | type: 'object', 120 | properties: { 121 | journey: { 122 | type: 'object', 123 | // todo 124 | }, 125 | realtimeDataUpdatedAt: { 126 | type: 'integer', 127 | }, 128 | }, 129 | required: [ 130 | 'journey', 131 | ], 132 | }, 133 | // todo: example(s) 134 | }, 135 | }, 136 | // todo: links 137 | }, 138 | // todo: non-2xx response 139 | }, 140 | }, 141 | }, 142 | }) 143 | 144 | refreshJourney.pathParameters = { 145 | 'ref': {type: 'string'}, 146 | } 147 | refreshJourney.queryParameters = { 148 | ...parsers, 149 | 'pretty': jsonPrettyPrintingParam, 150 | } 151 | return refreshJourney 152 | } 153 | 154 | export { 155 | createRefreshJourneyRoute, 156 | } 157 | -------------------------------------------------------------------------------- /routes/stop.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseStop, 3 | parseBoolean, 4 | parseString, 5 | parseQuery 6 | } from '../lib/parse.js' 7 | import {sendServerTiming} from '../lib/server-timing.js' 8 | import { 9 | configureJSONPrettyPrinting, 10 | jsonPrettyPrintingOpenapiParam, 11 | jsonPrettyPrintingParam, 12 | } from '../lib/json-pretty-printing.js' 13 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 14 | 15 | const createStopRoute = (hafas, config) => { 16 | const parsers = config.mapRouteParsers('stop', { 17 | linesOfStops: { 18 | description: 'Parse & expose lines at each stop/station?', 19 | type: 'boolean', 20 | default: false, 21 | parse: parseBoolean, 22 | }, 23 | language: { 24 | description: 'Language of the results.', 25 | type: 'string', 26 | default: 'en', 27 | parse: parseString, 28 | }, 29 | }) 30 | 31 | const stop = (req, res, next) => { 32 | if (res.headersSent) return next() 33 | 34 | const id = parseStop('id', req.params.id) 35 | 36 | const opt = parseQuery(parsers, req.query) 37 | config.addHafasOpts(opt, 'stop', req) 38 | 39 | hafas.stop(id, opt) 40 | .then((stop) => { 41 | sendServerTiming(res, stop) 42 | res.allowCachingFor(5 * 60) // 5 minutes 43 | configureJSONPrettyPrinting(req, res) 44 | res.json(stop) 45 | next() 46 | }) 47 | .catch(next) 48 | } 49 | 50 | stop.openapiPaths = config.mapRouteOpenapiPaths('stop', { 51 | '/stops/{id}': { 52 | get: { 53 | summary: 'Finds a stop/station by ID.', 54 | description: `\ 55 | Uses [\`hafasClient.stop()\`](https://github.com/public-transport/hafas-client/blob/6/docs/stop.md) to **find a stop/station by ID**.`, 56 | externalDocs: { 57 | description: '`hafasClient.stop()` documentation', 58 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/stop.md', 59 | }, 60 | parameters: [ 61 | { 62 | name: 'id', 63 | in: 'path', 64 | description: 'stop/station ID', 65 | required: true, 66 | schema: {type: 'string'}, 67 | // todo: examples? 68 | }, 69 | ...formatParsersAsOpenapiParams(parsers), 70 | jsonPrettyPrintingOpenapiParam, 71 | ], 72 | responses: { 73 | '2XX': { 74 | description: 'The stop, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/stop.md).', 75 | content: { 76 | 'application/json': { 77 | schema: { 78 | type: 'object', 79 | // todo 80 | }, 81 | // todo: example(s) 82 | }, 83 | }, 84 | // todo: links 85 | }, 86 | // todo: non-2xx response 87 | }, 88 | }, 89 | }, 90 | }) 91 | 92 | stop.pathParameters = { 93 | 'id': { 94 | description: 'stop/station ID', 95 | type: 'string', 96 | }, 97 | } 98 | stop.queryParameters = { 99 | ...parsers, 100 | 'pretty': jsonPrettyPrintingParam, 101 | } 102 | return stop 103 | } 104 | 105 | export { 106 | createStopRoute, 107 | } 108 | -------------------------------------------------------------------------------- /routes/trip.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseBoolean, 3 | parseString, 4 | parseQuery 5 | } from '../lib/parse.js' 6 | import {sendServerTiming} from '../lib/server-timing.js' 7 | import { 8 | configureJSONPrettyPrinting, 9 | jsonPrettyPrintingOpenapiParam, 10 | jsonPrettyPrintingParam, 11 | } from '../lib/json-pretty-printing.js' 12 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 13 | 14 | const err400 = (msg) => { 15 | const err = new Error(msg) 16 | err.statusCode = 400 17 | return err 18 | } 19 | 20 | const createTripRoute = (hafas, config) => { 21 | const parsers = config.mapRouteParsers('trip', { 22 | stopovers: { 23 | description: 'Fetch & parse stopovers on the way?', 24 | type: 'boolean', 25 | default: true, 26 | parse: parseBoolean, 27 | }, 28 | remarks: { 29 | description: 'Parse & return hints & warnings?', 30 | type: 'boolean', 31 | default: true, 32 | parse: parseBoolean, 33 | }, 34 | polyline: { 35 | description: 'Fetch & parse the geographic shape of the trip?', 36 | type: 'boolean', 37 | default: false, 38 | parse: parseBoolean, 39 | }, 40 | language: { 41 | description: 'Language of the results.', 42 | type: 'string', 43 | default: 'en', 44 | parse: parseString, 45 | }, 46 | }) 47 | 48 | const trip = (req, res, next) => { 49 | const id = req.params.id.trim() 50 | 51 | const opt = parseQuery(parsers, req.query) 52 | config.addHafasOpts(opt, 'trip', req) 53 | 54 | hafas.trip(id, opt) 55 | .then((tripRes) => { 56 | sendServerTiming(res, tripRes) 57 | res.allowCachingFor(30) // 30 seconds 58 | configureJSONPrettyPrinting(req, res) 59 | res.json(tripRes) 60 | next() 61 | }) 62 | .catch(next) 63 | } 64 | 65 | trip.openapiPaths = config.mapRouteOpenapiPaths('trip', { 66 | '/trips/{id}': { 67 | get: { 68 | summary: 'Fetches a trip by ID.', 69 | description: `\ 70 | Uses [\`hafasClient.trip()\`](https://github.com/public-transport/hafas-client/blob/6/docs/trip.md) to **fetch a trip by ID**.`, 71 | externalDocs: { 72 | description: '`hafasClient.trip()` documentation', 73 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/trip.md', 74 | }, 75 | parameters: [ 76 | { 77 | name: 'id', 78 | in: 'path', 79 | description: 'trip ID', 80 | required: true, 81 | schema: {type: 'string'}, 82 | // todo: examples? 83 | }, 84 | ...formatParsersAsOpenapiParams(parsers), 85 | jsonPrettyPrintingOpenapiParam, 86 | ], 87 | responses: { 88 | '2XX': { 89 | description: 'An object with the trip, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/trip.md).', 90 | content: { 91 | 'application/json': { 92 | schema: { 93 | type: 'object', 94 | properties: { 95 | trip: { 96 | type: 'object', 97 | // todo 98 | }, 99 | realtimeDataUpdatedAt: { 100 | type: 'integer', 101 | }, 102 | }, 103 | required: [ 104 | 'trip', 105 | ], 106 | }, 107 | // todo: example(s) 108 | }, 109 | }, 110 | // todo: links 111 | }, 112 | // todo: non-2xx response 113 | }, 114 | }, 115 | }, 116 | }) 117 | 118 | trip.pathParameters = { 119 | 'id': { 120 | description: 'trip ID', 121 | type: 'string', 122 | }, 123 | } 124 | trip.queryParameters = { 125 | ...parsers, 126 | 'pretty': jsonPrettyPrintingParam, 127 | } 128 | return trip 129 | } 130 | 131 | export { 132 | createTripRoute, 133 | } 134 | -------------------------------------------------------------------------------- /routes/trips.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit.js' 2 | import { 3 | parseWhen, 4 | parseStop, 5 | parseBoolean, 6 | parseString, 7 | parseArrayOfStrings, 8 | parseQuery, 9 | parseProducts, 10 | } from '../lib/parse.js' 11 | import {sendServerTiming} from '../lib/server-timing.js' 12 | import { 13 | configureJSONPrettyPrinting, 14 | jsonPrettyPrintingOpenapiParam, 15 | jsonPrettyPrintingParam, 16 | } from '../lib/json-pretty-printing.js' 17 | import {formatParsersAsOpenapiParams} from '../lib/format-parsers-as-openapi.js' 18 | import {formatProductParams} from '../lib/format-product-parameters.js' 19 | 20 | // const MINUTE = 60 * 1000 21 | 22 | const err400 = (msg) => { 23 | const err = new Error(msg) 24 | err.statusCode = 400 25 | return err 26 | } 27 | 28 | const createTripsRoute = (hafas, config) => { 29 | // todo: move to `hafas-client` 30 | const _parsers = { 31 | query: { 32 | description: 'line name or Fahrtnummer', 33 | type: 'string', 34 | default: '*', 35 | parse: parseString, 36 | }, 37 | when: { 38 | description: 'Date & time to get trips for.', 39 | type: 'date+time', 40 | defaultStr: '*now*', 41 | parse: parseWhen(hafas.profile.timezone), 42 | }, 43 | fromWhen: { 44 | description: 'Together with untilWhen, forms a time frame to get trips for. Mutually exclusive with `when`.', 45 | type: 'date+time', 46 | defaultStr: '*now*', 47 | parse: parseWhen(hafas.profile.timezone), 48 | }, 49 | untilWhen: { 50 | description: 'Together with fromWhen, forms a time frame to get trips for. Mutually exclusive with `when`.', 51 | type: 'date+time', 52 | defaultStr: '*now*', 53 | parse: parseWhen(hafas.profile.timezone), 54 | }, 55 | onlyCurrentlyRunning: { 56 | description: 'Only return trips that run within the specified time frame.', 57 | type: 'boolean', 58 | default: true, 59 | parse: parseBoolean, 60 | }, 61 | currentlyStoppingAt: { 62 | description: 'Only return trips that stop at the specified stop within the specified time frame.', 63 | type: 'string', 64 | parse: parseStop, 65 | }, 66 | lineName: { 67 | description: 'Only return trips with the specified line name.', 68 | type: 'string', 69 | parse: parseString, 70 | }, 71 | operatorNames: { 72 | description: 'Only return trips operated by operators specified by their names, separated by commas.', 73 | type: 'string', 74 | parse: parseArrayOfStrings, 75 | }, 76 | stopovers: { 77 | description: 'Fetch & parse stopovers of each trip?', 78 | type: 'boolean', 79 | default: true, 80 | parse: parseBoolean, 81 | }, 82 | remarks: { 83 | description: 'Parse & return hints & warnings?', 84 | type: 'boolean', 85 | default: true, 86 | parse: parseBoolean, 87 | }, 88 | subStops: { 89 | description: 'Parse & return sub-stops of stations?', 90 | type: 'boolean', 91 | default: true, 92 | parse: parseBoolean, 93 | }, 94 | entrances: { 95 | description: 'Parse & return entrances of stops/stations?', 96 | type: 'boolean', 97 | default: true, 98 | parse: parseBoolean, 99 | }, 100 | language: { 101 | description: 'Language of the results.', 102 | type: 'string', 103 | default: 'en', 104 | parse: parseString, 105 | }, 106 | } 107 | const parsers = config.mapRouteParsers('trips', _parsers) 108 | 109 | const tripsRoute = (req, res, next) => { 110 | const _parsedQuery = parseQuery(parsers, req.query) 111 | const query = 'query' in _parsedQuery ? _parsedQuery.query : '*' 112 | 113 | const opt = omit(_parsedQuery, ['query']) 114 | opt.products = parseProducts(hafas.profile.products, req.query) 115 | config.addHafasOpts(opt, 'tripsByName', req) 116 | 117 | hafas.tripsByName(_parsedQuery.query, opt) 118 | .then((tripsRes) => { 119 | sendServerTiming(res, tripsRes) 120 | // todo: send res.realtimeDataUpdatedAt as Last-Modified? 121 | // todo: appropriate cache time? 122 | res.allowCachingFor(30) // 30 seconds 123 | configureJSONPrettyPrinting(req, res) 124 | res.json(tripsRes) 125 | next() 126 | }) 127 | .catch(next) 128 | } 129 | 130 | tripsRoute.openapiPaths = config.mapRouteOpenapiPaths('trips', { 131 | '/trips': { 132 | get: { 133 | summary: 'Fetches all trips within a specified time frame (default: *now*) that match certain criteria.', 134 | description: `\ 135 | Uses [\`hafasClient.tripsByName()\`](https://github.com/public-transport/hafas-client/blob/6/docs/trips-by-name.md) to query trips.`, 136 | externalDocs: { 137 | description: '`hafasClient.tripsByName()` documentation', 138 | url: 'https://github.com/public-transport/hafas-client/blob/6/docs/trips-by-name.md', 139 | }, 140 | parameters: [ 141 | ...formatParsersAsOpenapiParams(parsers), 142 | jsonPrettyPrintingOpenapiParam, 143 | ], 144 | responses: { 145 | '2XX': { 146 | description: 'An object with a list of trips, in the [`hafas-client` format](https://github.com/public-transport/hafas-client/blob/6/docs/trips-by-name.md).', 147 | content: { 148 | 'application/json': { 149 | schema: { 150 | type: 'object', 151 | properties: { 152 | trips: { 153 | type: 'array', 154 | items: {type: 'object'}, // todo 155 | }, 156 | realtimeDataUpdatedAt: { 157 | type: 'integer', 158 | }, 159 | }, 160 | required: [ 161 | 'trips', 162 | ], 163 | }, 164 | // todo: example(s) 165 | }, 166 | }, 167 | }, 168 | // todo: non-2xx response 169 | }, 170 | }, 171 | }, 172 | }) 173 | 174 | tripsRoute.queryParameters = { 175 | ...parsers, 176 | ...formatProductParams(hafas.profile.products), 177 | 'pretty': jsonPrettyPrintingParam, 178 | } 179 | return tripsRoute 180 | } 181 | 182 | export { 183 | createTripsRoute, 184 | } 185 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import _tapePromise from 'tape-promise' 3 | const {default: tapePromise} = _tapePromise 4 | import LinkHeader from 'http-link-header' 5 | 6 | import { 7 | stationA, stationB, 8 | unmocked, 9 | fetchWithTestApi, 10 | } from './util.js' 11 | 12 | const test = tapePromise(tape) 13 | 14 | test('/ & basic headers', async(t) => { 15 | const {headers: h, data} = await fetchWithTestApi({}, {}, '/', { 16 | headers: {accept: 'application/json'}, 17 | }) 18 | t.equal(h['x-powered-by'], 'test 1.2.3a http://example.org') 19 | t.equal(h['access-control-allow-origin'], '*') 20 | t.equal(h['strict-transport-security'], 'max-age=864000; includeSubDomains') 21 | t.equal(h['x-content-type-options'], 'nosniff') 22 | t.equal(h['content-security-policy'], `default-src 'none'`) 23 | t.equal(h['x-api-version'], '1.2.3a') 24 | t.equal(h['content-type'] ,'application/json; charset=utf-8') 25 | // for some reason, the content-length entry is missing with axios@1. sigh. 26 | // t.ok(h['content-length']) 27 | t.ok(h['etag']) 28 | 29 | t.equal(data.stopUrl, '/stops{/id}{?linesOfStops,language,pretty}') 30 | 31 | const {headers: h2} = await fetchWithTestApi({}, { 32 | cors: false, 33 | etags: 'strong', 34 | }, '/', { 35 | headers: {accept: 'application/json'}, 36 | }) 37 | t.notOk(h2['access-control-allow-origin']) 38 | t.ok(h2['etag']) 39 | t.notOk(h2['etag'].slice(0, 2) === 'W/') 40 | t.end() 41 | }) 42 | 43 | test('/stop/:id', async(t) => { 44 | const mockHafas = { 45 | stop: (id) => { 46 | if (id !== stationA.id) throw new Error('stop() called with invalid ID') 47 | return Promise.resolve(stationA) 48 | } 49 | } 50 | 51 | const path = '/stops/' + stationA.id 52 | const {data} = await fetchWithTestApi(mockHafas, {}, path) 53 | t.deepEqual(data, stationA) 54 | t.end() 55 | }) 56 | 57 | test('/locations/nearby', async(t) => { 58 | const mockHafas = { 59 | nearby: (loc) => { 60 | if (loc.latitude !== 123) throw new Error('nearby() called with invalid latitude') 61 | if (loc.longitude !== 321) throw new Error('nearby() called with invalid longitude') 62 | return Promise.resolve([stationA, stationB]) 63 | } 64 | } 65 | 66 | const path = '/locations/nearby?latitude=123&longitude=321' 67 | const {data} = await fetchWithTestApi(mockHafas, {}, path) 68 | t.deepEqual(data, [stationA, stationB]) 69 | t.end() 70 | }) 71 | 72 | test('/journeys with POI', async(t) => { 73 | // fake data 74 | const someJourney = {_: Math.random().toString(16).slice(2)} 75 | const earlierRef = 'some-earlier-ref' 76 | const laterRef = 'some-later-ref' 77 | 78 | const mockHafas = { 79 | journeys: async (from, to) => { 80 | t.equal(from, '123') 81 | t.deepEqual(to, { 82 | type: 'location', 83 | id: '321', 84 | poi: true, 85 | name: 'Foo', 86 | latitude: 1.23, 87 | longitude: 3.21 88 | }) 89 | return { 90 | earlierRef, laterRef, 91 | journeys: [someJourney] 92 | } 93 | } 94 | } 95 | 96 | const query = '?from=123&to.id=321&to.name=Foo&to.latitude=1.23&to.longitude=3.21&foo=bar' 97 | const path = '/journeys' + query 98 | const {data, headers: h} = await fetchWithTestApi(mockHafas, {}, path) 99 | 100 | t.deepEqual(data.journeys, [someJourney]) 101 | t.equal(data.earlierRef, earlierRef) 102 | t.equal(data.laterRef, laterRef) 103 | 104 | t.ok(h.link) 105 | const l = LinkHeader.parse(h.link) 106 | t.deepEqual(l.refs, [{ 107 | rel: 'prev', 108 | uri: '?foo=bar&earlierThan=some-earlier-ref', 109 | }, { 110 | rel: 'next', 111 | uri: '?foo=bar&laterThan=some-later-ref', 112 | }]) 113 | t.end() 114 | }) 115 | 116 | test('/trips', async(t) => { 117 | const mockTrips = [{ 118 | id: 'trip-1234', 119 | line: {name: 'foo'}, 120 | }] 121 | const mockHafas = { 122 | tripsByName: async (query, opt) => { 123 | t.equal(query, 'RE 1', 'invalid query') 124 | t.equal(opt.onlyCurrentlyRunning, false, 'invalid opt.onlyCurrentlyRunning') 125 | t.same(opt.operatorNames, ['foo', 'bAr'], 'invalid opt.operatorNames') 126 | return { 127 | trips: mockTrips, 128 | realtimeDataUpdatedAt: 123, 129 | } 130 | } 131 | } 132 | 133 | const path = '/trips?query=RE%201&onlyCurrentlyRunning=false&operatorNames=foo,bAr' 134 | const {data} = await fetchWithTestApi(mockHafas, {}, path) 135 | t.deepEqual(data.trips, mockTrips) 136 | t.end() 137 | }) 138 | 139 | test('/trips works without `query` query param', async(t) => { 140 | const mockHafas = { 141 | tripsByName: async (query, opt) => { 142 | t.equal(query, '*', 'invalid query') 143 | return { 144 | trips: [], 145 | realtimeDataUpdatedAt: 123, 146 | } 147 | } 148 | } 149 | 150 | const {data} = await fetchWithTestApi(mockHafas, {}, '/trips') 151 | t.end() 152 | }) 153 | 154 | test('OPTIONS /', async (t) => { 155 | const {headers: h} = await fetchWithTestApi({}, {}, '/', { 156 | method: 'OPTIONS', 157 | }) 158 | 159 | t.equal(h['access-control-max-age'], '86400') 160 | 161 | t.end() 162 | }) 163 | 164 | // todo 165 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import {createClient as createHafas} from 'hafas-client' 2 | import {profile as dbProfile} from 'hafas-client/p/db/index.js' 3 | import getPort from 'get-port' 4 | import {createServer} from 'http' 5 | import {promisify} from 'util' 6 | import axios from 'axios' 7 | 8 | import {createHafasRestApi as createApi} from '../index.js' 9 | 10 | const stationA = { 11 | type: 'station', 12 | id: '12345678', 13 | name: 'A', 14 | location: {type: 'location', latitude: 1.23, longitude: 3.21} 15 | } 16 | const stationB = { 17 | type: 'station', 18 | id: '87654321', 19 | name: 'B', 20 | location: {type: 'location', latitude: 2.34, longitude: 4.32} 21 | } 22 | 23 | const createHealthCheck = hafas => async () => { 24 | const stop = await hafas.stop('8011306') 25 | return !!stop 26 | } 27 | 28 | // prevent hafas-client's user-agent randomization 29 | // todo: introduce a flag for this 30 | const unmocked = createHafas({ 31 | ...dbProfile, 32 | transformReq: (ctx, req) => { 33 | req.headers['user-agent'] = 'DB Navigator/21.10.04 (iPhone; iOS 14.8.1; Scale/3.00)' 34 | return req 35 | }, 36 | }, 'hafas-rest-api test') 37 | 38 | const createTestApi = async (mocks, cfg) => { 39 | const mocked = Object.assign(Object.create(unmocked), mocks) 40 | cfg = Object.assign({ 41 | hostname: 'localhost', 42 | name: 'test', 43 | version: '1.2.3a', 44 | homepage: 'http://example.org', 45 | description: 'test API', 46 | docsLink: 'https://example.org', 47 | logging: false, 48 | healthCheck: createHealthCheck(mocked) 49 | }, cfg) 50 | 51 | const api = await createApi(mocked, cfg, () => {}) 52 | const server = createServer(api) 53 | 54 | const port = await getPort() 55 | await promisify(server.listen.bind(server))(port) 56 | 57 | const stop = () => promisify(server.close.bind(server))() 58 | const fetch = (path, opt = {}) => { 59 | opt = Object.assign({ 60 | method: 'get', 61 | baseURL: `http://localhost:${port}/`, 62 | url: path, 63 | timeout: 5000 64 | }, opt) 65 | return axios(opt) 66 | } 67 | return {stop, fetch} 68 | } 69 | 70 | const fetchWithTestApi = async (mocks, cfg, path, opt = {}) => { 71 | const {fetch, stop} = await createTestApi(mocks, cfg) 72 | const res = await fetch(path, opt) 73 | await stop() 74 | return res 75 | } 76 | 77 | export { 78 | stationA, 79 | stationB, 80 | unmocked, 81 | createTestApi, 82 | fetchWithTestApi, 83 | } 84 | -------------------------------------------------------------------------------- /tools/generate-docs.js: -------------------------------------------------------------------------------- 1 | import {inspect} from 'util' 2 | import Slugger from 'github-slugger' 3 | 4 | const generateRouteDoc = (path, route) => { 5 | let doc = '' 6 | 7 | const params = Object.entries(route.queryParameters || {}) 8 | .filter(([_, spec]) => spec && spec.docs !== false) 9 | if (params.length > 0) { 10 | doc += ` 11 | parameter | description | type | default value 12 | ----------|-------------|------|-------------- 13 | ` 14 | for (const [name, spec] of params) { 15 | let desc = spec.required ? '**Required.** ' : '' 16 | desc += spec.description || '' 17 | if (spec.type === 'date+time') { 18 | desc += ' See [date/time parameters](#datetime-parameters).' 19 | } 20 | 21 | let defaultStr = spec.defaultStr 22 | if (!defaultStr && ('default' in spec)) { 23 | defaultStr = typeof spec.default === 'string' 24 | ? spec.default 25 | : inspect(spec.default) 26 | defaultStr = `\`${defaultStr}\`` 27 | } 28 | 29 | doc += [ 30 | `\`${name}\``, 31 | desc, 32 | spec.typeStr || spec.type, 33 | defaultStr || ' ', 34 | ].join(' | ') + '\n' 35 | } 36 | } 37 | 38 | return doc 39 | } 40 | 41 | const tail = `\ 42 | ## Date/Time Parameters 43 | 44 | Possible formats: 45 | 46 | - anything that [\`parse-human-relative-time\`](https://npmjs.com/package/parse-human-relative-time) can parse (e.g. \`tomorrow 2pm\`) 47 | - [ISO 8601 date/time string](https://en.wikipedia.org/wiki/ISO_8601#Combined_date_and_time_representations) (e.g. \`2020-04-26T22:43+02:00\`) 48 | - [UNIX timestamp](https://en.wikipedia.org/wiki/Unix_time) (e.g. \`1587933780\`) 49 | ` 50 | 51 | const slugger = new Slugger() 52 | const generateApiDocs = (routes) => { 53 | const r = Object.create(null) 54 | let listOfRoutes = `\ 55 | ## Routes 56 | 57 | *Note:* These routes only wrap [\`hafas-client@6\` methods](https://github.com/public-transport/hafas-client/blob/6/docs/api.md), check their docs for more details. 58 | 59 | ` 60 | 61 | for (const [path, route] of Object.entries(routes)) { 62 | r[path] = generateRouteDoc(path, route) 63 | const spec = `GET ${path}` 64 | listOfRoutes += ` 65 | - [\`${spec}\`](#${slugger.slug(spec)})` 66 | } 67 | 68 | listOfRoutes += ` 69 | - [date/time parameters](#datetime-parameters) 70 | ` 71 | 72 | return { 73 | listOfRoutes, 74 | routes: r, 75 | tail, 76 | } 77 | } 78 | 79 | export { 80 | generateApiDocs, 81 | } 82 | --------------------------------------------------------------------------------