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
|
* Core API client configuration
|
||||||
* Instantiates the shared OpenAPI client with portal-specific configuration.
|
* Wraps the shared generated client to inject portal-specific behavior like auth headers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient as createOpenApiClient } from "@customer-portal/api-client";
|
import {
|
||||||
import type { ApiClient as GeneratedApiClient } from "@customer-portal/api-client";
|
createClient as createOpenApiClient,
|
||||||
|
type ApiClient as GeneratedApiClient,
|
||||||
|
type AuthHeaderResolver,
|
||||||
|
} from "@customer-portal/api-client";
|
||||||
import { env } from "../config/env";
|
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;
|
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) => {
|
export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, {
|
||||||
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, {
|
|
||||||
getAuthHeader: resolveAuthHeader,
|
getAuthHeader: resolveAuthHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiClient = Object.assign(openApiClient, restMethods) as ApiClient;
|
export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => {
|
||||||
|
authHeaderResolver = resolver;
|
||||||
export const configureApiClientAuth = (getter?: AuthHeaderGetter) => {
|
|
||||||
authHeaderGetter = getter;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiClient = GeneratedApiClient;
|
||||||
@ -1,3 +1,5 @@
|
|||||||
export { apiClient, configureApiClientAuth } from "./client";
|
export { apiClient, configureApiClientAuth } from "./client";
|
||||||
export { queryKeys } from "./query-keys";
|
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) {
|
if (user) {
|
||||||
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
|
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
// Token is invalid, clear auth state
|
// Token is invalid, clear auth state
|
||||||
|
|||||||
@ -42,8 +42,11 @@ export function ChangePlanModal({
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
|
await apiClient.POST("/subscriptions/{id}/sim/change-plan", {
|
||||||
newPlanCode: newPlanCode,
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: {
|
||||||
|
newPlanCode,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
@ -58,7 +58,9 @@ export function SimActions({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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");
|
setSuccess("eSIM profile reissued successfully");
|
||||||
setShowReissueConfirm(false);
|
setShowReissueConfirm(false);
|
||||||
@ -75,7 +77,9 @@ export function SimActions({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
await apiClient.POST("/subscriptions/{id}/sim/cancel", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
});
|
||||||
|
|
||||||
setSuccess("SIM service cancelled successfully");
|
setSuccess("SIM service cancelled successfully");
|
||||||
setShowCancelConfirm(false);
|
setShowCancelConfirm(false);
|
||||||
|
|||||||
@ -75,10 +75,10 @@ export function SimFeatureToggles({
|
|||||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||||
|
|
||||||
if (Object.keys(featurePayload).length > 0) {
|
if (Object.keys(featurePayload).length > 0) {
|
||||||
await apiClient.post(
|
await apiClient.POST("/subscriptions/{id}/sim/features", {
|
||||||
`/subscriptions/${subscriptionId}/sim/features`,
|
params: { path: { id: subscriptionId } },
|
||||||
featurePayload
|
body: featurePayload,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess("Changes submitted successfully");
|
setSuccess("Changes submitted successfully");
|
||||||
|
|||||||
@ -30,24 +30,31 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const data = await apiClient.get<{
|
const response = await apiClient.GET("/subscriptions/{id}/sim", {
|
||||||
details: SimDetails;
|
params: { path: { id: subscriptionId } },
|
||||||
usage: SimUsage;
|
});
|
||||||
}>(`/subscriptions/${subscriptionId}/sim`);
|
|
||||||
|
|
||||||
setSimInfo(data);
|
const payload = response.data as SimInfo | undefined;
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("Failed to load SIM information");
|
||||||
|
}
|
||||||
|
|
||||||
|
setSimInfo(payload);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const hasStatus = (v: unknown): v is { status: number } =>
|
const hasStatus = (value: unknown): value is { status: number } =>
|
||||||
typeof v === "object" &&
|
typeof value === "object" &&
|
||||||
v !== null &&
|
value !== null &&
|
||||||
"status" in v &&
|
"status" in value &&
|
||||||
typeof (v as { status: unknown }).status === "number";
|
typeof (value as { status: unknown }).status === "number";
|
||||||
|
|
||||||
if (hasStatus(err) && err.status === 400) {
|
if (hasStatus(err) && err.status === 400) {
|
||||||
// Not a SIM subscription - this component shouldn't be shown
|
// Not a SIM subscription - this component shouldn't be shown
|
||||||
setError("This subscription is not a SIM service");
|
setError("This subscription is not a SIM service");
|
||||||
} else {
|
return;
|
||||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
quotaMb: getCurrentAmountMb(),
|
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();
|
onSuccess();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@ -22,14 +22,22 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
|||||||
return useQuery<SubscriptionList | Subscription[]>({
|
return useQuery<SubscriptionList | Subscription[]>({
|
||||||
queryKey: ["subscriptions", status],
|
queryKey: ["subscriptions", status],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
...(status && { status }),
|
...(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
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
@ -50,7 +58,9 @@ export function useActiveSubscriptions() {
|
|||||||
throw new Error("Authentication required");
|
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
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
@ -77,14 +87,25 @@ export function useSubscriptionStats() {
|
|||||||
throw new Error("Authentication required");
|
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;
|
total: number;
|
||||||
active: number;
|
active: number;
|
||||||
suspended: number;
|
suspended: number;
|
||||||
cancelled: number;
|
cancelled: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
}>(`/subscriptions/stats`);
|
|
||||||
return stats;
|
};
|
||||||
|
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
@ -105,7 +126,17 @@ export function useSubscription(subscriptionId: number) {
|
|||||||
throw new Error("Authentication required");
|
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
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||||
@ -130,13 +161,24 @@ export function useSubscriptionInvoices(
|
|||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const response = await apiClient.GET("/subscriptions/{id}/invoices", {
|
||||||
page: page.toString(),
|
params: {
|
||||||
limit: limit.toString(),
|
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
|
staleTime: 60 * 1000, // 1 minute
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
@ -152,7 +194,11 @@ export function useSubscriptionAction() {
|
|||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ id, action }: { id: number; action: string }) => {
|
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 }) => {
|
onSuccess: (_, { id }) => {
|
||||||
// Invalidate relevant queries after successful action
|
// Invalidate relevant queries after successful action
|
||||||
|
|||||||
@ -13,7 +13,11 @@ export function createClient(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
options: CreateClientOptions = {}
|
options: CreateClientOptions = {}
|
||||||
): ApiClient {
|
): ApiClient {
|
||||||
const client = createOpenApiClient<paths>({ baseUrl });
|
const client = createOpenApiClient<paths>({
|
||||||
|
baseUrl,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
if (typeof client.use === "function" && options.getAuthHeader) {
|
if (typeof client.use === "function" && options.getAuthHeader) {
|
||||||
const resolveAuthHeader = options.getAuthHeader;
|
const resolveAuthHeader = options.getAuthHeader;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user