@dunx/create-app 2.5.0 → 3.0.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/features.d.ts +6 -11
- package/package.json +1 -1
- package/templates/features/assets/assets.module.ts +6 -15
- package/templates/features/auth/auth.demo.ts +7 -19
- package/templates/features/auth/auth.module.ts +16 -31
- package/templates/features/auth/auth.tables.ts +7 -15
- package/templates/features/cache/cache.module.ts +7 -13
- package/templates/features/chat/chat.demo.ts +10 -21
- package/templates/features/chat/chat.gateway.ts +8 -19
- package/templates/features/database/database.module.ts +8 -19
- package/templates/features/database/ledger.controller.ts +13 -26
- package/templates/features/database/ledger.service.ts +16 -44
- package/templates/features/docs/docs.demo.ts +13 -32
- package/templates/features/health/health.module.ts +10 -19
- package/templates/features/health/indicators.ts +10 -26
- package/templates/features/http/compression.demo.ts +7 -15
- package/templates/features/http/http.demo.ts +7 -13
- package/templates/features/http/request-trail.ts +5 -8
- package/templates/features/jobs/jobs.controller.ts +7 -18
- package/templates/features/jobs/jobs.module.ts +8 -18
- package/templates/features/jobs/jobs.processor.ts +5 -13
- package/templates/features/schedule/maintenance.service.ts +11 -29
- package/templates/features/schedule/schedule.module.ts +3 -7
- package/templates/features/storage/files.controller.ts +10 -19
- package/templates/features/throttle/limits.controller.ts +5 -12
- package/templates/features/throttle/throttle.module.ts +8 -26
- package/templates/features/upstream/upstream.demo.ts +6 -15
- package/templates/features/upstream/upstream.module.ts +4 -13
- package/templates/features/users/users.repository.ts +4 -13
- package/templates/features/users/users.schemas.ts +11 -33
- package/templates/minimal/src/app.module.ts +0 -5
- package/templates/minimal/src/app.test.ts +0 -5
- package/templates/minimal/src/greetings.controller.ts +2 -12
- package/templates/minimal/src/greetings.service.ts +2 -10
- package/templates/minimal/src/main.ts +0 -5
|
@@ -2,21 +2,13 @@ import { Logger } from '@dunx/core';
|
|
|
2
2
|
import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The three decorators
|
|
5
|
+
* The three schedule decorators on one class, discovered off the prototype chain
|
|
6
|
+
* with no second registration. Nothing here coordinates across replicas: work
|
|
7
|
+
* that must happen once per fleet is a `@JobHandler`.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Nothing here is single-node-unsafe on purpose: two replicas would both run every
|
|
12
|
-
* one of these, because nothing in `@dunx/infra/schedule` coordinates. Work that
|
|
13
|
-
* must happen once across a fleet is a job, which is `@JobHandler` and bullmq.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* Counters are written as `x = x + 1` rather than `x += 1`, and that is not style.
|
|
17
|
-
* Bun 1.4.0 refuses to parse a class that has both a decorated member and a
|
|
18
|
-
* read-modify-write on a private field - `+=`, `++` and `??=` alike - with a
|
|
19
|
-
* `SyntaxError` naming neither. See docs/bun-apis.md.
|
|
9
|
+
* Counters are `x = x + 1` rather than `x += 1` because Bun 1.4.0 fails to parse
|
|
10
|
+
* a class with both a decorated member and a read-modify-write on a private
|
|
11
|
+
* field. See docs/bun-apis.md.
|
|
20
12
|
*/
|
|
21
13
|
export class Maintenance {
|
|
22
14
|
#sweeps = 0;
|
|
@@ -25,22 +17,15 @@ export class Maintenance {
|
|
|
25
17
|
|
|
26
18
|
constructor(private readonly logger: Logger) {}
|
|
27
19
|
|
|
28
|
-
/**
|
|
29
|
-
* `
|
|
30
|
-
* "ready" is `onInit`, which is the latest hook there is and runs **before**
|
|
31
|
-
* `Bun.serve` binds. Measured: this has already run by the time `listen()`
|
|
32
|
-
* resolves, so the first request never sees a cold cache.
|
|
33
|
-
*/
|
|
20
|
+
/** `0` fires on the next macrotask after `onInit`, which is before
|
|
21
|
+
* `Bun.serve` binds - so the first request never sees a cold cache. */
|
|
34
22
|
@OnceOnBoot(0, { name: 'maintenance.warm' })
|
|
35
23
|
warmCaches(): void {
|
|
36
24
|
this.#warmed = true;
|
|
37
25
|
this.logger.info('@OnceOnBoot(0): caches warmed, before listen() resolved');
|
|
38
26
|
}
|
|
39
27
|
|
|
40
|
-
/**
|
|
41
|
-
* Every ten minutes, so it does not fire during a tour or a suite. `trigger`
|
|
42
|
-
* runs it now, which is what makes a schedule testable without waiting.
|
|
43
|
-
*/
|
|
28
|
+
/** Ten minutes, so it never fires during a tour. `trigger` runs it now. */
|
|
44
29
|
@Interval(600_000, { name: 'maintenance.sweep' })
|
|
45
30
|
sweepSessions(): number {
|
|
46
31
|
this.#sweeps = this.#sweeps + 1;
|
|
@@ -48,11 +33,8 @@ export class Maintenance {
|
|
|
48
33
|
}
|
|
49
34
|
|
|
50
35
|
/**
|
|
51
|
-
* Minute resolution: `Bun.cron` rejects a sixth field
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `overlap` is `skip` by default, which is what `Bun.cron` does for free - it
|
|
55
|
-
* computes the next fire only once the returned promise settles.
|
|
36
|
+
* Minute resolution: `Bun.cron` rejects a sixth field, so sub-minute work is
|
|
37
|
+
* `@Interval`. `overlap` defaults to `skip`, which `Bun.cron` gives for free.
|
|
56
38
|
*/
|
|
57
39
|
@Cron('0 3 * * *', { name: 'maintenance.compact' })
|
|
58
40
|
async compactLedger(): Promise<number> {
|
|
@@ -7,13 +7,9 @@ import { ScheduleDemo } from './schedule.demo.js';
|
|
|
7
7
|
/**
|
|
8
8
|
* `Bun.cron` behind `@Cron`, `@Interval` and `@OnceOnBoot`, armed at boot.
|
|
9
9
|
*
|
|
10
|
-
* `keepAlive: false
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `tz` comes from the config because that is the one thing a zero-argument
|
|
15
|
-
* `forRoot` cannot reach. A named zone is refused at boot on a Bun that ignores
|
|
16
|
-
* `Bun.cron`'s `tz` option, rather than running at the UTC hour and saying nothing.
|
|
10
|
+
* `keepAlive: false`: `Bun.cron` would hold the event loop open, and this app has
|
|
11
|
+
* a server doing that already while `bun run tour` has to exit. `tz` comes from
|
|
12
|
+
* the config, and a named zone is refused at boot on a Bun that ignores it.
|
|
17
13
|
*/
|
|
18
14
|
@Module({
|
|
19
15
|
imports: [
|
|
@@ -11,10 +11,9 @@ import { LocalStorage, PathTraversalError, Storage } from '@dunx/infra/files';
|
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* The key is a query parameter
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* seeing: try `?key=../../etc/passwd`.
|
|
14
|
+
* The key is a query parameter because keys contain slashes: `reports/q1.csv` is
|
|
15
|
+
* one key, not two segments. `Storage` itself rejects traversal - try
|
|
16
|
+
* `?key=../../etc/passwd`.
|
|
18
17
|
*/
|
|
19
18
|
const FileKey = z.object({ key: z.string().min(1).max(200) }).meta({
|
|
20
19
|
id: 'FileKey',
|
|
@@ -34,15 +33,12 @@ const listFiles = {
|
|
|
34
33
|
const objectKey = { query: FileKey } as const;
|
|
35
34
|
const writeFile = { query: FileKey, body: WriteFile } as const;
|
|
36
35
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
* `forRoot` call in storage.module.ts and nothing here changes.
|
|
40
|
-
*/
|
|
36
|
+
/** Injects `Storage`, never `LocalStorage`: swapping disk for bucket is one
|
|
37
|
+
* `forRoot` call and nothing here changes. */
|
|
41
38
|
@Controller('files')
|
|
42
39
|
export class FilesController {
|
|
43
40
|
constructor(private readonly storage: Storage) {}
|
|
44
41
|
|
|
45
|
-
/** `list` is an AsyncIterable so a million objects page rather than accumulate. */
|
|
46
42
|
@Get('/', listFiles)
|
|
47
43
|
async list(
|
|
48
44
|
input: Input<typeof listFiles>,
|
|
@@ -54,8 +50,7 @@ export class FilesController {
|
|
|
54
50
|
})) {
|
|
55
51
|
keys.push(entry.key);
|
|
56
52
|
}
|
|
57
|
-
// The contract cannot promise a root
|
|
58
|
-
// reach anything backend-specific.
|
|
53
|
+
// The contract cannot promise a root, so narrowing reaches it.
|
|
59
54
|
const root =
|
|
60
55
|
this.storage instanceof LocalStorage ? this.storage.root : '(remote)';
|
|
61
56
|
return { root, keys: keys.sort() };
|
|
@@ -97,10 +92,8 @@ export class FilesController {
|
|
|
97
92
|
return { deleted: true };
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
/**
|
|
101
|
-
*
|
|
102
|
-
* message instead of handing back a URL that cannot work.
|
|
103
|
-
*/
|
|
95
|
+
/** Nothing signs bytes on a local disk, so this refuses rather than hand back
|
|
96
|
+
* a URL that cannot work. */
|
|
104
97
|
@Get('/presign', objectKey)
|
|
105
98
|
async presign(input: Input<typeof objectKey>): Promise<{ url: string }> {
|
|
106
99
|
const { key } = input.query;
|
|
@@ -115,10 +108,8 @@ export class FilesController {
|
|
|
115
108
|
}
|
|
116
109
|
}
|
|
117
110
|
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
120
|
-
* before any syscall, and without this it would surface as a 500.
|
|
121
|
-
*/
|
|
111
|
+
/** A traversal is a bad request: `Storage` rejects it before any syscall, and
|
|
112
|
+
* without this it would surface as a 500. */
|
|
122
113
|
private async present(key: string): Promise<void> {
|
|
123
114
|
try {
|
|
124
115
|
if (await this.storage.exists(key)) return;
|
|
@@ -1,33 +1,26 @@
|
|
|
1
1
|
import { Controller, Get, SkipThrottle, Throttle } from '@dunx/http';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The module's default is generous, so the interesting cases are the two
|
|
7
|
-
* decorators: `@Throttle` replaces it for one handler, `@SkipThrottle` opts out.
|
|
8
|
-
* A limit on the class would cover every handler here, and a handler's own would
|
|
9
|
-
* still win - the same precedence `@Roles` has.
|
|
4
|
+
* `@Throttle` replaces the module default for one handler, `@SkipThrottle` opts
|
|
5
|
+
* out. A class-level limit covers every handler, and a handler's own still wins.
|
|
10
6
|
*/
|
|
11
7
|
@Controller('limits')
|
|
12
8
|
export class LimitsController {
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
* thrown rather than returned, so it comes out in the app's own error shape.
|
|
16
|
-
*/
|
|
9
|
+
/** Three per minute per subject; the fourth is a thrown 429 with
|
|
10
|
+
* `retry-after`, so it takes the app's own error shape. */
|
|
17
11
|
@Throttle({ limit: 3, windowSeconds: 60 })
|
|
18
12
|
@Get('/burst')
|
|
19
13
|
burst(): { allowed: true } {
|
|
20
14
|
return { allowed: true };
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
/** Exempt
|
|
17
|
+
/** Exempt: a probe or internal callback that must never be counted. */
|
|
24
18
|
@SkipThrottle()
|
|
25
19
|
@Get('/exempt')
|
|
26
20
|
exempt(): { counted: false } {
|
|
27
21
|
return { counted: false };
|
|
28
22
|
}
|
|
29
23
|
|
|
30
|
-
/** The module default, which is what every other route in this app gets. */
|
|
31
24
|
@Get('/default')
|
|
32
25
|
standard(): { ok: true } {
|
|
33
26
|
return { ok: true };
|
|
@@ -13,28 +13,12 @@ import { LimitsController } from './limits.controller.js';
|
|
|
13
13
|
import { ThrottleDemo } from './throttle.demo.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* A fixed-window rate limit over the whole app
|
|
16
|
+
* A fixed-window rate limit over the whole app, picking its counter at boot by
|
|
17
|
+
* asking whether the cache answers. A real deployment names one outright.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* example has to work with nothing installed, so it probes and says which it got.
|
|
22
|
-
*
|
|
23
|
-
* That probe is `Sessions.status()`, which `CacheModule` already exports rather than
|
|
24
|
-
* a second ping written here.
|
|
25
|
-
*
|
|
26
|
-
* Falling back matters more than it looks. The guard fails **open**, so with
|
|
27
|
-
* `RedisThrottleStore` and no Redis nothing is counted at all: no 429, no
|
|
28
|
-
* `ratelimit-*` headers, every request through. That is the right call for a
|
|
29
|
-
* production limiter, and it would make this example demonstrate nothing on a
|
|
30
|
-
* machine without Redis.
|
|
31
|
-
*
|
|
32
|
-
* `RedisThrottleStore` takes its client structurally, so `RedisConnection` satisfies
|
|
33
|
-
* it with no adapter.
|
|
34
|
-
*
|
|
35
|
-
* The prefix carries the pid, the same trick `Sessions` uses: two runs against one
|
|
36
|
-
* Redis would otherwise spend each other's budget, and a leftover window would make
|
|
37
|
-
* a suite fail on the previous run's counters.
|
|
19
|
+
* The probe matters because the guard fails open: with `RedisThrottleStore` and
|
|
20
|
+
* no Redis nothing is counted. The prefix carries the pid, so two runs against one
|
|
21
|
+
* Redis cannot spend each other's budget.
|
|
38
22
|
*/
|
|
39
23
|
@Module({
|
|
40
24
|
imports: [
|
|
@@ -62,11 +46,9 @@ import { ThrottleDemo } from './throttle.demo.js';
|
|
|
62
46
|
? new RedisThrottleStore(redis)
|
|
63
47
|
: new MemoryThrottleStore(),
|
|
64
48
|
/**
|
|
65
|
-
* Who is
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* from. Only a guard ahead of this one knows which, which is why this is
|
|
69
|
-
* an option rather than something the package reads for itself.
|
|
49
|
+
* Who is counted: an API key when presented, else the address. Only a
|
|
50
|
+
* guard ahead of this one knows, so it is an option rather than
|
|
51
|
+
* something the package reads for itself.
|
|
70
52
|
*/
|
|
71
53
|
subject: (req: Bun.BunRequest) =>
|
|
72
54
|
req.headers.get('x-api-key') ?? address.of(req),
|
|
@@ -6,11 +6,9 @@ import {
|
|
|
6
6
|
} from '@dunx/http/client';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Calling out
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* non-2xx as an error carrying the parsed body, and forward the inbound request id
|
|
13
|
-
* so one trace covers both services.
|
|
9
|
+
* Calling out over `fetch`. Three things a bare `fetch` does not do: retry a 503
|
|
10
|
+
* with backoff, raise a non-2xx as an error carrying the parsed body, and forward
|
|
11
|
+
* the inbound request id.
|
|
14
12
|
*/
|
|
15
13
|
export class UpstreamDemo {
|
|
16
14
|
constructor(
|
|
@@ -24,7 +22,6 @@ export class UpstreamDemo {
|
|
|
24
22
|
);
|
|
25
23
|
this.logger.info(`GET api/notes -> ${JSON.stringify(notes)}`);
|
|
26
24
|
|
|
27
|
-
// The 503s are retried; the attempt callback is what makes that visible.
|
|
28
25
|
const attempts: string[] = [];
|
|
29
26
|
const recovered = await this.http.get<{ after: number }>(
|
|
30
27
|
new URL('api/upstream/flaky', url),
|
|
@@ -42,12 +39,8 @@ export class UpstreamDemo {
|
|
|
42
39
|
`recovered after ${recovered.after}`,
|
|
43
40
|
);
|
|
44
41
|
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
* reason the class exists: an upstream 401 arriving as an `HttpError(401)`
|
|
48
|
-
* would make *this* service answer 401, telling its own caller "you are
|
|
49
|
-
* unauthorized" when what failed was this service authenticating upstream.
|
|
50
|
-
*/
|
|
42
|
+
/** A 404 is a `FetchError`, not an `HttpError`: an upstream 401 arriving as
|
|
43
|
+
* `HttpError(401)` would make this service answer 401 to its own caller. */
|
|
51
44
|
try {
|
|
52
45
|
await this.http.get(new URL('api/upstream/missing', url), {
|
|
53
46
|
retry: { maxRetries: 0 },
|
|
@@ -61,9 +54,7 @@ export class UpstreamDemo {
|
|
|
61
54
|
);
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
// Not retried:
|
|
65
|
-
// caller's signal fired or the timeout did. `/slow` sleeps 300 ms, so this
|
|
66
|
-
// is a deadline rather than a race with the loopback.
|
|
57
|
+
// Not retried: an abort means the caller's signal or the timeout fired.
|
|
67
58
|
try {
|
|
68
59
|
await this.http.get(new URL('api/upstream/slow', url), {
|
|
69
60
|
timeoutMs: 25,
|
|
@@ -5,15 +5,9 @@ import { FlakyController } from './flaky.controller.js';
|
|
|
5
5
|
import { UpstreamDemo } from './upstream.demo.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The outbound half of `@dunx/http`, from the `./client` subpath
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* its own and `@dunx/http` exports `HttpFactory` for the inbound direction. The
|
|
12
|
-
* subpath is what keeps the two unambiguous at an import site; the alias is what
|
|
13
|
-
* keeps them unambiguous here.
|
|
14
|
-
*
|
|
15
|
-
* No `baseUrl`: this app calls itself, and its own url is not known until
|
|
16
|
-
* `listen()` has run. A real upstream sets one and every call names a path.
|
|
8
|
+
* The outbound half of `@dunx/http`, from the `./client` subpath, aliased because
|
|
9
|
+
* this app has an `HttpModule` of its own. No `baseUrl`: this app calls itself,
|
|
10
|
+
* and its url is not known until `listen()` has run.
|
|
17
11
|
*/
|
|
18
12
|
@Module({
|
|
19
13
|
imports: [
|
|
@@ -24,12 +18,9 @@ import { UpstreamDemo } from './upstream.demo.js';
|
|
|
24
18
|
retry: {
|
|
25
19
|
maxRetries: 3,
|
|
26
20
|
retryDelayMs: 20,
|
|
27
|
-
// Jitter comes from `crypto.getRandomValues`, not `Math.random`.
|
|
28
21
|
backoff: { jitterMs: 10, maxMs: 200 },
|
|
29
22
|
},
|
|
30
|
-
// The inbound request id, forwarded
|
|
31
|
-
// both services. Read from `RequestContext`, so it only carries when there
|
|
32
|
-
// is a request in scope.
|
|
23
|
+
// The inbound request id, forwarded so one trace spans both services.
|
|
33
24
|
propagateRequestId: true,
|
|
34
25
|
}),
|
|
35
26
|
inject: [AppConfigService] as const,
|
|
@@ -3,21 +3,13 @@ import { eq, like, sql } from 'drizzle-orm';
|
|
|
3
3
|
import * as schema from '../database/schema.js';
|
|
4
4
|
import { users, type User } from '../database/schema.js';
|
|
5
5
|
|
|
6
|
-
// The row type comes from the table, so the controller and the service import one
|
|
7
|
-
// definition rather than a hand-written copy of the columns.
|
|
8
6
|
export type { User };
|
|
9
7
|
|
|
10
8
|
export class UsersRepository {
|
|
11
9
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* token - and ignores the type argument, so the schema types survive injection.
|
|
16
|
-
*
|
|
17
|
-
* Every method below is `async` although bun-sqlite executes synchronously: the
|
|
18
|
-
* HTTP layer awaits them, and moving this table to the pooled backend then costs
|
|
19
|
-
* no signature change. `Ledger.transferSync` is what refusing that trade looks
|
|
20
|
-
* like.
|
|
10
|
+
* Every method is `async` although bun-sqlite executes synchronously, so moving
|
|
11
|
+
* this table to the pooled backend costs no signature change.
|
|
12
|
+
* `Ledger.transferSync` is what refusing that trade looks like.
|
|
21
13
|
*/
|
|
22
14
|
constructor(private readonly db: SyncDatabase<typeof schema>) {}
|
|
23
15
|
|
|
@@ -28,7 +20,7 @@ export class UsersRepository {
|
|
|
28
20
|
)`);
|
|
29
21
|
}
|
|
30
22
|
|
|
31
|
-
/**
|
|
23
|
+
/** `name` is UNIQUE, so a repeat boot is a no-op. */
|
|
32
24
|
async seed(names: readonly string[]): Promise<void> {
|
|
33
25
|
this.db
|
|
34
26
|
.insert(users)
|
|
@@ -52,7 +44,6 @@ export class UsersRepository {
|
|
|
52
44
|
return this.db.select().from(users).where(eq(users.id, id)).get() ?? null;
|
|
53
45
|
}
|
|
54
46
|
|
|
55
|
-
/** `.returning()`, so the id is the one the database wrote. */
|
|
56
47
|
async create(name: string): Promise<User> {
|
|
57
48
|
return this.db.insert(users).values({ name }).returning().get();
|
|
58
49
|
}
|
|
@@ -2,28 +2,13 @@ import type { RouteSchemas } from '@dunx/http';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `@dunx/http` validates against - so nothing adapts anything, and the framework
|
|
8
|
-
* still depends on no validator.
|
|
5
|
+
* Plain zod in a route's options: `z.object()` already carries `~standard`, the
|
|
6
|
+
* whole contract `@dunx/http` validates against.
|
|
9
7
|
*
|
|
10
|
-
* `.meta({ id })` names the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* **Prose goes in `description`, not `title`.** In JSON Schema `title` is a short
|
|
15
|
-
* display name, and Swagger UI labels a schema by it. A sentence there makes the
|
|
16
|
-
* whole Schemas list read as prose: `User` shows up as "A stored user" and is
|
|
17
|
-
* impossible to find.
|
|
18
|
-
*
|
|
19
|
-
* Leaving `title` out is right, and not because it is unused: `@dunx/openapi` fills
|
|
20
|
-
* it in with the component name when a schema is hoisted, which is what makes the
|
|
21
|
-
* item of `array<User>` read as `User` rather than as `object`.
|
|
22
|
-
*
|
|
23
|
-
* One more ordering trap: `.strict()` **after** `.meta()` discards the metadata, so
|
|
24
|
-
* the schema is inlined despite declaring an id. Put `.meta()` last.
|
|
25
|
-
*
|
|
26
|
-
* See `UsersDemo` for the generated document.
|
|
8
|
+
* Three traps. `.meta({ id })` names the `$defs` entry, and without it the schema
|
|
9
|
+
* is inlined at every use site. Prose goes in `description` - Swagger UI labels a
|
|
10
|
+
* schema by `title`, and `@dunx/openapi` fills that with the component name.
|
|
11
|
+
* `.strict()` after `.meta()` discards the metadata, so put `.meta()` last.
|
|
27
12
|
*/
|
|
28
13
|
export const Tag = z
|
|
29
14
|
.object({ label: z.string().min(1) })
|
|
@@ -49,15 +34,9 @@ export const ListUsers = z
|
|
|
49
34
|
.meta({ id: 'ListUsers', description: 'Filter and page the user list' });
|
|
50
35
|
|
|
51
36
|
/**
|
|
52
|
-
* The response side
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* declaring this cannot answer with anything else and still compile.
|
|
56
|
-
*
|
|
57
|
-
* That check is what caught this schema declaring a `tags: string[]` the `users`
|
|
58
|
-
* table has no column for. Every user response advertised an array no handler
|
|
59
|
-
* returned. `tags` stays on {@link CreateUser}, the request side, where it is
|
|
60
|
-
* read - a documented response is a view of the row, and this one is the row.
|
|
37
|
+
* The response side, never validated at runtime: the verb decorator holds the
|
|
38
|
+
* handler's return type to it at compile time instead. That check caught this
|
|
39
|
+
* schema advertising a `tags: string[]` the `users` table has no column for.
|
|
61
40
|
*/
|
|
62
41
|
export const User = z
|
|
63
42
|
.object({
|
|
@@ -70,8 +49,8 @@ export const NotFound = z
|
|
|
70
49
|
.object({ error: z.string(), status: z.literal(404) })
|
|
71
50
|
.meta({ id: 'NotFound', description: 'Nothing at that id' });
|
|
72
51
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
52
|
+
// A declared schema is what makes the matching `input` field exist and be
|
|
53
|
+
// parsed. `satisfies` keeps the literal types `Input<>` reads.
|
|
75
54
|
export const listUsers = {
|
|
76
55
|
query: ListUsers,
|
|
77
56
|
response: { 200: z.array(User) },
|
|
@@ -80,7 +59,6 @@ export const oneUser = {
|
|
|
80
59
|
params: UserIndex,
|
|
81
60
|
response: { 200: User, 404: NotFound },
|
|
82
61
|
} as const satisfies RouteSchemas;
|
|
83
|
-
// No status: POST defaults to 201, every other verb to 200.
|
|
84
62
|
export const createUser = {
|
|
85
63
|
body: CreateUser,
|
|
86
64
|
response: { 201: User },
|
|
@@ -2,11 +2,6 @@ import { Module } from '@dunx/core';
|
|
|
2
2
|
import { GreetingsController } from './greetings.controller.js';
|
|
3
3
|
import { GreetingsService } from './greetings.service.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The root module. `controllers` are discovered for routes, `providers` are
|
|
7
|
-
* everything else. Import order is construction order, and shutdown runs in
|
|
8
|
-
* reverse - which matters once there is a database to close.
|
|
9
|
-
*/
|
|
10
5
|
@Module({
|
|
11
6
|
controllers: [GreetingsController],
|
|
12
7
|
providers: [GreetingsService],
|
|
@@ -2,11 +2,6 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { createTestServer } from '@dunx/testing';
|
|
3
3
|
import { AppModule } from './app.module.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The whole app behind a real `Bun.serve` on port 0. This is also what CI runs to
|
|
7
|
-
* prove the example still boots - see `examples/testing` for overrides and the
|
|
8
|
-
* rest of `@dunx/testing`.
|
|
9
|
-
*/
|
|
10
5
|
describe('minimal', () => {
|
|
11
6
|
test('serves the greeting', async () => {
|
|
12
7
|
const server = await createTestServer({ modules: [AppModule] });
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { Controller, Get, type Input, type RouteSchemas } from '@dunx/http';
|
|
2
2
|
import { GreetingsService } from './greetings.service.js';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* A controller is a provider with routes on it. `GreetingsService` in the
|
|
6
|
-
* constructor is resolved the same way the service's own `Logger` was.
|
|
7
|
-
*
|
|
8
|
-
* Returning a plain object is enough - `@dunx/http` serialises it. There is no
|
|
9
|
-
* `Response.json()` to remember and no `res` to forget to send.
|
|
10
|
-
*/
|
|
11
4
|
@Controller('greetings')
|
|
12
5
|
export class GreetingsController {
|
|
13
6
|
constructor(private readonly greetings: GreetingsService) {}
|
|
@@ -17,11 +10,8 @@ export class GreetingsController {
|
|
|
17
10
|
return { routes: ['GET /greetings', 'GET /greetings/:name'] };
|
|
18
11
|
}
|
|
19
12
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* string. Declaring a `params` schema is what makes it typed and coerced -
|
|
23
|
-
* `examples/full` does that; this one is showing the shape, not validation.
|
|
24
|
-
*/
|
|
13
|
+
// With no `params` schema declared, a path param stays a string.
|
|
14
|
+
// `examples/full` shows the typed, coerced version.
|
|
25
15
|
@Get('/:name')
|
|
26
16
|
one(input: Input<RouteSchemas>): { greeting: string; served: number } {
|
|
27
17
|
return this.greetings.greet(input.req.params['name'] ?? 'world');
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { Logger, type OnInit } from '@dunx/core';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
* module's `providers` is what makes it injectable.
|
|
6
|
-
*
|
|
7
|
-
* `Logger` in the constructor is the whole dependency injection story: the
|
|
8
|
-
* container reads the parameter's type and resolves it. Nothing bound `Logger`
|
|
9
|
-
* here, so it gets core's default `ConsoleLogger`, which writes one JSON line
|
|
10
|
-
* per entry and needs no dependency.
|
|
11
|
-
*/
|
|
3
|
+
/** A provider is a plain class. Listing it in a module's `providers` is what
|
|
4
|
+
* makes it injectable; the container reads `Logger` off the constructor. */
|
|
12
5
|
export class GreetingsService implements OnInit {
|
|
13
6
|
#greeted = 0;
|
|
14
7
|
|
|
15
8
|
constructor(private readonly logger: Logger) {}
|
|
16
9
|
|
|
17
|
-
/** Runs after the whole graph is constructed, in dependency order. */
|
|
18
10
|
onInit(): void {
|
|
19
11
|
this.logger.info('greetings ready');
|
|
20
12
|
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { HttpFactory } from '@dunx/http';
|
|
2
2
|
import { AppModule } from './app.module.js';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* `create()` builds the container and discovers routes; `listen()` builds the
|
|
6
|
-
* `Bun.serve` route table and returns the URL. `enableShutdownHooks()` makes
|
|
7
|
-
* `ctrl-c` drain the graph in reverse construction order.
|
|
8
|
-
*/
|
|
9
4
|
const app = await HttpFactory.create(AppModule);
|
|
10
5
|
app.enableShutdownHooks();
|
|
11
6
|
|