200 lines
5.7 KiB
TypeScript
200 lines
5.7 KiB
TypeScript
import type { Connect, Plugin } from 'vite';
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { fakePrediction } from './handlers/prediction';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DATA_DIR = join(__dirname, 'data');
|
|
|
|
/**
|
|
* Dev-only mock for the Django REST API. Enable by setting
|
|
* `VITE_USE_MOCK_API=true` when running `vite dev`.
|
|
*
|
|
* Covered endpoints (everything under /api/):
|
|
* - GET/POST/PUT/DELETE /saved-points/…
|
|
* - GET/POST/PUT/DELETE /saved-templates/…
|
|
* - GET/POST/PUT/DELETE /saved-profiles/…
|
|
* - POST /predictions/
|
|
* - GET /session/, /whoami/, /csrf/
|
|
* - POST /login/, /logout/
|
|
*
|
|
* State lives on disk so edits survive dev-server restarts.
|
|
*/
|
|
|
|
interface MockSession {
|
|
isAuthenticated: boolean;
|
|
username: string | null;
|
|
}
|
|
|
|
let session: MockSession = { isAuthenticated: true, username: 'demo-user' };
|
|
|
|
function loadArray<T>(file: string, fallback: T[]): T[] {
|
|
const path = join(DATA_DIR, file);
|
|
if (!existsSync(path)) return fallback;
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf-8')) as T[];
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function saveArray<T>(file: string, items: T[]): void {
|
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
writeFileSync(join(DATA_DIR, file), JSON.stringify(items, null, '\t'));
|
|
}
|
|
|
|
function send(res: ServerResponse, status: number, body: unknown) {
|
|
res.statusCode = status;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function cors(res: ServerResponse, req: IncomingMessage) {
|
|
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRFToken');
|
|
}
|
|
|
|
async function readBody(req: IncomingMessage): Promise<unknown> {
|
|
return await new Promise((resolve) => {
|
|
const chunks: Uint8Array[] = [];
|
|
req.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
|
req.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
if (!raw) return resolve({});
|
|
try {
|
|
resolve(JSON.parse(raw));
|
|
} catch {
|
|
resolve({});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function crudEndpoint<T extends { id: number }>(
|
|
file: string,
|
|
path: string,
|
|
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
|
return async (req, res) => {
|
|
const url = req.url ?? '';
|
|
if (!url.startsWith(path)) return false;
|
|
|
|
const remainder = url.slice(path.length);
|
|
const [idPart] = remainder.replace(/\/$/, '').split('?');
|
|
const id = idPart ? Number(idPart) : null;
|
|
|
|
let items = loadArray<T>(file, []);
|
|
|
|
switch (req.method) {
|
|
case 'GET':
|
|
if (id == null || Number.isNaN(id)) {
|
|
send(res, 200, items);
|
|
} else {
|
|
const item = items.find((i) => i.id === id);
|
|
item ? send(res, 200, item) : send(res, 404, { detail: 'not found' });
|
|
}
|
|
return true;
|
|
case 'POST': {
|
|
const body = (await readBody(req)) as Partial<T>;
|
|
const nextId = items.reduce((m, i) => Math.max(m, i.id), 0) + 1;
|
|
const created = { ...body, id: nextId } as T;
|
|
items = [...items, created];
|
|
saveArray(file, items);
|
|
send(res, 201, created);
|
|
return true;
|
|
}
|
|
case 'PUT': {
|
|
const body = (await readBody(req)) as T;
|
|
items = items.map((i) => (i.id === (id ?? body.id) ? body : i));
|
|
saveArray(file, items);
|
|
send(res, 200, body);
|
|
return true;
|
|
}
|
|
case 'DELETE':
|
|
items = items.filter((i) => i.id !== id);
|
|
saveArray(file, items);
|
|
res.statusCode = 204;
|
|
res.end();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
}
|
|
|
|
export function mockApiPlugin(): Plugin {
|
|
const handlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise<boolean>> = [
|
|
crudEndpoint('points.json', '/api/saved-points/'),
|
|
crudEndpoint('scenarios.json', '/api/saved-templates/'),
|
|
crudEndpoint('profiles.json', '/api/saved-profiles/'),
|
|
];
|
|
|
|
const middleware: Connect.NextHandleFunction = async (req, res, next) => {
|
|
const url = req.url ?? '';
|
|
if (!url.startsWith('/api/')) return next();
|
|
|
|
cors(res, req);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.statusCode = 204;
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith('/api/csrf/')) {
|
|
res.setHeader('Set-Cookie', 'csrftoken=mock-csrf; Path=/');
|
|
send(res, 200, { detail: 'ok' });
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith('/api/session/')) {
|
|
send(res, 200, { isAuthenticated: session.isAuthenticated });
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith('/api/whoami/')) {
|
|
if (!session.username) return send(res, 401, { detail: 'not authenticated' });
|
|
send(res, 200, { username: session.username });
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith('/api/login/')) {
|
|
const body = (await readBody(req)) as { username: string; password: string };
|
|
if (!body.username || !body.password) {
|
|
return send(res, 400, { detail: 'missing credentials' });
|
|
}
|
|
session = { isAuthenticated: true, username: body.username };
|
|
send(res, 200, { detail: 'ok', username: body.username });
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith('/api/logout/')) {
|
|
session = { isAuthenticated: false, username: null };
|
|
send(res, 200, { detail: 'ok' });
|
|
return;
|
|
}
|
|
|
|
if (url.startsWith('/api/predictions/')) {
|
|
const body = (await readBody(req)) as Parameters<typeof fakePrediction>[0];
|
|
send(res, 200, fakePrediction(body));
|
|
return;
|
|
}
|
|
|
|
for (const h of handlers) {
|
|
if (await h(req, res)) return;
|
|
}
|
|
|
|
send(res, 404, { detail: 'mock: endpoint not implemented' });
|
|
};
|
|
|
|
return {
|
|
name: 'lsv-mock-api',
|
|
apply: 'serve',
|
|
configureServer(server) {
|
|
server.middlewares.use(middleware);
|
|
},
|
|
};
|
|
}
|