Compare commits

..

9 Commits

Author SHA1 Message Date
70e64e8d64 Bump api-schema version
All checks were successful
Publish Package / build-and-publish (push) Successful in 12s
2026-05-03 09:47:07 +01:00
935dc271a2 Add getByRid to TrainsModule
All checks were successful
Publish Package / build-and-publish (push) Successful in 13s
2026-05-03 00:51:03 +01:00
ed2f8527d6 Implement use of a custom fetch fn 2026-05-02 10:09:18 +01:00
50c2e5f427 Ensure that the ApiErrorCode type is exported
All checks were successful
Publish Package / build-and-publish (push) Successful in 11s
2026-04-28 17:52:45 +01:00
eb8bee65d9 Fix module res?
All checks were successful
Publish Package / build-and-publish (push) Successful in 7s
2026-04-27 23:48:08 +01:00
fc4c8745a6 Fix the date validator, was checking indexes, not values!
All checks were successful
Publish Package / build-and-publish (push) Successful in 8s
2026-04-27 23:39:36 +01:00
0474973588 Fix path to train module
All checks were successful
Publish Package / build-and-publish (push) Successful in 8s
2026-04-27 23:31:43 +01:00
7f2cb2c413 Fix path to helpers.js
All checks were successful
Publish Package / build-and-publish (push) Successful in 8s
2026-04-27 23:29:31 +01:00
3c81b5225f Add trains endpoint handler
All checks were successful
Publish Package / build-and-publish (push) Successful in 8s
2026-04-27 22:28:55 +01:00
12 changed files with 1950 additions and 21 deletions

1784
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,8 +30,10 @@
"author": "Frederick Boniface",
"license": "GPL-3.0",
"dependencies": {
"@owlboard/api-schema-types": "^3.0.2-alpha3",
"latlon-geohash": "^2.0.0"
"@owlboard/api-schema-types": "^3.0.3-alpha10",
"install": "^0.13.0",
"latlon-geohash": "^2.0.0",
"npm": "^11.13.0"
},
"devDependencies": {
"@types/latlon-geohash": "^2.0.4",

View File

@@ -1,5 +1,5 @@
export { OwlBoardClient } from './lib/client.js';
export { ValidationError, ApiError } from './lib/errors.js';
export { ValidationError, ApiError, type ApiErrorCode } from './lib/errors.js';
// Re-export API Schema types
export type * from '@owlboard/api-schema-types'
@@ -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';

View File

@@ -34,8 +34,9 @@ export class BaseClient {
/**
* Handles the Envelope logic
* @param fetcher - Optional custom fetch instance (e.g SvelteKit load fetch)
*/
public async request<T>(path: string, options: RequestInit = {}): Promise<ApiResult<T>> {
public async request<T>(path: string, options: RequestInit = {}, fetcher: typeof fetch = fetch): Promise<ApiResult<T>> {
const url = `${this.baseUrl}${path}`;
console.debug(`[API DEBUG] Calling: ${url}`);
@@ -43,7 +44,7 @@ export class BaseClient {
headers.set('Content-Type', 'application/json');
if (this.apiKey) headers.set('X-OWL-KEY', this.apiKey);
const response = await fetch(url, { ...options, headers });
const response = await fetcher(url, { ...options, headers });
if (!response.ok && !response.headers.get('content-type')?.includes('application/json')) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);

View File

@@ -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 "../modules/trains.js";
export class OwlBoardClient extends BaseClient {
public readonly pis: PisModule;
public readonly locationFilter: LocationFilterModule;
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);
}
}

20
src/lib/helpers.ts Normal file
View File

@@ -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')}`
}

View File

@@ -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
@@ -15,6 +25,24 @@ export const IsValidCrs = (CRS: string): boolean => {
return true;
}
/**
* Checks if a string is a valid RID, with 15-16 numeric characters
* @param rid The RID to validate
* @returns True/False - Whether valid or not
*/
export const IsValidRid = (rid: string): boolean => {
if (rid.length > 16 || rid.length < 15) {
return false
}
for (let i = 0; i < rid.length; i++) {
const char = rid.charCodeAt(i);
if (!isDigit(char)) {
return false
}
}
return true
}
/**
* Checks if a string is a valid TIPLOC (Syntactically Only)
* using byte level checking for max performance
@@ -49,6 +77,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(c)) 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)

View File

@@ -4,12 +4,13 @@ import type { BaseClient, ApiResult } from '../lib/base.js';
export class LocationFilterModule {
constructor(private client: BaseClient) {}
async getLocationFilterData(): Promise<ApiResult<ApiLocationFilter.LocationFilterObject[]>> {
async getLocationFilterData(customFetch?: typeof fetch): Promise<ApiResult<ApiLocationFilter.LocationFilterObject[]>> {
const path = '/locationFilter/data';
return this.client.request<ApiLocationFilter.LocationFilterObject[]>(
path,
{method: "GET"}
{method: "GET"},
customFetch,
);
}
}

View File

@@ -6,7 +6,7 @@ import { ValidationError } from '../lib/errors.js';
export class PisModule {
constructor(private client: BaseClient) {}
async getByStartEndCrs(startCrs: string, endCrs: string): Promise<ApiResult<ApiPisObject.PisObjects[]>> {
async getByStartEndCrs(startCrs: string, endCrs: string, customFetch?: typeof fetch): Promise<ApiResult<ApiPisObject.PisObjects[]>> {
if (!IsValidCrs(startCrs)) {
throw new ValidationError("startCrs", "Invalid CRS Format")
}
@@ -18,10 +18,10 @@ export class PisModule {
return this.client.request<ApiPisObject.PisObjects[]>(path, {
method: 'GET',
});
}, customFetch);
}
async getByCode(code: string): Promise<ApiResult<ApiPisObject.PisObjects[]>> {
async getByCode(code: string, customFetch?: typeof fetch): Promise<ApiResult<ApiPisObject.PisObjects[]>> {
if (!IsValidPis(code)) {
throw new ValidationError("code", "Invalid PIS Code Format")
}
@@ -30,6 +30,6 @@ export class PisModule {
return this.client.request<ApiPisObject.PisObjects[]>(path, {
method: 'GET',
})
}, customFetch)
}
}

View File

@@ -22,7 +22,7 @@ export class StationDataModule {
* @param geohash Geohash as string (up to six characters), generate using this.generateGeohash()
* @returns Nearest Stations API Response (CRS, Name)
*/
async getNearestStations(geohash: string): Promise<ApiResult<ApiStationsNearestStations.StationsNearestStations[]>> {
async getNearestStations(geohash: string, customFetch?: typeof fetch): Promise<ApiResult<ApiStationsNearestStations.StationsNearestStations[]>> {
if (!IsValidGeoHash(geohash)) {
throw new ValidationError("hash", "Invalid Geohash requested");
}
@@ -30,7 +30,7 @@ export class StationDataModule {
const path = `/stationData/nearest/${geohash}`;
return this.client.request<ApiStationsNearestStations.StationsNearestStations[]>(path, {
method: 'GET',
})
}, customFetch)
}
// getStationSate(crs: string){}

45
src/modules/trains.ts Normal file
View File

@@ -0,0 +1,45 @@
import { ApiTrainsTrainByHeadcode, ApiTrainsTrainDetails } from '@owlboard/api-schema-types';
import type { BaseClient, ApiResult } from '../lib/base.js';
import { IsValidHeadcode, IsValidRid, IsValidToc, IsValidDateStr } from '../lib/validation.js';
import { ensureDateString } from '../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 = "", customFetch?: typeof fetch): Promise<ApiResult<ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[]>> {
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<ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse[]>(path, {
method: 'GET',
}, customFetch);
}
async getByRid(rid: string, customFetch?: typeof fetch): Promise<ApiResult<ApiTrainsTrainDetails.TrainDetailsResponse>> {
if (!IsValidRid(rid)) {
throw new ValidationError("RID", "Invalid RID format, must be 15-16 numerals")
}
const path = `/train/${rid}`
return this.client.request<ApiTrainsTrainDetails.TrainDetailsResponse>(path, {
method: 'GET',
}, customFetch);
}
}

View File

@@ -7,9 +7,9 @@
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "esnext",
"module": "nodenext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleResolution": "nodenext",
"baseUrl": ".",
"types": [],
// For nodejs: