@dunx/create-app 0.3.1 → 0.5.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/dist/chunk-jgd5dmqh.js +698 -0
- package/dist/chunk-jgd5dmqh.js.map +12 -0
- package/dist/cli.js +68 -5
- package/dist/cli.js.map +3 -3
- package/dist/features.d.ts +74 -0
- package/dist/generate.d.ts +21 -0
- package/dist/index.js +1 -1
- package/dist/scaffold.d.ts +12 -1
- package/package.json +1 -1
- package/templates/base/_gitignore +5 -0
- package/templates/base/tsconfig.json +19 -0
- package/templates/features/auth/audit.service.ts +36 -0
- package/templates/features/auth/auth.demo.ts +173 -0
- package/templates/features/auth/auth.module.ts +54 -0
- package/templates/features/auth/auth.tables.ts +84 -0
- package/templates/features/auth/profile.controller.ts +37 -0
- package/templates/features/cache/cache.controller.ts +85 -0
- package/templates/features/cache/cache.module.ts +36 -0
- package/templates/features/cache/sessions.service.ts +90 -0
- package/templates/features/chat/chat.demo.ts +184 -0
- package/templates/features/chat/chat.gateway.ts +85 -0
- package/templates/features/chat/chat.module.ts +11 -0
- package/templates/features/chat/lobby.service.ts +20 -0
- package/templates/features/database/auth.schema.ts +75 -0
- package/templates/features/database/database.module.ts +39 -0
- package/templates/features/database/ledger.controller.ts +137 -0
- package/templates/features/database/ledger.service.ts +257 -0
- package/templates/features/database/schema.ts +27 -0
- package/templates/features/database/seeds/0001_ledger.seeder.ts +12 -0
- package/templates/features/database/seeds/0002_production_audit.seeder.ts +13 -0
- package/templates/features/docs/docs.demo.ts +130 -0
- package/templates/features/docs/docs.module.ts +9 -0
- package/templates/features/guards/auth.guard.ts +66 -0
- package/templates/features/guards/guards.demo.ts +75 -0
- package/templates/features/guards/guards.module.ts +16 -0
- package/templates/features/guards/reports.controller.ts +60 -0
- package/templates/features/guards/reports.service.ts +22 -0
- package/templates/features/health/health.controller.ts +65 -0
- package/templates/features/health/health.module.ts +5 -0
- package/templates/features/http/http.demo.ts +115 -0
- package/templates/features/http/http.module.ts +10 -0
- package/templates/features/http/request-log.ts +37 -0
- package/templates/features/jobs/jobs.controller.ts +102 -0
- package/templates/features/jobs/jobs.module.ts +35 -0
- package/templates/features/jobs/thumbnail.jobs.ts +53 -0
- package/templates/features/notes/notes.controller.ts +65 -0
- package/templates/features/notes/notes.module.ts +9 -0
- package/templates/features/notes/notes.service.ts +21 -0
- package/templates/features/pictures/images.controller.ts +74 -0
- package/templates/features/pictures/pictures.module.ts +22 -0
- package/templates/features/pictures/thumbnails.service.ts +108 -0
- package/templates/features/storage/files.controller.ts +130 -0
- package/templates/features/storage/storage.module.ts +21 -0
- package/templates/features/storage/uploads.service.ts +66 -0
- package/templates/features/storage/workspace.ts +33 -0
- package/templates/features/users/users.controller.ts +47 -0
- package/templates/features/users/users.demo.ts +62 -0
- package/templates/features/users/users.module.ts +11 -0
- package/templates/features/users/users.repository.ts +59 -0
- package/templates/features/users/users.schemas.ts +68 -0
- package/templates/features/users/users.service.ts +46 -0
- package/templates/minimal/_bunfig.toml +7 -0
- package/dist/chunk-rnjjb0bq.js +0 -69
- package/dist/chunk-rnjjb0bq.js.map +0 -10
- /package/templates/{minimal/bunfig.toml → base/_bunfig.toml} +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import type { OnInit, OnShutdown } from '@dunx/core';
|
|
3
|
+
import {
|
|
4
|
+
DbConnection,
|
|
5
|
+
runSeeds,
|
|
6
|
+
SqliteConnection,
|
|
7
|
+
SyncDatabase,
|
|
8
|
+
transaction,
|
|
9
|
+
transactionSync,
|
|
10
|
+
type SeedReport,
|
|
11
|
+
} from '@dunx/infra/db';
|
|
12
|
+
import { count, desc, eq, sql, sum } from 'drizzle-orm';
|
|
13
|
+
import * as schema from './schema.js';
|
|
14
|
+
import { ledger, type Entry } from './schema.js';
|
|
15
|
+
|
|
16
|
+
export class Ledger implements OnInit, OnShutdown {
|
|
17
|
+
/**
|
|
18
|
+
* `SyncDatabase` is drizzle's `BunSQLiteDatabase` under a name that says the
|
|
19
|
+
* connection was opened in synchronous mode - which is what makes
|
|
20
|
+
* `transactionSync` below reachable. `@dunx/transform` records the bare type name
|
|
21
|
+
* (a real runtime class, so a usable token) and ignores the type argument, so the
|
|
22
|
+
* schema types survive injection. `DbConnection` is the lifecycle and the driver
|
|
23
|
+
* underneath; drizzle has neither.
|
|
24
|
+
*/
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly db: SyncDatabase<typeof schema>,
|
|
27
|
+
private readonly connection: DbConnection,
|
|
28
|
+
private readonly logger: Logger,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Standing in for a migration rather than replacing one: schema changes are
|
|
33
|
+
* `drizzle-kit generate` plus drizzle-orm/bun-sqlite/migrator, which own the
|
|
34
|
+
* SQL, the journal and the snapshot folder. A `:memory:` database has nowhere
|
|
35
|
+
* to keep any of that, so the table is created here - and at `onInit`, so the
|
|
36
|
+
* routes below have somewhere to write before the first request arrives.
|
|
37
|
+
*/
|
|
38
|
+
async onInit(): Promise<void> {
|
|
39
|
+
this.db.run(sql`CREATE TABLE IF NOT EXISTS ledger (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
memo TEXT NOT NULL,
|
|
42
|
+
amount INTEGER NOT NULL
|
|
43
|
+
)`);
|
|
44
|
+
await runSeeds(this.db, { dir: `${import.meta.dir}/seeds` });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
list(limit: number): readonly Entry[] {
|
|
48
|
+
return this.db
|
|
49
|
+
.select()
|
|
50
|
+
.from(ledger)
|
|
51
|
+
.orderBy(desc(ledger.id))
|
|
52
|
+
.limit(limit)
|
|
53
|
+
.all();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
find(id: number): Entry | undefined {
|
|
57
|
+
return this.db.select().from(ledger).where(eq(ledger.id, id)).get();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `.returning()` hands back the row the database actually wrote. */
|
|
61
|
+
add(memo: string, amount: number): Entry {
|
|
62
|
+
return this.db.insert(ledger).values({ memo, amount }).returning().get();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
remove(id: number): boolean {
|
|
66
|
+
const gone = this.db
|
|
67
|
+
.delete(ledger)
|
|
68
|
+
.where(eq(ledger.id, id))
|
|
69
|
+
.returning()
|
|
70
|
+
.all();
|
|
71
|
+
return gone.length > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
balance(): number {
|
|
75
|
+
return (
|
|
76
|
+
this.db
|
|
77
|
+
.select({ total: sum(ledger.amount).mapWith(Number) })
|
|
78
|
+
.from(ledger)
|
|
79
|
+
.get()?.total ?? 0
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
rows(): number {
|
|
84
|
+
return this.db.select({ n: count() }).from(ledger).get()?.n ?? 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Both legs or neither. `transaction()` from `@dunx/infra/db`, not
|
|
89
|
+
* `db.transaction()`: drizzle's own on bun-sqlite delegates to `bun:sqlite`'s
|
|
90
|
+
* synchronous `transaction()`, which commits as soon as the callback returns its
|
|
91
|
+
* promise - so everything after the first `await` would run in autocommit. That
|
|
92
|
+
* is what makes the rollback below possible at all.
|
|
93
|
+
*/
|
|
94
|
+
transfer(
|
|
95
|
+
from: string,
|
|
96
|
+
to: string,
|
|
97
|
+
amount: number,
|
|
98
|
+
fail = false,
|
|
99
|
+
): Promise<number> {
|
|
100
|
+
return transaction(this.db, async (tx) => {
|
|
101
|
+
tx.insert(ledger).values({ memo: from, amount: -amount }).run();
|
|
102
|
+
await Bun.sleep(1);
|
|
103
|
+
if (fail) throw new Error('transfer failed after the first leg');
|
|
104
|
+
tx.insert(ledger).values({ memo: to, amount }).run();
|
|
105
|
+
return (
|
|
106
|
+
tx
|
|
107
|
+
.select({ total: sum(ledger.amount).mapWith(Number) })
|
|
108
|
+
.from(ledger)
|
|
109
|
+
.get()?.total ?? 0
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The same two legs, with nothing to await. `transactionSync` is drizzle's own
|
|
116
|
+
* `db.transaction()` - correct here precisely because the callback cannot return
|
|
117
|
+
* a promise, which is the case its early commit breaks. The return type is
|
|
118
|
+
* `number`, not `Promise<number>`, so a controller calling this needs no `async`
|
|
119
|
+
* and the request never yields.
|
|
120
|
+
*/
|
|
121
|
+
transferSync(from: string, to: string, amount: number, fail = false): number {
|
|
122
|
+
return transactionSync(this.db, (tx) => {
|
|
123
|
+
tx.insert(ledger).values({ memo: from, amount: -amount }).run();
|
|
124
|
+
if (fail) throw new Error('transfer failed after the first leg');
|
|
125
|
+
tx.insert(ledger).values({ memo: to, amount }).run();
|
|
126
|
+
return (
|
|
127
|
+
tx
|
|
128
|
+
.select({ total: sum(ledger.amount).mapWith(Number) })
|
|
129
|
+
.from(ledger)
|
|
130
|
+
.get()?.total ?? 0
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async demonstrate(): Promise<void> {
|
|
136
|
+
const { db, logger } = this;
|
|
137
|
+
logger.info(
|
|
138
|
+
`backend=${this.connection.backend} dialect=${this.connection.dialect}, ` +
|
|
139
|
+
'table "ledger" created at onInit',
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// The escape hatch. `raw` is `unknown` on the base - the abstract class cannot
|
|
143
|
+
// promise either driver - and `instanceof` is what restores the concrete type.
|
|
144
|
+
if (this.connection instanceof SqliteConnection) {
|
|
145
|
+
logger.info(`raw driver -> bun:sqlite ${this.connection.raw.filename}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
logger.info(
|
|
149
|
+
`insert -> ${JSON.stringify(this.add('opening balance', 100))}`,
|
|
150
|
+
);
|
|
151
|
+
this.add('coffee', -3);
|
|
152
|
+
|
|
153
|
+
const rows = db
|
|
154
|
+
.select({ memo: ledger.memo, amount: ledger.amount })
|
|
155
|
+
.from(ledger)
|
|
156
|
+
.orderBy(ledger.id)
|
|
157
|
+
.all();
|
|
158
|
+
logger.info(`select -> ${JSON.stringify(rows)}`);
|
|
159
|
+
|
|
160
|
+
// `.get()` is `undefined` when there is no row - never `null`.
|
|
161
|
+
const missing = db
|
|
162
|
+
.select()
|
|
163
|
+
.from(ledger)
|
|
164
|
+
.where(eq(ledger.memo, 'not in the book'))
|
|
165
|
+
.get();
|
|
166
|
+
logger.info(
|
|
167
|
+
`get() with no match -> ${missing === undefined ? 'undefined' : JSON.stringify(missing)}`,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await this.commits();
|
|
171
|
+
await this.rollsBack();
|
|
172
|
+
this.rollsBackSynchronously();
|
|
173
|
+
await this.seeds();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The same rollback with no promise anywhere - `transactionSync` throws where
|
|
178
|
+
* `transaction` rejects, so the recovery is `try`/`catch` rather than `.catch()`.
|
|
179
|
+
*/
|
|
180
|
+
private rollsBackSynchronously(): void {
|
|
181
|
+
const before = this.rows();
|
|
182
|
+
try {
|
|
183
|
+
transactionSync(this.db, (tx) => {
|
|
184
|
+
tx.insert(ledger).values({ memo: 'discarded', amount: 999 }).run();
|
|
185
|
+
throw new Error('rolled back on purpose, synchronously');
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.logger.info(`sync transaction threw: ${(error as Error).message}`);
|
|
189
|
+
}
|
|
190
|
+
this.logger.info(
|
|
191
|
+
`rolled back sync transaction -> still ${before} rows, no promise allocated`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The `await` inside is what proves the transaction is not autocommitting. */
|
|
196
|
+
private async commits(): Promise<void> {
|
|
197
|
+
const balance = await transaction(this.db, async (tx) => {
|
|
198
|
+
tx.insert(ledger).values({ memo: 'refund', amount: 12 }).run();
|
|
199
|
+
await Bun.sleep(1);
|
|
200
|
+
return tx
|
|
201
|
+
.select({ total: sum(ledger.amount).mapWith(Number) })
|
|
202
|
+
.from(ledger)
|
|
203
|
+
.get()?.total;
|
|
204
|
+
});
|
|
205
|
+
this.logger.info(
|
|
206
|
+
`committed transaction -> ${this.rows()} rows, balance ${balance}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Rolls back on throw, and the throw propagates rather than being swallowed. */
|
|
211
|
+
private async rollsBack(): Promise<void> {
|
|
212
|
+
const before = this.rows();
|
|
213
|
+
await transaction(this.db, async (tx) => {
|
|
214
|
+
tx.insert(ledger).values({ memo: 'discarded', amount: 999 }).run();
|
|
215
|
+
await Bun.sleep(1);
|
|
216
|
+
throw new Error('rolled back on purpose');
|
|
217
|
+
}).catch((error: unknown) =>
|
|
218
|
+
this.logger.info(`transaction threw: ${(error as Error).message}`),
|
|
219
|
+
);
|
|
220
|
+
this.logger.info(
|
|
221
|
+
`rolled back transaction -> still ${before} rows, ` +
|
|
222
|
+
'"discarded" never landed',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Seed *data*, which is the half `drizzle-kit` has no concept of. Numbered files
|
|
228
|
+
* in `seeds/`, each applied once and recorded in `dunx_seeds` - so this reports
|
|
229
|
+
* them journaled rather than applied, `onInit` having already run them.
|
|
230
|
+
*/
|
|
231
|
+
private async seeds(): Promise<void> {
|
|
232
|
+
const dir = `${import.meta.dir}/seeds`;
|
|
233
|
+
this.report('runSeeds after onInit', await runSeeds(this.db, { dir }));
|
|
234
|
+
this.logger.info(
|
|
235
|
+
`seeded ledger -> ${this.rows()} rows, applied once despite two runs`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private report(label: string, report: SeedReport): void {
|
|
240
|
+
this.logger.info(
|
|
241
|
+
`${label} -> applied ${JSON.stringify(report.applied)}, ` +
|
|
242
|
+
`journaled ${JSON.stringify(report.journaled)}, ` +
|
|
243
|
+
`skipped ${JSON.stringify(report.skipped)}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `close()` is idempotent, so `DbConnection.onShutdown` finding it already closed
|
|
249
|
+
* is fine. What this makes observable is the *order*: core drains in reverse
|
|
250
|
+
* construction order, so every service holding the connection has already run by
|
|
251
|
+
* the time this prints.
|
|
252
|
+
*/
|
|
253
|
+
async onShutdown(): Promise<void> {
|
|
254
|
+
await this.connection.close();
|
|
255
|
+
this.logger.info('database closed');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
|
|
3
|
+
// better-auth's `user`/`session`/`account`/`verification`, re-exported so they are
|
|
4
|
+
// part of the one schema object. That is what lets `drizzleDatabase(connection)` in
|
|
5
|
+
// auth.module.ts pass no schema of its own - the adapter reads `db._.fullSchema`.
|
|
6
|
+
export * from './auth.schema.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One schema module, because there is one connection and one drizzle handle.
|
|
10
|
+
* `typeof schema` is what flows into `BunSQLiteDatabase<typeof schema>` at every
|
|
11
|
+
* injection site, so a table added here is visible to every repository without
|
|
12
|
+
* anything being registered anywhere.
|
|
13
|
+
*/
|
|
14
|
+
export const ledger = sqliteTable('ledger', {
|
|
15
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
16
|
+
memo: text('memo').notNull(),
|
|
17
|
+
amount: integer('amount').notNull(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const users = sqliteTable('users', {
|
|
21
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
22
|
+
name: text('name').notNull().unique(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Inferred, not restated: a column change here reaches the services that use it. */
|
|
26
|
+
export type Entry = typeof ledger.$inferSelect;
|
|
27
|
+
export type User = typeof users.$inferSelect;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
|
|
2
|
+
import * as schema from '../schema.js';
|
|
3
|
+
import { ledger } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The handle is already inside a transaction that also writes the journal row, so a
|
|
7
|
+
* throw in here leaves neither the data nor the record - and the file is retried on
|
|
8
|
+
* the next boot.
|
|
9
|
+
*/
|
|
10
|
+
export function seed(db: BunSQLiteDatabase<typeof schema>): void {
|
|
11
|
+
db.insert(ledger).values({ memo: 'seeded: audit fee', amount: -7 }).run();
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
|
|
2
|
+
import * as schema from '../schema.js';
|
|
3
|
+
import { ledger } from '../schema.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A seed that belongs in one environment. A refused seed is *not* journaled, so it
|
|
7
|
+
* still runs the first time it reaches somewhere it does belong.
|
|
8
|
+
*/
|
|
9
|
+
export const when = (env: string): boolean => env === 'production';
|
|
10
|
+
|
|
11
|
+
export function seed(db: BunSQLiteDatabase<typeof schema>): void {
|
|
12
|
+
db.insert(ledger).values({ memo: 'seeded: opening audit', amount: 0 }).run();
|
|
13
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import type { HttpApp } from '@dunx/http';
|
|
3
|
+
import {
|
|
4
|
+
danglingRefs,
|
|
5
|
+
OpenApiExplorer,
|
|
6
|
+
type OpenApiDocument,
|
|
7
|
+
} from '@dunx/openapi';
|
|
8
|
+
|
|
9
|
+
const documentAt = async (
|
|
10
|
+
url: string,
|
|
11
|
+
path = 'api/openapi.json',
|
|
12
|
+
): Promise<[Response, OpenApiDocument]> => {
|
|
13
|
+
const response = await fetch(new URL(path, url));
|
|
14
|
+
return [response, (await response.json()) as OpenApiDocument];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export class DocsDemo {
|
|
18
|
+
constructor(private readonly logger: Logger) {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The document is served by a controller in the same graph, so it goes through the
|
|
22
|
+
* same middleware and the same CORS as everything else - and describes the paths
|
|
23
|
+
* the app really mounted, `setGlobalPrefix('api')` included.
|
|
24
|
+
*/
|
|
25
|
+
async demonstrate(app: HttpApp, url: string): Promise<void> {
|
|
26
|
+
const { logger } = this;
|
|
27
|
+
const [response, document] = await documentAt(url);
|
|
28
|
+
|
|
29
|
+
logger.info(
|
|
30
|
+
`GET /api/openapi.json -> ${response.status} openapi ${document.openapi}, ` +
|
|
31
|
+
`${Object.keys(document.paths).length} paths`,
|
|
32
|
+
);
|
|
33
|
+
logger.info(`paths: ${JSON.stringify(Object.keys(document.paths))}`);
|
|
34
|
+
logger.info(
|
|
35
|
+
`components/schemas: ${JSON.stringify(Object.keys(document.components.schemas))}`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// `.meta({ id: 'CreateUser' })` on the zod schema is what named this ref, and
|
|
39
|
+
// the $defs entry it referenced (`Tag`) came along with it.
|
|
40
|
+
const create = document.paths['/api/users']?.post;
|
|
41
|
+
logger.info(
|
|
42
|
+
`POST /api/users requestBody -> ` +
|
|
43
|
+
JSON.stringify(
|
|
44
|
+
create?.requestBody?.content['application/json']?.schema,
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
logger.info(
|
|
48
|
+
`POST /api/users 400 -> ` +
|
|
49
|
+
JSON.stringify(create?.responses['400']?.content?.['application/json']),
|
|
50
|
+
);
|
|
51
|
+
// `options.response` is the same contract as the request side, so a named
|
|
52
|
+
// response schema becomes a component and the operation $refs it. It is
|
|
53
|
+
// documentation only - nothing validates a response.
|
|
54
|
+
const one = document.paths['/api/users/{id}']?.get;
|
|
55
|
+
logger.info(
|
|
56
|
+
`GET /api/users/{id} responses -> ` +
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
Object.fromEntries(
|
|
59
|
+
Object.entries(one?.responses ?? {}).map(([status, response]) => [
|
|
60
|
+
status,
|
|
61
|
+
response.content?.['application/json']?.schema,
|
|
62
|
+
]),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
const list = document.paths['/api/users']?.get;
|
|
67
|
+
logger.info(`GET /api/users query -> ${JSON.stringify(list?.parameters)}`);
|
|
68
|
+
|
|
69
|
+
// The check that matters: a $ref that resolves to nothing renders as an empty
|
|
70
|
+
// box in every viewer and reports no error at all.
|
|
71
|
+
logger.info(
|
|
72
|
+
`unresolved $refs: ${danglingRefs(document).length}, warnings: ` +
|
|
73
|
+
JSON.stringify(app.get(OpenApiExplorer).warnings),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const page = await fetch(new URL('api/docs', url));
|
|
77
|
+
const html = await page.text();
|
|
78
|
+
// Two inline scripts: the document as JSON, and the explorer bundle. What
|
|
79
|
+
// still has to hold is that nothing is *fetched* - so the check is on the
|
|
80
|
+
// markup, with both script bodies removed. Inside a <script> everything is
|
|
81
|
+
// text, and minified React's own string table contains `src=` and `<script`.
|
|
82
|
+
const shell = html.replace(/(<script[^>]*>)[\s\S]*?(<\/script>)/g, '$1$2');
|
|
83
|
+
const external =
|
|
84
|
+
/\ssrc=/.test(shell) ||
|
|
85
|
+
/<link\b/.test(shell) ||
|
|
86
|
+
/url\(\s*["']?(https?:)?\/\//.test(html) ||
|
|
87
|
+
html.includes('//cdn');
|
|
88
|
+
logger.info(
|
|
89
|
+
`GET /api/docs -> ${page.status} ${page.headers.get('content-type')}, ` +
|
|
90
|
+
`${html.length} bytes, ${(html.match(/<\/script>/g) ?? []).length} inline scripts, ` +
|
|
91
|
+
`external requests: ${external ? 'some' : 'none'}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The guarded app, whose `AuthGuard` is global. Security in the document comes from
|
|
97
|
+
* the same `@Public()` and `@Roles()` metadata the guards read at runtime - there
|
|
98
|
+
* is no second annotation for the documentation to disagree with.
|
|
99
|
+
*/
|
|
100
|
+
async guarded(url: string): Promise<void> {
|
|
101
|
+
const { logger } = this;
|
|
102
|
+
const [, document] = await documentAt(url);
|
|
103
|
+
|
|
104
|
+
const rename = document.paths['/api/reports/{id}']?.patch;
|
|
105
|
+
logger.info(
|
|
106
|
+
`@Roles("editor") PATCH /api/reports/{id} -> security ` +
|
|
107
|
+
`${JSON.stringify(rename?.security)}, roles ` +
|
|
108
|
+
`${JSON.stringify(rename?.['x-required-roles'])}`,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const health = document.paths['/api/reports/health']?.get;
|
|
112
|
+
logger.info(
|
|
113
|
+
`@Public() GET /api/reports/health -> security ${JSON.stringify(health?.security)}`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// The class-level @Roles('admin') is merged into every one of its routes, so it
|
|
117
|
+
// is documented on this one too - even though no RolesGuard reads it here. The
|
|
118
|
+
// document describes what the metadata declares; which guard enforces it is a
|
|
119
|
+
// separate decision, and one no generator can see.
|
|
120
|
+
const list = document.paths['/api/reports']?.get;
|
|
121
|
+
logger.info(
|
|
122
|
+
`class-level @Roles("admin") GET /api/reports -> security ` +
|
|
123
|
+
`${JSON.stringify(list?.security)}, roles ` +
|
|
124
|
+
`${JSON.stringify(list?.['x-required-roles'])}`,
|
|
125
|
+
);
|
|
126
|
+
logger.info(
|
|
127
|
+
`securitySchemes: ${JSON.stringify(document.components.securitySchemes)}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Module } from '@dunx/core';
|
|
2
|
+
import { DocsDemo } from './docs.demo.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Only the demonstration lives here. The document's own routes come from
|
|
6
|
+
* `OpenApiModule.forRoot()` in `main.ts`, which wraps the root module it documents.
|
|
7
|
+
*/
|
|
8
|
+
@Module({ providers: [DocsDemo] })
|
|
9
|
+
export class DocsModule {}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import type { BunRequest } from 'bun';
|
|
3
|
+
import {
|
|
4
|
+
HttpError,
|
|
5
|
+
HttpStatusCode,
|
|
6
|
+
PUBLIC,
|
|
7
|
+
ROLES,
|
|
8
|
+
type Middleware,
|
|
9
|
+
type Next,
|
|
10
|
+
type RouteContext,
|
|
11
|
+
} from '@dunx/http';
|
|
12
|
+
|
|
13
|
+
/** `Authorization: Bearer <role>` - enough to demonstrate, short of a real token. */
|
|
14
|
+
const roleOf = (req: BunRequest): string | undefined =>
|
|
15
|
+
req.headers.get('authorization')?.replace(/^Bearer\s+/i, '');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Global middleware, so it sees every route. `ctx.get(PUBLIC)` is the only thing
|
|
19
|
+
* that can tell an opted-out route apart from one that needs credentials - which
|
|
20
|
+
* is what makes `@Public()` do something rather than decorate.
|
|
21
|
+
*/
|
|
22
|
+
export class AuthGuard implements Middleware {
|
|
23
|
+
constructor(private readonly logger: Logger) {}
|
|
24
|
+
|
|
25
|
+
handle(req: BunRequest, ctx: RouteContext, next: Next): Promise<Response> {
|
|
26
|
+
if (ctx.get(PUBLIC)) {
|
|
27
|
+
this.logger.info(
|
|
28
|
+
`AuthGuard: ${ctx.method} ${ctx.path} is @Public() - skipping`,
|
|
29
|
+
);
|
|
30
|
+
return next();
|
|
31
|
+
}
|
|
32
|
+
const role = roleOf(req);
|
|
33
|
+
if (role === undefined) {
|
|
34
|
+
throw new HttpError(HttpStatusCode.UNAUTHORIZED, 'No credentials');
|
|
35
|
+
}
|
|
36
|
+
this.logger.info(
|
|
37
|
+
`AuthGuard: ${ctx.controller}.${ctx.handler} authenticated as "${role}"`,
|
|
38
|
+
);
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A guard is middleware that throws. Applied with `@UseGuards(RolesGuard)` at
|
|
45
|
+
* method scope, it reads whichever `@Roles` won - the method's, else the class's.
|
|
46
|
+
*/
|
|
47
|
+
export class RolesGuard implements Middleware {
|
|
48
|
+
constructor(private readonly logger: Logger) {}
|
|
49
|
+
|
|
50
|
+
handle(req: BunRequest, ctx: RouteContext, next: Next): Promise<Response> {
|
|
51
|
+
const required = ctx.get(ROLES);
|
|
52
|
+
if (!required) return next();
|
|
53
|
+
|
|
54
|
+
const role = roleOf(req);
|
|
55
|
+
this.logger.info(
|
|
56
|
+
`RolesGuard: ${ctx.handler} requires [${required.join(', ')}], caller is "${role ?? '-'}"`,
|
|
57
|
+
);
|
|
58
|
+
if (role === undefined || !required.includes(role)) {
|
|
59
|
+
throw new HttpError(
|
|
60
|
+
HttpStatusCode.FORBIDDEN,
|
|
61
|
+
`Requires one of: ${required.join(', ')}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return next();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
|
|
3
|
+
interface Call {
|
|
4
|
+
readonly label: string;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly method?: string;
|
|
7
|
+
readonly role?: string;
|
|
8
|
+
readonly body?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CALLS: readonly Call[] = [
|
|
12
|
+
{
|
|
13
|
+
label: '@Public() GET /api/reports/health, no credentials',
|
|
14
|
+
path: 'api/reports/health',
|
|
15
|
+
},
|
|
16
|
+
{ label: 'GET /api/reports, no credentials', path: 'api/reports' },
|
|
17
|
+
{
|
|
18
|
+
label: 'GET /api/reports as "viewer"',
|
|
19
|
+
path: 'api/reports',
|
|
20
|
+
role: 'viewer',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
label: '@UseGuards(RolesGuard) POST /api/reports as "viewer"',
|
|
24
|
+
path: 'api/reports',
|
|
25
|
+
method: 'POST',
|
|
26
|
+
role: 'viewer',
|
|
27
|
+
body: { title: 'q2 revenue' },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
label: 'POST /api/reports as "admin" (class-level @Roles)',
|
|
31
|
+
path: 'api/reports',
|
|
32
|
+
method: 'POST',
|
|
33
|
+
role: 'admin',
|
|
34
|
+
body: { title: 'q2 revenue' },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
label:
|
|
38
|
+
'PATCH /api/reports/1 as "admin" (method-level @Roles("editor") won)',
|
|
39
|
+
path: 'api/reports/1',
|
|
40
|
+
method: 'PATCH',
|
|
41
|
+
role: 'admin',
|
|
42
|
+
body: { title: 'q1 revenue, restated' },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'PATCH /api/reports/1 as "editor"',
|
|
46
|
+
path: 'api/reports/1',
|
|
47
|
+
method: 'PATCH',
|
|
48
|
+
role: 'editor',
|
|
49
|
+
body: { title: 'q1 revenue, restated' },
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export class GuardsDemo {
|
|
54
|
+
constructor(private readonly logger: Logger) {}
|
|
55
|
+
|
|
56
|
+
async demonstrate(url: string): Promise<void> {
|
|
57
|
+
for (const call of CALLS) {
|
|
58
|
+
const response = await fetch(new URL(call.path, url), {
|
|
59
|
+
method: call.method ?? 'GET',
|
|
60
|
+
headers: {
|
|
61
|
+
...(call.role === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { authorization: `Bearer ${call.role}` }),
|
|
64
|
+
...(call.body === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { 'content-type': 'application/json' }),
|
|
67
|
+
},
|
|
68
|
+
...(call.body === undefined ? {} : { body: JSON.stringify(call.body) }),
|
|
69
|
+
});
|
|
70
|
+
this.logger.info(
|
|
71
|
+
`${call.label} -> ${response.status} ${JSON.stringify(await response.json())}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Module } from '@dunx/core';
|
|
2
|
+
import { AuthGuard, RolesGuard } from './auth.guard.js';
|
|
3
|
+
import { GuardsDemo } from './guards.demo.js';
|
|
4
|
+
import { ReportsController } from './reports.controller.js';
|
|
5
|
+
import { ReportsService } from './reports.service.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Part of the one app, not its own. `AuthGuard` is applied by
|
|
9
|
+
* `@UseGuards(AuthGuard)` on `ReportsController` rather than as global
|
|
10
|
+
* middleware, so it challenges `/api/reports` and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
@Module({
|
|
13
|
+
controllers: [ReportsController],
|
|
14
|
+
providers: [ReportsService, AuthGuard, RolesGuard, GuardsDemo],
|
|
15
|
+
})
|
|
16
|
+
export class GuardsModule {}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller,
|
|
3
|
+
Get,
|
|
4
|
+
Patch,
|
|
5
|
+
Post,
|
|
6
|
+
Public,
|
|
7
|
+
Roles,
|
|
8
|
+
UseGuards,
|
|
9
|
+
type Input,
|
|
10
|
+
} from '@dunx/http';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { AuthGuard, RolesGuard } from './auth.guard.js';
|
|
13
|
+
import { ReportsService } from './reports.service.js';
|
|
14
|
+
|
|
15
|
+
const renameReport = {
|
|
16
|
+
params: z.object({ id: z.coerce.number().int() }),
|
|
17
|
+
body: z.object({ title: z.string().min(1) }),
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
const createReport = { body: z.object({ title: z.string().min(1) }) } as const;
|
|
21
|
+
|
|
22
|
+
// `@UseGuards(AuthGuard)` at class scope rather than as global middleware: every
|
|
23
|
+
// other route in this app is meant to be reachable without credentials, and a
|
|
24
|
+
// global guard would challenge all of them. `@Roles('admin')` is a class-level
|
|
25
|
+
// default overridden per method below - metadata decides nothing until a guard
|
|
26
|
+
// reads it.
|
|
27
|
+
@Roles('admin')
|
|
28
|
+
@UseGuards(AuthGuard)
|
|
29
|
+
@Controller('reports')
|
|
30
|
+
export class ReportsController {
|
|
31
|
+
constructor(private readonly reports: ReportsService) {}
|
|
32
|
+
|
|
33
|
+
// The class-level AuthGuard reads this and skips: no credentials needed.
|
|
34
|
+
@Public()
|
|
35
|
+
@Get('/health')
|
|
36
|
+
health(): { ok: true } {
|
|
37
|
+
return { ok: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Authenticated, but no RolesGuard reads the class-level @Roles here.
|
|
41
|
+
@Get('/')
|
|
42
|
+
list(): readonly string[] {
|
|
43
|
+
return this.reports.titles();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Method-scoped guard, reading the class-level @Roles('admin').
|
|
47
|
+
@UseGuards(RolesGuard)
|
|
48
|
+
@Post('/', createReport)
|
|
49
|
+
create(input: Input<typeof createReport>): readonly string[] {
|
|
50
|
+
return this.reports.add(input.body.title);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A method-level @Roles wins over the class-level one.
|
|
54
|
+
@Roles('editor')
|
|
55
|
+
@UseGuards(RolesGuard)
|
|
56
|
+
@Patch('/:id', renameReport)
|
|
57
|
+
rename(input: Input<typeof renameReport>): readonly string[] {
|
|
58
|
+
return this.reports.rename(input.params.id, input.body.title);
|
|
59
|
+
}
|
|
60
|
+
}
|