From 3c81b5225fbefa125a752768e94dcb95c1c922e3 Mon Sep 17 00:00:00 2001 From: Fred Boniface Date: Mon, 27 Apr 2026 22:28:55 +0100 Subject: [PATCH] Add trains endpoint handler --- package-lock.json | 8 +++--- package.json | 2 +- src/index.ts | 1 + src/lib/client.ts | 5 +++- src/lib/helpers.ts | 20 ++++++++++++++ src/lib/validation.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ src/modules/trains.ts | 34 +++++++++++++++++++++++ 7 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/lib/helpers.ts create mode 100644 src/modules/trains.ts diff --git a/package-lock.json b/package-lock.json index c012257..9cfa580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.0", "license": "GPL-3.0", "dependencies": { - "@owlboard/api-schema-types": "^3.0.2-alpha3", + "@owlboard/api-schema-types": "^3.0.3-alpha1", "latlon-geohash": "^2.0.0" }, "devDependencies": { @@ -504,9 +504,9 @@ } }, "node_modules/@owlboard/api-schema-types": { - "version": "3.0.2-alpha3", - "resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.2-alpha3/api-schema-types-3.0.2-alpha3.tgz", - "integrity": "sha512-3NFP21QdSfjziwlGQixlNnUWC55HlKZGyWANIOimWu0FZejWQWExJiaAVfb6m3Sbv+zvQMu3B8mzcMCcGadZCQ==", + "version": "3.0.3-alpha1", + "resolved": "https://git.fjla.uk/api/packages/OwlBoard/npm/%40owlboard%2Fapi-schema-types/-/3.0.3-alpha1/api-schema-types-3.0.3-alpha1.tgz", + "integrity": "sha512-UWe2nbJWb2B/LuZW1UXHJ2lpqOGwugiXTa4G6X5xLiaws3ISEdciweorX8kr2/JAz5+iFYIe1xXRFAWsFtpn/w==", "license": "MIT" }, "node_modules/@tsconfig/node10": { diff --git a/package.json b/package.json index 28e0681..cc385d4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "author": "Frederick Boniface", "license": "GPL-3.0", "dependencies": { - "@owlboard/api-schema-types": "^3.0.2-alpha3", + "@owlboard/api-schema-types": "^3.0.3-alpha1", "latlon-geohash": "^2.0.0" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index 232d250..c7f2209 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export type * from '@owlboard/api-schema-types' export { PisModule } from './modules/pis.js'; export { LocationFilterModule } from './modules/locationFilter.js'; export { StationDataModule } from './modules/stationData.js'; +export { TrainsModule } from './modules/trains.js'; diff --git a/src/lib/client.ts b/src/lib/client.ts index 076a6dc..54e2af6 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -2,11 +2,13 @@ import { BaseClient } from "./base.js"; import { PisModule } from "../modules/pis.js"; import { LocationFilterModule } from "../modules/locationFilter.js"; import { StationDataModule } from "../modules/stationData.js"; +import { TrainsModule } from "src/modules/trains.js"; export class OwlBoardClient extends BaseClient { public readonly pis: PisModule; public readonly locationFilter: LocationFilterModule; - public readonly stationData: StationDataModule; + public readonly stationData: StationDataModule; + public readonly trains: TrainsModule; constructor(baseUrl: string, apiKey?: string) { super(baseUrl, apiKey); @@ -14,5 +16,6 @@ export class OwlBoardClient extends BaseClient { this.pis = new PisModule(this); this.locationFilter = new LocationFilterModule(this); this.stationData = new StationDataModule(this); + this.trains = new TrainsModule(this); } } \ No newline at end of file diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts new file mode 100644 index 0000000..df8dab2 --- /dev/null +++ b/src/lib/helpers.ts @@ -0,0 +1,20 @@ +/** + * Normalises input to YYYY-MM-DD string + * - Strings are passed through as is + * - Date objects are converted to UK time + */ +export const ensureDateString = (input: Date | string): string => { + if (typeof input === 'string') return input.trim(); + + const d = new Intl.DateTimeFormat('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'Europe/London' + }).formatToParts(input); + + const part = (type: string) => d.find(p => p.type === type)?.value; + + return `${part('year')}-${part('month')}-${part('day')}` + +} \ No newline at end of file diff --git a/src/lib/validation.ts b/src/lib/validation.ts index a57c2e4..f8aa168 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,3 +1,13 @@ +/** + * Checks if a character code is a digit + */ +const isDigit = (c: number) => c >= 48 && c <= 57; + +/** + * Checks if a character code is an upper case letter + */ +const isUpper = (c: number) => c >= 65 && c <= 90; + /** * Checks if a string is a valid CRS (Syntactically Only) * using byte level checking for max performance @@ -49,6 +59,60 @@ export const IsValidPis = (PIS: string): boolean => { return true; } +/** + * Checks if a string is the correct format for a Headcode + * USes byte level checking for max performance + * @param Headcode The headcode to be validated + */ +export const IsValidHeadcode = (Headcode: string): boolean => { + if (Headcode.length !== 4) return false; + const c0 = Headcode.charCodeAt(0); + const c1 = Headcode.charCodeAt(1); + const c2 = Headcode.charCodeAt(2); + const c3 = Headcode.charCodeAt(3); + + return isDigit(c0) && isUpper(c1) && isDigit(c2) && isDigit(c3) +} + +/** + * Checks if a string is the correct format for a TOC Code + */ +export const IsValidToc = (toc: string): boolean => { + if (toc.length !== 2) { + return false; + } + + const c0 = toc.charCodeAt(0); + const c1 = toc.charCodeAt(1); + return isUpper(c0) && isUpper(c1); +} + +/** + * Checks if a string is the correct format, and is a valid date + */ +export const IsValidDateStr = (s: string): boolean => { + if (s.length !== 10) return false; + if (s[4] !== '-' || s[7] !== '-') return false; + + for (let i = 0; i < 10; i++) { + if (i === 4 || i === 7) continue; + const c = s.charCodeAt(i); + if (!isDigit(i)) return false; + } + + const y = parseInt(s.substring(0, 4), 10); + const m = parseInt(s.substring(5, 7), 10) - 1; // JS months are 0-11 + const d = parseInt(s.substring(8, 10), 10); + + const date = new Date(y, m, d); + + return ( + date.getFullYear() === y && + date.getMonth() === m && + date.getDate() === d + ); +} + /** * Validates Geohash string against standard b32 alphabet * (Syntactically Only validation) diff --git a/src/modules/trains.ts b/src/modules/trains.ts new file mode 100644 index 0000000..4fcaa40 --- /dev/null +++ b/src/modules/trains.ts @@ -0,0 +1,34 @@ +import { ApiTrainsTrainByHeadcode } from '@owlboard/api-schema-types'; +import type { BaseClient, ApiResult } from '../lib/base.js'; +import { IsValidHeadcode, IsValidToc, IsValidDateStr } from '../lib/validation.js'; +import { ensureDateString } from 'src/lib/helpers.js'; +import { ValidationError } from '../lib/errors.js'; + +export class TrainsModule { + constructor(private client: BaseClient) { } + + async getByHeadcode(headcode: string, date: string | Date = new Date, toc: string = ""): Promise> { + if (!IsValidHeadcode(headcode)) { + throw new ValidationError("headcode", "Invalid headcode format") + } + if (toc !== "" && !IsValidToc(toc)) { + throw new ValidationError("TOC", "Invalid TOC Format, needs to be two characters"); + } + + const dateStr = ensureDateString(date); + if (!IsValidDateStr(dateStr)) { + throw new ValidationError("Date", "Invalid date format, need YYYY-MM-DD"); + } + + const searchParams = new URLSearchParams(); + searchParams.append('d', dateStr); + searchParams.append('h', headcode); + if (toc) searchParams.append('t', toc); + + const path = `/trains?${searchParams.toString()}`; + + return this.client.request(path, { + method: 'GET', + }); + } +} \ No newline at end of file