├── .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 | [](https://www.npmjs.com/package/hafas-rest-api)
6 | 
7 | [](https://github.com/sponsors/derhuerst)
8 | [](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 |
--------------------------------------------------------------------------------