hvv-client 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.editorconfig +8 -0
  2. package/.eslintignore +1 -0
  3. package/.eslintrc.json +3 -0
  4. package/.idea/inspectionProfiles/Project_Default.xml +6 -0
  5. package/.idea/jsLinters/eslint.xml +6 -0
  6. package/.idea/modules.xml +8 -0
  7. package/.idea/prettier.xml +7 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/.prettierrc.js +3 -0
  10. package/package.json +29 -0
  11. package/src/client/hvvClient.ts +206 -0
  12. package/src/client/hvvClientOptions.ts +6 -0
  13. package/src/converters/attributes.converter.ts +13 -0
  14. package/src/converters/date.converter.ts +96 -0
  15. package/src/converters/line.converter.ts +90 -0
  16. package/src/converters/lineDepartureConverter.ts +42 -0
  17. package/src/converters/routePoint.converter.ts +164 -0
  18. package/src/index.ts +2 -0
  19. package/src/models/attribute.ts +16 -0
  20. package/src/models/coordinate.ts +4 -0
  21. package/src/models/direction.ts +1 -0
  22. package/src/models/filter.ts +1 -0
  23. package/src/models/line.ts +95 -0
  24. package/src/models/lineDeparture.ts +14 -0
  25. package/src/models/routePoint.ts +34 -0
  26. package/src/models/service.ts +25 -0
  27. package/src/models/stationDepartureInfo.ts +6 -0
  28. package/src/models/timeRange.ts +4 -0
  29. package/src/models/timeRealtime.ts +20 -0
  30. package/src/validators/apiResponse/gtiCheckName.ts +24 -0
  31. package/src/validators/apiResponse/gtiDepartureList.ts +10 -0
  32. package/src/validators/gtiAttribute.ts +20 -0
  33. package/src/validators/gtiCoordinate.ts +6 -0
  34. package/src/validators/gtiDeparture.ts +40 -0
  35. package/src/validators/gtiDirection.ts +9 -0
  36. package/src/validators/gtiFilterEntry.ts +8 -0
  37. package/src/validators/gtiFilterServiceType.ts +18 -0
  38. package/src/validators/gtiSDName.ts +75 -0
  39. package/src/validators/gtiService.ts +111 -0
  40. package/src/validators/gtiServiceType.ts +13 -0
  41. package/src/validators/gtiTariffDetails.ts +16 -0
  42. package/tsconfig.json +12 -0
package/.editorconfig ADDED
@@ -0,0 +1,8 @@
1
+ root = true
2
+
3
+ [*]
4
+ indent_style = space
5
+ indent_size = 2
6
+ end_of_line = lf
7
+ charset = utf-8
8
+ insert_final_newline = true
package/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ build/
package/.eslintrc.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./node_modules/gts/"
3
+ }
@@ -0,0 +1,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
5
+ </profile>
6
+ </component>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="EslintConfiguration">
4
+ <option name="fix-on-save" value="true" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/hvv-client.iml" filepath="$PROJECT_DIR$/.idea/hvv-client.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="PrettierConfiguration">
4
+ <option name="myConfigurationMode" value="AUTOMATIC" />
5
+ <option name="myRunOnSave" value="true" />
6
+ </component>
7
+ </project>
package/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
5
+ </component>
6
+ </project>
package/.prettierrc.js ADDED
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ ...require('gts/.prettierrc.json')
3
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "hvv-client",
3
+ "version": "0.0.0",
4
+ "description": "",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "lint": "gts lint",
9
+ "clean": "gts clean",
10
+ "compile": "tsc",
11
+ "fix": "gts fix",
12
+ "prepare": "npm run compile",
13
+ "pretest": "npm run compile",
14
+ "posttest": "npm run lint"
15
+ },
16
+ "dependencies": {
17
+ "crypto-js": "^4.2.0",
18
+ "date-fns": "^4.1.0",
19
+ "date-fns-tz": "^3.2.0",
20
+ "zod": "^3.24.1"
21
+ },
22
+ "devDependencies": {
23
+ "@types/crypto-js": "^4.2.2",
24
+ "@types/node": "^22.7.5",
25
+ "gts": "^6.0.2",
26
+ "typescript": "^5.6.3"
27
+ },
28
+ "private": false
29
+ }
@@ -0,0 +1,206 @@
1
+ import {ZodSchema} from 'zod';
2
+ import hmacSHA1 from 'crypto-js/hmac-sha1';
3
+ import Base64 from 'crypto-js/enc-base64';
4
+
5
+ import {HvvClientOptions} from './hvvClientOptions';
6
+ import {RoutePointStation, RoutePointStationSimple} from '../models/routePoint';
7
+ import {Filter} from '../models/filter';
8
+ import {Service} from '../models/service';
9
+ import {StationDepartureInfo} from '../models/stationDepartureInfo';
10
+ import {dateConverter} from '../converters/date.converter';
11
+ import {gtiDepartureListResponseSchema} from '../validators/apiResponse/gtiDepartureList';
12
+ import {lineDepartureConverter} from '../converters/lineDepartureConverter';
13
+ import {routePointConverter} from '../converters/routePoint.converter';
14
+ import {Coordinate} from '../models/coordinate';
15
+ import {gtiCheckNameCoordinatesResponseSchema} from '../validators/apiResponse/gtiCheckName';
16
+
17
+ type AllHvvClientOptions = Required<HvvClientOptions>;
18
+
19
+ const defaultOptions: AllHvvClientOptions = {
20
+ user: '',
21
+ key: '',
22
+ version: 59,
23
+ host: 'https://gti.geofox.de',
24
+ };
25
+
26
+ export class HvvClient {
27
+ private readonly options: AllHvvClientOptions;
28
+
29
+ public constructor(options: HvvClientOptions) {
30
+ this.options = {
31
+ ...defaultOptions,
32
+ ...options,
33
+ };
34
+ }
35
+
36
+ /**
37
+ *
38
+ */
39
+ public async getNearbyStations({
40
+ coordinate,
41
+ maxDistanceMeters = 800,
42
+ returnTariffDetails = false,
43
+ }: {
44
+ coordinate: Coordinate;
45
+ maxDistanceMeters: number;
46
+ returnTariffDetails?: boolean;
47
+ }): Promise<{
48
+ stations: {
49
+ routePoint: RoutePointStation;
50
+ distanceMeters: number;
51
+ walkingDistanceMinutes: number;
52
+ }[];
53
+ }> {
54
+ const body = {
55
+ theName: {
56
+ type: 'STATION',
57
+ coordinate: {
58
+ x: coordinate.long,
59
+ y: coordinate.lat,
60
+ },
61
+ },
62
+ maxDistance: maxDistanceMeters,
63
+ coordinateType: 'EPSG_4326',
64
+ tariffDetails: returnTariffDetails,
65
+ allowTypeSwitch: false,
66
+ };
67
+
68
+ return await this.makeRequest({
69
+ endpoint: 'checkName',
70
+ body,
71
+ validator: gtiCheckNameCoordinatesResponseSchema,
72
+ converter: data => {
73
+ // filter stations
74
+ const filtered = data.results.filter(item => {
75
+ return item.type === 'STATION';
76
+ });
77
+
78
+ return {
79
+ stations: filtered.map(result => ({
80
+ routePoint: routePointConverter.stationToDto(result),
81
+ distanceMeters: result.distance,
82
+ walkingDistanceMinutes: result.time,
83
+ })),
84
+ };
85
+ },
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Get departures of a given station.
91
+ */
92
+ public async getDepartures({
93
+ stations,
94
+ time,
95
+ filters,
96
+ serviceFilters,
97
+ realtime = true,
98
+ maxResults = 4,
99
+ maxOffsetMinutes = 60,
100
+ allStationsInChangingNode = true,
101
+ returnFilters = false,
102
+ }: {
103
+ stations: RoutePointStationSimple[];
104
+ time: Date;
105
+ filters?: Filter[];
106
+ serviceFilters?: Service[];
107
+ realtime?: boolean;
108
+ maxResults?: number;
109
+ maxOffsetMinutes?: number;
110
+ allStationsInChangingNode?: boolean;
111
+ returnFilters?: boolean;
112
+ }): Promise<{departures: StationDepartureInfo[]}> {
113
+ const body: Record<string, unknown> = {
114
+ stations: stations.map(routePointConverter.dtoToGti),
115
+ time: dateConverter.dateToGtiTime(time),
116
+ maxList: maxResults,
117
+ maxTimeOffset: maxOffsetMinutes,
118
+ allStationsInChangingNode: allStationsInChangingNode,
119
+ returnFilters: returnFilters,
120
+ useRealtime: realtime,
121
+ };
122
+
123
+ if (filters !== undefined) {
124
+ body.filter = filters;
125
+ }
126
+ if (serviceFilters !== undefined) {
127
+ body.serviceTypes = serviceFilters;
128
+ }
129
+
130
+ return await this.makeRequest({
131
+ endpoint: 'departureList',
132
+ body,
133
+ validator: gtiDepartureListResponseSchema,
134
+ converter: data => {
135
+ const departures = data.departures.map(departure => ({
136
+ stationId: departure.station?.id ?? null,
137
+ departure: lineDepartureConverter.toDto(departure, time),
138
+ }));
139
+
140
+ return {
141
+ departures: departures,
142
+ // TODO: other fields?
143
+ };
144
+ },
145
+ });
146
+ }
147
+
148
+ // ---
149
+
150
+ private async makeRequest<ValidatorOutput, ConverterOutput>({
151
+ endpoint,
152
+ body,
153
+ validator,
154
+ converter,
155
+ }: {
156
+ endpoint: string;
157
+ body: Record<string, unknown>;
158
+ validator: ZodSchema<ValidatorOutput>;
159
+ converter: (data: ValidatorOutput) => ConverterOutput;
160
+ }): Promise<ConverterOutput> {
161
+ const fullBody = {
162
+ version: this.options.version,
163
+ ...body,
164
+ };
165
+
166
+ const headers = {
167
+ ...this.getAuthHeaders(fullBody),
168
+ 'Content-Type': 'application/json',
169
+ };
170
+
171
+ console.log('full API request body', fullBody);
172
+
173
+ const response = await fetch(
174
+ `${this.options.host}/gti/public/${endpoint}`,
175
+ {
176
+ method: 'POST',
177
+ body: JSON.stringify(fullBody),
178
+ headers,
179
+ },
180
+ );
181
+
182
+ const responseBody = await response.json();
183
+
184
+ console.dir(responseBody);
185
+
186
+ const {success, data, error} = validator.safeParse(responseBody);
187
+
188
+ if (!success) {
189
+ // eslint-disable-next-line no-debugger
190
+ debugger;
191
+ throw error;
192
+ }
193
+
194
+ return converter(data);
195
+ }
196
+
197
+ private getAuthHeaders(requestBody: Record<string, unknown>) {
198
+ const hmac = hmacSHA1(JSON.stringify(requestBody), this.options.key);
199
+ const signature = Base64.stringify(hmac);
200
+
201
+ return {
202
+ 'geofox-auth-user': this.options.user,
203
+ 'geofox-auth-signature': signature,
204
+ };
205
+ }
206
+ }
@@ -0,0 +1,6 @@
1
+ export type HvvClientOptions = {
2
+ user: string;
3
+ key: string;
4
+ version?: number;
5
+ host?: string;
6
+ };
@@ -0,0 +1,13 @@
1
+ import {GtiAttribute} from '../validators/gtiAttribute';
2
+ import {Attribute} from '../models/attribute';
3
+
4
+ export const attributeConverter = {
5
+ toDto(gti: GtiAttribute): Attribute {
6
+ return {
7
+ id: gti.id ?? null,
8
+ isPlanned: gti.isPlanned,
9
+ value: gti.value,
10
+ types: gti.types,
11
+ };
12
+ },
13
+ };
@@ -0,0 +1,96 @@
1
+ import {format, toZonedTime, fromZonedTime} from 'date-fns-tz';
2
+ import {addSeconds} from 'date-fns';
3
+ import {TimeRealtime} from '../models/timeRealtime';
4
+ import {TimeRange} from '../models/timeRange';
5
+
6
+ export type GtiTime = {date: string; time: string};
7
+ export type GtiTimeRange = {begin: string; end: string};
8
+
9
+ const GTI_TIMEZONE = 'Europe/Berlin';
10
+
11
+ export const dateConverter = {
12
+ /**
13
+ * Converts a Date into the DateTime object required by the Geofox API (GTI).
14
+ * @param date
15
+ */
16
+ dateToGtiTime(date: Date): GtiTime {
17
+ // Geofox dates must always be in german timezone as it does not accept ISO dates.
18
+ // Figure out german timezone and respect that when converting to GtiTime.
19
+
20
+ // convert to germany time
21
+ const germanyTime = toZonedTime(date, GTI_TIMEZONE);
22
+
23
+ const gti = {
24
+ date: format(germanyTime, 'dd.MM.y'),
25
+ time: format(germanyTime, 'HH:mm'),
26
+ };
27
+
28
+ return gti;
29
+ },
30
+
31
+ /**
32
+ * Converts a date into a `DateTime` string used by the Geofox API.
33
+ * This is not a standard ISO string, as the docs require a timezone offset at the end.
34
+ * The default `.toISOString()` generates something like:
35
+ * `2024-11-02T09:58:00.000Z`, however the Geofox API expects something like
36
+ * `2024-11-02T09:58:00.000+0000`
37
+ * @param date
38
+ */
39
+ dateToApiDateTime(date: Date): string {
40
+ const isoString = date.toISOString();
41
+ // remove last letter from isoString
42
+ return isoString.slice(0, -1) + '+0000';
43
+ },
44
+
45
+ apiDateTimeToDate(apiDateTime: string): Date {
46
+ return new Date(apiDateTime);
47
+ },
48
+
49
+ gtiTimeToDate(gtiTime: GtiTime): Date {
50
+ const dateParts = gtiTime.date.split('.');
51
+
52
+ const germanyTime = new Date(
53
+ `${dateParts[2]}-${dateParts[1]}-${dateParts[0]}T${gtiTime.time}`,
54
+ );
55
+ const utc = fromZonedTime(germanyTime, GTI_TIMEZONE);
56
+
57
+ return utc;
58
+ },
59
+
60
+ gtiTimeRangeToDto(gtiTimeRange: GtiTimeRange): TimeRange {
61
+ const fromDateGermanyTime = new Date(gtiTimeRange.begin);
62
+ const toDateGermanyTime = new Date(gtiTimeRange.end);
63
+
64
+ return {
65
+ from: fromZonedTime(fromDateGermanyTime, GTI_TIMEZONE),
66
+ to: fromZonedTime(toDateGermanyTime, GTI_TIMEZONE),
67
+ };
68
+ },
69
+
70
+ gtiTimeRealtimeCancelToDto(
71
+ gtiTime: GtiTime,
72
+ delaySeconds: number | null,
73
+ isCancelled: boolean,
74
+ ): TimeRealtime {
75
+ const planned = dateConverter.gtiTimeToDate(gtiTime);
76
+
77
+ return {
78
+ planned,
79
+ actual: isCancelled ? null : addSeconds(planned, delaySeconds ?? 0),
80
+ delaySeconds,
81
+ };
82
+ },
83
+
84
+ apiTimeRealtimeToDto(
85
+ apiDateTime: string,
86
+ delaySeconds: number | null,
87
+ ): TimeRealtime {
88
+ const planned = dateConverter.apiDateTimeToDate(apiDateTime);
89
+
90
+ return {
91
+ planned,
92
+ actual: addSeconds(planned, delaySeconds ?? 0),
93
+ delaySeconds,
94
+ };
95
+ },
96
+ };
@@ -0,0 +1,90 @@
1
+ import {
2
+ GtiService,
3
+ GtiSimpleServiceType,
4
+ isGtiServicePublicTransport,
5
+ } from '../validators/gtiService';
6
+ import {GtiDirection} from '../validators/gtiDirection';
7
+ import {Line} from '../models/line';
8
+
9
+ function getLineImageUrl(lineId: string): string {
10
+ return `https://cloud.geofox.de/icon/line?height=28&lineKey=${lineId}`;
11
+ }
12
+
13
+ /**
14
+ * Converter for a `Line`.
15
+ *
16
+ * In Geofox, this is called a `Service`.
17
+ */
18
+ export const lineConverter = {
19
+ toDto(gti: GtiService): Line {
20
+ const base = {
21
+ name: gti.name,
22
+ };
23
+
24
+ if (isGtiServicePublicTransport(gti)) {
25
+ switch (gti.type.simpleType) {
26
+ case GtiSimpleServiceType.BUS:
27
+ return {
28
+ ...base,
29
+ type: 'bus',
30
+ direction: gti.direction,
31
+ directionType:
32
+ gti.directionId === GtiDirection.FORWARDS
33
+ ? 'forward'
34
+ : 'backward',
35
+ id: gti.id,
36
+ iconUrl: getLineImageUrl(gti.id),
37
+ };
38
+ case GtiSimpleServiceType.TRAIN:
39
+ return {
40
+ ...base,
41
+ type: 'train',
42
+ direction: gti.direction,
43
+ directionType:
44
+ gti.directionId === GtiDirection.FORWARDS
45
+ ? 'forward'
46
+ : 'backward',
47
+ id: gti.id,
48
+ iconUrl: getLineImageUrl(gti.id),
49
+ };
50
+ case GtiSimpleServiceType.SHIP:
51
+ return {
52
+ ...base,
53
+ type: 'ship',
54
+ direction: gti.direction,
55
+ directionType:
56
+ gti.directionId === GtiDirection.FORWARDS
57
+ ? 'forward'
58
+ : 'backward',
59
+ id: gti.id,
60
+ iconUrl: getLineImageUrl(gti.id),
61
+ };
62
+ }
63
+ }
64
+
65
+ switch (gti.type.simpleType) {
66
+ case GtiSimpleServiceType.BICYCLE:
67
+ return {
68
+ ...base,
69
+ type: 'bicycle',
70
+ };
71
+ case GtiSimpleServiceType.FOOTPATH:
72
+ return {
73
+ ...base,
74
+ type: 'footwalk',
75
+ };
76
+ case GtiSimpleServiceType.CHANGE:
77
+ return {
78
+ ...base,
79
+ type: 'changeStation',
80
+ };
81
+ case GtiSimpleServiceType.CHANGE_SAME_PLATFORM:
82
+ return {
83
+ ...base,
84
+ type: 'changePlatform',
85
+ };
86
+ }
87
+
88
+ throw new Error(`unknown line type: ${gti.type.simpleType}`);
89
+ },
90
+ };
@@ -0,0 +1,42 @@
1
+ import {addMinutes, addSeconds} from 'date-fns';
2
+
3
+ import {lineConverter} from './line.converter';
4
+
5
+ import {GtiDeparture} from '../validators/gtiDeparture';
6
+ import {LineDeparture} from '../models/lineDeparture';
7
+ import {isLinePublicTransport} from '../models/line';
8
+ import {attributeConverter} from './attributes.converter';
9
+
10
+ export const lineDepartureConverter = {
11
+ toDto(gti: GtiDeparture, referenceTime: Date): LineDeparture {
12
+ const isCancelled = gti.cancelled ?? false;
13
+ const delaySeconds = gti.delay ?? null;
14
+ const planned = addMinutes(referenceTime, gti.timeOffset);
15
+ const actual = isCancelled ? null : addSeconds(planned, delaySeconds ?? 0);
16
+
17
+ const line = lineConverter.toDto({
18
+ directionId: gti.directionId, // for some reason the directionId is not in the line object for this request...
19
+ ...gti.line,
20
+ });
21
+ if (!isLinePublicTransport(line)) {
22
+ throw new Error('Invalid line: not public transport');
23
+ }
24
+
25
+ return {
26
+ line,
27
+ departure: {
28
+ planned,
29
+ actual,
30
+ delaySeconds,
31
+ },
32
+ departurePlatformPlanned: gti.platform ?? null,
33
+ departurePlatformActual: gti.realtimePlatform ?? null,
34
+ attributes: (gti.attributes ?? []).map(att =>
35
+ attributeConverter.toDto(att),
36
+ ),
37
+ isExtra: gti.extra ?? false,
38
+ isCancelled,
39
+ uniqueServiceId: gti.serviceId,
40
+ };
41
+ },
42
+ };