@nano-app/plugins 0.8.0 → 0.9.0
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 +25 -3
- package/build/crud/index.d.ts +6 -3
- package/build/crud/index.js +3 -4
- package/build/crud/routes/index.route.js +39 -29
- package/build/health/index.js +2 -3
- package/build/rate-limit/index.js +2 -1
- package/build/types/crud.d.ts +10 -0
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -20,14 +20,36 @@ createNanoApp({ plugins: [health()] });
|
|
|
20
20
|
| Plugin | Import | Endpoints | Options |
|
|
21
21
|
| -------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
|
22
22
|
| `health` | `import health from '@nano-app/plugins/health'` | `GET /health` → `{ success: true, result: { status: "ok" } }` | `basePath` (default `"/health"`) |
|
|
23
|
-
| `crud` | `import crud from '@nano-app/plugins/crud'` | On the tables listed in `models`, by their name in the app's schema (any other answers 404): `GET` and `QUERY` on `/api/<model>{/<id>}` → a list `{ result }` or one record, with `QueryParams` as the body of `QUERY`: Drizzle's relational query
|
|
24
|
-
| `rate-limit` | `import rateLimit from '@nano-app/plugins/rate-limit'` | None: limits every request of the routes and plugins mounted after it, per client IP (see [Client IP](../backend/README.md#client-ip)). Each request spends a token of the client's bucket, which holds `capacity` and refills `refillPerSecond` tokens per second; without one it answers `429` with `message` and `Retry-After` (seconds until the next token). List it first in `plugins`. The buckets live in the memory of each process | `capacity` (default `100`), `refillPerSecond` (default `1`), `message` |
|
|
23
|
+
| `crud` | `import crud from '@nano-app/plugins/crud'` | On the tables listed in `models`, by their name in the app's schema (any other answers 404): `GET` and `QUERY` on `/api/<model>{/<id>}` → a list `{ result }` or one record, with `QueryParams` as the body of `QUERY`: Drizzle's relational query (`where`, `columns`, `orderBy`, `limit`, `offset`, `with`); `POST /api/<model>`, `PUT` and `DELETE` on `/api/<model>/<id>` → the record created, updated or deleted. Postgres casts `<id>` to the type of the `id` column. With Better Auth, each method asks for a permission on the table: `read` (`GET`, `QUERY`), `create`, `update`, `delete`, see [crud and related tables](#crud-and-related-tables) | `models` (the published ones), `dbSchema` (the app's Drizzle schema, as in `mainPlugin.database`), `validations` (zod schemas of the bodies, see [crud and the bodies](#crud-and-the-bodies)), `basePath` (default `"/api"`) |
|
|
24
|
+
| `rate-limit` | `import rateLimit from '@nano-app/plugins/rate-limit'` | None: limits every request of the routes and plugins mounted after it, per client IP (see [Client IP](../backend/README.md#client-ip)); the addresses of an IPv6 `/56` network are one client. Each request spends a token of the client's bucket, which holds `capacity` and refills `refillPerSecond` tokens per second; without one it answers `429` with `message` and `Retry-After` (seconds until the next token). List it first in `plugins`. The buckets live in the memory of each process | `capacity` (default `100`), `refillPerSecond` (default `1`), `message` |
|
|
25
25
|
|
|
26
26
|
What is not CRUD, such as creating a record with business logic, goes in a route of your own with its own URL: a route of yours on a `crud` URL takes every method of it.
|
|
27
27
|
|
|
28
|
+
### crud and the bodies
|
|
29
|
+
|
|
30
|
+
`crud` sends each body to Drizzle as it came: any column of the table can be written, and a `QUERY` can ask for any `limit` (100 without one). What a body may bring is up to the app: `validations` takes, per model, a [zod](https://zod.dev) schema by action, the same names as the permissions: `read` (the body of `QUERY`, and `{}` for `GET`), `create` (`POST`) and `update` (`PUT`). A body that breaks its validation answers 400 with the field and the message of zod; one that passes goes on as zod gives it, so a validation also drops the keys it does not name and sets its defaults. [`drizzle-orm/zod`](https://orm.drizzle.team/docs/zod) builds them from the table:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createInsertSchema, createUpdateSchema } from 'drizzle-orm/zod';
|
|
34
|
+
import { z } from 'zod';
|
|
35
|
+
import * as schema from './db/schema';
|
|
36
|
+
|
|
37
|
+
crud({
|
|
38
|
+
models: ['note'],
|
|
39
|
+
dbSchema: schema,
|
|
40
|
+
validations: {
|
|
41
|
+
note: {
|
|
42
|
+
create: createInsertSchema(schema.note).omit({ id: true }),
|
|
43
|
+
update: createUpdateSchema(schema.note).omit({ id: true }),
|
|
44
|
+
read: z.looseObject({ limit: z.number().max(50).default(20) }),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
28
50
|
### crud and related tables
|
|
29
51
|
|
|
30
|
-
A `crud` query
|
|
52
|
+
A `crud` query runs on the app's client, so it reaches the related tables Drizzle's relations give: `with` loads them and `where` filters by them, also the ones that are not in `models`. With Better Auth, the permission asked is only the one of the URL's table. What a query may load is up to the app: the `read` validation of the model decides it (a `z.strictObject` without `with` keeps a query in its table). Better Auth's tables (`user`, `account` with its password hashes, `session` with its tokens) hold secrets: never publish `account`, `session` or `verification`, and do not relate them to a published table without a `read` validation that leaves them out.
|
|
31
53
|
|
|
32
54
|
### Layout
|
|
33
55
|
|
package/build/crud/index.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { CrudValidations } from "../types/crud.js";
|
|
1
2
|
import { NanoObject } from "@nano-app/backend";
|
|
2
|
-
/** Automatic CRUD: the listed tables of the app's
|
|
3
|
+
/** Automatic CRUD: the listed tables of the app's database on `/api/<model>{/<id>}` */
|
|
3
4
|
declare const _default: (options?: {
|
|
4
5
|
basePath?: string;
|
|
5
6
|
models: string[];
|
|
6
|
-
|
|
7
|
+
dbSchema: NanoObject;
|
|
8
|
+
validations?: NanoObject<CrudValidations>;
|
|
7
9
|
} | undefined) => import("@nano-app/backend").PluginConfig<{
|
|
8
10
|
basePath?: string;
|
|
9
11
|
models: string[];
|
|
10
|
-
|
|
12
|
+
dbSchema: NanoObject;
|
|
13
|
+
validations?: NanoObject<CrudValidations>;
|
|
11
14
|
}>;
|
|
12
15
|
export { _default as default };
|
package/build/crud/index.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { createPlugin } from "@nano-app/backend";
|
|
2
|
-
import {
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
// crud/index.ts
|
|
5
|
-
/** Automatic CRUD: the listed tables of the app's
|
|
4
|
+
/** Automatic CRUD: the listed tables of the app's database on `/api/<model>{/<id>}` */
|
|
6
5
|
var crud_default = createPlugin((options) => ({
|
|
7
6
|
name: "crud",
|
|
8
7
|
basePath: options?.basePath ?? "/api",
|
|
9
8
|
stage: "after",
|
|
10
|
-
routesDir:
|
|
9
|
+
routesDir: join(import.meta.dirname, "routes"),
|
|
11
10
|
options
|
|
12
11
|
}));
|
|
13
12
|
export { crud_default as default };
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { NanoError, createRoute, isGranted } from "@nano-app/backend";
|
|
2
|
-
import {
|
|
3
|
-
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
4
3
|
// crud/routes/index.route.ts
|
|
5
|
-
/**
|
|
4
|
+
/** Action of each method: its permission of Better Auth and the validation of its body */
|
|
6
5
|
const ACTIONS = {
|
|
7
6
|
GET: "read",
|
|
8
7
|
QUERY: "read",
|
|
@@ -17,48 +16,59 @@ function first(aRecords) {
|
|
|
17
16
|
if (!aRecords[0]) throw new NanoError("Not found", 404);
|
|
18
17
|
return { result: aRecords[0] };
|
|
19
18
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
...oBody,
|
|
32
|
-
where: { id },
|
|
33
|
-
limit: 1
|
|
19
|
+
/** Every record of a query */
|
|
20
|
+
function list(aRecords) {
|
|
21
|
+
return { result: aRecords };
|
|
22
|
+
}
|
|
23
|
+
/** A list of the table, or one record by its id, with Drizzle's relational query of the body */
|
|
24
|
+
async function read(_oReq, { db, onBeforeResult }) {
|
|
25
|
+
const { model, page, answer, body } = onBeforeResult ?? {};
|
|
26
|
+
return answer(await db.query[model].findMany({
|
|
27
|
+
limit: DEFAULT_LIMIT,
|
|
28
|
+
...body,
|
|
29
|
+
...page
|
|
34
30
|
}));
|
|
35
|
-
return { result: await oQuery.findMany({
|
|
36
|
-
...oBody,
|
|
37
|
-
where: oBody.where ?? {},
|
|
38
|
-
limit: oBody.limit ?? DEFAULT_LIMIT
|
|
39
|
-
}) };
|
|
40
31
|
}
|
|
41
32
|
/** `GET` reads without parameters, `QUERY` with `QueryParams` as its body */
|
|
42
33
|
var index_route_default = createRoute({
|
|
43
34
|
expression: "/:model{/:id}",
|
|
44
35
|
callbacks: { onBefore: async (oReq, sMethod, oParams) => {
|
|
45
36
|
const sModel = String(oReq.params.model);
|
|
37
|
+
const sAction = ACTIONS[sMethod];
|
|
46
38
|
const { id } = oReq.params;
|
|
47
|
-
const oTable = oParams.
|
|
39
|
+
const oTable = oParams.dbSchema[sModel];
|
|
48
40
|
if (!oParams.models.includes(sModel) || !oTable) throw new NanoError("Not found", 404);
|
|
49
|
-
if (!await isGranted(oParams, { [sModel]: [
|
|
41
|
+
if (!await isGranted(oParams, { [sModel]: [sAction] })) throw new NanoError("Forbidden", 403);
|
|
42
|
+
const oBody = oReq.body ?? {};
|
|
43
|
+
const oResult = (oParams.validations?.[sModel]?.[sAction])?.safeParse(oBody) ?? { data: oBody };
|
|
44
|
+
if (oResult.error) {
|
|
45
|
+
const { path, message } = oResult.error.issues[0];
|
|
46
|
+
throw new NanoError([...path, message].join(": "), 400);
|
|
47
|
+
}
|
|
48
|
+
const oRead = id ? {
|
|
49
|
+
page: {
|
|
50
|
+
where: { id },
|
|
51
|
+
limit: 1
|
|
52
|
+
},
|
|
53
|
+
answer: first
|
|
54
|
+
} : { answer: list };
|
|
50
55
|
return {
|
|
51
56
|
model: sModel,
|
|
52
57
|
table: oTable,
|
|
53
|
-
id
|
|
58
|
+
id,
|
|
59
|
+
body: oResult.data,
|
|
60
|
+
...oRead
|
|
54
61
|
};
|
|
55
62
|
} },
|
|
56
63
|
GET: read,
|
|
57
64
|
QUERY: read,
|
|
58
|
-
POST: async (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
POST: async (_oReq, { db, onBeforeResult }) => {
|
|
66
|
+
const { table, body } = onBeforeResult ?? {};
|
|
67
|
+
return first(await db.insert(table).values(body).returning());
|
|
68
|
+
},
|
|
69
|
+
PUT: async (_oReq, { db, onBeforeResult }) => {
|
|
70
|
+
const { table, id, body } = onBeforeResult ?? {};
|
|
71
|
+
return first(await db.update(table).set(body).where(eq(table.id, id)).returning());
|
|
62
72
|
},
|
|
63
73
|
DELETE: async (_oReq, { db, onBeforeResult }) => {
|
|
64
74
|
const { table, id } = onBeforeResult ?? {};
|
package/build/health/index.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { createPlugin } from "@nano-app/backend";
|
|
2
|
-
import {
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
// health/index.ts
|
|
5
4
|
/** Health check: `GET /health` answers `{ status: "ok" }` while the server is up. */
|
|
6
5
|
var health_default = createPlugin((options) => ({
|
|
7
6
|
name: "health",
|
|
8
7
|
basePath: options?.basePath ?? "/health",
|
|
9
8
|
stage: "before",
|
|
10
|
-
routesDir:
|
|
9
|
+
routesDir: join(import.meta.dirname, "routes")
|
|
11
10
|
}));
|
|
12
11
|
export { health_default as default };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ipKeyGenerator } from "express-rate-limit";
|
|
1
2
|
import { createPlugin } from "@nano-app/backend";
|
|
2
3
|
// rate-limit/index.ts
|
|
3
4
|
/**
|
|
@@ -18,7 +19,7 @@ var rate_limit_default = createPlugin((options) => {
|
|
|
18
19
|
for (const [sIp, oBucket] of oBuckets) if (fnRefill(oBucket, iNow) >= capacity) oBuckets.delete(sIp);
|
|
19
20
|
}, 6e4).unref();
|
|
20
21
|
oApp.use((oReq, oRes, fnNext) => {
|
|
21
|
-
const sIp = oReq.ip ?? "";
|
|
22
|
+
const sIp = ipKeyGenerator(oReq.ip ?? "");
|
|
22
23
|
const iNow = Date.now();
|
|
23
24
|
const iTokens = fnRefill(oBuckets.get(sIp) ?? {
|
|
24
25
|
iTokens: capacity,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ZodType } from "zod";
|
|
2
|
+
/** Validations of a model: a zod schema by action, each one optional */
|
|
3
|
+
export type CrudValidations = {
|
|
4
|
+
/** Body of a `QUERY`, and `{}` for a `GET` */
|
|
5
|
+
read?: ZodType;
|
|
6
|
+
/** Body of a `POST` */
|
|
7
|
+
create?: ZodType;
|
|
8
|
+
/** Body of a `PUT` */
|
|
9
|
+
update?: ZodType;
|
|
10
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/plugins",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Official plugins for @nano-app/backend",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@nano-app/backend": ">=0.15.0",
|
|
41
|
-
"drizzle-orm": "^1.0.0-rc.5"
|
|
41
|
+
"drizzle-orm": "^1.0.0-rc.5",
|
|
42
|
+
"zod": "^4.6.7"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@happy-dom/global-registrator": "^21.0.4",
|
|
@@ -52,11 +53,18 @@
|
|
|
52
53
|
"pg": "^8.23.1",
|
|
53
54
|
"tsdown": "^0.23.5",
|
|
54
55
|
"tsx": "^4.23.15",
|
|
55
|
-
"typescript": "^7.0.2"
|
|
56
|
+
"typescript": "^7.0.2",
|
|
57
|
+
"zod": "^4.6.7"
|
|
56
58
|
},
|
|
57
59
|
"peerDependenciesMeta": {
|
|
58
60
|
"drizzle-orm": {
|
|
59
61
|
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"zod": {
|
|
64
|
+
"optional": true
|
|
60
65
|
}
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"express-rate-limit": "^8.7.3"
|
|
61
69
|
}
|
|
62
70
|
}
|