Merge pull request #9 from NTumurbars/codex/extend-api-client-for-auth-header-os9pej
refactor: unify api client usage around openapi client
This commit is contained in:
commit
39b7e796f8
@ -1,174 +1,27 @@
|
||||
/**
|
||||
* Core API Client
|
||||
* Instantiates the shared OpenAPI client with portal-specific configuration.
|
||||
|
||||
* Core API client configuration
|
||||
* Wraps the shared generated client to inject portal-specific behavior like auth headers.
|
||||
*/
|
||||
|
||||
import { createClient as createOpenApiClient } from "@customer-portal/api-client";
|
||||
import type { ApiClient as GeneratedApiClient } from "@customer-portal/api-client";
|
||||
import {
|
||||
createClient as createOpenApiClient,
|
||||
type ApiClient as GeneratedApiClient,
|
||||
type AuthHeaderResolver,
|
||||
} from "@customer-portal/api-client";
|
||||
import { env } from "../config/env";
|
||||
|
||||
const DEFAULT_JSON_CONTENT_TYPE = "application/json";
|
||||
|
||||
export type AuthHeaderGetter = () => string | undefined;
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type ApiRequestOptions = Omit<RequestInit, "method" | "body" | "headers"> & {
|
||||
body?: unknown;
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
|
||||
export interface HttpError extends Error {
|
||||
status: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
type RestMethods = {
|
||||
request: <T = unknown>(method: HttpMethod, path: string, options?: ApiRequestOptions) => Promise<T>;
|
||||
get: <T = unknown>(path: string, options?: Omit<ApiRequestOptions, "body">) => Promise<T>;
|
||||
post: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
put: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
patch: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
body?: B,
|
||||
options?: Omit<ApiRequestOptions, "body">
|
||||
) => Promise<T>;
|
||||
delete: <T = unknown, B = unknown>(
|
||||
path: string,
|
||||
options?: Omit<ApiRequestOptions, "body"> & { body?: B }
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
export type ApiClient = GeneratedApiClient & RestMethods;
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_API_BASE;
|
||||
|
||||
let authHeaderGetter: AuthHeaderGetter | undefined;
|
||||
let authHeaderResolver: AuthHeaderResolver | undefined;
|
||||
|
||||
const resolveAuthHeader = () => authHeaderGetter?.();
|
||||
const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.();
|
||||
|
||||
const joinUrl = (base: string, path: string) => {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const trimmedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${trimmedBase}${normalizedPath}`;
|
||||
};
|
||||
|
||||
const applyAuthHeader = (headersInit?: HeadersInit) => {
|
||||
const headers =
|
||||
headersInit instanceof Headers ? headersInit : new Headers(headersInit ?? undefined);
|
||||
|
||||
const headerValue = resolveAuthHeader();
|
||||
|
||||
if (headerValue && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", headerValue);
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
const serializeBody = (body: unknown, headers: Headers): BodyInit | undefined => {
|
||||
if (body === undefined || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
body instanceof FormData ||
|
||||
body instanceof Blob ||
|
||||
body instanceof URLSearchParams ||
|
||||
typeof body === "string"
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", DEFAULT_JSON_CONTENT_TYPE);
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
};
|
||||
|
||||
const parseResponseBody = async (response: Response) => {
|
||||
if (response.status === 204) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const createHttpError = (response: Response, data: unknown): HttpError => {
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data &&
|
||||
typeof (data as Record<string, unknown>).message === "string"
|
||||
? (data as { message: string }).message
|
||||
: `Request failed with status ${response.status}`;
|
||||
|
||||
const error = new Error(message) as HttpError;
|
||||
error.status = response.status;
|
||||
if (data !== undefined) {
|
||||
error.data = data;
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
const request = async <T = unknown>(
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<T> => {
|
||||
const { body, headers: headersInit, ...rest } = options;
|
||||
const headers = applyAuthHeader(headersInit);
|
||||
const serializedBody = serializeBody(body, headers);
|
||||
const response = await fetch(joinUrl(baseUrl, path), {
|
||||
...rest,
|
||||
method,
|
||||
headers,
|
||||
body: serializedBody,
|
||||
});
|
||||
|
||||
const parsedBody = await parseResponseBody(response);
|
||||
if (!response.ok) {
|
||||
throw createHttpError(response, parsedBody);
|
||||
}
|
||||
|
||||
return parsedBody as T;
|
||||
};
|
||||
|
||||
const restMethods: RestMethods = {
|
||||
request,
|
||||
get: (path, options) => request("GET", path, options as ApiRequestOptions),
|
||||
post: (path, body, options) => request("POST", path, { ...options, body }),
|
||||
put: (path, body, options) => request("PUT", path, { ...options, body }),
|
||||
patch: (path, body, options) => request("PATCH", path, { ...options, body }),
|
||||
delete: (path, options) => request("DELETE", path, options as ApiRequestOptions),
|
||||
};
|
||||
|
||||
const openApiClient = createOpenApiClient(baseUrl, {
|
||||
export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, {
|
||||
getAuthHeader: resolveAuthHeader,
|
||||
});
|
||||
|
||||
export const apiClient = Object.assign(openApiClient, restMethods) as ApiClient;
|
||||
|
||||
export const configureApiClientAuth = (getter?: AuthHeaderGetter) => {
|
||||
authHeaderGetter = getter;
|
||||
export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => {
|
||||
authHeaderResolver = resolver;
|
||||
};
|
||||
|
||||
export type ApiClient = GeneratedApiClient;
|
||||
@ -1,3 +1,5 @@
|
||||
export { apiClient, configureApiClientAuth } from "./client";
|
||||
export { queryKeys } from "./query-keys";
|
||||
export type { ApiClient, ApiRequestOptions, AuthHeaderGetter, HttpError } from "./client";
|
||||
|
||||
export type { ApiClient } from "./client";
|
||||
export type { AuthHeaderResolver } from "@customer-portal/api-client";
|
||||
|
||||
@ -343,7 +343,16 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
if (user) {
|
||||
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
|
||||
} else {
|
||||
set({ loading: false, hasCheckedAuth: true });
|
||||
// No user data returned, clear auth state
|
||||
set({
|
||||
user: null,
|
||||
tokens: null,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasCheckedAuth: true,
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
// Token is invalid, clear auth state
|
||||
|
||||
@ -42,8 +42,11 @@ export function ChangePlanModal({
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
|
||||
newPlanCode: newPlanCode,
|
||||
await apiClient.POST("/subscriptions/{id}/sim/change-plan", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: {
|
||||
newPlanCode,
|
||||
},
|
||||
});
|
||||
onSuccess();
|
||||
} catch (e: unknown) {
|
||||
|
||||
@ -58,7 +58,9 @@ export function SimActions({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
||||
await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
setSuccess("eSIM profile reissued successfully");
|
||||
setShowReissueConfirm(false);
|
||||
@ -75,7 +77,9 @@ export function SimActions({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
||||
await apiClient.POST("/subscriptions/{id}/sim/cancel", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
setSuccess("SIM service cancelled successfully");
|
||||
setShowCancelConfirm(false);
|
||||
|
||||
@ -75,10 +75,10 @@ export function SimFeatureToggles({
|
||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||
|
||||
if (Object.keys(featurePayload).length > 0) {
|
||||
await apiClient.post(
|
||||
`/subscriptions/${subscriptionId}/sim/features`,
|
||||
featurePayload
|
||||
);
|
||||
await apiClient.POST("/subscriptions/{id}/sim/features", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: featurePayload,
|
||||
});
|
||||
}
|
||||
|
||||
setSuccess("Changes submitted successfully");
|
||||
|
||||
@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const data = await apiClient.get<{
|
||||
details: SimDetails;
|
||||
usage: SimUsage;
|
||||
}>(`/subscriptions/${subscriptionId}/sim`);
|
||||
const response = await apiClient.GET("/subscriptions/{id}/sim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
setSimInfo(data);
|
||||
const payload = response.data as SimInfo | undefined;
|
||||
|
||||
if (!payload) {
|
||||
throw new Error("Failed to load SIM information");
|
||||
}
|
||||
|
||||
setSimInfo(payload);
|
||||
} catch (err: unknown) {
|
||||
const hasStatus = (v: unknown): v is { status: number } =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
"status" in v &&
|
||||
typeof (v as { status: unknown }).status === "number";
|
||||
const hasStatus = (value: unknown): value is { status: number } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"status" in value &&
|
||||
typeof (value as { status: unknown }).status === "number";
|
||||
|
||||
if (hasStatus(err) && err.status === 400) {
|
||||
// Not a SIM subscription - this component shouldn't be shown
|
||||
setError("This subscription is not a SIM service");
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
quotaMb: getCurrentAmountMb(),
|
||||
};
|
||||
|
||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
||||
await apiClient.POST("/subscriptions/{id}/sim/top-up", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
|
||||
@ -22,14 +22,22 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
||||
return useQuery<SubscriptionList | Subscription[]>({
|
||||
queryKey: ["subscriptions", status],
|
||||
queryFn: async () => {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
...(status && { status }),
|
||||
});
|
||||
if (!hasValidToken) {
|
||||
throw new Error("Authentication required");
|
||||
|
||||
|
||||
const response = await apiClient.GET(
|
||||
"/subscriptions",
|
||||
status ? { params: { query: { status } } } : undefined
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
return [] as Subscription[];
|
||||
}
|
||||
|
||||
return apiClient.get<SubscriptionList | Subscription[]>(`/subscriptions?${params}`);
|
||||
return response.data as SubscriptionList | Subscription[];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -50,7 +58,9 @@ export function useActiveSubscriptions() {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
return apiClient.get<Subscription[]>(`/subscriptions/active`);
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/active");
|
||||
return (response.data ?? []) as Subscription[];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -77,14 +87,25 @@ export function useSubscriptionStats() {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const stats = await apiClient.get<{
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/stats");
|
||||
return (response.data ?? {
|
||||
total: 0,
|
||||
active: 0,
|
||||
suspended: 0,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
}) as {
|
||||
|
||||
|
||||
total: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
cancelled: number;
|
||||
pending: number;
|
||||
}>(`/subscriptions/stats`);
|
||||
return stats;
|
||||
|
||||
};
|
||||
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -105,7 +126,17 @@ export function useSubscription(subscriptionId: number) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
return apiClient.get<Subscription>(`/subscriptions/${subscriptionId}`);
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/{id}", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Subscription not found");
|
||||
}
|
||||
|
||||
return response.data as Subscription;
|
||||
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
@ -130,13 +161,24 @@ export function useSubscriptionInvoices(
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
const response = await apiClient.GET("/subscriptions/{id}/invoices", {
|
||||
params: {
|
||||
path: { id: subscriptionId },
|
||||
query: { page, limit },
|
||||
},
|
||||
});
|
||||
return apiClient.get<InvoiceList>(
|
||||
`/subscriptions/${subscriptionId}/invoices?${params}`
|
||||
);
|
||||
|
||||
return (
|
||||
response.data ?? {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
},
|
||||
}
|
||||
) as InvoiceList;
|
||||
|
||||
},
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||
@ -152,7 +194,11 @@ export function useSubscriptionAction() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, action }: { id: number; action: string }) => {
|
||||
return apiClient.post(`/subscriptions/${id}/actions`, { action });
|
||||
await apiClient.POST("/subscriptions/{id}/actions", {
|
||||
params: { path: { id } },
|
||||
body: { action },
|
||||
});
|
||||
|
||||
},
|
||||
onSuccess: (_, { id }) => {
|
||||
// Invalidate relevant queries after successful action
|
||||
|
||||
@ -13,7 +13,11 @@ export function createClient(
|
||||
baseUrl: string,
|
||||
options: CreateClientOptions = {}
|
||||
): ApiClient {
|
||||
const client = createOpenApiClient<paths>({ baseUrl });
|
||||
const client = createOpenApiClient<paths>({
|
||||
baseUrl,
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
|
||||
if (typeof client.use === "function" && options.getAuthHeader) {
|
||||
const resolveAuthHeader = options.getAuthHeader;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user