Initial commit for Greenlens
This commit is contained in:
277
services/backend/backendApiClient.ts
Normal file
277
services/backend/backendApiClient.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
BackendApiError,
|
||||
BackendErrorCode,
|
||||
BillingSummary,
|
||||
HealthCheckResponse,
|
||||
PurchaseProductId,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchResponse,
|
||||
ServiceHealthResponse,
|
||||
SimulatedWebhookEvent,
|
||||
SimulatePurchaseResponse,
|
||||
SimulateWebhookResponse,
|
||||
} from './contracts';
|
||||
import { getAuthToken } from './userIdentityService';
|
||||
import { mockBackendService } from './mockBackendService';
|
||||
import { CareInfo, Language } from '../../types';
|
||||
|
||||
const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim();
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
if (status === 400) return 'BAD_REQUEST';
|
||||
if (status === 401 || status === 403) return 'UNAUTHORIZED';
|
||||
if (status === 402) return 'INSUFFICIENT_CREDITS';
|
||||
if (status === 408 || status === 504) return 'TIMEOUT';
|
||||
return 'PROVIDER_ERROR';
|
||||
};
|
||||
|
||||
const buildBackendUrl = (path: string): string => {
|
||||
const base = BACKEND_BASE_URL.replace(/\/$/, '');
|
||||
return `${base}${path}`;
|
||||
};
|
||||
|
||||
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeRequest = async <T,>(
|
||||
path: string,
|
||||
options: {
|
||||
method: 'GET' | 'POST';
|
||||
token: string;
|
||||
idempotencyKey?: string;
|
||||
body?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<T> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${options.token}`,
|
||||
};
|
||||
if (options.idempotencyKey) {
|
||||
headers['Idempotency-Key'] = options.idempotencyKey;
|
||||
}
|
||||
|
||||
const response = await fetch(buildBackendUrl(path), {
|
||||
method: options.method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const jsonPayload = parseMaybeJson(rawText);
|
||||
|
||||
if (!response.ok) {
|
||||
const payloadCode = jsonPayload?.code;
|
||||
const code = typeof payloadCode === 'string'
|
||||
? payloadCode as BackendErrorCode
|
||||
: mapHttpStatusToErrorCode(response.status);
|
||||
const payloadMessage = jsonPayload?.message;
|
||||
const message = typeof payloadMessage === 'string'
|
||||
? payloadMessage
|
||||
: `Backend request failed with status ${response.status}.`;
|
||||
throw new BackendApiError(code, message, response.status, jsonPayload || undefined);
|
||||
}
|
||||
|
||||
if (jsonPayload == null) {
|
||||
throw new BackendApiError('PROVIDER_ERROR', 'Backend returned invalid JSON.', 502);
|
||||
}
|
||||
|
||||
return jsonPayload as T;
|
||||
} catch (error) {
|
||||
if (error instanceof BackendApiError) throw error;
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new BackendApiError('TIMEOUT', 'Backend request timed out.', 408);
|
||||
}
|
||||
throw new BackendApiError('PROVIDER_ERROR', 'Backend request failed unexpectedly.', 500, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
export const backendApiClient = {
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return {
|
||||
ok: true,
|
||||
uptimeSec: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
|
||||
dbReady: true,
|
||||
dbPath: 'in-app-mock-backend',
|
||||
stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY),
|
||||
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
return makeRequest<ServiceHealthResponse>('/health', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
getBillingSummary: async (): Promise<BillingSummary> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
|
||||
return makeRequest<BillingSummary>('/v1/billing/summary', {
|
||||
method: 'GET',
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
scanPlant: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.scanPlant({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<ScanPlantResponse>('/v1/scan', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
semanticSearch: async (params: {
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.semanticSearch({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SemanticSearchResponse>('/v1/search/semantic', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
query: params.query,
|
||||
language: params.language,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
runHealthCheck: async (params: {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
plantContext?: {
|
||||
name: string;
|
||||
botanicalName: string;
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.healthCheck({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<HealthCheckResponse>('/v1/health-check', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
imageUri: params.imageUri,
|
||||
language: params.language,
|
||||
plantContext: params.plantContext,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulatePurchase: async (params: {
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
productId: params.productId,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulatePurchaseResponse>('/v1/billing/simulate-purchase', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
productId: params.productId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
simulateWebhook: async (params: {
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: { credits?: number };
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
event: params.event,
|
||||
payload: params.payload,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SimulateWebhookResponse>('/v1/billing/simulate-webhook', {
|
||||
method: 'POST',
|
||||
token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
body: {
|
||||
event: params.event,
|
||||
payload: params.payload || {},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const isInsufficientCreditsError = (error: unknown): boolean => {
|
||||
return error instanceof BackendApiError && error.code === 'INSUFFICIENT_CREDITS';
|
||||
};
|
||||
Reference in New Issue
Block a user