UNPKG

@athenox/talos

Version:

Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.

310 lines (309 loc) • 17.5 kB
import { createError, defineEventHandler, getHeader, getQuery, getRequestHost, getRequestIP, getRequestProtocol, getRouterParams, readBody, readMultipartFormData, sendStream, setHeader, } from 'h3'; import { planText } from './runtime.js'; // The HTTP method already says which kind of operation a route is. const OPS = { GET: ['SELECT'], POST: ['INSERT', 'CALL'], PUT: ['UPDATE'], PATCH: ['UPDATE'], DELETE: ['DELETE'], }; /** * An h3 route that is a sentence. * * export default sentenceRoute(app, 'supprime la categorie {id}', { guard: requireAdmin }) * * Parameters come from the route params, the body and the query, in that order * of precedence. Three facts about the request are handed to the runtime * rather than re-read by the model from the sentence: the HTTP method fixes the * operation, an `id` in the URL fixes the row, and a body's fields are written * to the columns they name. See `route` for the template syntax and for how a * sentence is compiled once and reused. */ export function sentenceRoute(app, template, opts = {}) { const answer = answerer(app, template, opts); return defineEventHandler(event => answer(event, getRouterParams(event))); } /** * A whole API as a table of sentences, served by one catch-all handler. * * // server/api/[...].ts * export default defineRoutes(app, { * 'GET /api/events': 'montre les evenements publies', * 'DELETE /api/admin/events/:id': { say: 'supprime l evenement {id}', guard: 'events' }, * }, { guards: { events: e => requirePerm(e, 'events') } }) * * An entry is a sentence, or the options of `sentenceRoute` plus `say`, with * `guard` naming one of `guards` (or given as a function). `:name` segments are * parameters. Route files the app still has win over the table, so the table * holds what is a sentence and files hold what is code. */ export function defineRoutes(app, table, opts = {}) { const routes = Object.entries(table).map(([key, entry]) => { const [method, path] = key.trim().split(/\s+/); const names = []; const re = new RegExp(`^${path.replace(/\/:(\w+)/g, (_, n) => { names.push(n); return '/([^/]+)'; })}/?$`); // A handler (an `accounts()` endpoint, say) sits in the table as it is. // It sees the URL's parameters as a file route would (`getRouterParams`). if (typeof entry === 'function') return { method: method.toUpperCase(), re, names, answer: (event, params) => { event.context.params = { ...event.context.params, ...params }; return entry(event); } }; const e = typeof entry === 'string' ? { say: entry } : entry; const guard = typeof e.guard === 'string' ? opts.guards?.[e.guard] : e.guard; if (typeof e.guard === 'string' && !guard) throw new Error(`${key}: no guard named ${e.guard}`); return { method: method.toUpperCase(), re, names, answer: answerer(app, e.say, { ...opts.defaults, ...e, guard }) }; }); return defineEventHandler(async (event) => { const path = event.path.split('?')[0]; for (const r of routes) { if (r.method !== event.method.toUpperCase()) continue; const m = r.re.exec(path); if (m) { const params = Object.fromEntries(r.names.map((n, i) => [n, decodeURIComponent(m[i + 1])])); event.context.params = { ...event.context.params, ...params }; return r.answer(event, params); } } if (opts.passthrough) return; throw createError({ statusCode: 404, message: 'no such route' }); }); } function answerer(app, template, opts) { const { guard, ctx, where, shape, stamp, fill, row, reply, only, form, firstError, fields, download, pin, notFound = 'not found', ...base } = opts; // Keep only the listed fields; `author.name` keeps one field of a relation. const keep = (r) => { if (!fields) return r; const out = {}; for (const f of fields) { const [a, b] = f.split('.'); if (b) { if (r[a] && typeof r[a] === 'object') (out[a] ??= {})[b] = r[a][b]; else if (a in r) out[a] ??= r[a]; } else if (a in r) out[a] = r[a]; } return out; }; const compiled = {}; // A string, or the whole `data` of the 404 when the front reads it there. const missing = () => createError({ statusCode: 404, message: typeof notFound === 'string' ? notFound : String(notFound.message ?? 'not found'), data: typeof notFound === 'string' ? undefined : notFound }); // An HTTP error the route means (a 404, a failed rule) is an answer; anything // else thrown while answering is a runtime error, for telemetry. return async (event, params) => { try { return await answer(event, params); } catch (e) { if (!e?.statusCode || e.statusCode >= 500) app.telemetry?.error(e, 'route', event.context.talosCall); throw e; } }; async function answer(event, params) { const user = (guard ? await guard(event, params) : undefined); const method = event.method.toUpperCase(); // A multipart form's text fields are values like any other; its files are // handed to the route's functions as `p.files`, for the code that stores them. let body = {}; let files = []; if (['POST', 'PUT', 'PATCH'].includes(method)) { if (String(getHeader(event, 'content-type') ?? '').startsWith('multipart/')) { const parts = (await readMultipartFormData(event)) ?? []; files = parts.filter(f => f.filename); body = Object.fromEntries(parts.filter(f => !f.filename && f.name).map(f => [f.name, f.data.toString('utf8')])); } else body = (await readBody(event).catch(() => ({}))) ?? {}; } if (form && ['POST', 'PUT', 'PATCH'].includes(method)) { const on = (v) => v === true || v === 'true' || v === 'on' || v === '1' || v === 1; for (const k of form.checkboxes ?? []) body[k] = on(body[k]); // Absent or blank alike: a form that leaves a field out has emptied it. for (const k of form.nullable ?? []) if (body[k] === undefined || (typeof body[k] === 'string' && !body[k].trim())) body[k] = null; } const p = { ...getQuery(event), ...body, ...params, ...(files.length ? { files } : {}) }; for (const [k, f] of Object.entries(fill ?? {})) if (p[k] === undefined || p[k] === '') p[k] = await f(p, user); const run = compiled[method] ??= app.route(template, { ops: OPS[method], ...base }); const more = (await shape?.(p, user, event)) ?? {}; // Every URL segment names the row (`/:id`, `/:slug`); segments that are not // columns are ignored by the runtime. const pinned = { ...(typeof where === 'function' ? where(p, user) : where), ...more.where, ...Object.fromEntries(Object.entries(params).filter(([k]) => pin === undefined || (pin !== false && pin.includes(k))) .map(([k, v]) => [k, k === 'id' ? Number(v) : v])), }; const sent = only ? Object.fromEntries(Object.entries(body).filter(([k]) => only.includes(k))) : body; const out = await run(p, ctx ? ctx(user) : {}, { ...more, where: Object.keys(pinned).length ? pinned : undefined, values: { ...sent, ...more.values, ...Object.fromEntries(Object.keys(fill ?? {}).map(k => [k, p[k]])) }, stamp: { ...more.stamp, ...await stamp?.(p, user) }, uploads: files.length ? files.map(f => ({ name: f.name, filename: f.filename, type: f.type, data: f.data })) : undefined, }); // In the response headers, so a wrong plan is never a mystery — and the // call that made it, for feedback or for whoever helps with it. setHeader(event, 'x-talos-plan', encodeURIComponent(`${out.sentence} => ${planText(out.plan)}${out.cached ? ' (compiled)' : ''}`)); if (out.callId) { setHeader(event, 'x-talos-call', out.callId); event.context.talosCall = out.callId; } const r = out.result; // A failed rule comes back as `data.errors: [{ msg }]`, the shape forms expect. if (out.status === 404) throw missing(); if (out.status !== 200) { const errors = r?.errors && (firstError ? r.errors.slice(0, 1) : r.errors); throw createError({ statusCode: out.status, message: errors?.[0]?.msg ?? r?.error, data: r?.data ?? (errors ? { errors } : undefined) }); } if (r.row && download) { const f = download(r.row, p); if (!f) throw missing(); const { createReadStream } = await import('node:fs'); const { stat } = await import('node:fs/promises'); if (!(await stat(f.path).catch(() => null))?.isFile()) throw missing(); if (f.type) setHeader(event, 'content-type', f.type); setHeader(event, 'x-content-type-options', 'nosniff'); if (f.name) setHeader(event, 'content-disposition', `${f.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`); for (const [k, v] of Object.entries(f.headers ?? {})) setHeader(event, k, v); return sendStream(event, createReadStream(f.path)); } if (r.rows && row) r.rows = r.rows.map(row); if (r.row && row) r.row = row(r.row); if (r.rows && fields) r.rows = r.rows.map(keep); if (r.row && fields) r.row = keep(r.row); // A write that matched nothing is a 404 before any custom reply is made. // (A bulk write reports created/updated/skipped: none of them is "not found".) if (('updated' in r || 'deleted' in r) && !(r.updated ?? r.deleted) && !r.dedupe && !('created' in r)) throw missing(); if (reply) return reply(r, p, user, event); if (r.row) return r.row; if (r.rows && r.total === undefined && !('deleted' in r)) return r.rows; if ('created' in r && ('updated' in r || 'skipped' in r)) return { created: r.created, updated: r.updated, skipped: r.skipped }; if ('updated' in r || 'deleted' in r) { if (!(r.updated ?? r.deleted) && !r.dedupe) throw missing(); return { success: true }; } if ('created' in r) return { id: r.created }; return r; } } /** * Files stored by a table's `files` columns, served back: the path is checked to * stay inside `dir`, the type follows the extension, and `guard` may refuse one * (a private photo) by throwing. * * 'GET /uploads/:name': serveFiles('uploads', { guard: (name, event) => ... }) */ export function serveFiles(dir, opts = {}) { const TYPES = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4' }; return defineEventHandler(async (event) => { const { resolve, extname, sep } = await import('node:path'); const name = decodeURIComponent(event.path.split('?')[0].split('/').pop() ?? ''); const root = resolve(dir), path = resolve(root, name); if (!name || !path.startsWith(root + sep)) throw createError({ statusCode: 404, message: 'not found' }); await opts.guard?.(name, event); const { stat } = await import('node:fs/promises'); if (!(await stat(path).catch(() => null))?.isFile()) throw createError({ statusCode: 404, message: 'not found' }); setHeader(event, 'content-type', TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'); setHeader(event, 'x-content-type-options', 'nosniff'); // The guard may have set its own policy (a private photo is not cached publicly). if (!event.node.res.getHeader('cache-control')) setHeader(event, 'cache-control', `public, max-age=${opts.maxAge ?? 86400}`); const { createReadStream } = await import('node:fs'); return sendStream(event, createReadStream(path)); }); } /** * A per-client limit on requests, as middleware: `rateLimit({ max: 20, windowMs: 60_000 })`. * In memory, so per process. */ export function rateLimit(opts) { // ponytail: in-process counters; share them (redis) when the app runs on several nodes. const hits = new Map(); const methods = opts.methods ?? ['POST', 'PUT', 'PATCH', 'DELETE']; return defineEventHandler((event) => { if (!methods.includes(event.method.toUpperCase())) return; const now = Date.now(); const key = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'; const h = hits.get(key); if (!h || h.until < now) { hits.set(key, { n: 1, until: now + opts.windowMs }); return; } if (++h.n > opts.max) throw createError({ statusCode: 429, message: opts.message ?? 'too many requests' }); }); } /** * An RSS feed or a sitemap from rows — the rows a sentence route reads, mapped * to items. `sitemap-news` is Google News' flavour (last two days only). * * 'GET /rss.xml': feed('rss', { title: 'Le Journal', rows: () => latest(), item: a => ({ url: `/articles/${a.slug}`, title: a.title, date: a.published_at }) }) */ export function feed(kind, o) { const x = (s) => String(s ?? '').replace(/[<>&'"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[c])); const when = (d) => { const t = d ? new Date(d) : null; return t && !Number.isNaN(t.getTime()) ? t : null; }; return defineEventHandler(async (event) => { const site = (typeof o.site === 'function' ? o.site(event) : o.site) ?? `${getRequestProtocol(event)}://${getRequestHost(event)}`; const abs = (u) => /^https?:/.test(u) ? u : `${site.replace(/\/$/, '')}${u.startsWith('/') ? '' : '/'}${u}`; let items = [...(o.extra ?? []), ...(await o.rows(event)).map(o.item).filter((i) => !!i)]; // Google News reads the last two days; `days: 0` keeps every item. const days = o.days ?? 2; if (kind === 'sitemap-news' && days > 0) { const since = Date.now() - days * 86_400_000; items = items.filter(i => (when(i.date)?.getTime() ?? 0) >= since); } setHeader(event, 'content-type', kind === 'rss' ? 'application/rss+xml; charset=utf-8' : 'application/xml; charset=utf-8'); setHeader(event, 'cache-control', `public, max-age=${o.maxAge ?? 900}`); if (kind === 'rss') return `<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel> <title>${x(o.title)}</title><link>${x(site)}</link><description>${x(o.description ?? o.title)}</description><language>${x(o.language ?? 'fr')}</language> <atom:link href="${x(abs(event.path.split('?')[0]))}" rel="self" type="application/rss+xml"/> ${items.map(i => `<item><title>${x(i.title)}</title><link>${x(abs(i.url))}</link><guid isPermaLink="true">${x(abs(i.url))}</guid>` + (when(i.date) ? `<pubDate>${when(i.date).toUTCString()}</pubDate>` : '') + (i.description ? `<description>${x(i.description)}</description>` : '') + (i.author ? `<dc:creator>${x(i.author)}</dc:creator>` : '') + (i.category ? `<category>${x(i.category)}</category>` : '') + (i.image ? `<enclosure url="${x(abs(i.image))}" type="image/jpeg"/>` : '') + '</item>').join('\n')} </channel></rss>`; const news = kind === 'sitemap-news'; return `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${news ? ' xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"' : ''} xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> ${items.map(i => `<url><loc>${x(abs(i.url))}</loc>` + (when(i.date) && !news ? `<lastmod>${when(i.date).toISOString()}</lastmod>` : '') + (i.changefreq && !news ? `<changefreq>${i.changefreq}</changefreq>` : '') + (i.priority !== undefined && !news ? `<priority>${i.priority}</priority>` : '') + (news ? `<news:news><news:publication><news:name>${x(o.title)}</news:name><news:language>${x(o.language ?? 'fr')}</news:language></news:publication>` + `<news:publication_date>${when(i.date)?.toISOString() ?? ''}</news:publication_date><news:title>${x(i.title)}</news:title></news:news>` : '') + (i.image ? `<image:image><image:loc>${x(abs(i.image))}</image:loc></image:image>` : '') + '</url>').join('\n')} </urlset>`; }); } /** `show_in_nav` → `showInNav`, for fronts written against an ORM that camelCases. */ export const camelRow = (row) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k.replace(/_(\w)/g, (_, c) => c.toUpperCase()), v]));