fix(api): serialize dated writes after request body parsing
This commit is contained in:
@@ -5,11 +5,11 @@ import type { AppDatabase, AuthEnv, createAuth } from '../auth';
|
||||
import { HabitService, ApiError } from './service';
|
||||
import { calendarSettingsSchema, chartInput, chartPatch, dateSchema, doneInput, habitInput, habitPatch, progressInput, taskInput, taskPatch, timezoneInput } from './contracts';
|
||||
import { dayNumber } from './calendar';
|
||||
type Env = AuthEnv & { Variables: { service: HabitService } };
|
||||
async function body<T extends z.ZodType>(c: Context<Env>, schema: T): Promise<z.output<T>> {
|
||||
if (!c.req.header('Content-Type')?.toLowerCase().startsWith('application/json')) throw new ApiError(400, 'Expected application/json');
|
||||
let value: unknown;
|
||||
try { value = await c.req.json(); } catch { throw new ApiError(400, 'Malformed JSON'); }
|
||||
type Env = AuthEnv & { Variables: { service: HabitService; payload: { value: unknown; malformed: boolean } } };
|
||||
function body<T extends z.ZodType>(c: Context<Env>, schema: T): z.output<T> {
|
||||
if (c.req.header('Content-Type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') throw new ApiError(400, 'Expected application/json');
|
||||
const { value, malformed } = c.get('payload');
|
||||
if (malformed) throw new ApiError(400, 'Malformed JSON');
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) throw new ApiError(422, result.error.issues.map(i => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '));
|
||||
return result.data;
|
||||
@@ -34,6 +34,13 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
if (!protectedPath(c.req.path, c.req.method)) return next();
|
||||
return authenticate(c, async () => {
|
||||
const proceed = async () => { const response = await limit(c, async () => {
|
||||
// Finish all asynchronous body reads before taking a dated database snapshot.
|
||||
// The handlers below then read, validate and write synchronously in one turn.
|
||||
const payload: Env['Variables']['payload'] = { value: undefined, malformed: false };
|
||||
if (['POST', 'PATCH', 'PUT'].includes(c.req.method)) {
|
||||
try { payload.value = await c.req.json(); } catch { payload.malformed = true; }
|
||||
}
|
||||
c.set('payload', payload);
|
||||
const service = new HabitService(db, c.get('user'), now());
|
||||
service.sync(); c.set('service', service); await next();
|
||||
}); if (response) c.res = response; };
|
||||
@@ -49,28 +56,28 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
if (!q.success) throw new ApiError(422, 'archived must be true or false');
|
||||
return c.json({ habits: service(c).list(q.data.archived === 'true') });
|
||||
});
|
||||
app.post('/habits', async c => {
|
||||
const habit = service(c).create(await body(c, habitInput));
|
||||
app.post('/habits', c => {
|
||||
const habit = service(c).create(body(c, habitInput));
|
||||
c.header('Location', `/api/habits/${habit.id}`); return c.json(habit, 201);
|
||||
});
|
||||
app.get('/habits/:id', c => c.json(service(c).current(id(c))));
|
||||
app.patch('/habits/:id', async c => c.json(service(c).edit(id(c), await body(c, habitPatch))));
|
||||
app.patch('/habits/:id', c => c.json(service(c).edit(id(c), body(c, habitPatch))));
|
||||
app.delete('/habits/:id', c => { service(c).edit(id(c), { archived: true }); return c.body(null, 204); });
|
||||
app.get('/habits/:id/history', c => c.json({ revisions: service(c).history(id(c)) }));
|
||||
app.get('/habits/:id/tasks', c => c.json({ tasks: service(c).tasks(id(c)) }));
|
||||
app.post('/habits/:id/tasks', async c => c.json(service(c).changeTask(id(c), undefined, await body(c, taskInput)), 201));
|
||||
app.post('/habits/:id/tasks', c => c.json(service(c).changeTask(id(c), undefined, body(c, taskInput)), 201));
|
||||
app.get('/habits/:id/tasks/:taskId', c => {
|
||||
const task = service(c).tasks(id(c)).find(t => t.id === c.req.param('taskId'));
|
||||
if (!task) throw new ApiError(404, 'Not found'); return c.json(task);
|
||||
});
|
||||
app.patch('/habits/:id/tasks/:taskId', async c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), await body(c, taskPatch))));
|
||||
app.patch('/habits/:id/tasks/:taskId', c => c.json(service(c).changeTask(id(c), c.req.param('taskId'), body(c, taskPatch))));
|
||||
app.delete('/habits/:id/tasks/:taskId', c => { service(c).changeTask(id(c), c.req.param('taskId'), null); return c.body(null, 204); });
|
||||
app.get('/habits/:id/days/:date', c => c.json(service(c).day(id(c), date(c))));
|
||||
app.get('/habits/:id/days/:date/audit', c => c.json({ records: service(c).audit(id(c), date(c)) }));
|
||||
app.put('/habits/:id/days/:date/progress', async c => c.json(service(c).progress(id(c), date(c), await body(c, progressInput))));
|
||||
app.put('/habits/:id/days/:date/tasks/:taskId', async c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (await body(c, doneInput)).done)));
|
||||
app.put('/habits/:id/days/:date/progress', c => c.json(service(c).progress(id(c), date(c), body(c, progressInput))));
|
||||
app.put('/habits/:id/days/:date/tasks/:taskId', c => c.json(service(c).completeTask(id(c), date(c), c.req.param('taskId'), (body(c, doneInput)).done)));
|
||||
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
|
||||
app.put('/habits/:id/calendar-settings', async c => c.json(service(c).saveSettings(id(c), await body(c, calendarSettingsSchema))));
|
||||
app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
|
||||
app.get('/habits/:id/calendar', c => {
|
||||
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false));
|
||||
});
|
||||
@@ -82,12 +89,12 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
const s = service(c); const day = date(c); return c.json({ date: day, habits: s.list(true).map(h => s.day(h.id, day)) });
|
||||
});
|
||||
app.get('/charts', c => c.json({ charts: service(c).charts() }));
|
||||
app.post('/charts', async c => {
|
||||
const chart = service(c).saveChart(await body(c, chartInput)); c.header('Location', `/api/charts/${chart.id}`); return c.json(chart, 201);
|
||||
app.post('/charts', c => {
|
||||
const chart = service(c).saveChart(body(c, chartInput)); c.header('Location', `/api/charts/${chart.id}`); return c.json(chart, 201);
|
||||
});
|
||||
app.get('/charts/:id', c => c.json(service(c).chart(id(c))));
|
||||
app.patch('/charts/:id', async c => {
|
||||
const patch = await body(c, chartPatch); const old = service(c).chart(id(c));
|
||||
app.patch('/charts/:id', c => {
|
||||
const patch = body(c, chartPatch); const old = service(c).chart(id(c));
|
||||
return c.json(service(c).saveChart({ name: patch.name ?? old.name, habitIds: patch.habitIds ?? old.habitIds, settings: patch.settings ?? old.settings }, id(c)));
|
||||
});
|
||||
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
|
||||
@@ -97,6 +104,6 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
|
||||
app.get('/charts/:id/days/:date', c => {
|
||||
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);
|
||||
});
|
||||
app.patch('/me', async c => c.json(service(c).setTimezone((await body(c, timezoneInput)).timezone)));
|
||||
app.patch('/me', c => c.json(service(c).setTimezone((body(c, timezoneInput)).timezone)));
|
||||
return app;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user