Electronics Catalog: API, MCP server, frontend and deployment
Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
130
frontend/src/api/client.js
Normal file
130
frontend/src/api/client.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
// Where the access token lives. sessionStorage, not localStorage: the token is
|
||||
// a bearer credential, and a tab-scoped store means closing the tab ends the
|
||||
// session rather than leaving a working credential on disk.
|
||||
export const TOKEN_STORAGE_KEY = 'app_access_token';
|
||||
|
||||
// Read at module load so a page refresh is already authenticated before
|
||||
// AuthContext mounts and the first request goes out.
|
||||
let authToken = (() => {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
let onUnauthorized = null;
|
||||
|
||||
/** Called by AuthContext on login/logout. Pass null to clear. */
|
||||
export function setAuthToken(token) {
|
||||
authToken = token || null;
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
} catch {
|
||||
/* private browsing with storage disabled - the in-memory copy still works */
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered by AuthContext so an expired token anywhere in the app drops the
|
||||
* session once, rather than leaving every panel to render its own 401 error.
|
||||
*/
|
||||
export function setUnauthorizedHandler(fn) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
|
||||
`(uvicorn app.main:app) and reachable?`,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || JSON.stringify(body);
|
||||
} catch {
|
||||
/* ignore parse errors, keep generic message */
|
||||
}
|
||||
// 401 means the token is missing, expired or invalid - the session is over.
|
||||
// 403 is a live session lacking a permission, so it must NOT log you out.
|
||||
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
|
||||
throw new ApiError(detail, res.status);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function qs(params = {}) {
|
||||
const usp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '' && v !== false) usp.set(k, v);
|
||||
});
|
||||
const s = usp.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getHealth: () => request('/api/health'),
|
||||
|
||||
// --- Auth ---
|
||||
login: (username, password) =>
|
||||
request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
getMe: () => request('/api/auth/me'),
|
||||
getRoles: () => request('/api/auth/roles'),
|
||||
|
||||
// --- Electronics catalogue (verified products only) ---
|
||||
getCategories: () => request('/api/elec/categories'),
|
||||
getBrands: (category) => request(`/api/elec/brands${qs({ category })}`),
|
||||
getProducts: (params) => request(`/api/elec/products${qs(params)}`),
|
||||
getProduct: (id) => request(`/api/elec/products/${id}`),
|
||||
getPriceHistory: (id) => request(`/api/elec/products/${id}/price-history`),
|
||||
getSites: () => request('/api/elec/sites'),
|
||||
|
||||
// --- Admin ---
|
||||
startRun: (body) => request('/api/elec/admin/runs', { method: 'POST', body: JSON.stringify(body) }),
|
||||
getRunJob: (jobId) => request(`/api/elec/admin/runs/${jobId}`),
|
||||
getRuns: () => request('/api/elec/admin/runs'),
|
||||
probeSite: (domain) => request(`/api/elec/admin/sites/${encodeURIComponent(domain)}/probe`, { method: 'POST' }),
|
||||
getReviewQueue: () => request('/api/elec/admin/review'),
|
||||
reviewMatch: (listingId, approve) =>
|
||||
request(`/api/elec/admin/review/${listingId}`, { method: 'POST', body: JSON.stringify({ approve }) }),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
Reference in New Issue
Block a user