@xeplr/base-apis 2.0.2 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,35 +1,279 @@
|
|
|
1
1
|
# @xeplr/base-apis
|
|
2
2
|
|
|
3
|
-
The service layer for a xeplr app
|
|
4
|
-
`genericRoute` gives any model a full CRUD API. Composes with `@xeplr/db` and
|
|
5
|
-
`@xeplr/auth`.
|
|
3
|
+
**The service layer for a xeplr app.** `createApp` builds an Express service that is **authenticated by default**, `genericRoute` gives a model hierarchy a full CRUD API, and the rest — env checks, uploads, server-sent events, clustering — is what every service otherwise hand-rolls.
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
```js
|
|
6
|
+
var { createApp, genericRoute } = require('@xeplr/base-apis')
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
createApp(process.env.PORT, 'myapp', {
|
|
9
|
+
requiredEnv: require('./env.required'),
|
|
10
|
+
routes: { '/api/departments': genericRoute({ key: 'department', model: Department }) }
|
|
11
|
+
})
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## How the pieces fit
|
|
15
|
+
|
|
16
|
+
| what | where |
|
|
17
|
+
|---|---|
|
|
18
|
+
| An Express service: logging, CORS, body parsing, auth gate, routes, 404, errors | `createApp(port, appName, options)` |
|
|
19
|
+
| Who the caller is | the **auth gate** — asks `@xeplr/auth` at `AUTH_URL`; sets `req.user` and `req.access` |
|
|
20
|
+
| List / get / save / delete for a model and its children | `genericRoute(hierarchy)` |
|
|
21
|
+
| Refusing to start without the settings a service needs | `checkEnv` / `requiredEnv` / `xeplr-check-env` |
|
|
22
|
+
| Server → browser push | `sse` |
|
|
23
|
+
| Several processes | `runCluster`, `broadcast` |
|
|
24
|
+
| A server without Express | `createHttpServer.init(port, name, handler)` |
|
|
25
|
+
|
|
26
|
+
Models, tenancy and connections come from [`@xeplr/db`](https://www.npmjs.com/package/@xeplr/db).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm i @xeplr/base-apis express
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Peers: `express` (4 or 5), `multer` (optional — only for uploads). Depends on `@xeplr/logs` and `@xeplr/utils`.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
// env.required.js
|
|
40
|
+
module.exports = ['XEPLR_DB_CONNECTION', 'ENCRYPTION_KEY', 'DB_API'].concat(require('@xeplr/base-apis').gateRequiredEnv)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
var { createApp, genericRoute } = require('@xeplr/base-apis')
|
|
45
|
+
var { getConnection, registerMTs, mtMiddleware, BaseModel } = require('@xeplr/db')
|
|
46
|
+
|
|
47
|
+
registerMTs({ l1: { name: 'companyId', header: 'x-company-id' } })
|
|
48
|
+
await getConnection(process.env.DB_API, process.env.XEPLR_DB_CONNECTION)
|
|
49
|
+
|
|
50
|
+
class Department extends BaseModel { static get tableName() { return 'departments' } }
|
|
51
|
+
|
|
52
|
+
createApp(3000, 'myapp', {
|
|
53
|
+
requiredEnv: require('./env.required'),
|
|
54
|
+
auth: { publicPaths: ['/public/'] }, // omitted = every route gated
|
|
55
|
+
middleware: [mtMiddleware()], // runs after the gate, so req.user is set
|
|
56
|
+
routes: { '/api/departments': genericRoute({ key: 'department', model: Department }) }
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Exports
|
|
61
|
+
|
|
62
|
+
| export | |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `createApp(port, appName, options)` | Express service → `{ app, server }` |
|
|
65
|
+
| `genericRoute(hierarchy, { auth })` | CRUD router |
|
|
66
|
+
| `genericController` | `{ list, getById, save, deleteByIds }` — the handlers behind `genericRoute` |
|
|
67
|
+
| `remoteAuth(config)` | the default gate, as middleware |
|
|
68
|
+
| `gateRequiredEnv` | `['AUTH_URL']` |
|
|
69
|
+
| `checkEnv(names, options)` | missing-variable check |
|
|
70
|
+
| `uploadRoute(options)` | a standalone "receive a file, return its path" router |
|
|
71
|
+
| `sse` | `{ handler, createHandler, publish, defaultAuthorizeTopics }` |
|
|
72
|
+
| `runCluster(options)`, `broadcast(msg)`, `isPrimary()`, `isWorker()` | Node cluster helpers |
|
|
73
|
+
| `createHttpServer` | `{ init(port, appName, handler) }` — a plain `http.Server` |
|
|
14
74
|
|
|
15
|
-
|
|
75
|
+
## createApp
|
|
16
76
|
|
|
17
|
-
|
|
18
|
-
var { resolveConfig, getConnection, bindModels } = require('@xeplr/db');
|
|
77
|
+
`createApp(port, appName, options)` listens on `port` (a number or a named pipe) and returns `{ app, server }`. `EADDRINUSE` / `EACCES` print a message and exit.
|
|
19
78
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
79
|
+
| option | |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `requiredEnv` | variable names; any missing → message and `process.exit(1)` before anything starts |
|
|
82
|
+
| `auth` | the gate — see below. **On when omitted** |
|
|
83
|
+
| `middleware` | array, mounted after the gate |
|
|
84
|
+
| `routes` | `{ '/path': router }` |
|
|
85
|
+
| `log` | `@xeplr/logs` settings (`logDir` `'./logs'`, `fatalThreshold`, `emailThreshold`, `emailTo`, `emailService`), or `false` |
|
|
86
|
+
| `corsOptions` | passed to `cors` (default: allow all) |
|
|
87
|
+
| `staticDir` | `express.static` directory |
|
|
88
|
+
| `views` | `{ engine: 'pug', dir }` — also switches 404 and errors to rendered pages |
|
|
89
|
+
| `upload` | file upload — see below |
|
|
90
|
+
| `sse` | `true` for `GET /events`, or `{ path }` |
|
|
91
|
+
| `errorHandler` | replaces the default `{ error: message }` JSON handler |
|
|
27
92
|
|
|
28
|
-
|
|
93
|
+
### Order of the middleware
|
|
94
|
+
|
|
95
|
+
1. request logger (`@xeplr/logs` — sets `req.log`), unless `log: false`
|
|
96
|
+
2. view engine, `cors`, `express.json`, `express.urlencoded`, `cookie-parser`
|
|
97
|
+
3. `staticDir`
|
|
98
|
+
4. uploaded files served at `upload.serveRoute` — **before the gate, so public**
|
|
99
|
+
5. **the auth gate**
|
|
100
|
+
6. `options.middleware`
|
|
101
|
+
7. `POST upload.route`
|
|
102
|
+
8. SSE endpoint
|
|
103
|
+
9. `options.routes`
|
|
104
|
+
10. 404, then the error handler
|
|
105
|
+
|
|
106
|
+
### The auth gate
|
|
107
|
+
|
|
108
|
+
| `auth` | result |
|
|
109
|
+
|---|---|
|
|
110
|
+
| omitted | gated through `remoteAuth` with `AUTH_URL`. **No `AUTH_URL` → `createApp` throws** |
|
|
111
|
+
| `{ url, publicPaths, cacheSeconds, endpoint, timeoutMs }` | gated, configured here |
|
|
112
|
+
| `{ middleware: fn }` | your gate instead — how `@xeplr/auth`'s own server gates itself (it cannot ask itself over HTTP) |
|
|
113
|
+
| `false` | public, on purpose and visible in the diff |
|
|
114
|
+
|
|
115
|
+
Why on by default: a service that forgot to configure auth used to boot clean, pass every health check, and serve every route to anybody — nothing distinguished it from a working one.
|
|
116
|
+
|
|
117
|
+
`remoteAuth` sends the caller's `Authorization: Bearer …` to `url + endpoint` (`/auth/api/me`) and puts the answer's `user` and `access` on `req.user` / `req.access`.
|
|
118
|
+
|
|
119
|
+
| situation | response |
|
|
120
|
+
|---|---|
|
|
121
|
+
| path starts with a `publicPaths` entry | skipped |
|
|
122
|
+
| no `Bearer` token | 401 `No token provided` |
|
|
123
|
+
| auth answers 401 / 403 | same status, `Invalid or expired token` |
|
|
124
|
+
| auth unreachable, times out (`timeoutMs`, 5000), answers anything else, or answers non-JSON | **503 `Authentication service unavailable` — never let through** |
|
|
125
|
+
|
|
126
|
+
- **Validated over HTTP, not in-process** — so a service needs no `@xeplr/auth` dependency (it would be a cycle), no JWT secret and no Redis. The cost is a hop per request.
|
|
127
|
+
- **`cacheSeconds` (default 5)** caches a validated token per process; a revoked token keeps working that long here. `0` checks every request.
|
|
128
|
+
- **`X-New-Token` is forwarded** (with `Access-Control-Expose-Headers`) when the auth service slides a token, and that answer is **not cached** — otherwise the next requests would be answered from memory without telling the client to switch, and it would be logged out when the window closed.
|
|
129
|
+
|
|
130
|
+
### Uploads
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
createApp(port, 'myapp', { upload: { destination: './uploads', allowedTypes: ['image/*'], maxSize: 2e6 } })
|
|
29
134
|
```
|
|
30
135
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
136
|
+
| option | default |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `route` | `/internal/upload` — `POST`, multipart, behind the gate |
|
|
139
|
+
| `fieldName` | `file` |
|
|
140
|
+
| `maxFiles` | `10` |
|
|
141
|
+
| `destination` | `UPLOAD_DIR`, else `./uploads` |
|
|
142
|
+
| `allowedTypes` | `['*']` |
|
|
143
|
+
| `maxSize` | `5242880` (5 MB) |
|
|
144
|
+
| `serveRoute` | `/uploads` — static, **before the gate** |
|
|
145
|
+
| `auth` | a function passed to `@xeplr/utils`' `FileUploader` |
|
|
146
|
+
|
|
147
|
+
Returns `{ filename, originalName, size, mimetype }`, or an array of them for several files; 400 when none arrived. Files are stored under a generated id plus the original extension.
|
|
148
|
+
|
|
149
|
+
`uploadRoute({ destination, maxSize, allowedTypes, fieldName, auth })` is the same as a router of its own: `POST /` (single file) → `{ filePath, fileName, originalName, size, mimetype }`.
|
|
150
|
+
|
|
151
|
+
## genericRoute
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
genericRoute({
|
|
155
|
+
key: 'department',
|
|
156
|
+
model: Department,
|
|
157
|
+
children: [
|
|
158
|
+
{ key: 'employees', model: Employee, foreignKey: 'departmentId',
|
|
159
|
+
children: [{ key: 'monthlySalaries', model: MonthlySalary, foreignKey: 'employeeId' }] }
|
|
160
|
+
]
|
|
161
|
+
}, { auth: { list: gate, getById: gate, save: [gate, adminOnly], delete: [gate, adminOnly] } })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`children[].key` is the Objection relation name; it becomes the eager graph (`employees.monthlySalaries`). `auth` is a middleware, an array, or per operation; omitted means no extra middleware (the `createApp` gate still applies).
|
|
165
|
+
|
|
166
|
+
| route | does |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `GET /` | list with children; `?page=1&limit=50` → `pagination: { page, limit, total, totalPages }`; `?limit=0` returns everything |
|
|
169
|
+
| `GET /:id` | one record with children; 404 if none |
|
|
170
|
+
| `POST /save` | a changeset array, **in one transaction** → `updatedIds` |
|
|
171
|
+
| `POST /delete` | `{ ids }` → `isActive = false` |
|
|
172
|
+
|
|
173
|
+
Queries go through `Model.query()`, so a `BaseModel` gets its active, tenant and application filters.
|
|
174
|
+
|
|
175
|
+
### The changeset
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
[
|
|
179
|
+
{ "id": "a1b2", "name": "Engineering", "employees": [
|
|
180
|
+
{ "name": "New Hire" },
|
|
181
|
+
{ "id": "d4e5", "role": "Senior" },
|
|
182
|
+
{ "id": "x1y2", "deleted": true } ] },
|
|
183
|
+
{ "name": "New Department" }
|
|
184
|
+
]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
| entry | action |
|
|
188
|
+
|---|---|
|
|
189
|
+
| no `id` | insert with a generated id |
|
|
190
|
+
| `id` that exists | patch the fields given |
|
|
191
|
+
| `id` that does not exist | insert with that id |
|
|
192
|
+
| `deleted: true` | `isActive = false` |
|
|
193
|
+
| a key matching a child `key` | processed recursively; the child's `foreignKey` is set to the parent id |
|
|
194
|
+
|
|
195
|
+
Every query carries `.context({ user: req.user, afterCommit: [] })`. A model hook that must write to **another connection** pushes a function onto `afterCommit` instead of writing inline: the callbacks run only after the transaction commits, so a later failure in the same changeset cannot leave an orphan behind. A failing callback is logged, not returned.
|
|
196
|
+
|
|
197
|
+
### Responses and errors
|
|
198
|
+
|
|
199
|
+
Responses use `@xeplr/utils`' shape: `{ code, message, error, dataArray, updatedIds, sessionId }` (+ `pagination`). Database errors are mapped by `err.name` (so two copies of objection still match):
|
|
200
|
+
|
|
201
|
+
| error | status |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `ValidationError` | 422 |
|
|
204
|
+
| `UniqueViolationError`, `ForeignKeyViolationError` | 409 |
|
|
205
|
+
| `NotNullViolationError` (`<column> is required`), `CheckViolationError`, `DataError` | 400 |
|
|
206
|
+
| `NotFoundError` | 404 |
|
|
207
|
+
| anything else | 500 |
|
|
208
|
+
|
|
209
|
+
## checkEnv
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
checkEnv(['XEPLR_DB_CONNECTION', 'AUTH_URL'], { appName: 'myapp' })
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Options: `appName`, `exit` (default `true` — `process.exit(1)` on failure), `throw` (throw instead), `silent` (no success line). Returns `{ ok, missing }`.
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
npx xeplr-check-env # checks ./env.required.js
|
|
219
|
+
npx xeplr-check-env --file ./env.list.js --name myapp
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The same check at build time. It reads `process.env` only; load the `.env` first (e.g. `dotenv-cli`).
|
|
223
|
+
|
|
224
|
+
`gateRequiredEnv` is separate from any plain `requiredEnv` on purpose: only an app using the default gate needs `AUTH_URL`; one with `auth: false` or its own `middleware` would have to invent a value.
|
|
225
|
+
|
|
226
|
+
## Server-sent events
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
var { sse } = require('@xeplr/base-apis')
|
|
230
|
+
|
|
231
|
+
app.get('/events', sse.createHandler({ validateTicket: ticketService.consumeTicket }))
|
|
232
|
+
sse.publish('user:42:jobs', { jobId, status })
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
new EventSource('/events?ticket=<tk>&topics=user:me:jobs').onmessage = (e) => { const { topic, data } = JSON.parse(e.data) }
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
| | |
|
|
240
|
+
|---|---|
|
|
241
|
+
| `createHandler({ validateTicket, authorizeTopics? })` | production handler. `?ticket` required (401), `?topics` required (400). `validateTicket(ticket)` → `{ userId, scopes }` or `null` (401); throwing → 500. No authorised topic → 403 |
|
|
242
|
+
| `defaultAuthorizeTopics(payload, requested)` | rewrites `user:me:` to `user:<userId>:`, keeps topics matching a scope glob (default scope `user:me:*`), and **always drops another user's `user:<id>:` topic**, whatever the scopes say |
|
|
243
|
+
| `handler` | **no ticket, no topic check** — what `createApp({ sse: true })` mounts. For development and private networks |
|
|
244
|
+
| `publish(topic, data)` | writes to local subscribers and relays to every cluster worker |
|
|
245
|
+
|
|
246
|
+
A comment line is sent every 25 s so proxies do not drop idle streams; subscriptions are removed when the connection closes. The per-topic check exists because checking the ticket on connect is easy, while trusting client-supplied topics is how one user ends up subscribed to another's.
|
|
247
|
+
|
|
248
|
+
## Cluster
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
runCluster({
|
|
252
|
+
workers: 4, // default: CPU count
|
|
253
|
+
respawn: true, // default
|
|
254
|
+
start: function () { createApp(3000, 'myapp', { … }) },
|
|
255
|
+
onMessage: function (msg) { if (msg.type === 'cache-clear') clearCache() }
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
broadcast({ type: 'cache-clear' }) // from a worker: relayed to every other worker
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Environment
|
|
262
|
+
|
|
263
|
+
| variable | |
|
|
264
|
+
|---|---|
|
|
265
|
+
| `AUTH_URL` | the auth service base URL — **required** for the default gate |
|
|
266
|
+
| `UPLOAD_DIR` | upload destination when `upload.destination` is not given |
|
|
267
|
+
| `NODE_ENV` | anything but `production` puts the logger in dev mode |
|
|
268
|
+
|
|
269
|
+
## Tests
|
|
270
|
+
|
|
271
|
+
```sh
|
|
272
|
+
npm test
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
The auth gate against a stand-in auth service (refusal to boot, 401 / 503, public paths, caching, `X-New-Token`), SSE publish and subscribe, and ticket-authorised SSE.
|
|
276
|
+
|
|
277
|
+
## License
|
|
278
|
+
|
|
279
|
+
MIT
|