Update CBS Mobile App codebase
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import type { AxiosError, AxiosInstance } from 'axios';
|
||||
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
|
||||
|
||||
import type { CbsEnvelope } from '../../types/cbs';
|
||||
import { sessionManager } from '../security/sessionManager';
|
||||
import { secureStorage } from '../storage/secureStorage';
|
||||
import { apiLogger } from './apiLogger';
|
||||
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
|
||||
|
||||
const unauthorizedHandlers = new Set<() => void>();
|
||||
|
||||
@@ -12,6 +16,63 @@ export const registerUnauthorizedHandler = (handler: () => void) => {
|
||||
};
|
||||
};
|
||||
|
||||
const looksLikeEnvelope = (value: unknown): value is CbsEnvelope<unknown> =>
|
||||
typeof value === 'object' && value !== null && 'success' in value;
|
||||
|
||||
/**
|
||||
* `VALIDATION_ERROR` is the one failure that also fills `data`, with a
|
||||
* field-to-message map. Anything else in there on a failure is not a field map.
|
||||
*/
|
||||
const readFieldErrors = (envelope: CbsEnvelope<unknown>): FieldErrors | undefined => {
|
||||
if (envelope.errorCode !== 'VALIDATION_ERROR') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { data } = envelope;
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = Object.entries(data as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
);
|
||||
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
};
|
||||
|
||||
/** The absolute URL a failed request was aimed at, for a dev-only diagnostic. */
|
||||
const describeTarget = (error: AxiosError) => {
|
||||
const base = error.config?.baseURL ?? '';
|
||||
const path = error.config?.url ?? '';
|
||||
|
||||
return `${base}${path}` || 'the server';
|
||||
};
|
||||
|
||||
const envelopeToError = (status: number, envelope: CbsEnvelope<unknown>) =>
|
||||
new CbsApiError({
|
||||
status,
|
||||
code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR',
|
||||
message: envelope.message || 'Unexpected service error',
|
||||
fieldErrors: readFieldErrors(envelope),
|
||||
});
|
||||
|
||||
/**
|
||||
* `SESSION_INVALID` means the token we hold is dead and every screen must drop
|
||||
* it. `INVALID_CREDENTIALS` shares the 401 but means a wrong MPIN on the
|
||||
* sign-in form — bouncing the user out of a screen they are not in yet would
|
||||
* be nonsense, so only the former trips the global handler.
|
||||
*/
|
||||
const handleSessionLoss = async (error: CbsApiError) => {
|
||||
if (error.code !== 'SESSION_INVALID') {
|
||||
return;
|
||||
}
|
||||
|
||||
await secureStorage.clearSession();
|
||||
sessionManager.clear();
|
||||
unauthorizedHandlers.forEach((handler) => handler());
|
||||
};
|
||||
|
||||
export const attachInterceptors = (client: AxiosInstance) => {
|
||||
client.interceptors.request.use(async (config) => {
|
||||
const token = await secureStorage.getAccessToken();
|
||||
@@ -22,18 +83,100 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
||||
config.headers = nextHeaders as never;
|
||||
}
|
||||
|
||||
// Logged here, at the end of the interceptor, so the curl carries the
|
||||
// Authorization header the request will actually be sent with.
|
||||
apiLogger.request(config);
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
await secureStorage.clearSession();
|
||||
unauthorizedHandlers.forEach((handler) => handler());
|
||||
async (response: AxiosResponse) => {
|
||||
apiLogger.response(response);
|
||||
|
||||
if (!looksLikeEnvelope(response.data)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
const envelope = response.data as CbsEnvelope<unknown>;
|
||||
|
||||
// Branch on `success`, not on the HTTP status: a failure can arrive with
|
||||
// a 2xx, and treating it as a payload would hand callers `undefined`.
|
||||
if (!envelope.success) {
|
||||
const error = envelopeToError(response.status, envelope);
|
||||
apiLogger.failure(response, `envelope said success: false -> ${error.code}`);
|
||||
await handleSessionLoss(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Any authenticated call refreshes the server's idle timer, so mirror
|
||||
// that locally — otherwise the app would sign the user out while the
|
||||
// session is still very much alive.
|
||||
if (response.config.headers?.Authorization) {
|
||||
sessionManager.touch();
|
||||
}
|
||||
|
||||
// Unwrap so callers read the payload directly rather than the envelope.
|
||||
return { ...response, data: envelope.data };
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
if (error instanceof CbsApiError) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response) {
|
||||
apiLogger.failure(error.response, `HTTP error ${error.code ?? ''}`.trim());
|
||||
} else {
|
||||
apiLogger.networkFailure(error);
|
||||
}
|
||||
|
||||
const status = Number(error.response?.status ?? 0);
|
||||
const body = error.response?.data;
|
||||
|
||||
if (looksLikeEnvelope(body)) {
|
||||
const cbsError = envelopeToError(status, body);
|
||||
await handleSessionLoss(cbsError);
|
||||
|
||||
return Promise.reject(cbsError);
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
return Promise.reject(
|
||||
new CbsApiError({
|
||||
status: 0,
|
||||
code: 'REQUEST_TIMEOUT',
|
||||
message: 'The request timed out',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// No response at all: nothing answered on the other end. The usual causes
|
||||
// are the backend not running, it being bound to 127.0.0.1 instead of
|
||||
// 0.0.0.0, the handset being on a different network from the host, a
|
||||
// firewall on the host port, or cleartext HTTP blocked in a release build.
|
||||
//
|
||||
// Naming the URL that failed matters more than it looks: without it this
|
||||
// is indistinguishable from a server-side fault, and the address the
|
||||
// phone needs is often not the one that works in the laptop's browser.
|
||||
if (!error.response) {
|
||||
return Promise.reject(
|
||||
new CbsApiError({
|
||||
status: 0,
|
||||
code: 'NETWORK_ERROR',
|
||||
message: __DEV__
|
||||
? `Could not reach ${describeTarget(error)}. Check the backend is running and that this device can reach that address.`
|
||||
: error.message || 'Could not reach the server',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
new CbsApiError({
|
||||
status,
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message || 'Unexpected service error',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user