diff --git a/CHANGELOG.md b/CHANGELOG.md index 2568c3b..1c4aad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - `@crudy/restloom`: discovery from the catalog, relations from foreign keys and junction tables, the read grammar, the overridable SQL renderer, `createREST` and a Node `http` listener. +- Writes: create, update and delete by key, bulk by filter where enabled, scope applied, rows read back with `Prefer: return=representation`. +- Procedures: database functions at declared paths, arguments checked by name and type. - `@crudy/restloom-postgres`: the Postgres dialect. +- `@crudy/restloom-fastify` and `@crudy/restloom-express`: framework adapters. diff --git a/README.md b/README.md index 98b8dfc..2b0b71e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ Your backend stays in charge: |---|---| | `@crudy/restloom` | The core: grammar, discovery, `createREST`, a Node `http` listener | | `@crudy/restloom-postgres` | PostgreSQL | +| `@crudy/restloom-fastify` | Fastify plugin | +| `@crudy/restloom-express` | Express handler | ```sh npm install @crudy/restloom @crudy/restloom-postgres pg @@ -49,7 +51,17 @@ http.createServer(nodeListener(rest)).listen(3000); ## Documentation -- [Query grammar](docs/query-grammar.md): select, filters, relations, order, pages, counts, aggregates. +1. [Getting started](docs/getting-started.md) +2. [Discovery and overrides](docs/discovery.md): what becomes a resource, hidden columns, relation names. +3. [Query grammar](docs/query-grammar.md): select, filters, relations, order, pages, counts, aggregates. +4. [Writes and procedures](docs/writes.md) +5. [Security and hooks](docs/security.md) +6. [Adapters](docs/adapters.md): Node, Fastify, Express, your own routes. +7. [Dialects](docs/dialects.md) and [writing a dialect](docs/writing-a-dialect.md) +8. [Errors](docs/errors.md) +9. [Compared with PostgREST](docs/postgrest.md) + +The examples in the grammar and writes pages are the conformance suite: each one runs against every database and must answer exactly what the page shows. ## Licence diff --git a/conformance/examples.mjs b/conformance/examples.mjs index 4062ad3..1ffc9d2 100644 --- a/conformance/examples.mjs +++ b/conformance/examples.mjs @@ -1,33 +1,49 @@ import { readFileSync } from 'node:fs'; +const CASE = /```http\n([\s\S]*?)```[ \t]*\n+(?:Answer: (\d{3})[ \t]*\n+)?(?:```json\n([\s\S]*?)```)?/g; + +function request(block) { + const [head, ...rest] = block.split(/\n\s*\n/); + const lines = head.split('\n').map((l) => l.trim()).filter(Boolean); + const headers = {}; + let line = ''; + for (const l of lines) { + const h = /^([A-Za-z-]+):\s*(.*)$/.exec(l); + if (h && line) headers[h[1].toLowerCase()] = h[2]; + else line += l; + } + const body = rest.join('\n\n').trim(); + return { line, headers, body: body ? JSON.parse(body) : undefined }; +} + /** - * Every ```http block followed by a ```json block in a docs page is one - * conformance case: the request, then the exact body it must answer. A body - * with an `error` key must come with a 4xx or 5xx status. + * The conformance cases of a docs page: every ```http block (request line, + * header lines, a blank line and a JSON body) followed by an optional + * `Answer: ` line and an optional ```json block with the exact body. + * Without an Answer line the status is 2xx, or 4xx when the body has `error`. */ export function examples(file) { const text = readFileSync(file, 'utf8'); const out = []; - const re = /```http\n([\s\S]*?)```\s*\n+```json\n([\s\S]*?)```/g; - for (let m; (m = re.exec(text));) { - const line = text.slice(0, m.index).split('\n').length; - const request = m[1].split('\n').map((l) => l.trim()).filter(Boolean).join(''); - const space = request.indexOf(' '); - const target = new URL(request.slice(space + 1), 'http://example'); + for (let m; (m = CASE.exec(text));) { + if (!m[2] && m[3] === undefined) continue; + const { line, headers, body } = request(m[1]); + const space = line.indexOf(' '); + const target = new URL(line.slice(space + 1), 'http://example'); out.push({ - name: `${file.split('/').pop()}:${line} ${request}`, - method: request.slice(0, space), - path: target.pathname, - query: target.searchParams, - expected: JSON.parse(m[2]), + name: `${file.split('/').pop()}:${text.slice(0, m.index).split('\n').length} ${line}`, + method: line.slice(0, space), path: target.pathname, query: target.searchParams, headers, body, + status: m[2] ? Number(m[2]) : undefined, + expected: m[3] === undefined ? undefined : JSON.parse(m[3]), }); } return out; } export async function run(rest, example, assert) { - const res = await rest.handle({ method: example.method, path: example.path, query: example.query, headers: {}, context: undefined }); + const res = await rest.handle({ method: example.method, path: example.path, query: example.query, headers: example.headers, body: example.body, context: undefined }); assert.deepEqual(res.body, example.expected, `status ${res.status}`); - if (example.expected && typeof example.expected === 'object' && 'error' in example.expected) assert.ok(res.status >= 400, `status ${res.status}`); + if (example.status) assert.equal(res.status, example.status); + else if (example.expected && typeof example.expected === 'object' && 'error' in example.expected) assert.ok(res.status >= 400, `status ${res.status}`); else assert.ok(res.status < 300, `status ${res.status}`); } diff --git a/conformance/fixtures/postgres.sql b/conformance/fixtures/postgres.sql index be8c0c0..a6407bf 100644 --- a/conformance/fixtures/postgres.sql +++ b/conformance/fixtures/postgres.sql @@ -58,3 +58,11 @@ INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES (1, 'VPN outage', 1, 3, '2026-01-10'), (2, 'Spoofed mail', 2, 2, '2026-02-01'), (3, 'Brute force', 1, 4, '2026-03-05'); + +CREATE FUNCTION demo.retire_asset(asset integer, reason text DEFAULT NULL) RETURNS integer + LANGUAGE sql AS $$ UPDATE demo.assets SET status = 'retired' WHERE id = asset RETURNING id $$; + +CREATE FUNCTION demo.assets_tagged(tag text) RETURNS SETOF demo.assets + LANGUAGE sql STABLE AS $$ + SELECT a.* FROM demo.assets a JOIN demo.asset_tags t ON t.asset_id = a.id JOIN demo.tags g ON g.id = t.tag_id + WHERE g.name = tag ORDER BY a.id $$; diff --git a/conformance/pages.mjs b/conformance/pages.mjs new file mode 100644 index 0000000..2bd2840 --- /dev/null +++ b/conformance/pages.mjs @@ -0,0 +1,16 @@ +/** The docs pages whose examples are conformance cases, and the options every database runs them with. */ +export const PAGES = ['query-grammar.md', 'writes.md']; + +export const OPTIONS = { + expose: { schemas: ['demo'] }, + resources: { + asset_tags: { expose: false }, + tags: { write: true }, + assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } }, + incidents: { write: { create: true, update: true, delete: true, bulk: true } }, + }, + procedures: { + 'assets/{asset}/retire': { function: 'demo.retire_asset' }, + 'tags/{tag}/assets': { function: 'demo.assets_tagged' }, + }, +}; diff --git a/conformance/postgres.test.mjs b/conformance/postgres.test.mjs index 64e7c75..082b677 100644 --- a/conformance/postgres.test.mjs +++ b/conformance/postgres.test.mjs @@ -1,29 +1,30 @@ -import { test, before, after } from 'node:test'; +import { describe, test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { createREST } from '../packages/core/dist/index.js'; import { postgres } from '../packages/postgres/dist/index.js'; import { examples, run } from './examples.mjs'; +import { PAGES, OPTIONS } from './pages.mjs'; const require = createRequire(new URL('../packages/postgres/package.json', import.meta.url)); const { Pool } = require('pg'); const url = process.env.RESTLOOM_PG_URL; -const docs = new URL('../docs/', import.meta.url).pathname; -const pages = ['query-grammar.md']; +const fixture = readFileSync(new URL('./fixtures/postgres.sql', import.meta.url), 'utf8'); let pool; -let rest; -before(async () => { - if (!url) return; - pool = new Pool({ connectionString: url }); - await pool.query(readFileSync(new URL('./fixtures/postgres.sql', import.meta.url), 'utf8')); - rest = createREST({ db: postgres(pool), expose: { schemas: ['demo'] }, resources: { asset_tags: { expose: false } } }); -}); +before(() => { if (url) pool = new Pool({ connectionString: url }); }); after(async () => { await pool?.end(); }); -for (const page of pages) { - for (const example of examples(docs + page)) { - test(`postgres ${example.name}`, { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => run(rest, example, assert)); - } +for (const page of PAGES) { + describe(`postgres ${page}`, { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => { + let rest; + before(async () => { + await pool.query(fixture); + rest = createREST({ db: postgres(pool), ...OPTIONS }); + }); + for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) { + test(example.name, () => run(rest, example, assert)); + } + }); } diff --git a/docs/adapters.md b/docs/adapters.md new file mode 100644 index 0000000..1967559 --- /dev/null +++ b/docs/adapters.md @@ -0,0 +1,48 @@ +# Adapters + +The core answers a plain request object, so it fits any framework: + +```ts +const response = await rest.handle({ method, path, query, headers, body, context }); +// { status, headers, body } +``` + +`handle` never throws: every failure is a response. `path` is the path below where the API is mounted (`/assets/4`), `query` a `URLSearchParams`, `body` the parsed JSON. + +## Node + +`nodeListener` from the core serves `node:http` with no other dependency. It reads JSON bodies up to 1 MiB (`maxBodyBytes`). + +```ts +http.createServer(nodeListener(rest, { prefix: '/api', context: (req) => userFrom(req) })).listen(3000); +``` + +## Fastify + +```sh +npm install @crudy/restloom-fastify +``` + +```ts +import { restloom } from '@crudy/restloom-fastify'; + +await app.register(restloom(rest, { context: (req) => req.user }), { prefix: '/api' }); +``` + +Fastify's own hooks, rate limits and logging run as for any route. + +## Express + +```sh +npm install @crudy/restloom-express +``` + +```ts +import { restloom } from '@crudy/restloom-express'; + +app.use('/api', express.json(), restloom(rest, { context: (req) => req.user })); +``` + +## Your own routes + +A host that registers every route itself, to attach its own permissions or documentation to each, calls `rest.handle` from those routes. That is how crudy mounts restloom: each declared route checks its permission, then hands the request to restloom. diff --git a/docs/dialects.md b/docs/dialects.md new file mode 100644 index 0000000..bfed003 --- /dev/null +++ b/docs/dialects.md @@ -0,0 +1,38 @@ +# Dialects + +A dialect is the package that knows one database: how to read its catalog, how to write its SQL, how to run a transaction and what its errors mean. + +| Package | Database | Embeds | Writes return rows | Estimated count | Row security | +|---|---|---|---|---|---| +| `@crudy/restloom-postgres` | PostgreSQL 13+ | `json_build_object`, `json_agg` | `RETURNING` | planner estimate | yes | + +## Postgres + +```ts +import { postgres } from '@crudy/restloom-postgres'; + +const db = postgres(pool); +``` + +- **Transactions:** every request is one transaction, `READ ONLY` for reads, with `SET LOCAL statement_timeout` from `limits.timeoutMs`. +- **Types:** enums read as text. A domain reads as its base type. +- **Timestamps:** they are written in the session's time zone, as Postgres writes them in JSON. +- **Procedures:** calls use named arguments, so the order in the body does not matter. + +## Changing how SQL is written + +Every piece of SQL comes from one overridable method of the dialect's renderer. To change one, subclass it and pass it in: + +```ts +import { PostgresRenderer, postgres } from '@crudy/restloom-postgres'; + +class Renderer extends PostgresRenderer { + like(expr: string, pattern: string, insensitive: boolean) { + return insensitive ? `unaccent(${expr}) ILIKE unaccent(${pattern})` : super.like(expr, pattern, false); + } +} + +const db = postgres(pool, { renderer: new Renderer() }); +``` + +The pieces are listed in [writing a dialect](./writing-a-dialect.md). diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 0000000..69df101 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,65 @@ +# Discovery and overrides + +restloom reads the database's catalog once, on the first request, and builds the API from it. `rest.reload()` reads it again on the next request, after a migration for example. + +## What becomes a resource + +`expose` names what is served. Nothing else is. + +```ts +expose: { schemas: ['app'] } // every table and view of app +expose: { tables: ['app.assets', 'app.users'] } // only these +``` + +A resource is named after its table. When two exposed schemas hold a table of the same name, both are named `schema_table`. `resources..expose: false` removes one, for example a junction table you only want used as a relation: + +```ts +resources: { asset_tags: { expose: false } } +``` + +A resource's key is its primary key. A view has none, so it uses a column named `id` when it has one, or the columns you give it: `resources: { asset_rows: { key: ['asset_id'] } }`. A resource without a key can still be listed, but has no `/{id}` path and no stable page order. + +## Columns + +Every column is visible except: + +- **Sensitive names:** columns whose name contains one of these words: `password`, `passwd`, `pwd`, `hash`, `hashed`, `salt`, `secret`, `token`, `api_key`, `private_key`, `otp`, `totp`, `mfa`, `recovery`. A word is a whole part of the name between underscores: `password_hash` and `reset_token` are hidden, `tokenizer` is not. +- **Columns you hide:** `resources: { users: { hide: ['phone'] } }`. + +`show` makes a sensitive-looking column visible on purpose: `resources: { files: { show: ['sha256_hash'] } }`. `sensitive` replaces the whole list with your own pattern. + +A hidden column cannot be read, filtered, sorted or written, and naming it answers exactly as naming a column that does not exist. It can still join a relation. + +## Relations + +Relations come from foreign keys between exposed resources: + +| In the catalog | Gives | +|---|---| +| `assets.owner_id` references `users.id` | `assets.owner`, to-one; `users.assets`, to-many | +| `asset_tags` has a primary key made of a key to `assets` and a key to `tags` | `assets.tags` and `tags.assets`, many-to-many | + +Names: + +- **To-one:** the foreign key column without `_id` (`owner_id` gives `owner`). Otherwise the target table's name. +- **To-many:** the source table's name. +- **Collisions:** when a name is taken by a column or another relation, the next candidate is used. For to-one, that is the target table's name, then `_`. For to-many, it is `
_by_`, and for many-to-many `
_via_`. + +A relation to a table that is not exposed does not exist. A foreign key to a hidden table exposes nothing. + +`relations` overrides, adds or removes them: + +```ts +resources: { + users: { + relations: { + users: false, // remove the discovered one + reports: { many: 'users', via: 'manager_id' }, // to-many: users whose manager_id is my key + team: { one: 'teams', via: 'team_id' }, // to-one, e.g. for a view without foreign keys + badges: { many: 'badges', through: 'user_badges' }, + }, + }, +} +``` + +An override with the name of a column is refused when the catalog is read, and so is any option naming a resource, column or table that does not exist. Call `await rest.resources()` at start so a typo stops your server from booting instead of failing its first request. diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..d390b52 --- /dev/null +++ b/docs/errors.md @@ -0,0 +1,65 @@ +# Errors + +Every error is a JSON object with a stable code, and a message naming the part of the request at fault when there is one: + +```json +{ "error": "unknown_column", "message": "password_hash" } +``` + +A message never contains SQL or database text. + +## The request + +| Code | Status | Means | +|---|---|---| +| `not_found` | 404 | no such resource, or no such row, or a row you may not see | +| `method_not_allowed` | 405 | the resource does not allow this method, or a procedure was not called with POST | +| `unknown_column` | 400 | the column does not exist or is hidden | +| `unknown_relation` | 400 | the relation does not exist | +| `unknown_operator` | 400 | the operator is not one of `eq neq gt gte lt lte in like ilike is` | +| `bad_operator` | 400 | the operator does not apply to the column's type | +| `invalid_value` | 400 | the value does not fit the column's type | +| `bad_filter` | 400 | a filter is not `operator.value` | +| `bad_logic` | 400 | an `or` or `and` is not `(term,term)` | +| `bad_list` | 400 | an `in` list is malformed, empty, or too long | +| `bad_name` | 400 | a dotted or quoted name is malformed | +| `bad_syntax` | 400 | unbalanced quotes or parentheses | +| `bad_select` | 400 | a select item is malformed | +| `duplicate_select` | 400 | a name is selected twice | +| `unknown_aggregate` | 400 | not one of `count sum avg min max` | +| `bad_aggregate` | 400 | the aggregate does not apply to the column's type | +| `aggregate_with_embed` | 400 | a grouped read cannot embed relations | +| `aggregate_in_embed` | 400 | an embed cannot hold aggregates | +| `bad_order` | 400 | an order term names a to-many column, an unorderable type, or, in a grouped read, an unselected column | +| `not_embedded` | 400 | `relation.order`, `.limit` or `.offset` names a relation that is not embedded | +| `too_deep` | 400 | a path or an embed walks more relations than `limits.depth` | +| `too_many_filters` | 400 | more filters than `limits.filters` | +| `bad_limit`, `bad_offset` | 400 | not a whole number | +| `limit_exceeded` | 400 | a limit over `limits.maxPage` | +| `bad_count` | 400 | `count` is not `exact` or `estimated` | + +## Writes and procedures + +| Code | Status | Means | +|---|---|---| +| `bad_body` | 400 | the body is not an object or a list of objects within `limits.bodyRows` | +| `body_too_large` | 413 | the body is over the adapter's size limit | +| `mixed_columns` | 400 | the rows of a body name different columns | +| `not_writable` | 400 | the column cannot be written by this method | +| `filter_required` | 400 | a bulk update or delete has no filter | +| `out_of_scope` | 403 | a value contradicts the host's scope | +| `unknown_argument` | 400 | the procedure's function takes no argument of that name | +| `missing_argument` | 400 | an argument without a default is missing | + +## The database + +| Code | Status | Means | +|---|---|---| +| `duplicate` | 409 | a unique constraint refused the row | +| `reference_missing` | 409 | a foreign key points to a row that does not exist | +| `still_referenced` | 409 | the row is still referenced by another | +| `value_required` | 400 | a required column has no value | +| `value_rejected` | 400 | a check constraint refused the value | +| `forbidden` | 403 | the database refused the session's role | +| `timeout` | 503 | the statement ran past `limits.timeoutMs` | +| `internal` | 500 | anything else; your `onError` hook receives it | diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..4c5e804 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,43 @@ +# Getting started + +## Install + +Install the core, the package for your database, and its driver: + +```sh +npm install @crudy/restloom @crudy/restloom-postgres pg +``` + +| Database | Package | Driver | +|---|---|---| +| PostgreSQL 13+ | `@crudy/restloom-postgres` | `pg` | + +## Serve a schema + +```ts +import http from 'node:http'; +import pg from 'pg'; +import { createREST, nodeListener } from '@crudy/restloom'; +import { postgres } from '@crudy/restloom-postgres'; + +const rest = createREST({ + db: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })), + expose: { schemas: ['app'] }, +}); + +http.createServer(nodeListener(rest, { prefix: '/api' })).listen(3000); +``` + +Every table and view of `app` is now a resource, read-only: + +```sh +curl -g 'http://localhost:3000/api/assets?select=id,name,owner(name)&status=eq.open' +``` + +## Next + +- [Discovery and overrides](./discovery.md): what becomes a resource, which columns are hidden, how relations are named. +- [Query grammar](./query-grammar.md): everything a GET can ask. +- [Writes and procedures](./writes.md): turning writes on, calling functions. +- [Security and hooks](./security.md): authorising requests, row security, scoping. +- [Adapters](./adapters.md): Fastify, Express, or any framework. diff --git a/docs/postgrest.md b/docs/postgrest.md new file mode 100644 index 0000000..abd7ba8 --- /dev/null +++ b/docs/postgrest.md @@ -0,0 +1,20 @@ +# Compared with PostgREST + +restloom keeps what makes PostgREST pleasant, the catalog as the source and a query string anyone can type, and changes where control sits. + +| | PostgREST | restloom | +|---|---|---| +| Runs as | a separate server | a library inside your backend | +| Databases | PostgreSQL | PostgreSQL; more through dialects | +| What is served | everything the role can reach in the schema | what you name in `expose`, minus hidden columns | +| Sensitive columns | served unless the role cannot read them | hidden by name unless shown | +| Writes | everything the role may write | off until a resource enables them, by column | +| Authorisation | database roles and row security | your hooks, plus row security where the database has it | +| Relations | inferred from foreign keys | inferred from foreign keys, overridable, removable | +| Filtering through a to-many relation | an embed with `!inner` | `tags.name=eq.pci`: at least one; `not.`: none | +| Ordering by a related column | on embedded resources | `order=owner.name`, `order=incidents.count` | +| A name that is a keyword | not addressable as a filter | quote it: `"limit"=eq.5` | +| Over-sized limit | capped | refused with `limit_exceeded` | +| Errors | database messages | stable codes, no database text | + +The grammar is close enough that most PostgREST reads work unchanged: `select`, `eq`, `in`, `is`, `like`, `order`, `limit`, `offset`, `or`, embeds, and `Prefer: return=representation`. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..9c81df6 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,66 @@ +# Security and hooks + +restloom decides what can be asked. Your backend decides who may ask it, through hooks. + +## What restloom enforces + +- **Exposure:** only the schemas and tables you name are served, and only their visible columns. +- **Bound values:** every value is a bound parameter, checked against its column's type before any SQL runs. Names come from the catalog and are quoted; nothing from a request is written into SQL. +- **Bounded requests:** every request is limited: + +| Limit | Default | Option | +|---|---|---| +| Rows per page | 50, at most 1000 | `limits.page`, `limits.maxPage` | +| Relations walked by a path or an embed | 2 | `limits.depth` | +| Filters, every or/and term included | 50 | `limits.filters` | +| Items in one `in` list | 500 | `limits.inList` | +| Rows in one write body | 1000 | `limits.bodyRows` | +| Statement time | 5 s | `limits.timeoutMs` | + +A request over a limit is refused, never quietly cut down. + +- **Errors:** they answer stable codes. A database message never reaches the client. Unexpected errors answer `internal` and reach your `onError` hook. + +## Hooks + +```ts +createREST({ + db, + expose: { schemas: ['app'] }, + hooks: { + authorize(request, target) { /* throw a RestError to refuse */ }, + async transaction(tx, context) { /* session settings, role */ }, + scope(resource, context) { return { tenant_id: context.tenantId }; }, + onError(error, request) { log.error(error); }, + }, +}); +``` + +| Hook | Runs | Use it to | +|---|---|---| +| `authorize(request, target)` | before any SQL, with `{ resource, action }` or `{ procedure, action: 'call' }` | refuse a request: `throw new RestError(403, 'forbidden')` | +| `transaction(tx, context)` | first, in every transaction | set the role and session settings row security reads | +| `scope(resource, context)` | for every resource a query touches, embeds and relation filters included | add equality filters, e.g. a tenant column, on databases without row security | +| `onError(error, request)` | for every answer of `internal` | log it | + +`context` is what your adapter builds from the request, usually the authenticated user. restloom never reads it. + +## Row security + +With Postgres, row security is the strongest perimeter: the database itself hides the rows a session may not see. Connect as a role without `BYPASSRLS`, and set what your policies read in the `transaction` hook: + +```ts +async transaction(tx, context) { + await tx.query("SELECT set_config('app.user_id', $1, true)", [context.userId]); +} +``` + +Every read and write of the request then runs under those policies, embeds and relation filters included. + +## Scope + +On a database without row security, `scope` keeps a request inside its perimeter. It returns column values a row must have, and restloom applies them: + +- **Reads:** every query on the resource, and every relation reaching it, only matches rows with those values. +- **Inserts:** the scoped columns are filled in. A body giving another value is refused with `out_of_scope`. +- **Updates and deletes:** only rows with those values are reached. An update moving a row out of scope is refused. diff --git a/docs/writes.md b/docs/writes.md new file mode 100644 index 0000000..788e8d9 --- /dev/null +++ b/docs/writes.md @@ -0,0 +1,196 @@ +# Writes and procedures + +Writes are off until a resource enables them. The examples on this page run with these options, against the [demo schema](../conformance/fixtures/postgres.sql): + +```ts +resources: { + tags: { write: true }, + assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } }, + incidents: { write: { create: true, update: true, delete: true, bulk: true } }, +}, +procedures: { + 'assets/{asset}/retire': { function: 'demo.retire_asset' }, + 'tags/{tag}/assets': { function: 'demo.assets_tagged' }, +}, +``` + +`write: true` allows create, update and delete of every visible column. A list allows only the columns named. Generated and identity columns are never writable, and neither is a key on update. + +| Method | Path | Does | Answers | +|---|---|---|---| +| POST | `/tags` | creates one row, or an array of rows | 201 | +| PATCH | `/tags/4` | updates one row | 204, or 200 with the row | +| DELETE | `/tags/4` | deletes one row | 204, or 200 with the row | +| PATCH, DELETE | `/incidents?severity=lt.3` | by filter, only where `bulk: true` | 204, or 200 with the rows | + +## Getting the rows back + +Without a `Prefer` header a write answers with no body. `Prefer: return=representation` answers the written rows, read back with the request's `select`, so embeds work too. + +```http +POST /tags?select=id,name +Prefer: return=representation + +{ "id": 4, "name": "critical" } +``` + +Answer: 201 + +```json +[{ "id": 4, "name": "critical" }] +``` + +```http +POST /tags + +[{ "id": 5, "name": "legacy" }, { "id": 6, "name": "edge" }] +``` + +Answer: 201 + +Every row of a body names the same columns, so one statement writes them all. + +```http +POST /tags + +[{ "id": 7, "name": "a" }, { "name": "b" }] +``` + +```json +{ "error": "mixed_columns" } +``` + +A column outside the resource's writable list is refused, whatever its value: + +```http +POST /assets + +{ "name": "new laptop", "status": "open", "active": false } +``` + +```json +{ "error": "not_writable", "message": "active" } +``` + +## Updating and deleting + +PATCH and DELETE act on one row, by its key. A row that does not exist, or is not visible, answers 404. + +```http +PATCH /assets/2?select=id,name,owner(name) +Prefer: return=representation + +{ "owner_id": 1 } +``` + +Answer: 200 + +```json +{ "id": 2, "name": "mail relay", "owner": { "name": "Ada" } } +``` + +```http +DELETE /tags/4 +``` + +Answer: 204 + +A row still referenced by another is not deleted: + +```http +DELETE /tags/3 +``` + +```json +{ "error": "still_referenced" } +``` + +```http +DELETE /assets/1 +``` + +```json +{ "error": "method_not_allowed" } +``` + +A resource with `bulk: true` also takes PATCH and DELETE on the collection, with at least one filter. Without a filter they are refused, so no request can empty a table by accident. + +```http +DELETE /incidents?severity=lt.3&select=id,title +Prefer: return=representation +``` + +Answer: 200 + +```json +[{ "id": 2, "title": "Spoofed mail" }] +``` + +```http +DELETE /incidents +``` + +```json +{ "error": "filter_required" } +``` + +## Values + +Body values are checked against each column's type, as query values are: a number for a number column, `true` or `false` for a boolean, a string in ISO 8601 for a date or a time. The database's own constraints answer with stable codes: + +```http +POST /tags + +{ "id": 1, "name": "duplicate key" } +``` + +```json +{ "error": "duplicate" } +``` + +```http +POST /incidents + +{ "id": 9, "title": "Lost badge", "asset_id": 99, "severity": 1, "opened_on": "2026-04-01" } +``` + +```json +{ "error": "reference_missing" } +``` + +## Procedures + +A procedure calls a database function at a path you choose. A `{name}` in the path passes that segment as the argument of the same name; the body's keys pass the others. Arguments are checked by name and type, and one with a default may be left out. A procedure is always a POST. + +```http +POST /assets/3/retire + +{ "reason": "end of life" } +``` + +```json +3 +``` + +A function returning a set answers an array of its rows. Mail relay belongs to Ada since the update above. + +```http +POST /tags/gdpr/assets +``` + +```json +[ + { "id": 2, "name": "mail relay", "status": "open", "owner_id": 1, "cost": 300, "active": true, "created_at": "2026-01-05T09:30:00+00:00" }, + { "id": 4, "name": "payroll db", "status": "open", "owner_id": 3, "cost": 4000, "active": true, "created_at": "2026-02-11T16:45:00+00:00" } +] +``` + +```http +POST /assets/3/retire + +{ "why": "no reason" } +``` + +```json +{ "error": "unknown_argument", "message": "why" } +``` diff --git a/docs/writing-a-dialect.md b/docs/writing-a-dialect.md new file mode 100644 index 0000000..702170f --- /dev/null +++ b/docs/writing-a-dialect.md @@ -0,0 +1,54 @@ +# Writing a dialect + +A dialect implements one interface: + +```ts +interface Dialect { + name: string; + capabilities: { returning: boolean; estimatedCount: boolean; rowSecurity: boolean }; + renderer: SqlRenderer; + introspect(scope: { schemas: string[]; tables: string[] }): Promise; + transaction(fn: (tx: Tx) => Promise, options: { timeoutMs: number; readOnly: boolean }): Promise; + mapError(err: unknown): RestError | null; + estimate?(tx: Tx, sql: string, params: unknown[]): Promise; +} +``` + +| Member | Does | +|---|---| +| `introspect` | reads tables, views, columns, primary keys, foreign keys and functions, and maps each type to a logical one: `text`, `integer`, `number`, `boolean`, `uuid`, `date`, `timestamp`, `time`, `json`, `binary`, `array` or `other` | +| `transaction` | runs a request's statements in one transaction with a statement timeout | +| `mapError` | turns an error the client caused into a stable code; anything else returns `null` and answers `internal` | +| `renderer` | writes the SQL | + +The core decides everything else from the logical types: which operators a column accepts, which values are valid, which columns can be summed. + +## The renderer + +`SqlRenderer` writes Postgres SQL. A dialect subclasses it and overrides what its database writes differently. Statements are built only from these pieces, so an override reaches every statement that uses it: + +| Method | Postgres writes | +|---|---| +| `quote(name)` | `"name"` | +| `placeholder(n)` | `$n` | +| `jsonObject(pairs)` | `json_build_object('a', x, ...)` | +| `jsonArray(row, order)` | `COALESCE(json_agg(row ORDER BY order), '[]'::json)` | +| `jsonValue(expr, column)` | the column, unchanged | +| `nested(expr)` | a nested JSON subquery, unchanged | +| `like(expr, pattern, insensitive)` | `LIKE` or `ILIKE ... ESCAPE '\'` | +| `isBoolean(expr, value)` | `IS TRUE`, `IS FALSE` | +| `orderBy(expr, desc, nulls)` | `ASC`/`DESC NULLS FIRST`/`LAST` | +| `page(limit, offset)` | `LIMIT n OFFSET m` | +| `first(select)` | `... LIMIT 1` | +| `rowNumber(order)` | `ROW_NUMBER() OVER (ORDER BY ...)` | +| `returning(resource)` | `RETURNING key` | +| `writeTarget(resource, alias)` | `table AS alias` | +| `call(fn, args)` | `SELECT to_json(fn(arg => $1))` | + +Larger units can be overridden too: `relationSource`, `exists`, `filter`, `embed`, `read`, `count`, `insert`, `update`, `delete`. + +Placeholders are written last: fragments carry internal tokens, and the final text gets the database's own placeholders in text order. A dialect with positional placeholders (`?`) gets its values in the right order whatever order the fragments were built in. + +## Proving it + +A dialect passes when the conformance suite does. Add a fixture with the demo schema and data in `conformance/fixtures/.sql`, and a runner next to `postgres.test.mjs`. Every example of the docs pages then runs against your database and must answer exactly what the page shows. diff --git a/package-lock.json b/package-lock.json index 71ed5c5..64ceb9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "name": "restloom-workspace", "workspaces": [ "packages/core", - "packages/postgres" + "packages/postgres", + "packages/fastify", + "packages/express" ], "engines": { "node": ">=20" @@ -17,10 +19,212 @@ "resolved": "packages/core", "link": true }, + "node_modules/@crudy/restloom-express": { + "resolved": "packages/express", + "link": true + }, + "node_modules/@crudy/restloom-fastify": { + "resolved": "packages/fastify", + "link": true + }, "node_modules/@crudy/restloom-postgres": { "resolved": "packages/postgres", "link": true }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.7.tgz", + "integrity": "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.4.tgz", + "integrity": "sha512-kc8GQq5gX9RRhw0rov3DMrN8wS8yRlxJRdDzsYwwdBV/wazuVgc/1K7CKL90tlvu3RlHan6Y0R0aE3nJtBz2eQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.1.tgz", + "integrity": "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.1.tgz", + "integrity": "sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.1.2.tgz", + "integrity": "sha512-89WiRG+hW3VQSfCwg4Hb8b43PeyEX15K5+lEfsZ4EUth/FyOjS/lv8HsVuswUNXWiSnxMGNouE/fq/0w3VumHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.2.tgz", + "integrity": "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.2.tgz", + "integrity": "sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.19.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", @@ -43,6 +247,967 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.1.tgz", + "integrity": "sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz", + "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.2.1.tgz", + "integrity": "sha512-GU16K+1IH8XSJ/suUaX/ONlJ5glCFvmBdra2nGI2ZGpBBXzqM/r/lzO02gjHYcRad1KKj6WvC+/RROggpg6acg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.3.tgz", + "integrity": "sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.7", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.7.tgz", + "integrity": "sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-my-way": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.10.0.tgz", + "integrity": "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", + "integrity": "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", + "integrity": "sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "@fastify/deepmerge": "^3.1.0", + "cookie": "^2.0.0", + "process-warning": "^5.0.0", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", @@ -140,6 +1305,46 @@ "split2": "^4.1.0" } }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -183,6 +1388,384 @@ "node": ">=0.10.0" } }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.1.tgz", + "integrity": "sha512-Z/nyvABwcRnRD2mMP7Kz6G1dFZZqX1mNErOnxF42OCQKE07b9k9hK9SQxHL3LFEZUdzQaLM4dId1E/E6Vm4WhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz", + "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.2.2.tgz", + "integrity": "sha512-df017qMeYuXo4emL09nlyMpi2i2MtEIKS/6jtXH3vmjFgIRsOi852/5RmhJ4EkvN3ZF8jXadM/EkTPXgc8yPIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -193,6 +1776,68 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -214,6 +1859,33 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -236,6 +1908,45 @@ "node": ">=20" } }, + "packages/express": { + "name": "@crudy/restloom-express", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^24.0.0", + "express": "^5.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^5.0.0" + } + }, + "packages/fastify": { + "name": "@crudy/restloom-fastify", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "fastify": "^5.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "fastify": "^5.0.0" + } + }, "packages/postgres": { "name": "@crudy/restloom-postgres", "version": "0.1.0", diff --git a/package.json b/package.json index 01e99f1..a7046f3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "type": "module", "workspaces": [ "packages/core", - "packages/postgres" + "packages/postgres", + "packages/fastify", + "packages/express" ], "engines": { "node": ">=20" diff --git a/packages/core/src/grammar/body.ts b/packages/core/src/grammar/body.ts new file mode 100644 index 0000000..fcfa143 --- /dev/null +++ b/packages/core/src/grammar/body.ts @@ -0,0 +1,50 @@ +import type { LogicalType } from '../catalog.js'; +import { badRequest } from '../errors.js'; +import type { Value } from '../plan.js'; +import type { Column, Resource } from '../resources.js'; +import { typed } from './values.js'; + +export type Row = Map; + +/** A body value as a value to bind, checked against the column's type the same way a query value is. */ +function bodyValue(v: unknown, c: Column): unknown { + if (v === null) return null; + const type: LogicalType = c.type; + if (type === 'json') return JSON.stringify(v); + if (type === 'boolean') { + if (typeof v !== 'boolean') throw badRequest('invalid_value', c.name); + return v; + } + if (type === 'integer' || type === 'number') { + if (typeof v === 'number' && Number.isFinite(v) && (type === 'number' || Number.isInteger(v))) return v; + if (typeof v === 'string') return typed(v, type, c.name); + throw badRequest('invalid_value', c.name); + } + if (typeof v !== 'string') throw badRequest('invalid_value', c.name); + return typed(v, type, c.name) as Value; +} + +/** + * The rows of a write body, each a map of writable columns to checked values. + * Every row must name the same columns, so one statement writes them all. + */ +export function bodyRows(resource: Resource, body: unknown, kind: 'create' | 'update', maxRows: number): Row[] { + const list = Array.isArray(body) ? body : [body]; + if (list.length === 0 || list.length > maxRows) throw badRequest('bad_body', `between 1 and ${maxRows} rows`); + if (kind === 'update' && Array.isArray(body)) throw badRequest('bad_body', 'an update takes one object'); + let keys: string | null = null; + return list.map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) throw badRequest('bad_body', 'rows are objects'); + const names = Object.keys(item).sort(); + if (names.length === 0) throw badRequest('bad_body', 'a row names at least one column'); + if (keys !== null && keys !== names.join(',')) throw badRequest('mixed_columns'); + keys = names.join(','); + const row: Row = new Map(); + for (const name of names) { + const c = resource.columns.get(name); + if (!c || !c[kind]) throw badRequest('not_writable', name); + row.set(name, bodyValue((item as Record)[name], c)); + } + return row; + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 598cb9f..ec3443e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,5 +8,6 @@ export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, Catalo export type { Column, Junction, Relation, Resource } from './resources.js'; export type { Aggregate, Embed, Filter, Operator, OrderTerm, Path, ReadPlan, Selection, Value } from './plan.js'; export { SqlPrimitives } from './sql/primitives.js'; +export { SqlReader } from './sql/reader.js'; export { SqlRenderer, type Statement } from './sql/renderer.js'; export { RenderContext } from './sql/context.js'; diff --git a/packages/core/src/procedures.ts b/packages/core/src/procedures.ts new file mode 100644 index 0000000..942636e --- /dev/null +++ b/packages/core/src/procedures.ts @@ -0,0 +1,67 @@ +import type { Catalog, CatalogFunction } from './catalog.js'; +import { badRequest } from './errors.js'; +import { typed } from './grammar/values.js'; +import type { ProcedureOptions } from './options.js'; + +export interface Procedure { + template: string; + pattern: RegExp; + params: string[]; + fn: CatalogFunction; +} + +/** Declared procedures, each bound to exactly one function of the catalog. */ +export function buildProcedures(catalog: Catalog, declared: Record): Procedure[] { + return Object.entries(declared).map(([template, o]) => { + const [schema, name] = o.function.includes('.') ? o.function.split('.', 2) : ['', o.function]; + const matches = catalog.functions.filter((f) => f.name === name && (!schema || f.schema === schema)); + if (matches.length !== 1) throw new Error(`restloom: procedure ${template} needs exactly one function named ${o.function} in an exposed schema, found ${matches.length}`); + const params: string[] = []; + const source = template.replace(/^\/+|\/+$/g, '').split('/').map((part) => { + const p = /^\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(part); + if (!p) return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!matches[0].args.some((a) => a.name === p[1])) throw new Error(`restloom: procedure ${template} names {${p[1]}}, which ${o.function} does not take`); + params.push(p[1]); + return '([^/]+)'; + }).join('/'); + return { template, pattern: new RegExp(`^/${source}/?$`), params, fn: matches[0] }; + }); +} + +export function matchProcedure(list: Procedure[], path: string): { procedure: Procedure; values: Record } | null { + for (const procedure of list) { + const m = procedure.pattern.exec(path); + if (!m) continue; + try { + return { procedure, values: Object.fromEntries(procedure.params.map((p, i) => [p, decodeURIComponent(m[i + 1])])) }; + } catch { + return null; + } + } + return null; +} + +/** The call's arguments from the path and the body, by name, each checked against its type. */ +export function procedureArgs(fn: CatalogFunction, fromPath: Record, body: unknown): [string, unknown][] { + if (body !== undefined && (body === null || typeof body !== 'object' || Array.isArray(body))) throw badRequest('bad_body', 'arguments are an object'); + const given: Record = { ...(body as Record | undefined), ...fromPath }; + for (const name of Object.keys(given)) if (!fn.args.some((a) => a.name === name)) throw badRequest('unknown_argument', name); + const out: [string, unknown][] = []; + for (const a of fn.args) { + if (!(a.name in given)) { + if (!a.hasDefault) throw badRequest('missing_argument', a.name); + continue; + } + const v = given[a.name]; + if (v === null) out.push([a.name, null]); + else if (a.type === 'json') out.push([a.name, JSON.stringify(v)]); + else if (a.type === 'array') { + if (!Array.isArray(v)) throw badRequest('invalid_value', a.name); + out.push([a.name, v]); + } else if (a.type === 'other' && (typeof v === 'string' || typeof v === 'number')) out.push([a.name, v]); + else if (typeof v === 'boolean' || typeof v === 'number') out.push([a.name, typed(String(v), a.type, a.name)]); + else if (typeof v === 'string') out.push([a.name, typed(v, a.type, a.name)]); + else throw badRequest('invalid_value', a.name); + } + return out; +} diff --git a/packages/core/src/read.ts b/packages/core/src/read.ts index b2e9eb8..52dcbbd 100644 --- a/packages/core/src/read.ts +++ b/packages/core/src/read.ts @@ -32,8 +32,9 @@ export function keyFilter(resource: Resource, id: string): Filter { /** GET on a collection, or on one row when id is given. */ export async function readRows(db: Dialect, tx: Tx, resource: Resource, request: RestRequest, limits: Limits, - scope: (r: Resource) => Filter[], id?: string): Promise { + scope: (r: Resource) => Filter[], id?: string, extra: Filter[] = []): Promise { const plan = parseRead(resource, request.query, limits); + plan.where.push(...extra); if (id !== undefined) { plan.where.unshift(keyFilter(resource, id)); plan.limit = 1; diff --git a/packages/core/src/rest.ts b/packages/core/src/rest.ts index cd33d87..1464b94 100644 --- a/packages/core/src/rest.ts +++ b/packages/core/src/rest.ts @@ -1,9 +1,11 @@ import type { Catalog } from './catalog.js'; import { RestError, notFound } from './errors.js'; import { DEFAULT_LIMITS, type Limits, type RestOptions, type RestRequest, type RestResponse } from './options.js'; +import { buildProcedures, matchProcedure, procedureArgs, type Procedure } from './procedures.js'; import { readRows } from './read.js'; import { buildResources, type Resource } from './resources.js'; import { scopeFilters } from './scope.js'; +import { create, remove, update, type WriteContext } from './write.js'; export interface Rest { /** Answers one request. Never throws: every failure is a response. */ @@ -14,6 +16,10 @@ export interface Rest { resources(): Promise>; } +interface Api { resources: Map; procedures: Procedure[] } + +const ACTIONS = { GET: 'read', POST: 'create', PATCH: 'update', DELETE: 'delete' } as const; + function errorBody(err: RestError): RestResponse { return { status: err.status, headers: {}, body: { error: err.code, ...(err.detail ? { message: err.detail } : {}) } }; } @@ -35,23 +41,48 @@ function route(path: string): { resource: string; id?: string } | null { export function createREST(options: RestOptions): Rest { const limits: Limits = { ...DEFAULT_LIMITS, ...options.limits }; const db = options.db; - let loading: Promise> | null = null; + let loading: Promise | null = null; const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] }) - .then((catalog: Catalog) => buildResources(catalog, options as RestOptions)) + .then((catalog: Catalog) => ({ + resources: buildResources(catalog, options as RestOptions), + procedures: buildProcedures(catalog, options.procedures ?? {}), + })) .catch((err: unknown) => { loading = null; throw err; })); - async function dispatch(request: RestRequest): Promise { - const target = route(request.path); - const resource = target && (await load()).get(target.resource); - if (!target || !resource) throw notFound(); - if (request.method !== 'GET') throw new RestError(405, 'method_not_allowed'); - await options.hooks?.authorize?.(request, { resource: resource.name, action: 'read' }); - const scope = scopeFilters(options, request.context); + async function inTransaction(request: RestRequest, readOnly: boolean, fn: (tx: Parameters[0]>[0]) => Promise): Promise { return db.transaction(async (tx) => { await options.hooks?.transaction?.(tx, request.context); - return readRows(db, tx, resource, request as RestRequest, limits, scope, target.id); - }, { timeoutMs: limits.timeoutMs, readOnly: true }); + return fn(tx); + }, { timeoutMs: limits.timeoutMs, readOnly }); + } + + async function dispatch(request: RestRequest): Promise { + const api = await load(); + const scope = scopeFilters(options, request.context); + const call = matchProcedure(api.procedures, request.path); + if (call) { + if (request.method !== 'POST') throw new RestError(405, 'method_not_allowed'); + await options.hooks?.authorize?.(request, { procedure: call.procedure.template, action: 'call' }); + const args = procedureArgs(call.procedure.fn, call.values, request.body); + return inTransaction(request, false, async (tx) => { + const stmt = db.renderer.call(call.procedure.fn, args, db.renderer.context(scope)); + const rows = (await tx.query(stmt.sql, stmt.params)).map((r) => (typeof r.r === 'string' ? JSON.parse(r.r) : r.r)); + return { status: 200, headers: {}, body: call.procedure.fn.returnsSet ? rows : rows[0] ?? null }; + }); + } + const target = route(request.path); + const resource = target && api.resources.get(target.resource); + if (!target || !resource) throw notFound(); + const action = ACTIONS[request.method as keyof typeof ACTIONS]; + if (!action || (action === 'create' && target.id !== undefined)) throw new RestError(405, 'method_not_allowed'); + await options.hooks?.authorize?.(request, { resource: resource.name, action }); + const generic = request as RestRequest; + if (action === 'read') return inTransaction(request, true, (tx) => readRows(db, tx, resource, generic, limits, scope, target.id)); + return inTransaction(request, false, (tx) => { + const c: WriteContext = { db, tx, resource, request: generic, limits, scope }; + return action === 'create' ? create(c) : action === 'update' ? update(c, target.id) : remove(c, target.id); + }); } return { @@ -61,12 +92,13 @@ export function createREST(options: RestOptions): Rest { } catch (err) { if (err instanceof RestError) return errorBody(err); const mapped = db.mapError(err); + if (mapped?.code === 'reference_missing' && request.method === 'DELETE') return errorBody(new RestError(409, 'still_referenced')); if (mapped) return errorBody(mapped); options.hooks?.onError?.(err, request); return errorBody(new RestError(500, 'internal')); } }, reload() { loading = null; }, - resources: load, + resources: async () => (await load()).resources, }; } diff --git a/packages/core/src/sql/reader.ts b/packages/core/src/sql/reader.ts new file mode 100644 index 0000000..ac668f4 --- /dev/null +++ b/packages/core/src/sql/reader.ts @@ -0,0 +1,149 @@ +import type { Embed, Filter, OrderTerm, Path, ReadPlan, Selection, Value } from '../plan.js'; +import type { Relation, Resource } from '../resources.js'; +import { RenderContext } from './context.js'; +import { SqlPrimitives } from './primitives.js'; + +export interface Statement { sql: string; params: unknown[] } + +const CMP: Record = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' }; + +/** + * Writes read statements from a plan. Relations are correlated subqueries, so + * a statement is one SQL text whatever the depth, and every table it touches + * carries the host's scope filters. Each method can be overridden by a dialect. + */ +export class SqlReader extends SqlPrimitives { + context(scope: (r: Resource) => Filter[]): RenderContext { + return new RenderContext(scope); + } + + /** FROM clause and correlation of a relation's target, seen from the parent alias. */ + relationSource(rel: Relation, parent: string, alias: string, ctx: RenderContext): { from: string; where: string[] } { + const target = this.table(rel.target.schema, rel.target.table); + const scoped = this.filters(rel.target, alias, ctx.scope(rel.target), ctx); + if (!rel.through) { + const on = rel.to.map((c, i) => `${this.column(alias, c)} = ${this.column(parent, rel.from[i])}`); + return { from: `${target} ${alias}`, where: [...on, ...scoped] }; + } + const j = ctx.alias('j'); + const join = rel.to.map((c, i) => `${this.column(j, rel.through!.to[i])} = ${this.column(alias, c)}`).join(' AND '); + const on = rel.from.map((c, i) => `${this.column(j, rel.through!.from[i])} = ${this.column(parent, c)}`); + return { from: `${target} ${alias} JOIN ${this.table(rel.through.schema, rel.through.table)} ${j} ON ${join}`, where: [...on, ...scoped] }; + } + + /** EXISTS through a chain of relations, with `inner` written against the last alias. */ + exists(relations: Relation[], parent: string, ctx: RenderContext, inner: (alias: string) => string | null): string { + const [rel, ...rest] = relations; + const alias = ctx.alias(); + const src = this.relationSource(rel, parent, alias, ctx); + const tail = rest.length ? this.exists(rest, alias, ctx, inner) : inner(alias); + const where = [...src.where, ...(tail ? [tail] : [])]; + return `EXISTS (SELECT 1 FROM ${src.from}${where.length ? ` WHERE ${where.join(' AND ')}` : ''})`; + } + + comparison(expr: string, f: Extract, ctx: RenderContext): string { + const bind = (v: Value) => ctx.params.add(v); + switch (f.op) { + case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`; + case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean); + case 'like': return this.like(expr, bind(f.value as Value), false); + case 'ilike': return this.like(expr, bind(f.value as Value), true); + default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`; + } + } + + filter(alias: string, f: Filter, ctx: RenderContext): string { + if (f.kind === 'present') return `${f.negate ? 'NOT ' : ''}${this.exists(f.relations, alias, ctx, () => null)}`; + if (f.kind !== 'cmp') { + const body = `(${f.terms.map((t) => this.filter(alias, t, ctx)).join(f.kind === 'or' ? ' OR ' : ' AND ')})`; + return f.negate ? `NOT ${body}` : body; + } + if (f.path.relations.length === 0) { + const cmp = this.comparison(this.column(alias, f.path.column), f, ctx); + return f.negate ? `NOT (${cmp})` : cmp; + } + const found = this.exists(f.path.relations, alias, ctx, (a) => this.comparison(this.column(a, f.path.column), f, ctx)); + return f.negate ? `NOT ${found}` : found; + } + + filters(_resource: Resource, alias: string, list: Filter[], ctx: RenderContext): string[] { + return list.map((f) => this.filter(alias, f, ctx)); + } + + /** A value reached through to-one relations, or a count of a to-many one, as a scalar subquery. */ + pathValue(relations: Relation[], parent: string, ctx: RenderContext, leaf: (alias: string) => string): string { + const [rel, ...rest] = relations; + const alias = ctx.alias(); + const src = this.relationSource(rel, parent, alias, ctx); + const value = rest.length ? this.pathValue(rest, alias, ctx, leaf) : leaf(alias); + return `(${this.first(`SELECT ${value} FROM ${src.from} WHERE ${src.where.join(' AND ')}`)})`; + } + + orderExpr(t: OrderTerm, alias: string, ctx: RenderContext): string { + if (t.key.kind === 'count') { + const rels = t.key.relations; + const counted = (parent: string) => { + const a = ctx.alias(); + const src = this.relationSource(rels[rels.length - 1], parent, a, ctx); + return `(SELECT COUNT(*) FROM ${src.from} WHERE ${src.where.join(' AND ')})`; + }; + return rels.length === 1 ? counted(alias) : this.pathValue(rels.slice(0, -1), alias, ctx, counted); + } + const path: Path = t.key.path; + return path.relations.length === 0 + ? this.column(alias, path.column) + : this.pathValue(path.relations, alias, ctx, (a) => this.column(a, path.column)); + } + + order(terms: OrderTerm[], alias: string, ctx: RenderContext): string { + return terms.map((t) => this.orderBy(this.orderExpr(t, alias, ctx), t.desc, t.nulls)).join(', '); + } + + rowJson(resource: Resource, select: Selection, alias: string, ctx: RenderContext): string { + const pairs: [string, string][] = select.columns.map((c) => [c, this.jsonValue(this.column(alias, c), resource.columns.get(c)!)]); + for (const a of select.aggregates) pairs.push([a.fn, this.aggregate(a.fn, a.column && this.column(alias, a.column))]); + for (const e of select.embeds) pairs.push([e.name, this.nested(this.embed(e, alias, ctx))]); + return this.jsonObject(pairs); + } + + embed(e: Embed, parent: string, ctx: RenderContext): string { + const alias = ctx.alias(); + const src = this.relationSource(e.relation, parent, alias, ctx); + const target = e.relation.target; + const row = this.rowJson(target, e.select, alias, ctx); + const where = ` WHERE ${src.where.join(' AND ')}`; + if (e.relation.kind === 'one') return `(${this.first(`SELECT ${row} FROM ${src.from}${where}`)})`; + const terms = e.order.length ? e.order : target.key.map((k) => ({ key: { kind: 'column' as const, path: { relations: [], column: k } }, desc: false })); + const ordered = this.order(terms, alias, ctx); + const inner = `SELECT ${row} AS r, ${this.rowNumber(ordered || '1')} AS n FROM ${src.from}${where}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(e.limit, e.offset)}`; + return `(SELECT ${this.jsonArray('x.r', 'x.n')} FROM (${inner}) x)`; + } + + where(plan: ReadPlan, alias: string, ctx: RenderContext): string { + const parts = [...this.filters(plan.resource, alias, ctx.scope(plan.resource), ctx), ...this.filters(plan.resource, alias, plan.where, ctx)]; + return parts.length ? ` WHERE ${parts.join(' AND ')}` : ''; + } + + groupBy(plan: ReadPlan, alias: string): string { + return plan.select.aggregates.length && plan.select.columns.length + ? ` GROUP BY ${plan.select.columns.map((c) => this.column(alias, c)).join(', ')}` : ''; + } + + read(plan: ReadPlan, ctx: RenderContext): Statement { + const alias = ctx.alias(); + const row = this.rowJson(plan.resource, plan.select, alias, ctx); + const where = this.where(plan, alias, ctx); + const ordered = this.order(plan.order, alias, ctx); + const sql = `SELECT ${row} AS r FROM ${this.table(plan.resource.schema, plan.resource.table)} ${alias}${where}${this.groupBy(plan, alias)}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(plan.limit, plan.offset)}`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } + + count(plan: ReadPlan, ctx: RenderContext): Statement { + const alias = ctx.alias(); + const from = `${this.table(plan.resource.schema, plan.resource.table)} ${alias}${this.where(plan, alias, ctx)}`; + const sql = plan.select.aggregates.length && plan.select.columns.length + ? `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM ${from}${this.groupBy(plan, alias)}) g` + : plan.select.aggregates.length ? 'SELECT 1 AS n' : `SELECT COUNT(*) AS n FROM ${from}`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } +} diff --git a/packages/core/src/sql/renderer.ts b/packages/core/src/sql/renderer.ts index ed94d83..d968197 100644 --- a/packages/core/src/sql/renderer.ts +++ b/packages/core/src/sql/renderer.ts @@ -1,149 +1,50 @@ -import type { Embed, Filter, OrderTerm, Path, ReadPlan, Selection, Value } from '../plan.js'; -import type { Relation, Resource } from '../resources.js'; -import { RenderContext } from './context.js'; -import { SqlPrimitives } from './primitives.js'; +import type { CatalogFunction } from '../catalog.js'; +import type { Filter } from '../plan.js'; +import type { Resource } from '../resources.js'; +import type { RenderContext } from './context.js'; +import { SqlReader, type Statement } from './reader.js'; -export interface Statement { sql: string; params: unknown[] } - -const CMP: Record = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' }; +export type { Statement } from './reader.js'; /** - * Writes read statements from a plan. Relations are correlated subqueries, so - * a statement is one SQL text whatever the depth, and every table it touches - * carries the host's scope filters. Each method can be overridden by a dialect. + * Every statement restloom writes: reads from SqlReader, then inserts, updates, + * deletes and function calls. Writes return the key of each written row, which + * the core reads back when the client asks for the rows. */ -export class SqlRenderer extends SqlPrimitives { - context(scope: (r: Resource) => Filter[]): RenderContext { - return new RenderContext(scope); +export class SqlRenderer extends SqlReader { + returning(resource: Resource): string { + return ` RETURNING ${resource.key.map((k) => this.quote(k)).join(', ')}`; } - /** FROM clause and correlation of a relation's target, seen from the parent alias. */ - relationSource(rel: Relation, parent: string, alias: string, ctx: RenderContext): { from: string; where: string[] } { - const target = this.table(rel.target.schema, rel.target.table); - const scoped = this.filters(rel.target, alias, ctx.scope(rel.target), ctx); - if (!rel.through) { - const on = rel.to.map((c, i) => `${this.column(alias, c)} = ${this.column(parent, rel.from[i])}`); - return { from: `${target} ${alias}`, where: [...on, ...scoped] }; - } - const j = ctx.alias('j'); - const join = rel.to.map((c, i) => `${this.column(j, rel.through!.to[i])} = ${this.column(alias, c)}`).join(' AND '); - const on = rel.from.map((c, i) => `${this.column(j, rel.through!.from[i])} = ${this.column(parent, c)}`); - return { from: `${target} ${alias} JOIN ${this.table(rel.through.schema, rel.through.table)} ${j} ON ${join}`, where: [...on, ...scoped] }; - } - - /** EXISTS through a chain of relations, with `inner` written against the last alias. */ - exists(relations: Relation[], parent: string, ctx: RenderContext, inner: (alias: string) => string | null): string { - const [rel, ...rest] = relations; - const alias = ctx.alias(); - const src = this.relationSource(rel, parent, alias, ctx); - const tail = rest.length ? this.exists(rest, alias, ctx, inner) : inner(alias); - const where = [...src.where, ...(tail ? [tail] : [])]; - return `EXISTS (SELECT 1 FROM ${src.from}${where.length ? ` WHERE ${where.join(' AND ')}` : ''})`; - } - - comparison(expr: string, f: Extract, ctx: RenderContext): string { - const bind = (v: Value) => ctx.params.add(v); - switch (f.op) { - case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`; - case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean); - case 'like': return this.like(expr, bind(f.value as Value), false); - case 'ilike': return this.like(expr, bind(f.value as Value), true); - default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`; - } - } - - filter(alias: string, f: Filter, ctx: RenderContext): string { - if (f.kind === 'present') return `${f.negate ? 'NOT ' : ''}${this.exists(f.relations, alias, ctx, () => null)}`; - if (f.kind !== 'cmp') { - const body = `(${f.terms.map((t) => this.filter(alias, t, ctx)).join(f.kind === 'or' ? ' OR ' : ' AND ')})`; - return f.negate ? `NOT ${body}` : body; - } - if (f.path.relations.length === 0) { - const cmp = this.comparison(this.column(alias, f.path.column), f, ctx); - return f.negate ? `NOT (${cmp})` : cmp; - } - const found = this.exists(f.path.relations, alias, ctx, (a) => this.comparison(this.column(a, f.path.column), f, ctx)); - return f.negate ? `NOT ${found}` : found; - } - - filters(_resource: Resource, alias: string, list: Filter[], ctx: RenderContext): string[] { - return list.map((f) => this.filter(alias, f, ctx)); - } - - /** A value reached through to-one relations, or a count of a to-many one, as a scalar subquery. */ - pathValue(relations: Relation[], parent: string, ctx: RenderContext, leaf: (alias: string) => string): string { - const [rel, ...rest] = relations; - const alias = ctx.alias(); - const src = this.relationSource(rel, parent, alias, ctx); - const value = rest.length ? this.pathValue(rest, alias, ctx, leaf) : leaf(alias); - return `(${this.first(`SELECT ${value} FROM ${src.from} WHERE ${src.where.join(' AND ')}`)})`; - } - - orderExpr(t: OrderTerm, alias: string, ctx: RenderContext): string { - if (t.key.kind === 'count') { - const rels = t.key.relations; - const counted = (parent: string) => { - const a = ctx.alias(); - const src = this.relationSource(rels[rels.length - 1], parent, a, ctx); - return `(SELECT COUNT(*) FROM ${src.from} WHERE ${src.where.join(' AND ')})`; - }; - return rels.length === 1 ? counted(alias) : this.pathValue(rels.slice(0, -1), alias, ctx, counted); - } - const path: Path = t.key.path; - return path.relations.length === 0 - ? this.column(alias, path.column) - : this.pathValue(path.relations, alias, ctx, (a) => this.column(a, path.column)); - } - - order(terms: OrderTerm[], alias: string, ctx: RenderContext): string { - return terms.map((t) => this.orderBy(this.orderExpr(t, alias, ctx), t.desc, t.nulls)).join(', '); - } - - rowJson(resource: Resource, select: Selection, alias: string, ctx: RenderContext): string { - const pairs: [string, string][] = select.columns.map((c) => [c, this.jsonValue(this.column(alias, c), resource.columns.get(c)!)]); - for (const a of select.aggregates) pairs.push([a.fn, this.aggregate(a.fn, a.column && this.column(alias, a.column))]); - for (const e of select.embeds) pairs.push([e.name, this.nested(this.embed(e, alias, ctx))]); - return this.jsonObject(pairs); - } - - embed(e: Embed, parent: string, ctx: RenderContext): string { - const alias = ctx.alias(); - const src = this.relationSource(e.relation, parent, alias, ctx); - const target = e.relation.target; - const row = this.rowJson(target, e.select, alias, ctx); - const where = ` WHERE ${src.where.join(' AND ')}`; - if (e.relation.kind === 'one') return `(${this.first(`SELECT ${row} FROM ${src.from}${where}`)})`; - const terms = e.order.length ? e.order : target.key.map((k) => ({ key: { kind: 'column' as const, path: { relations: [], column: k } }, desc: false })); - const ordered = this.order(terms, alias, ctx); - const inner = `SELECT ${row} AS r, ${this.rowNumber(ordered || '1')} AS n FROM ${src.from}${where}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(e.limit, e.offset)}`; - return `(SELECT ${this.jsonArray('x.r', 'x.n')} FROM (${inner}) x)`; - } - - where(plan: ReadPlan, alias: string, ctx: RenderContext): string { - const parts = [...this.filters(plan.resource, alias, ctx.scope(plan.resource), ctx), ...this.filters(plan.resource, alias, plan.where, ctx)]; - return parts.length ? ` WHERE ${parts.join(' AND ')}` : ''; - } - - groupBy(plan: ReadPlan, alias: string): string { - return plan.select.aggregates.length && plan.select.columns.length - ? ` GROUP BY ${plan.select.columns.map((c) => this.column(alias, c)).join(', ')}` : ''; - } - - read(plan: ReadPlan, ctx: RenderContext): Statement { - const alias = ctx.alias(); - const row = this.rowJson(plan.resource, plan.select, alias, ctx); - const where = this.where(plan, alias, ctx); - const ordered = this.order(plan.order, alias, ctx); - const sql = `SELECT ${row} AS r FROM ${this.table(plan.resource.schema, plan.resource.table)} ${alias}${where}${this.groupBy(plan, alias)}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(plan.limit, plan.offset)}`; + insert(resource: Resource, columns: string[], rows: unknown[][], ctx: RenderContext): Statement { + const values = rows.map((row) => `(${row.map((v) => ctx.params.add(v)).join(', ')})`).join(', '); + const sql = `INSERT INTO ${this.table(resource.schema, resource.table)} (${columns.map((c) => this.quote(c)).join(', ')}) VALUES ${values}${this.returning(resource)}`; return ctx.params.finalize(sql, (i) => this.placeholder(i)); } - count(plan: ReadPlan, ctx: RenderContext): Statement { + writeTarget(resource: Resource, alias: string): string { + return `${this.table(resource.schema, resource.table)} AS ${alias}`; + } + + update(resource: Resource, set: [string, unknown][], where: Filter[], ctx: RenderContext): Statement { const alias = ctx.alias(); - const from = `${this.table(plan.resource.schema, plan.resource.table)} ${alias}${this.where(plan, alias, ctx)}`; - const sql = plan.select.aggregates.length && plan.select.columns.length - ? `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM ${from}${this.groupBy(plan, alias)}) g` - : plan.select.aggregates.length ? 'SELECT 1 AS n' : `SELECT COUNT(*) AS n FROM ${from}`; + const assignments = set.map(([c, v]) => `${this.quote(c)} = ${ctx.params.add(v)}`).join(', '); + const conditions = this.filters(resource, alias, where, ctx); + const sql = `UPDATE ${this.writeTarget(resource, alias)} SET ${assignments}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}${this.returning(resource)}`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } + + delete(resource: Resource, where: Filter[], ctx: RenderContext): Statement { + const alias = ctx.alias(); + const conditions = this.filters(resource, alias, where, ctx); + const sql = `DELETE FROM ${this.writeTarget(resource, alias)}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}${this.returning(resource)}`; + return ctx.params.finalize(sql, (i) => this.placeholder(i)); + } + + /** A function call by argument name; a set-returning function answers its rows, any other its value. */ + call(fn: CatalogFunction, args: [string, unknown][], ctx: RenderContext): Statement { + const call = `${this.table(fn.schema, fn.name)}(${args.map(([n, v]) => `${this.quote(n)} => ${ctx.params.add(v)}`).join(', ')})`; + const sql = fn.returnsSet ? `SELECT to_json(x) AS r FROM ${call} x` : `SELECT to_json(${call}) AS r`; return ctx.params.finalize(sql, (i) => this.placeholder(i)); } } diff --git a/packages/core/src/write.ts b/packages/core/src/write.ts new file mode 100644 index 0000000..e423407 --- /dev/null +++ b/packages/core/src/write.ts @@ -0,0 +1,98 @@ +import type { Dialect, Tx } from './dialect.js'; +import { RestError, badRequest, notFound } from './errors.js'; +import { bodyRows } from './grammar/body.js'; +import { parseRead } from './grammar/read.js'; +import type { Limits, RestRequest, RestResponse } from './options.js'; +import type { Filter, Value } from './plan.js'; +import { keyFilter, readRows } from './read.js'; +import type { Resource } from './resources.js'; + +export interface WriteContext { + db: Dialect; + tx: Tx; + resource: Resource; + request: RestRequest; + limits: Limits; + scope: (r: Resource) => Filter[]; +} + +const wantsRows = (r: RestRequest) => /\breturn=representation\b/.test(r.headers.prefer ?? ''); + +/** The value a scope filter pins a column to, keyed by column. */ +function pinned(c: WriteContext): Map { + const out = new Map(); + for (const f of c.scope(c.resource)) if (f.kind === 'cmp' && f.op === 'eq') out.set(f.path.column, f.value as Value); + return out; +} + +function keysFilter(resource: Resource, keys: Record[]): Filter { + const one = (k: Record): Filter => ({ kind: 'and', negate: false, terms: resource.key.map((col): Filter => ({ + kind: 'cmp', path: { relations: [], column: col }, op: 'eq', negate: false, value: k[col] as Value, + })) }); + return { kind: 'or', negate: false, terms: keys.map(one) }; +} + +function selectOnly(request: RestRequest, limit: number): URLSearchParams { + const query = new URLSearchParams({ limit: String(limit) }); + const select = request.query.get('select'); + if (select) query.set('select', select); + return query; +} + +/** Reads the written rows back with the request's select, embeds included. */ +async function readBack(c: WriteContext, keys: Record[], status: number): Promise { + if (keys.length === 0) return { status, headers: {}, body: [] }; + const query = selectOnly(c.request, Math.min(keys.length, c.limits.maxPage)); + const read = await readRows(c.db, c.tx, c.resource, { ...c.request, query }, c.limits, c.scope, undefined, [keysFilter(c.resource, keys)]); + return { status, headers: {}, body: read.body }; +} + +/** The filters a PATCH or DELETE applies: the id, or the query's filters on a bulk-enabled resource. */ +function target(c: WriteContext, id: string | undefined): Filter[] { + if (id !== undefined) return [keyFilter(c.resource, id)]; + if (!c.resource.write.bulk) throw new RestError(405, 'method_not_allowed'); + const filters = parseRead(c.resource, c.request.query, c.limits).where; + if (filters.length === 0) throw badRequest('filter_required'); + return filters; +} + +export async function create(c: WriteContext): Promise { + if (!c.resource.write.create) throw new RestError(405, 'method_not_allowed'); + const rows = bodyRows(c.resource, c.request.body, 'create', c.limits.bodyRows); + const scope = pinned(c); + const columns = [...new Set([...rows[0].keys(), ...scope.keys()])]; + const values = rows.map((row) => columns.map((col) => { + if (scope.has(col) && row.has(col) && row.get(col) !== scope.get(col)) throw new RestError(403, 'out_of_scope', col); + return scope.has(col) ? scope.get(col) : row.get(col); + })); + const stmt = c.db.renderer.insert(c.resource, columns, values, c.db.renderer.context(c.scope)); + const keys = await c.tx.query(stmt.sql, stmt.params); + return wantsRows(c.request) ? readBack(c, keys, 201) : { status: 201, headers: {}, body: undefined }; +} + +export async function update(c: WriteContext, id: string | undefined): Promise { + if (!c.resource.write.update) throw new RestError(405, 'method_not_allowed'); + const [row] = bodyRows(c.resource, c.request.body, 'update', 1); + const scope = pinned(c); + for (const [col, v] of row) if (scope.has(col) && v !== scope.get(col)) throw new RestError(403, 'out_of_scope', col); + const where = [...c.scope(c.resource), ...target(c, id)]; + const stmt = c.db.renderer.update(c.resource, [...row], where, c.db.renderer.context(c.scope)); + const keys = await c.tx.query(stmt.sql, stmt.params); + if (id !== undefined && keys.length === 0) throw notFound(); + if (!wantsRows(c.request)) return { status: 204, headers: {}, body: undefined }; + const back = await readBack(c, keys, 200); + return id !== undefined ? { ...back, body: (back.body as unknown[])[0] } : back; +} + +export async function remove(c: WriteContext, id: string | undefined): Promise { + if (!c.resource.write.delete) throw new RestError(405, 'method_not_allowed'); + const where = [...c.scope(c.resource), ...target(c, id)]; + const before = wantsRows(c.request) + ? await readRows(c.db, c.tx, c.resource, { ...c.request, query: selectOnly(c.request, c.limits.maxPage) }, c.limits, c.scope, undefined, where) + : null; + const stmt = c.db.renderer.delete(c.resource, where, c.db.renderer.context(c.scope)); + const keys = await c.tx.query(stmt.sql, stmt.params); + if (id !== undefined && keys.length === 0) throw notFound(); + if (!before) return { status: 204, headers: {}, body: undefined }; + return { status: 200, headers: {}, body: id !== undefined ? (before.body as unknown[])[0] : before.body }; +} diff --git a/packages/core/test/fake-dialect.mjs b/packages/core/test/fake-dialect.mjs new file mode 100644 index 0000000..b5068ae --- /dev/null +++ b/packages/core/test/fake-dialect.mjs @@ -0,0 +1,20 @@ +import { SqlRenderer } from '../dist/index.js'; + +/** A dialect with one table and canned rows, for adapter and wiring tests without a database. */ +export function fakeDialect(rows = [{ id: 1, name: 'one' }]) { + const statements = []; + return { + statements, + name: 'fake', + capabilities: { returning: true, estimatedCount: false, rowSecurity: false }, + renderer: new SqlRenderer(), + async introspect() { + const col = (name, type) => ({ name, type, nativeType: type, nullable: false, generated: false }); + return { functions: [], tables: [{ schema: 'app', name: 'items', kind: 'table', primaryKey: ['id'], foreignKeys: [], columns: [col('id', 'integer'), col('name', 'text')] }] }; + }, + async transaction(fn) { + return fn({ async query(sql) { statements.push(sql); return sql.startsWith('SELECT COUNT') ? [{ n: rows.length }] : rows.map((r) => ({ r })); } }); + }, + mapError: () => null, + }; +} diff --git a/packages/core/test/node.test.mjs b/packages/core/test/node.test.mjs new file mode 100644 index 0000000..bb35dfc --- /dev/null +++ b/packages/core/test/node.test.mjs @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { createREST, nodeListener } from '../dist/index.js'; +import { fakeDialect } from './fake-dialect.mjs'; + +test('the node listener serves under its prefix and refuses bad bodies', async () => { + const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } }); + const server = http.createServer(nodeListener(rest, { prefix: '/api' })).listen(0); + const base = `http://127.0.0.1:${server.address().port}`; + try { + assert.equal((await fetch(`${base}/api/items`)).status, 200); + assert.equal((await fetch(`${base}/items`)).status, 404); + const bad = await fetch(`${base}/api/items`, { method: 'POST', body: '{nope' }); + assert.equal(bad.status, 400); + assert.deepEqual(await bad.json(), { error: 'bad_body' }); + } finally { + server.close(); + } +}); diff --git a/packages/express/package.json b/packages/express/package.json new file mode 100644 index 0000000..5911d9f --- /dev/null +++ b/packages/express/package.json @@ -0,0 +1,50 @@ +{ + "name": "@crudy/restloom-express", + "version": "0.1.0", + "type": "module", + "description": "Express adapter for restloom", + "keywords": [ + "restloom", + "express", + "rest", + "api" + ], + "author": "Marc Villain", + "homepage": "https://restloom.crudy.fr", + "repository": { + "type": "git", + "url": "git+https://github.com/crudylabs/restloom.git", + "directory": "packages/express" + }, + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p .", + "prepack": "npm run build" + }, + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "peerDependencies": { + "express": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0", + "express": "^5.0.0", + "@types/express": "^5.0.0" + } +} diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts new file mode 100644 index 0000000..2ce3789 --- /dev/null +++ b/packages/express/src/index.ts @@ -0,0 +1,33 @@ +import type { Rest } from '@crudy/restloom'; +import type { NextFunction, Request, Response } from 'express'; + +export interface RestloomExpressOptions { + /** Builds the context hooks receive, e.g. the authenticated user. */ + context?(req: Request): C | Promise; +} + +/** + * An Express handler answering every path it is mounted on with restloom. + * Mount it after a JSON body parser: `app.use('/api', express.json(), restloom(rest))`. + */ +export function restloom(rest: Rest, options: RestloomExpressOptions = {}) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const url = new URL(req.url, 'http://localhost'); + const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v])); + const out = await rest.handle({ + method: req.method, + path: url.pathname || '/', + query: url.searchParams, + headers, + body: req.body, + context: (await options.context?.(req)) as C, + }); + res.status(out.status).set(out.headers); + if (out.body === undefined) res.end(); + else res.json(out.body); + } catch (err) { + next(err); + } + }; +} diff --git a/packages/express/test/express.test.mjs b/packages/express/test/express.test.mjs new file mode 100644 index 0000000..4475256 --- /dev/null +++ b/packages/express/test/express.test.mjs @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createREST } from '@crudy/restloom'; +import { restloom } from '../dist/index.js'; +import { fakeDialect } from '../../core/test/fake-dialect.mjs'; + +const require = createRequire(import.meta.url); +const express = require('express'); + +test('serves restloom where it is mounted', async () => { + const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } }); + const app = express(); + app.use('/api', express.json(), restloom(rest)); + const server = app.listen(0); + const port = server.address().port; + try { + const res = await fetch(`http://127.0.0.1:${port}/api/items?select=id`); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), [{ id: 1, name: 'one' }]); + assert.equal(res.headers.get('content-range'), '0-0/*'); + const write = await fetch(`http://127.0.0.1:${port}/api/items`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"name":"x"}' }); + assert.equal(write.status, 405); + } finally { + server.close(); + } +}); diff --git a/packages/express/tsconfig.json b/packages/express/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/express/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/fastify/package.json b/packages/fastify/package.json new file mode 100644 index 0000000..5ca9d1f --- /dev/null +++ b/packages/fastify/package.json @@ -0,0 +1,49 @@ +{ + "name": "@crudy/restloom-fastify", + "version": "0.1.0", + "type": "module", + "description": "Fastify adapter for restloom", + "keywords": [ + "restloom", + "fastify", + "rest", + "api" + ], + "author": "Marc Villain", + "homepage": "https://restloom.crudy.fr", + "repository": { + "type": "git", + "url": "git+https://github.com/crudylabs/restloom.git", + "directory": "packages/fastify" + }, + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p .", + "prepack": "npm run build" + }, + "dependencies": { + "@crudy/restloom": "^0.1.0" + }, + "peerDependencies": { + "fastify": "^5.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0", + "fastify": "^5.0.0" + } +} diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts new file mode 100644 index 0000000..7b9502f --- /dev/null +++ b/packages/fastify/src/index.ts @@ -0,0 +1,32 @@ +import type { Rest } from '@crudy/restloom'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; + +export interface RestloomFastifyOptions { + /** Builds the context hooks receive, e.g. the authenticated user. */ + context?(req: FastifyRequest): C | Promise; +} + +/** + * A Fastify plugin answering every path under its prefix with restloom. + * Register it with a prefix: `app.register(restloom(rest), { prefix: '/api' })`. + */ +export function restloom(rest: Rest, options: RestloomFastifyOptions = {}) { + return async function plugin(app: FastifyInstance): Promise { + const handler = async (req: FastifyRequest, reply: FastifyReply) => { + const url = new URL(req.raw.url ?? '/', 'http://localhost'); + const prefix = app.prefix ?? ''; + const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v])); + const res = await rest.handle({ + method: req.method, + path: url.pathname.slice(prefix.length) || '/', + query: url.searchParams, + headers, + body: req.body, + context: (await options.context?.(req)) as C, + }); + reply.code(res.status).headers(res.headers); + return res.body === undefined ? reply.send() : reply.send(res.body); + }; + app.route({ method: ['GET', 'POST', 'PATCH', 'DELETE'], url: '/*', handler }); + }; +} diff --git a/packages/fastify/test/fastify.test.mjs b/packages/fastify/test/fastify.test.mjs new file mode 100644 index 0000000..30971a9 --- /dev/null +++ b/packages/fastify/test/fastify.test.mjs @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createREST } from '@crudy/restloom'; +import { restloom } from '../dist/index.js'; +import { fakeDialect } from '../../core/test/fake-dialect.mjs'; + +const require = createRequire(import.meta.url); +const Fastify = require('fastify'); + +test('serves restloom under its prefix with the request context', async () => { + let seen; + const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] }, hooks: { authorize(req) { seen = req.context; } } }); + const app = Fastify(); + await app.register(restloom(rest, { context: (req) => ({ user: req.headers['x-user'] }) }), { prefix: '/api' }); + const res = await app.inject({ method: 'GET', url: '/api/items?select=id&count=exact', headers: { 'x-user': 'ada' } }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), [{ id: 1, name: 'one' }]); + assert.equal(res.headers['content-range'], '0-0/1'); + assert.deepEqual(seen, { user: 'ada' }); + assert.equal((await app.inject({ method: 'GET', url: '/api/nope' })).statusCode, 404); + await app.close(); +}); diff --git a/packages/fastify/tsconfig.json b/packages/fastify/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/fastify/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +}