BigBrainParking/parksmarter-client/src/http.ts
Hank 1dede50995 Initial commit: parksmarter-client + BigBrainParking app
Reverse-engineered ParkSmarter API client (TypeScript, live-verified) plus a
de-Googled Expo/React Native app for GrapheneOS: MapLibre meter map with GPS,
VisionCamera QR kiosk scanning with save/share, local session-expiry reminders,
UnifiedPush wiring, and Gitea CI to publish signed APKs for Obtainium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:33:47 -07:00

252 lines
8.4 KiB
TypeScript

/**
* Transport layer for the ParkSmarter API.
*
* This mirrors the request pipeline used by the official app:
* - Base URL + query string are concatenated onto the endpoint path.
* - Query params are serialized as `?k=encodeURIComponent(v)&...`.
* - Headers:
* Application_Token always (identifies the app build)
* X-Request-Id always (a fresh UUID per request)
* Content-Type: application/json on POST/PUT
* Auth_Token when the endpoint requires an authenticated user
* ParkSmarter_SessionId when a server session id is available
* - The user auth token and session id are NOT HTTP bearer tokens; they are
* custom headers named exactly `Auth_Token` and `ParkSmarter_SessionId`.
*/
import { Environment } from './environments.js';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export interface TokenStore {
/** Current user auth token, or null if signed out. */
getAuthToken(): string | null | Promise<string | null>;
setAuthToken(token: string | null): void | Promise<void>;
/** Current server session id, or null. */
getSessionId(): string | null | Promise<string | null>;
setSessionId(sessionId: string | null): void | Promise<void>;
}
/** Simple in-memory token store. Swap for expo-secure-store / localStorage in real apps. */
export class MemoryTokenStore implements TokenStore {
private authToken: string | null = null;
private sessionId: string | null = null;
getAuthToken() {
return this.authToken;
}
setAuthToken(token: string | null) {
this.authToken = token;
}
getSessionId() {
return this.sessionId;
}
setSessionId(sessionId: string | null) {
this.sessionId = sessionId;
}
}
export interface RequestOptions {
method: HttpMethod;
/** Endpoint path, e.g. `/api/Auth`. */
path: string;
query?: Record<string, unknown> | undefined;
body?: unknown;
/** Send the `Auth_Token` header (default: true except for auth/public endpoints). */
includeAuthToken?: boolean;
/** Send the `ParkSmarter_SessionId` header when available (default: true). */
includeSessionId?: boolean;
/** Abort signal (also drives the per-request timeout). */
signal?: AbortSignal;
}
export interface ParkSmarterResponse<T> {
status: number;
data: T;
headers: Headers;
requestId: string;
}
/** Thrown for non-2xx responses. `body` is the parsed server payload when JSON. */
export class ParkSmarterApiError extends Error {
status: number;
body: unknown;
requestId: string;
/** Server-provided message when present (server uses PascalCase `Message`). */
serverMessage?: string;
constructor(status: number, body: unknown, requestId: string) {
const serverMessage =
body && typeof body === 'object'
? (body as Record<string, unknown>).Message ??
(body as Record<string, unknown>).message
: undefined;
super(
`ParkSmarter API error ${status}` +
(serverMessage ? `: ${serverMessage}` : ''),
);
this.name = 'ParkSmarterApiError';
this.status = status;
this.body = body;
this.requestId = requestId;
if (typeof serverMessage === 'string') this.serverMessage = serverMessage;
}
}
export interface HttpClientConfig {
environment: Environment;
tokens: TokenStore;
/** BCP-47-ish locale code sent as the `localeCode` query param (default: 'en'). */
localeCode?: string;
/** Per-request timeout in ms (default: 30000). */
timeoutMs?: number;
/** Override fetch (e.g. for React Native or tests). Defaults to global fetch. */
fetchImpl?: typeof fetch;
/** Override UUID generation. Defaults to crypto.randomUUID when available. */
uuid?: () => string;
}
function defaultUuid(): string {
const c = (globalThis as { crypto?: Crypto }).crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
// RFC4122-ish fallback (non-crypto) for older runtimes.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const r = (Math.random() * 16) | 0;
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function serializeQuery(query: Record<string, unknown>): string {
const parts: string[] = [];
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) continue;
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
}
return parts.length ? `?${parts.join('&')}` : '';
}
export class HttpClient {
private cfg: Required<Omit<HttpClientConfig, 'environment' | 'tokens'>> &
Pick<HttpClientConfig, 'environment' | 'tokens'>;
constructor(config: HttpClientConfig) {
this.cfg = {
environment: config.environment,
tokens: config.tokens,
localeCode: config.localeCode ?? 'en',
timeoutMs: config.timeoutMs ?? 30000,
fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
uuid: config.uuid ?? defaultUuid,
};
if (!this.cfg.fetchImpl) {
throw new Error(
'No fetch implementation available. Pass config.fetchImpl (e.g. node-fetch, or a polyfill).',
);
}
}
get environment(): Environment {
return this.cfg.environment;
}
get tokens(): TokenStore {
return this.cfg.tokens;
}
setLocaleCode(localeCode: string) {
this.cfg.localeCode = localeCode;
}
async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> {
const { method } = opts;
const requestId = this.cfg.uuid();
// The app appends `localeCode` to the query params of essentially every call.
const query: Record<string, unknown> = {
localeCode: this.cfg.localeCode,
...(opts.query ?? {}),
};
const url =
this.cfg.environment.baseUrl + opts.path + serializeQuery(query);
const headers: Record<string, string> = {
Application_Token: this.cfg.environment.appToken,
'X-Request-Id': requestId,
Accept: 'application/json',
};
if (method === 'POST' || method === 'PUT') {
headers['Content-Type'] = 'application/json';
}
const includeAuthToken = opts.includeAuthToken ?? true;
const includeSessionId = opts.includeSessionId ?? true;
if (includeAuthToken) {
const authToken = await this.cfg.tokens.getAuthToken();
if (authToken) headers['Auth_Token'] = authToken;
}
if (includeSessionId) {
const sessionId = await this.cfg.tokens.getSessionId();
if (sessionId) headers['ParkSmarter_SessionId'] = sessionId;
}
// Timeout wired through an AbortController, honoring any caller-supplied signal.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
if (opts.signal) {
if (opts.signal.aborted) controller.abort();
else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
let res: Response;
try {
res = await this.cfg.fetchImpl(url, {
method,
headers,
body:
opts.body !== undefined && opts.body !== null
? JSON.stringify(opts.body)
: undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
const text = await res.text();
let data: unknown = undefined;
if (text && text.trim().length) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
// Keep the token store fresh from what the server echoes back:
// - SessionId is returned on bootstrap/login.
// - A fresh Auth_Token may be returned either at the top level (login) or
// inside the common `Response: { Auth_Token, Message, Status }` envelope
// (a rolling token-refresh mechanism). Only overwrite on a non-empty value.
if (data && typeof data === 'object') {
const obj = data as Record<string, unknown>;
const envelope = obj.Response as Record<string, unknown> | undefined;
const sessionId = obj.SessionId;
if (typeof sessionId === 'string' && sessionId) {
await this.cfg.tokens.setSessionId(sessionId);
}
const refreshed =
(typeof obj.Auth_Token === 'string' && obj.Auth_Token) ||
(envelope && typeof envelope.Auth_Token === 'string' && envelope.Auth_Token);
if (refreshed) {
await this.cfg.tokens.setAuthToken(refreshed);
}
}
if (!res.ok) {
throw new ParkSmarterApiError(res.status, data ?? text, requestId);
}
return { status: res.status, data: data as T, headers: res.headers, requestId };
}
}