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:
NTumurbars 2025-09-18 16:38:52 +09:00 committed by GitHub
commit 39b7e796f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 132 additions and 201 deletions

View File

@ -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;

View File

@ -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";

View File

@ -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

View File

@ -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) {

View File

@ -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);

View File

@ -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");

View File

@ -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);
}

View File

@ -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) {

View File

@ -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

View File

@ -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;