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>
252 lines
8.4 KiB
TypeScript
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 };
|
|
}
|
|
}
|