Fix admin SQL count parsing for local SpacetimeDB
This commit is contained in:
222
apps/admin-web/src/api/adminApiClient.ts
Normal file
222
apps/admin-web/src/api/adminApiClient.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type {
|
||||
AdminDebugHttpRequest,
|
||||
AdminDebugHttpResponse,
|
||||
AdminDisableProfileRedeemCodeRequest,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
ApiErrorEnvelope,
|
||||
ApiMeta,
|
||||
ApiSuccessEnvelope,
|
||||
ProfileRedeemCodeAdminResponse,
|
||||
} from './adminApiTypes';
|
||||
|
||||
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
|
||||
const ADMIN_API_BASE_URL = normalizeBaseUrl(
|
||||
import.meta.env.VITE_ADMIN_API_BASE_URL ?? '',
|
||||
);
|
||||
|
||||
interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details: Record<string, unknown> | null;
|
||||
meta: ApiMeta | null;
|
||||
responseText: string;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
status: number;
|
||||
code?: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
meta?: ApiMeta | null;
|
||||
responseText?: string;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = 'AdminApiError';
|
||||
this.status = params.status;
|
||||
this.code = params.code ?? 'ADMIN_API_ERROR';
|
||||
this.details = params.details ?? null;
|
||||
this.meta = params.meta ?? null;
|
||||
this.responseText = params.responseText ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdminApiError(error: unknown): error is AdminApiError {
|
||||
return error instanceof AdminApiError;
|
||||
}
|
||||
|
||||
export function formatAdminApiError(error: unknown) {
|
||||
if (isAdminApiError(error)) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return '请求失败';
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
options: AdminRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const method = options.method ?? 'GET';
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
|
||||
...(options.headers ?? {}),
|
||||
};
|
||||
|
||||
const token = options.token?.trim();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: options.signal,
|
||||
};
|
||||
|
||||
if (typeof options.body !== 'undefined') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(buildRequestUrl(path), init);
|
||||
const responseText = await response.text();
|
||||
const payload = parseJsonResponse(responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw buildAdminApiError(response, payload, responseText);
|
||||
}
|
||||
|
||||
return unwrapSuccessPayload<T>(payload);
|
||||
}
|
||||
|
||||
export function loginAdmin(username: string, password: string) {
|
||||
return request<AdminLoginResponse>('/admin/api/login', {
|
||||
method: 'POST',
|
||||
body: {username, password},
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminMe(token: string) {
|
||||
return request<AdminMeResponse>('/admin/api/me', {token});
|
||||
}
|
||||
|
||||
export function getAdminOverview(token: string) {
|
||||
return request<AdminOverviewResponse>('/admin/api/overview', {token});
|
||||
}
|
||||
|
||||
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
|
||||
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertProfileRedeemCode(
|
||||
token: string,
|
||||
payload: AdminUpsertProfileRedeemCodeRequest,
|
||||
) {
|
||||
return request<ProfileRedeemCodeAdminResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function disableProfileRedeemCode(
|
||||
token: string,
|
||||
payload: AdminDisableProfileRedeemCodeRequest,
|
||||
) {
|
||||
return request<ProfileRedeemCodeAdminResponse>(
|
||||
'/admin/api/profile/redeem-codes/disable',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function buildRequestUrl(path: string) {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function parseJsonResponse(responseText: string): unknown {
|
||||
if (!responseText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapSuccessPayload<T>(payload: unknown): T {
|
||||
if (isRecord(payload) && 'data' in payload) {
|
||||
return (payload as ApiSuccessEnvelope<T>).data as T;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function buildAdminApiError(
|
||||
response: Response,
|
||||
payload: unknown,
|
||||
responseText: string,
|
||||
) {
|
||||
const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null;
|
||||
const errorPayload = envelope?.error;
|
||||
const details = isRecord(errorPayload?.details)
|
||||
? errorPayload.details
|
||||
: null;
|
||||
const detailsMessage =
|
||||
typeof details?.message === 'string' ? details.message.trim() : '';
|
||||
const payloadMessage =
|
||||
typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : '';
|
||||
const topLevelMessage =
|
||||
typeof envelope?.message === 'string' ? envelope.message.trim() : '';
|
||||
const message =
|
||||
detailsMessage ||
|
||||
payloadMessage ||
|
||||
topLevelMessage ||
|
||||
response.statusText ||
|
||||
`HTTP ${response.status}`;
|
||||
|
||||
return new AdminApiError({
|
||||
message,
|
||||
status: response.status,
|
||||
code: errorPayload?.code,
|
||||
details,
|
||||
meta: envelope?.meta ?? null,
|
||||
responseText,
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
135
apps/admin-web/src/api/adminApiTypes.ts
Normal file
135
apps/admin-web/src/api/adminApiTypes.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
export interface ApiMeta {
|
||||
apiVersion?: string;
|
||||
requestId?: string;
|
||||
routeVersion?: string;
|
||||
operation?: string;
|
||||
latencyMs?: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: {
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ApiSuccessEnvelope<T> {
|
||||
ok?: boolean;
|
||||
data?: T;
|
||||
error?: ApiErrorPayload | null;
|
||||
meta?: ApiMeta;
|
||||
}
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
ok?: boolean;
|
||||
data?: unknown;
|
||||
error?: ApiErrorPayload;
|
||||
meta?: ApiMeta;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AdminSessionPayload {
|
||||
subject: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginResponse {
|
||||
token: string;
|
||||
admin: AdminSessionPayload;
|
||||
}
|
||||
|
||||
export interface AdminMeResponse {
|
||||
admin: AdminSessionPayload;
|
||||
}
|
||||
|
||||
export interface AdminOverviewResponse {
|
||||
service: AdminServiceOverviewPayload;
|
||||
database: AdminDatabaseOverviewPayload;
|
||||
}
|
||||
|
||||
export interface AdminServiceOverviewPayload {
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
jwtIssuer: string;
|
||||
adminEnabled: boolean;
|
||||
spacetimeServerUrl: string;
|
||||
spacetimeDatabase: string;
|
||||
}
|
||||
|
||||
export interface AdminDatabaseOverviewPayload {
|
||||
databaseIdentity: string | null;
|
||||
ownerIdentity: string | null;
|
||||
hostType: string | null;
|
||||
schemaTableNames: string[];
|
||||
tableStats: AdminDatabaseTableStatPayload[];
|
||||
fetchErrors: string[];
|
||||
}
|
||||
|
||||
export interface AdminDatabaseTableStatPayload {
|
||||
tableName: string;
|
||||
rowCount: number | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface AdminDebugHeaderInput {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type AdminDebugHttpMethod =
|
||||
| 'GET'
|
||||
| 'POST'
|
||||
| 'PUT'
|
||||
| 'PATCH'
|
||||
| 'DELETE';
|
||||
|
||||
export interface AdminDebugHttpRequest {
|
||||
method: AdminDebugHttpMethod;
|
||||
path: string;
|
||||
headers?: AdminDebugHeaderInput[];
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface AdminDebugHttpResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: AdminDebugHeaderInput[];
|
||||
bodyText: string;
|
||||
bodyJson: unknown | null;
|
||||
}
|
||||
|
||||
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
rewardPoints: number;
|
||||
maxUses: number;
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
allowedPublicUserCodes: string[];
|
||||
}
|
||||
|
||||
export interface AdminDisableProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ProfileRedeemCodeAdminResponse {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
rewardPoints: number;
|
||||
maxUses: number;
|
||||
globalUsedCount: number;
|
||||
enabled: boolean;
|
||||
allowedUserIds: string[];
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user