# mtbREST

[![npm](https://img.shields.io/npm/v/@mathislair/mtbrest.svg)](https://www.npmjs.com/package/@mathislair/mtbrest)

Generate Express REST routes from your [`@mathislair/mtbdb`](https://www.npmjs.com/package/@mathislair/mtbdb)
DAOs. Three layered APIs: a zero-config global facade, a fluent router, and
opt-in class decorators.

```ts
// Zero config: paths derived from the bean's tableName, app started for you.
import { mtbREST } from '@mathislair/mtbrest';
import { UserDao, PostDao } from './generated';

await mtbREST.connect({ driver: 'postgres', database: 'myapp', user: 'postgres', password: 'postgres' });

mtbREST.get(UserDao);                  // GET /user, GET /user/:id
mtbREST.post(UserDao, auth);           // POST /user (with auth middleware)
mtbREST.put(UserDao, auth);            // PUT /user/:id
mtbREST.delete(UserDao, auth);         // DELETE /user/:id
mtbREST.resource(PostDao);             // full CRUD bundle

await mtbREST.start(3000);
```

## Install

```bash
npm install @mathislair/mtbrest @mathislair/mtbdb express
```

## Why

mtbDB gives you typed beans + DAOs from your database schema. mtbREST takes
the next step: turning those DAOs into REST endpoints without writing the
glue. You stay in control of paths, middlewares, and validation — but the
plumbing for list / get / create / update / delete is generated for you.

## Three ways to declare routes

Pick whichever fits the project. They're all backed by the same router and
can be mixed in the same app.

### 1. Global facade — `mtbREST` (zero config)

For when you want REST endpoints with the least possible boilerplate. Path is
derived from the bean's `tableName`; override with `@Path` on the DAO class.

```ts
import { mtbREST, Path } from '@mathislair/mtbrest';
import { UserDao as UserDaoBase, PostDao } from './generated/_base';

// Override the derived path for one DAO:
@Path('/users')
class UserDao extends UserDaoBase {}

await mtbREST.connect({ driver: 'postgres', /* ... */ });

mtbREST.get(UserDao);                  // GET /users + GET /users/:id  (overridden)
mtbREST.post(UserDao, auth);           // POST /users
mtbREST.resource(PostDao);             // /post + /post/:id (derived from tableName)

// Optional: add custom Express middleware before mounting routes.
mtbREST.app().use(cors());

await mtbREST.start(3000);
```

You can declare routes **before** calling `connect()` — they're buffered and
flushed once the connection is bound. Need more than one app? Use
`createMtbREST()` instead of the singleton.

For finer control:

| Method                | Purpose                                                       |
| --------------------- | ------------------------------------------------------------- |
| `mtbREST.connect(opts)` | Open a new mtbDB connection.                                |
| `mtbREST.use(conn)`   | Bind to a connection you already have (or a test fake).       |
| `mtbREST.withUser(fn)`| Resolve the current user from `req`; auto-fills auth columns. |
| `mtbREST.cors(opts)`  | Enable CORS without an extra package (calls before `mount()`).|
| `mtbREST.beforeRoutes(...mw)` | Add Express middleware that runs before the REST router. |
| `mtbREST.paginate(on)`| Default `{ data, total, limit, offset }` envelope on list routes. |
| `mtbREST.caseConvention(c)`| Wire-format casing: `'snake'` (default) / `'camel'` / `'preserve'`. |
| `mtbREST.app()`       | Get the underlying Express app (lazily created).              |
| `mtbREST.mount()`     | Append the router to the app (idempotent; `start()` calls it).|
| `mtbREST.router()`    | Access the underlying `RestRouter` for advanced ops.          |

> Calling `mtbREST.app().use(mw)` *after* `mount()`/`start()` adds the middleware
> AFTER the router, so it can never apply to REST routes — mtbREST emits a
> console warning when this happens. Use `beforeRoutes(mw)` (or `cors()`) instead.

### Current-user binding

`withUser(req => req.session?.userId)` forces auth-bound columns on writes
**after** sanitizeBody runs, so a client cannot impersonate by sending those
columns themselves.

```ts
mtbREST.use(conn).withUser((req) => req.session?.userId);
// Defaults: POST sets user_id + created_by; PUT/PATCH sets updated_by.
mtbREST.withUser((req) => req.session?.userId, {
  onCreate: ['author_id'],
  onUpdate: ['last_editor_id'],
});
```

If the resolver returns `undefined`, the columns are left untouched — useful
for routes that allow anonymous writes.

### Wire-format case convention

mtbDB beans expose **camelCase** setters/getters but back them with
**snake_case** columns — `bean.toJSON()` is snake_case, `Object.assign(bean, body)`
needs camelCase keys to reach the setters. mtbREST hides that asymmetry
with a single facade-level option:

```ts
mtbREST.caseConvention('snake');    // default
mtbREST.caseConvention('camel');
mtbREST.caseConvention('preserve'); // legacy v0.2.x
```

| Mode        | Request body                                     | Query filter                  | Response                       |
| ----------- | ------------------------------------------------ | ----------------------------- | ------------------------------ |
| `'snake'` (default) | `snake_case` → translated to camel for setters; camelCase still passes through | passthrough (snake) | snake_case (passthrough)       |
| `'camel'`   | camelCase → translated to snake for filter SQL   | camelCase → snake             | snake → **camelCase**          |
| `'preserve'`| no transformation (camel works, snake silently drops) | no transformation        | snake_case (passthrough)       |

**Default change in v0.3.0:** before, `POST /api/messages { "room_id": 1 }`
was silently dropped (the bean has `setRoomId`, not `set room_id`). Now in
`'snake'` mode the snake_case key is translated to `roomId` before the
setter runs. CamelCase input keeps working, so no migration is needed.

### CORS

```ts
mtbREST.cors();                                    // wildcard origin
mtbREST.cors({ origin: ['https://app.example.com'], credentials: true });
mtbREST.cors({ origin: (o) => o?.endsWith('.mycorp.com') ?? false });
```

The built-in CORS middleware covers the common cases (allow-list, preflight,
credentials). For richer behavior, install the `cors` package and pass
`cors()` to `mtbREST.app().use(...)`.

### Filter operators

GET list routes accept operator suffixes on query keys, mapping to mtbdb's
`WhereOp`s:

```
GET /room?score__gt=5
GET /room?score__gte=5&score__lte=10
GET /room?title__like=hello%25
GET /room?id__in=1,2,3
GET /room?author__null=true
```

| Suffix    | Op           |
| --------- | ------------ |
| `__gt`    | `>`          |
| `__gte`   | `>=`         |
| `__lt`    | `<`          |
| `__lte`   | `<=`         |
| `__ne`    | `!=`         |
| `__like`  | `LIKE` (wildcards verbatim) |
| `__in`    | `IN` (comma-split, scalar coerced) |
| `__null`  | `IS NULL` if `true`, `IS NOT NULL` if `false` |

Plain `?col=value` continues to mean equality. Mix and match freely:
`?room_id=1&score__gte=10`. Unknown suffixes are treated as part of the
column name (so column names containing `__` keep working).

### Pagination

```ts
mtbREST.paginate(true);                              // facade default
mtbREST.resource(PostDao, { paginate: true });       // per-resource override
```

When pagination is on, list responses are wrapped:

```json
{ "data": [...], "total": 123, "limit": 20, "offset": 40 }
```

Adds an extra `count(*)` query per request, so it's opt-in.

### Postgres errors → HTTP statuses

The default `errorMiddleware` recognises common pg `SQLSTATE` codes and turns
them into structured responses:

| SQLSTATE | Meaning                  | HTTP   |
| -------- | ------------------------ | ------ |
| `23505`  | unique violation         | **409 Conflict**           |
| `23503`  | foreign key violation    | **422 Unprocessable Entity** |
| `23502`  | not-null violation       | **400 Bad Request**          |
| `23514`  | check constraint         | **400 Bad Request**          |

Body shape: `{ "error": "...", "status": 409, "details": { "code": "unique_violation", "constraint": "...", "table": "...", "detail": "..." } }`.

`ConnectionError` from mtbdb (`reason: 'unreachable'`) maps to **503**.
| `mtbREST.start(port)` | Mount + listen. Returns the http.Server.                      |
| `mtbREST.close()`     | Close server + connection. Useful in tests.                   |

### 2. Fluent builder — `RestRouter`

```ts
const api = new RestRouter(conn);

api.get('/user', UserDao);              // list (supports ?limit, ?offset, ?orderBy, ?<col>=)
api.get('/user/:id', UserDao);          // findById, 404 if missing
api.post('/user', UserDao);             // create — body keys map to column setters
api.put('/user/:id', UserDao);          // full update
api.patch('/user/:id', UserDao);        // partial update
api.delete('/user/:id', UserDao);       // delete, 204 on success
```

Or all of the above in one line:

```ts
api.resource('/user', UserDao, {
  middlewares: [auth],                  // applied to every verb
  perMethod: { delete: [adminOnly] },   // appended for specific verbs
  except: ['patch'],                    // skip specific verbs
});
```

### 3. Class decorators

The class is a passive metadata carrier — no method bodies needed.

```ts
import { Path, Get, Post, Put, Delete, RestRouter } from '@mathislair/mtbrest';

@Path('/user')
@Get(UserDao)
@Get('/:id', UserDao)
@Post(UserDao, auth)
@Put(UserDao, auth)
@Delete(UserDao, auth)
class UserRoutes {}

api.register(UserRoutes);
```

Requires `experimentalDecorators: true` in `tsconfig.json` (mtbREST itself
ships with that set).

## Middlewares & validators

Every verb method takes any number of trailing functions. Functions with arity
**≥ 3** are treated as Express middleware `(req, res, next)`; functions with
arity **< 3** are treated as validators and auto-adapted:

```ts
import { errors } from '@mathislair/mtbrest';

// Express middleware (3 args)
const auth: express.RequestHandler = (req, res, next) => {
  if (!req.headers.authorization) return next(errors.unauthorized());
  next();
};

// Validator (1-2 args). Throw, or return an Error.
const requireName = (req) => {
  if (!req.body.name) throw errors.badRequest('name required');
};

api.post('/user', UserDao, auth, requireName);
```

Throwing or returning an `HttpError` short-circuits the request with the
matching status code and JSON body:

```ts
{ "error": "name required", "status": 400 }
```

Throwing any other `Error` produces a 500.

## Error helpers

```ts
import { errors, HttpError } from '@mathislair/mtbrest';

errors.badRequest('email already taken');     // 400
errors.unauthorized();                        // 401
errors.forbidden();                           // 403
errors.notFound();                            // 404
errors.conflict('duplicate slug');            // 409
errors.unprocessable('invalid date format');  // 422
errors.internal();                            // 500

// Or build your own:
throw new HttpError(418, "I'm a teapot");
```

The error-to-JSON middleware is mounted automatically when you call
`api.express()`. To use it on a router you built by hand, import
`errorMiddleware()` and add it last.

## Generated routes — what they do

| Verb     | Path           | Behaviour                                                                 |
| -------- | -------------- | ------------------------------------------------------------------------- |
| `GET`    | `/<base>`      | `dao.findAll` (or `dao.find(query)` if any non-paging query keys present) |
| `GET`    | `/<base>/:id`  | `dao.findById`, returns 404 if not found                                  |
| `POST`   | `/<base>`      | `dao.create()`, copy body, `dao.save`, returns 201 + JSON                 |
| `PUT`    | `/<base>/:id`  | `findById`, copy body, `save` (full replace semantics)                    |
| `PATCH`  | `/<base>/:id`  | `findById`, copy body, `save` (mtbDB only saves dirty columns)            |
| `DELETE` | `/<base>/:id`  | `findById`, `dao.delete`, returns 204                                     |

Server-managed columns (primary key, generated columns) are stripped from
incoming bodies automatically — clients can't overwrite them by accident.

The list endpoint accepts `?limit`, `?offset`, `?orderBy` plus any other
query keys, which are forwarded to `dao.find` as a filter.

## Composite primary keys

mtbREST handles single-column primary keys out of the box. For composite PKs,
write a custom route:

```ts
api.express().get('/user-role/:userId/:roleId', async (req, res, next) => {
  try {
    const dao = conn.dao(UserRoleDao);
    const row = await dao.findById(+req.params.userId, +req.params.roleId);
    if (!row) throw errors.notFound();
    res.json(row.toJSON());
  } catch (e) { next(e); }
});
```

## License

MIT © mathislair
