Skip to content

support rest.exe endpoints #134

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 38 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6dd9cb6
WIP
derhuerst Oct 23, 2021
ceafec1
formatProductsFilter: extract bitmask formatting
derhuerst Oct 14, 2019
8f6835f
parseLine product parsing: fall back to prodCtx.catCode ✅
derhuerst Nov 6, 2019
604e566
parseLocation id parsing: fall back to lid.b ✅
derhuerst Nov 6, 2019
8153bfe
parseWhen: make sure we have a date 🐛
derhuerst Nov 6, 2019
1424b1e
more accurate mgate.exe error messages
derhuerst Nov 6, 2019
95dcbe6
parseLocation: parse coordinates properly 🐛
derhuerst Oct 6, 2020
68585bc
split fetch logic from lib/request
derhuerst Sep 30, 2021
fa5a076
rest.exe: client & DB profile boilerplate
derhuerst Sep 3, 2019
e542405
boilerplate [f]
derhuerst Nov 22, 2022
50aa4cb
rest.exe: add lib/rest-request
derhuerst Nov 6, 2019
806b038
rest-request [f]
derhuerst Nov 22, 2022
5c371d6
rest.exe: format(Date|Time), parse(When|Location|Polyline|Line|Hint)
derhuerst Nov 6, 2019
aa4416a
rest.exe: locations() & nearby()
derhuerst Nov 6, 2019
16be3b3
rest.exe: departures() & arrivals()
derhuerst Nov 6, 2019
1db8896
rest.exe: journeys()
derhuerst Nov 6, 2019
ee2e363
rest.exe: trip(), tripAlternatives()
derhuerst Oct 6, 2020
ec96fba
rest.exe examples 📝
derhuerst Nov 6, 2019
4483838
rest.exe: more methods as comments [wip]
derhuerst Nov 6, 2019
4e58ab1
rest.exe: db-rest E2E tests ✅ [wip]
derhuerst Oct 6, 2020
359ce9c
add VBB rest.exe profile ✅ [todo]
derhuerst Sep 29, 2021
cec2217
rest.exe parseLocation: parse products, add type: station
derhuerst Sep 30, 2021
46a9609
rest.exe: add vbb-rest parseDeparture test ✅
derhuerst Sep 30, 2021
842b9c4
rest.exe: add vbb-rest parseLocation test ✅
derhuerst Sep 30, 2021
2fa9340
rest.exe: add vbb-rest parseJourney test ✅
derhuerst Sep 30, 2021
82d18bd
WIP
derhuerst May 20, 2021
03e2831
WIP
derhuerst Oct 11, 2021
00e5adb
WIP
derhuerst Oct 23, 2021
9cee7b6
WIP
derhuerst Feb 14, 2022
e30c700
rest.exe: radar: more options, parse movements [todo:tests,docs]
derhuerst Feb 14, 2022
f5f4091
VBB rest.exe: parse DHID/IFOPT stop IDs [todo:test]
derhuerst Feb 14, 2022
c3d6c88
WIP
derhuerst Feb 14, 2022
921bb75
WIP
derhuerst Feb 17, 2022
0f8afc2
WIP
derhuerst Feb 17, 2022
8dafa9f
WIP
derhuerst Mar 15, 2022
4525307
rest.exe trip(): more options
derhuerst Mar 15, 2022
17aa899
WIP
derhuerst Mar 16, 2022
43416a8
WIP
derhuerst Apr 10, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions format-rest/date.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {DateTime, IANAZone} from 'luxon'

const timezones = new WeakMap()

const formatDate = (profile, when) => {
let timezone
if (timezones.has(profile)) timezone = timezones.get(profile)
else {
timezone = new IANAZone(profile.timezone)
timezones.set(profile, timezone)
}

return DateTime.fromMillis(+when, {
locale: profile.locale,
zone: timezone
}).toFormat('yyyy-MM-dd')
}

export {
formatDate,
}
21 changes: 21 additions & 0 deletions format-rest/time.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {DateTime, IANAZone} from 'luxon'

const timezones = new WeakMap()

const formatTime = (profile, when) => {
let timezone
if (timezones.has(profile)) timezone = timezones.get(profile)
else {
timezone = new IANAZone(profile.timezone)
timezones.set(profile, timezone)
}

return DateTime.fromMillis(+when, {
locale: profile.locale,
zone: timezone
}).toFormat('HH:mm:ss')
}

export {
formatTime,
}
30 changes: 30 additions & 0 deletions format/products-bitmask.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import isObj from 'lodash/isObject.js'

const formatProductsBitmask = (ctx, filter) => {
if (!isObj(filter)) throw new TypeError('products filter must be an object')
const {profile} = ctx

const byProduct = Object.create(null)
const defaultProducts = Object.create(null)
for (let product of profile.products) {
byProduct[product.id] = product
defaultProducts[product.id] = product.default
}

filter = Object.assign(Object.create(null), defaultProducts, filter)

let res = 0
for (const product in filter) {
if (filter[product] !== true) continue
if (!byProduct[product]) throw new TypeError('unknown product ' + product)

for (const bitmask of byProduct[product].bitmasks) {
res = res ^ bitmask
}
}
return res
}

export {
formatProductsBitmask,
}
25 changes: 3 additions & 22 deletions format/products-filter.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,13 @@
import isObj from 'lodash/isObject.js'

const hasProp = (o, k) => Object.prototype.hasOwnProperty.call(o, k)

const formatProductsFilter = (ctx, filter) => {
if (!isObj(filter)) throw new TypeError('products filter must be an object')
const {profile} = ctx

const byProduct = {}
const defaultProducts = {}
for (let product of profile.products) {
byProduct[product.id] = product
defaultProducts[product.id] = product.default
}
filter = Object.assign({}, defaultProducts, filter)

let res = 0, products = 0
for (let product in filter) {
if (!hasProp(filter, product) || filter[product] !== true) continue
if (!byProduct[product]) throw new TypeError('unknown product ' + product)
products++
for (let bitmask of byProduct[product].bitmasks) res = res | bitmask
}
if (products === 0) throw new Error('no products used')
const bitmask = profile.formatProductsBitmask(ctx, filter)
if (bitmask === 0) throw new Error('no products used')

return {
type: 'PROD',
mode: 'INC',
value: res + ''
value: bitmask + ''
}
}

Expand Down
2 changes: 2 additions & 0 deletions lib/default-profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {formatCoord} from '../format/coord.js'
import {formatDate} from '../format/date.js'
import {formatLocationFilter} from '../format/location-filter.js'
import {formatProductsFilter} from '../format/products-filter.js'
import {formatProductsBitmask} from '../format/products-bitmask.js'
import {formatPoi} from '../format/poi.js'
import {formatStation} from '../format/station.js'
import {formatTime} from '../format/time.js'
Expand Down Expand Up @@ -108,6 +109,7 @@ const defaultProfile = {
formatDate,
formatLocationFilter,
formatProductsFilter,
formatProductsBitmask,
formatPoi,
formatStation,
formatTime,
Expand Down
56 changes: 56 additions & 0 deletions lib/default-rest-profile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {basename} from 'path'
import {defaultProfile} from './default-profile.js'
import {request} from './rest-request.js'
import {parseWhen} from '../parse-rest/when.js'
import {parseLocation, parseLocationsResult} from '../parse-rest/location.js'
import {parsePolyline} from '../parse-rest/polyline.js'
import {parseLine} from '../parse-rest/line.js'
import {parseHint} from '../parse-rest/hint.js'
import {parseScheduledDays} from '../parse-rest/scheduled-days.js'
import {parseStopover} from '../parse-rest/stopover.js'
import {parseArrival, parseDeparture} from '../parse-rest/arrival-or-departure.js'
import {parseMovement} from '../parse-rest/movement.js'
import {parseJourneyLeg} from '../parse-rest/journey-leg.js'
import {parseJourney} from '../parse-rest/journey.js'
import {parseTrip} from '../parse-rest/trip.js'
import {formatDate} from '../format-rest/date.js'
import {formatTime} from '../format-rest/time.js'

const DEBUG = /(^|,)hafas-client(,|$)/.test(process.env.DEBUG || '')
const logRequest = DEBUG
? (_, req, reqId) => {
const url = new URL(req.url)
console.error(basename(url.pathname) + url.search)
}
: () => {}
const logResponse = DEBUG
? (_, res, body, reqId) => console.error(body)
: () => {}

const defaultRestProfile = {
...defaultProfile,

request,
logRequest,
logResponse,

parseWhen,
parseLocation, parseLocationsResult,
parsePolyline,
parseLine,
parseHint,
parseScheduledDays,
parseStopover,
parseArrival, parseDeparture,
parseMovement,
parseJourneyLeg,
parseJourney,
parseTrip,

formatDate,
formatTime,
}

export {
defaultRestProfile,
}
12 changes: 6 additions & 6 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const byErrorCode = Object.assign(Object.create(null), {
},
H390: {
Error: HafasInvalidRequestError,
message: 'journeys search: departure/arrival station replaced',
message: 'journeys search: origin/destination replaced',
props: {
},
},
Expand Down Expand Up @@ -186,7 +186,7 @@ const byErrorCode = Object.assign(Object.create(null), {
},
H895: {
Error: HafasInvalidRequestError,
message: 'journeys search: departure & arrival are too near',
message: 'journeys search: origin & destination are too close',
props: {
},
},
Expand Down Expand Up @@ -233,19 +233,19 @@ const byErrorCode = Object.assign(Object.create(null), {
},
H9260: {
Error: HafasInvalidRequestError,
message: 'journeys search: unknown departure station',
message: 'journeys search: unknown origin',
props: {
},
},
H9280: {
Error: HafasInvalidRequestError,
message: 'journeys search: unknown intermediate station',
message: 'journeys search: unknown change/via station',
props: {
},
},
H9300: {
Error: HafasInvalidRequestError,
message: 'journeys search: unknown arrival station',
message: 'journeys search: unknown destination',
props: {
},
},
Expand All @@ -263,7 +263,7 @@ const byErrorCode = Object.assign(Object.create(null), {
},
H9380: {
Error: HafasInvalidRequestError,
message: 'journeys search: departure/arrival/intermediate station defined more than once',
message: 'journeys search: origin/destination/via station defined more than once',
props: {
},
},
Expand Down
142 changes: 142 additions & 0 deletions lib/fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import ProxyAgent from 'https-proxy-agent'
import {isIP} from 'net'
import {Agent as HttpsAgent} from 'https'
import roundRobin from '@derhuerst/round-robin-scheduler'
import {randomBytes} from 'crypto'
import createHash from 'create-hash'
import {Buffer} from 'node:buffer'
import {stringify} from 'qs'
import {Request, fetch} from 'cross-fetch'
import {parse as parseContentType} from 'content-type'
import {HafasError} from './errors.js'
import {randomizeUserAgent} from './randomize-user-agent.js'

const proxyAddress = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null
const localAddresses = process.env.LOCAL_ADDRESS || null

if (proxyAddress && localAddresses) {
console.error('Both env vars HTTPS_PROXY/HTTP_PROXY and LOCAL_ADDRESS are not supported.')
process.exit(1)
}

const plainAgent = new HttpsAgent({
keepAlive: true,
})
let getAgent = () => plainAgent

if (proxyAddress) {
const agent = new ProxyAgent(proxyAddress, {
keepAlive: true,
keepAliveMsecs: 10 * 1000, // 10s
})
getAgent = () => agent
} else if (localAddresses) {
const agents = process.env.LOCAL_ADDRESS.split(',')
.map((addr) => {
const family = isIP(addr)
if (family === 0) throw new Error('invalid local address:' + addr)
return new HttpsAgent({
localAddress: addr, family,
keepAlive: true,
})
})
const pool = roundRobin(agents)
getAgent = () => pool.get()
}

const md5 = input => createHash('md5').update(input).digest()

const fetchFromHafas = async (ctx, userAgent, resource, req, opt = {}) => {
const {profile} = ctx
const {
throwIfNotOk,
} = {
throwIfNotOk: true,
...opt,
}

req = profile.transformReq(ctx, {
...req,
agent: getAgent(),
method: 'post',
// todo: CORS? referrer policy?
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'gzip, br, deflate',
'Accept': 'application/json',
'user-agent': profile.randomizeUserAgent
? randomizeUserAgent(userAgent)
: userAgent,
'connection': 'keep-alive', // prevent excessive re-connecting
...(req.headers || {}),
},
redirect: 'follow',
})

if (profile.addChecksum || profile.addMicMac) {
if (!Buffer.isBuffer(profile.salt) && 'string' !== typeof profile.salt) {
throw new TypeError('profile.salt must be a Buffer or a string.')
}
// Buffer.from(buf, 'hex') just returns buf
const salt = Buffer.from(profile.salt, 'hex')

if (profile.addChecksum) {
const checksum = md5(Buffer.concat([
Buffer.from(req.body, 'utf8'),
salt,
]))
req.query.checksum = checksum.toString('hex')
}
if (profile.addMicMac) {
const mic = md5(Buffer.from(req.body, 'utf8'))
req.query.mic = mic.toString('hex')

const micAsHex = Buffer.from(mic.toString('hex'), 'utf8')
const mac = md5(Buffer.concat([micAsHex, salt]))
req.query.mac = mac.toString('hex')
}
}

const reqId = randomBytes(3).toString('hex')
const url = resource + '?' + stringify(req.query)
const fetchReq = new Request(url, req)
profile.logRequest(ctx, fetchReq, reqId)

const res = await fetch(url, req)

const errProps = {
request: fetchReq,
response: res,
url,
}

if (throwIfNotOk && !res.ok) {
// todo [breaking]: make this a FetchError or a HafasClientError?
const err = new Error(res.statusText)
Object.assign(err, errProps)
throw err
}

let cType = res.headers.get('content-type')
if (cType) {
const {type} = parseContentType(cType)
if (type !== 'application/json') {
throw new HafasError('invalid/unsupported response content-type: ' + cType, null, errProps)
}
}

const bodyAsText = await res.text()
profile.logResponse(ctx, res, bodyAsText, reqId)

const body = JSON.parse(bodyAsText)

return {
res,
body,
errProps,
}
}

export {
fetchFromHafas,
}
19 changes: 19 additions & 0 deletions lib/randomize-user-agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {randomBytes} from 'crypto'

const id = randomBytes(3).toString('hex')
const randomizeUserAgent = (userAgent) => {
let ua = userAgent
for (
let i = Math.round(5 + Math.random() * 5);
i < ua.length;
i += Math.round(5 + Math.random() * 5)
) {
ua = ua.slice(0, i) + id + ua.slice(i)
i += id.length
}
return ua
}

export {
randomizeUserAgent,
}
Loading