@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.
103 lines (85 loc) • 5.13 kB
Markdown
# Routes
## A route is a sentence
```ts
const del = app.route('delete the recipe {id}', { ops: ['DELETE'] })
const { status, result, plan, callId } = await del({ id: 3 }, { userId: 7, role: 'member' })
```
- `{k}` is a parameter; `[... {k} ...]` is dropped when `k` is missing;
`{k:no|yes}` says a boolean with one of two words.
- The first call for each shape of the sentence asks the model. The plan is
kept with every value that came from a parameter replaced by a slot; later
calls bind new values and never run the model.
- A plan holding a value that is neither a parameter nor a word of the template
is not kept — asked again next time rather than trusted forever.
- Before the model reads a sentence, a misspelt word of the app (a table, a
column, a value) or a misspelt verb is corrected, and an opening verb
(*delete*, *add*, *change*, *show*, and their French forms) fixes the
operation.
## A whole API as a table
```ts
export default defineRoutes<Member>(app, {
'GET /api/recipes': { say: 'show the recipes', orderBy: '-date', limit: 12, total: true,
shape: p => ({ search: p.q, page: Number(p.page) || 1 }),
where: p => (p.course ? { course: p.course } : {}) },
'POST /api/auth/login': users.login, // any h3 handler sits in the table as it is
'GET /uploads/:name': serveFiles('uploads'),
}, { guards: { member }, defaults: { firstError: true }, passthrough: false })
```
The HTTP method fixes the operation (GET → SELECT, POST → INSERT or CALL,
PUT/PATCH → UPDATE, DELETE → DELETE); refusal stays possible. URL segments
(`/:id`, `/:slug`) name the row: they replace any condition the model wrote on
those columns. The body's fields are written by name.
`defineRoutes` options: `guards` (named guards for `guard: 'name'`), `defaults`
(options every route gets), `passthrough: true` to mount it as middleware that
lets unmatched requests through.
### Route options
| option | |
|---|---|
| `say` | the sentence |
| `guard` | `(event, params) => user` — throws to refuse; its user is given to the functions below |
| `ctx` | `user => ({ userId, role })`: what roles are checked against |
| `where` | conditions pinned by code, `{ col: v }` or `(p, user) => ({...})`. Comparisons: `{ gte }`, `{ lt }`, `{ between: [a, b] }`, `{ ne }`, `{ in: [...] }`, `{ null: true }`; `any: [{...}, {...}]` for OR; `'relation.column'` |
| `stamp` | `(p, user) => ({ author_id: user.id })`: columns the route writes itself |
| `fill` | defaults for missing parameters: `{ visibility: () => 'public' }` |
| `only` | the body fields a caller may send |
| `form` | `{ checkboxes: [...], nullable: [...] }` for HTML forms |
| `shape` | `(p, user, event) => Shape`: per-request options (below) |
| `fields` | keep these fields of each row: `['id', 'title', 'author.name']` |
| `row` | `row => row`: map each row |
| `reply` | `(result, p, user, event) => body`: the response |
| `notFound` | the 404 message (or data object) |
| `firstError` | only the first failed rule in `data.errors` |
| `pin` | which URL segments name the row (`false`: none) |
| `download` | `row => ({ path, name, type, inline })`: send a file |
| and every `Shape` option | `orderBy`, `limit`, `one`, `total`… |
### Shape (per request)
| option | |
|---|---|
| `orderBy` | `'-date'`, `['-likes', 'title']`; `'minutes~'` puts empty values last; a declared-values column sorts in declared order |
| `limit`, `offset`, `page`, `total` | paging; `total` adds `{ total, page, limit }` |
| `one` | a single row, 404 when none |
| `search` | full text over `searchable` |
| `find`, `similar`, `recommend` | ranking — see [search.md](search.md) |
| `lists` | which `lists` to load (`false`: none) |
| `counts`, `sums` | `{ pending: { status: 'pending' } }`, `{ revenue: { column: 'amount', where } }` |
| `returning` | an INSERT returns its row, a DELETE the rows it removed |
| `rows`, `upsert`, `ignore` | bulk inserts: `{ created, updated, skipped }` |
| `dedupe` | `{ by: 'email' }`: a DELETE that keeps the first of each |
| `before` | `row => extra`: runs after the rules, before the write |
| `images` | this route's own image processing |
| `ops`, `where`, `values`, `stamp` | as above |
## Answers
A route answers `200` with the row (`one`), the rows, `{ rows, total, page, limit }`
(`total`), `{ id }` for a creation, `{ success: true }` for an update or delete —
or what `reply` returns. Errors: `400` with `data.errors: [{ msg, column }]` for
rules, `401`/`403` from guards and roles, `404` when nothing matched, `422` when
the request is not something this app does.
Headers: `x-talos-plan` (the sentence and its plan), `x-talos-call` (the model
call, for [feedback](telemetry.md)).
## Free-text requests
```ts
const { status, result, plan, callId } = await app.run('the easy desserts under 30 minutes', ctx, { ops: ['SELECT'], limit: 10 })
```
`run` asks the model every time (no cache): for a search box, an assistant, an
admin console. `decide(sentence, ctx, ops?)` returns the plan without running
it; `execute(plan, ms, sentence, ctx, shape)` runs a plan however it was made.