@dunx/create-app 0.4.0 → 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,108 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import {
|
|
3
|
+
EncodableFormat,
|
|
4
|
+
type EncodedImage,
|
|
5
|
+
ImageFit,
|
|
6
|
+
Images,
|
|
7
|
+
ImagesOptions,
|
|
8
|
+
} from '@dunx/infra/images';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A 4x4 RGB gradient PNG - the only binary in the example, and small enough to
|
|
12
|
+
* read as a constant. Everything larger is derived from it at runtime by
|
|
13
|
+
* `Bun.Image` itself, so nothing is checked in and nothing is downloaded.
|
|
14
|
+
*/
|
|
15
|
+
const SEED_PNG_BASE64 =
|
|
16
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAKElEQVR42g3HMQEAAAzC' +
|
|
17
|
+
'MEzip3pqki1fktBgWEhKi2X9SEWZn9Hh2DgEahfxpRmu7gAAAABJRU5ErkJggg==';
|
|
18
|
+
|
|
19
|
+
export interface RenderOptions {
|
|
20
|
+
readonly width: number;
|
|
21
|
+
readonly height?: number | undefined;
|
|
22
|
+
readonly fit: ImageFit;
|
|
23
|
+
readonly format: EncodableFormat;
|
|
24
|
+
readonly quality?: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Thumbnails {
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly images: Images,
|
|
30
|
+
private readonly config: ImagesOptions,
|
|
31
|
+
private readonly logger: Logger,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
/** The 64x48 source every route below derives from, grown from the 4x4 seed. */
|
|
35
|
+
private async source(): Promise<Uint8Array> {
|
|
36
|
+
const seed = new Uint8Array(Buffer.from(SEED_PNG_BASE64, 'base64'));
|
|
37
|
+
const seeded = await this.images.load(seed);
|
|
38
|
+
return seeded
|
|
39
|
+
.resize(64, 48, { fit: ImageFit.FILL })
|
|
40
|
+
.to(EncodableFormat.PNG)
|
|
41
|
+
.toBytes();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async render(options: RenderOptions): Promise<EncodedImage> {
|
|
45
|
+
const pipeline = await this.images.load(await this.source());
|
|
46
|
+
const resized = pipeline.resize(options.width, options.height, {
|
|
47
|
+
fit: options.fit,
|
|
48
|
+
});
|
|
49
|
+
// A quality only means something to a lossy encoder, so it is only passed
|
|
50
|
+
// when one was asked for.
|
|
51
|
+
return options.quality === undefined
|
|
52
|
+
? resized.to(options.format).encode()
|
|
53
|
+
: resized.to(options.format, { quality: options.quality }).encode();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async describe(
|
|
57
|
+
base64: string,
|
|
58
|
+
): Promise<{ width: number; height: number; format: string }> {
|
|
59
|
+
const bytes = new Uint8Array(Buffer.from(base64, 'base64'));
|
|
60
|
+
const meta = await this.images.metadata(bytes);
|
|
61
|
+
return { width: meta.width, height: meta.height, format: meta.format };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async demonstrate(): Promise<void> {
|
|
65
|
+
const { images, logger } = this;
|
|
66
|
+
const seed = new Uint8Array(Buffer.from(SEED_PNG_BASE64, 'base64'));
|
|
67
|
+
|
|
68
|
+
const seeded = await images.load(seed);
|
|
69
|
+
const source = await seeded
|
|
70
|
+
.resize(64, 48, { fit: ImageFit.FILL })
|
|
71
|
+
.to(EncodableFormat.PNG)
|
|
72
|
+
.toBytes();
|
|
73
|
+
logger.info(
|
|
74
|
+
`quality=${this.config.quality}, generated a 64x48 source from the 4x4 seed ` +
|
|
75
|
+
`at runtime: ${source.byteLength} bytes, detected ${images.detect(source)}`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Content-based: the container comes from magic bytes, never a filename. And
|
|
79
|
+
// this is a header read, not a decode - a truncated file would still answer.
|
|
80
|
+
const meta = await images.metadata(source);
|
|
81
|
+
logger.info(`metadata -> ${meta.width}x${meta.height} ${meta.format}`);
|
|
82
|
+
|
|
83
|
+
const pipeline = await images.load(source);
|
|
84
|
+
const thumb = await pipeline
|
|
85
|
+
.resize(16, 16, { fit: ImageFit.INSIDE })
|
|
86
|
+
.encode();
|
|
87
|
+
logger.info(
|
|
88
|
+
`resize 16x16 inside -> ${thumb.width}x${thumb.height} ${thumb.format}, ` +
|
|
89
|
+
`${thumb.bytes.byteLength} bytes`,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const webp = await pipeline
|
|
93
|
+
.resize(32)
|
|
94
|
+
.to(EncodableFormat.WEBP, { quality: 70 })
|
|
95
|
+
.encode();
|
|
96
|
+
logger.info(
|
|
97
|
+
`convert 32px wide -> ${webp.width}x${webp.height} ${webp.mimeType}, ` +
|
|
98
|
+
`${webp.bytes.byteLength} bytes`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// `Bun.Image` mutates and returns `this`; an ImagePipeline returns a new
|
|
102
|
+
// value from every operation, so the two resizes above did not collide.
|
|
103
|
+
const again = await pipeline.sourceMetadata();
|
|
104
|
+
logger.info(
|
|
105
|
+
`the pipeline is immutable: the source is still ${again.width}x${again.height} ${again.format}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller,
|
|
3
|
+
Delete,
|
|
4
|
+
Get,
|
|
5
|
+
HttpError,
|
|
6
|
+
HttpStatusCode,
|
|
7
|
+
Put,
|
|
8
|
+
type Input,
|
|
9
|
+
} from '@dunx/http';
|
|
10
|
+
import { LocalStorage, PathTraversalError, Storage } from '@dunx/infra/files';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The key is a query parameter rather than a path segment because keys contain
|
|
15
|
+
* slashes - `reports/q1.csv` is one key, not two segments. Traversal is rejected
|
|
16
|
+
* by `Storage` itself rather than by a pattern here, which is the behaviour worth
|
|
17
|
+
* seeing: try `?key=../../etc/passwd`.
|
|
18
|
+
*/
|
|
19
|
+
const FileKey = z
|
|
20
|
+
.object({ key: z.string().min(1).max(200) })
|
|
21
|
+
.meta({ id: 'FileKey', title: 'An object key inside the storage root' });
|
|
22
|
+
|
|
23
|
+
const WriteFile = z
|
|
24
|
+
.object({ content: z.string().max(64 * 1024) })
|
|
25
|
+
.meta({ id: 'WriteFile', title: 'Text to store under the key' });
|
|
26
|
+
|
|
27
|
+
const listFiles = {
|
|
28
|
+
query: z.object({
|
|
29
|
+
prefix: z.string().default(''),
|
|
30
|
+
glob: z.string().default('**/*'),
|
|
31
|
+
}),
|
|
32
|
+
} as const;
|
|
33
|
+
const objectKey = { query: FileKey } as const;
|
|
34
|
+
const writeFile = { query: FileKey, body: WriteFile } as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Injects `Storage`, never `LocalStorage` - swapping a disk for a bucket is one
|
|
38
|
+
* `forRoot` call in storage.module.ts and nothing here changes.
|
|
39
|
+
*/
|
|
40
|
+
@Controller('files')
|
|
41
|
+
export class FilesController {
|
|
42
|
+
constructor(private readonly storage: Storage) {}
|
|
43
|
+
|
|
44
|
+
/** `list` is an AsyncIterable so a million objects page rather than accumulate. */
|
|
45
|
+
@Get('/', listFiles)
|
|
46
|
+
async list(
|
|
47
|
+
input: Input<typeof listFiles>,
|
|
48
|
+
): Promise<{ root: string; keys: readonly string[] }> {
|
|
49
|
+
const keys: string[] = [];
|
|
50
|
+
for await (const entry of this.storage.list({
|
|
51
|
+
prefix: input.query.prefix,
|
|
52
|
+
glob: input.query.glob,
|
|
53
|
+
})) {
|
|
54
|
+
keys.push(entry.key);
|
|
55
|
+
}
|
|
56
|
+
// The contract cannot promise a root - narrowing to the backend is how you
|
|
57
|
+
// reach anything backend-specific.
|
|
58
|
+
const root =
|
|
59
|
+
this.storage instanceof LocalStorage ? this.storage.root : '(remote)';
|
|
60
|
+
return { root, keys: keys.sort() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Get('/object', objectKey)
|
|
64
|
+
async read(
|
|
65
|
+
input: Input<typeof objectKey>,
|
|
66
|
+
): Promise<{ key: string; size: number; type: string; content: string }> {
|
|
67
|
+
const { key } = input.query;
|
|
68
|
+
await this.present(key);
|
|
69
|
+
const stat = await this.storage.stat(key);
|
|
70
|
+
return {
|
|
71
|
+
key,
|
|
72
|
+
size: stat.size,
|
|
73
|
+
type: stat.type,
|
|
74
|
+
content: await this.storage.read(key),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Put('/object', writeFile)
|
|
79
|
+
async write(
|
|
80
|
+
input: Input<typeof writeFile>,
|
|
81
|
+
): Promise<{ key: string; bytes: number }> {
|
|
82
|
+
const { key } = input.query;
|
|
83
|
+
try {
|
|
84
|
+
return { key, bytes: await this.storage.write(key, input.body.content) };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!(error instanceof PathTraversalError)) throw error;
|
|
87
|
+
throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Delete('/object', objectKey)
|
|
92
|
+
async remove(input: Input<typeof objectKey>): Promise<{ deleted: boolean }> {
|
|
93
|
+
const { key } = input.query;
|
|
94
|
+
await this.present(key);
|
|
95
|
+
await this.storage.delete(key);
|
|
96
|
+
return { deleted: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Nothing signs bytes on a local disk, so this refuses with the backend's own
|
|
101
|
+
* message instead of handing back a URL that cannot work.
|
|
102
|
+
*/
|
|
103
|
+
@Get('/presign', objectKey)
|
|
104
|
+
async presign(input: Input<typeof objectKey>): Promise<{ url: string }> {
|
|
105
|
+
const { key } = input.query;
|
|
106
|
+
await this.present(key);
|
|
107
|
+
try {
|
|
108
|
+
return { url: this.storage.presign(key) };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new HttpError(
|
|
111
|
+
HttpStatusCode.NOT_IMPLEMENTED,
|
|
112
|
+
(error as Error).message,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A traversal is a bad request, not a server fault - `Storage` rejects it
|
|
119
|
+
* before any syscall, and without this it would surface as a 500.
|
|
120
|
+
*/
|
|
121
|
+
private async present(key: string): Promise<void> {
|
|
122
|
+
try {
|
|
123
|
+
if (await this.storage.exists(key)) return;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (!(error instanceof PathTraversalError)) throw error;
|
|
126
|
+
throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message);
|
|
127
|
+
}
|
|
128
|
+
throw new HttpError(HttpStatusCode.NOT_FOUND, `No object "${key}"`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Module } from '@dunx/core';
|
|
2
|
+
import { FilesModule, LocalStorageOptions } from '@dunx/infra/files';
|
|
3
|
+
import { FilesController } from './files.controller.js';
|
|
4
|
+
import { Uploads } from './uploads.service.js';
|
|
5
|
+
import { Workspace } from './workspace.js';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [
|
|
9
|
+
// The root has to exist before `LocalStorageOptions` names it, and creating
|
|
10
|
+
// it is async - which is the whole reason `forRootAsync` takes a factory that
|
|
11
|
+
// may await and may inject.
|
|
12
|
+
FilesModule.forRootAsync({
|
|
13
|
+
useFactory: async (workspace: Workspace) =>
|
|
14
|
+
new LocalStorageOptions(await workspace.create()),
|
|
15
|
+
inject: [Workspace],
|
|
16
|
+
}),
|
|
17
|
+
],
|
|
18
|
+
controllers: [FilesController],
|
|
19
|
+
providers: [Workspace, Uploads],
|
|
20
|
+
})
|
|
21
|
+
export class StorageModule {}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import { LocalStorage, Storage } from '@dunx/infra/files';
|
|
3
|
+
|
|
4
|
+
const REPORT = 'reports/q1.csv';
|
|
5
|
+
const ARCHIVE = 'reports/q2.csv';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Injects `Storage`, never `LocalStorage` - swapping a disk for a bucket is then
|
|
9
|
+
* one `forRoot` call in storage.module.ts and nothing here changes.
|
|
10
|
+
*/
|
|
11
|
+
export class Uploads {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly storage: Storage,
|
|
14
|
+
private readonly logger: Logger,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async demonstrate(): Promise<void> {
|
|
18
|
+
const { storage, logger } = this;
|
|
19
|
+
|
|
20
|
+
// The contract cannot promise a root - narrowing to the backend is how you
|
|
21
|
+
// reach anything backend-specific.
|
|
22
|
+
if (storage instanceof LocalStorage) {
|
|
23
|
+
logger.info(`root ${storage.root} (an OS temp dir, removed on shutdown)`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const bytes = await storage.write(REPORT, 'quarter,amount\nQ1,100\n');
|
|
27
|
+
logger.info(`write ${REPORT} -> ${bytes} bytes`);
|
|
28
|
+
await storage.write(ARCHIVE, 'quarter,amount\nQ2,140\n');
|
|
29
|
+
|
|
30
|
+
logger.info(
|
|
31
|
+
`read ${REPORT} -> ${JSON.stringify(await storage.read(REPORT))}`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const stat = await storage.stat(REPORT);
|
|
35
|
+
logger.info(`stat ${REPORT} -> ${stat.size} bytes, ${stat.type}`);
|
|
36
|
+
|
|
37
|
+
// Bun.Glob, as an AsyncIterable so a million objects page rather than
|
|
38
|
+
// accumulate. Order is the filesystem's, hence the sort.
|
|
39
|
+
const keys: string[] = [];
|
|
40
|
+
for await (const entry of storage.list({
|
|
41
|
+
prefix: 'reports',
|
|
42
|
+
glob: '*.csv',
|
|
43
|
+
})) {
|
|
44
|
+
keys.push(entry.key);
|
|
45
|
+
}
|
|
46
|
+
logger.info(`glob reports/*.csv -> ${JSON.stringify(keys.sort())}`);
|
|
47
|
+
|
|
48
|
+
await storage.delete(ARCHIVE);
|
|
49
|
+
logger.info(`delete ${ARCHIVE} -> exists=${await storage.exists(ARCHIVE)}`);
|
|
50
|
+
|
|
51
|
+
// Rejected before any syscall, not sanitised into something that "works".
|
|
52
|
+
try {
|
|
53
|
+
await storage.read('../../etc/passwd');
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.info(`traversal rejected: ${(error as Error).message}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Nothing signs bytes on a local disk, so this refuses instead of handing
|
|
59
|
+
// back a URL that cannot work.
|
|
60
|
+
try {
|
|
61
|
+
storage.presign(REPORT);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logger.info(`presign refused: ${(error as Error).message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { OnShutdown } from '@dunx/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Owns a scratch directory under the OS temp dir - never inside the repo - and
|
|
9
|
+
* removes it on shutdown, so two consecutive `bun start` runs cannot see each
|
|
10
|
+
* other's bytes.
|
|
11
|
+
*
|
|
12
|
+
* The storage factory injects this, so it is constructed *before* `Storage`, and
|
|
13
|
+
* reverse-order shutdown therefore removes the directory after everything that
|
|
14
|
+
* writes into it has drained.
|
|
15
|
+
*/
|
|
16
|
+
export class Workspace implements OnShutdown {
|
|
17
|
+
#root: string | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly logger: Logger) {}
|
|
20
|
+
|
|
21
|
+
/** `mkdtemp`, so a concurrent run gets its own directory rather than sharing. */
|
|
22
|
+
async create(): Promise<string> {
|
|
23
|
+
this.#root ??= await mkdtemp(join(tmpdir(), 'dunx-full-'));
|
|
24
|
+
return this.#root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async onShutdown(): Promise<void> {
|
|
28
|
+
if (this.#root === undefined) return;
|
|
29
|
+
await rm(this.#root, { recursive: true, force: true });
|
|
30
|
+
this.logger.info(`workspace removed: ${this.#root}`);
|
|
31
|
+
this.#root = undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller,
|
|
3
|
+
Get,
|
|
4
|
+
HttpError,
|
|
5
|
+
HttpStatusCode,
|
|
6
|
+
Post,
|
|
7
|
+
type Input,
|
|
8
|
+
} from '@dunx/http';
|
|
9
|
+
import type { User } from './users.repository.js';
|
|
10
|
+
import { createUser, listUsers, oneUser } from './users.schemas.js';
|
|
11
|
+
import { UsersService } from './users.service.js';
|
|
12
|
+
|
|
13
|
+
@Controller('users')
|
|
14
|
+
export class UsersController {
|
|
15
|
+
constructor(private readonly users: UsersService) {}
|
|
16
|
+
|
|
17
|
+
// `Input<typeof listUsers>` has to be written out - a standard method decorator
|
|
18
|
+
// can check a parameter's type but cannot contextually type an unannotated one.
|
|
19
|
+
// Every field type still comes from the schema, so nothing is declared twice.
|
|
20
|
+
@Get('/', listUsers)
|
|
21
|
+
list(input: Input<typeof listUsers>): Promise<readonly User[]> {
|
|
22
|
+
return this.users.findAll(input.query.limit, input.query.q);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Get('/:id', oneUser)
|
|
26
|
+
async one(input: Input<typeof oneUser>): Promise<User> {
|
|
27
|
+
// Already a number: the params schema coerced it before this ran.
|
|
28
|
+
const user = await this.users.find(input.params.id);
|
|
29
|
+
if (user === null) {
|
|
30
|
+
throw new HttpError(
|
|
31
|
+
HttpStatusCode.NOT_FOUND,
|
|
32
|
+
`No user ${input.params.id}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return user;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// No req.json(), no Response.json(), no status - the body arrives validated and
|
|
39
|
+
// typed, and 201 is the POST default.
|
|
40
|
+
@Post('/', createUser)
|
|
41
|
+
create(input: Input<typeof createUser>): Promise<User> {
|
|
42
|
+
return this.users.create(
|
|
43
|
+
input.body.name,
|
|
44
|
+
input.body.tags.map((tag) => tag.label),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { CreateUser } from './users.schemas.js';
|
|
4
|
+
import { UsersService } from './users.service.js';
|
|
5
|
+
|
|
6
|
+
const post = (url: string, body: unknown): Promise<Response> =>
|
|
7
|
+
fetch(new URL('api/users', url), {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: { 'content-type': 'application/json' },
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const show = async (response: Response): Promise<string> =>
|
|
14
|
+
`${response.status} ${JSON.stringify(await response.json())}`;
|
|
15
|
+
|
|
16
|
+
export class UsersDemo {
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly users: UsersService,
|
|
19
|
+
private readonly logger: Logger,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
async demonstrate(url: string): Promise<void> {
|
|
23
|
+
const { logger } = this;
|
|
24
|
+
logger.info(await this.users.summary());
|
|
25
|
+
|
|
26
|
+
const listed = await fetch(new URL('api/users', url));
|
|
27
|
+
logger.info(`GET /api/users -> ${await show(listed)}`);
|
|
28
|
+
|
|
29
|
+
const paged = await fetch(new URL('api/users?limit=1&q=ad', url));
|
|
30
|
+
logger.info(
|
|
31
|
+
`GET /api/users?limit=1&q=ad -> ${await show(paged)} (query coerced by zod)`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const one = await fetch(new URL('api/users/1', url));
|
|
35
|
+
logger.info(
|
|
36
|
+
`GET /api/users/1 -> ${await show(one)} (params.id coerced to a number)`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const created = await post(url, {
|
|
40
|
+
name: 'linus',
|
|
41
|
+
tags: [{ label: 'kernel' }],
|
|
42
|
+
});
|
|
43
|
+
logger.info(`POST /api/users -> ${await show(created)}`);
|
|
44
|
+
|
|
45
|
+
// zod rejects, @dunx/http turns the issues into a 400 body.
|
|
46
|
+
const rejected = await post(url, { name: 42 });
|
|
47
|
+
logger.info(`POST /api/users {"name":42} -> ${await show(rejected)}`);
|
|
48
|
+
|
|
49
|
+
// A nested issue's path is flattened to dots - the same rendering OpenAPI
|
|
50
|
+
// clients expect.
|
|
51
|
+
const nested = await post(url, { name: 'ada', tags: [{ label: '' }] });
|
|
52
|
+
logger.info(
|
|
53
|
+
`POST /api/users {"tags":[{"label":""}]} -> ${await show(nested)}`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// The zod-specific half, and the path OpenAPI generation will take: `id` from
|
|
57
|
+
// .meta() names the $defs entry, `title` lands inline.
|
|
58
|
+
logger.info(
|
|
59
|
+
`z.toJSONSchema(CreateUser) -> ${JSON.stringify(z.toJSONSchema(CreateUser))}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from '@dunx/core';
|
|
2
|
+
import { UsersController } from './users.controller.js';
|
|
3
|
+
import { UsersDemo } from './users.demo.js';
|
|
4
|
+
import { UsersRepository } from './users.repository.js';
|
|
5
|
+
import { UsersService } from './users.service.js';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
controllers: [UsersController],
|
|
9
|
+
providers: [UsersService, UsersRepository, UsersDemo],
|
|
10
|
+
})
|
|
11
|
+
export class UsersModule {}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { SyncDatabase } from '@dunx/infra/db';
|
|
2
|
+
import { eq, like, sql } from 'drizzle-orm';
|
|
3
|
+
import * as schema from '../database/schema.js';
|
|
4
|
+
import { users, type User } from '../database/schema.js';
|
|
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
|
+
export type { User };
|
|
9
|
+
|
|
10
|
+
export class UsersRepository {
|
|
11
|
+
/**
|
|
12
|
+
* `SyncDatabase` because `DatabaseModule` configured synchronous mode; it is
|
|
13
|
+
* drizzle's `BunSQLiteDatabase` with a name the container can tell apart.
|
|
14
|
+
* `@dunx/transform` records the bare type name - a real runtime class, so a usable
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
constructor(private readonly db: SyncDatabase<typeof schema>) {}
|
|
23
|
+
|
|
24
|
+
async migrate(): Promise<void> {
|
|
25
|
+
this.db.run(sql`CREATE TABLE IF NOT EXISTS users (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
name TEXT NOT NULL UNIQUE
|
|
28
|
+
)`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One statement for every name; `name` is UNIQUE, so a repeat boot is a no-op. */
|
|
32
|
+
async seed(names: readonly string[]): Promise<void> {
|
|
33
|
+
this.db
|
|
34
|
+
.insert(users)
|
|
35
|
+
.values(names.map((name) => ({ name })))
|
|
36
|
+
.onConflictDoNothing()
|
|
37
|
+
.run();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async findAll(limit: number, q?: string): Promise<readonly User[]> {
|
|
41
|
+
return this.db
|
|
42
|
+
.select()
|
|
43
|
+
.from(users)
|
|
44
|
+
.where(like(users.name, q === undefined ? '%' : `%${q}%`))
|
|
45
|
+
.orderBy(users.id)
|
|
46
|
+
.limit(limit)
|
|
47
|
+
.all();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `.get()` is `undefined` for no row; the controller's 404 turns on `null`. */
|
|
51
|
+
async find(id: number): Promise<User | null> {
|
|
52
|
+
return this.db.select().from(users).where(eq(users.id, id)).get() ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `.returning()`, so the id is the one the database wrote. */
|
|
56
|
+
async create(name: string): Promise<User> {
|
|
57
|
+
return this.db.insert(users).values({ name }).returning().get();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { RouteSchemas } from '@dunx/http';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Real zod, dropped straight into a route's options: `z.object()` already carries
|
|
6
|
+
* `~standard` (vendor `zod`, version 1), which is the entire contract
|
|
7
|
+
* `@dunx/http` validates against - so nothing adapts anything, and the framework
|
|
8
|
+
* still depends on no validator.
|
|
9
|
+
*
|
|
10
|
+
* `.meta({ id })` names the definition zod emits under `$defs`, which is the slot
|
|
11
|
+
* OpenAPI calls `components/schemas`. `.meta({ title })` lands inline. See
|
|
12
|
+
* `UsersDemo` for the generated document.
|
|
13
|
+
*/
|
|
14
|
+
export const Tag = z
|
|
15
|
+
.object({ label: z.string().min(1) })
|
|
16
|
+
.meta({ id: 'Tag', title: 'A label attached to a user' });
|
|
17
|
+
|
|
18
|
+
export const CreateUser = z
|
|
19
|
+
.object({
|
|
20
|
+
name: z.string().min(1).max(40),
|
|
21
|
+
tags: z.array(Tag).default([]),
|
|
22
|
+
})
|
|
23
|
+
.meta({ id: 'CreateUser', title: 'Create a user' });
|
|
24
|
+
|
|
25
|
+
/** Path params arrive as strings; `z.coerce` is where `:id` becomes a number. */
|
|
26
|
+
export const UserIndex = z
|
|
27
|
+
.object({ id: z.coerce.number().int().min(1) })
|
|
28
|
+
.meta({ id: 'UserIndex', title: 'A user id in the path' });
|
|
29
|
+
|
|
30
|
+
export const ListUsers = z
|
|
31
|
+
.object({
|
|
32
|
+
q: z.string().min(1).optional(),
|
|
33
|
+
limit: z.coerce.number().int().min(1).max(50).default(10),
|
|
34
|
+
})
|
|
35
|
+
.meta({ id: 'ListUsers', title: 'Filter and page the user list' });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The response side. Same Standard Schema contract as a request, so it hoists into
|
|
39
|
+
* `components/schemas` the same way - but it is **never validated**: it documents
|
|
40
|
+
* what comes back, and the handler's return type is what checks it.
|
|
41
|
+
*/
|
|
42
|
+
export const User = z
|
|
43
|
+
.object({
|
|
44
|
+
id: z.number().int(),
|
|
45
|
+
name: z.string(),
|
|
46
|
+
tags: z.array(z.string()),
|
|
47
|
+
})
|
|
48
|
+
.meta({ id: 'User', title: 'A stored user' });
|
|
49
|
+
|
|
50
|
+
export const NotFound = z
|
|
51
|
+
.object({ error: z.string(), status: z.literal(404) })
|
|
52
|
+
.meta({ id: 'NotFound', title: 'Nothing at that id' });
|
|
53
|
+
|
|
54
|
+
// Declaring a schema is what makes the matching `input` field exist, get parsed
|
|
55
|
+
// and get validated. `satisfies` keeps the literal types `Input<>` reads.
|
|
56
|
+
export const listUsers = {
|
|
57
|
+
query: ListUsers,
|
|
58
|
+
response: { 200: z.array(User) },
|
|
59
|
+
} as const satisfies RouteSchemas;
|
|
60
|
+
export const oneUser = {
|
|
61
|
+
params: UserIndex,
|
|
62
|
+
response: { 200: User, 404: NotFound },
|
|
63
|
+
} as const satisfies RouteSchemas;
|
|
64
|
+
// No status: POST defaults to 201, every other verb to 200.
|
|
65
|
+
export const createUser = {
|
|
66
|
+
body: CreateUser,
|
|
67
|
+
response: { 201: User },
|
|
68
|
+
} as const satisfies RouteSchemas;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import type { OnInit, OnShutdown } from '@dunx/core';
|
|
3
|
+
import { AppConfigService } from '../config.js';
|
|
4
|
+
import { UsersRepository, type User } from './users.repository.js';
|
|
5
|
+
|
|
6
|
+
export class UsersService implements OnInit, OnShutdown {
|
|
7
|
+
// `ConfigService<AppConfig>` injects because the transform records the bare
|
|
8
|
+
// name of a generic annotation - the type argument costs nothing at runtime.
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly repository: UsersRepository,
|
|
11
|
+
private readonly logger: Logger,
|
|
12
|
+
private readonly config: AppConfigService,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
/** `onInit` is awaited, so the schema exists before the first request arrives. */
|
|
16
|
+
async onInit(): Promise<void> {
|
|
17
|
+
await this.repository.migrate();
|
|
18
|
+
await this.repository.seed(this.config.get('seedUsers'));
|
|
19
|
+
this.logger.info(`${this.config.get('appName')}: users ready`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
onShutdown(): void {
|
|
23
|
+
this.logger.info('users draining');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
findAll(limit: number, q?: string): Promise<readonly User[]> {
|
|
27
|
+
return this.repository.findAll(limit, q);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
find(id: number): Promise<User | null> {
|
|
31
|
+
return this.repository.find(id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
create(name: string, tags: readonly string[]): Promise<User> {
|
|
35
|
+
const labels = tags.length === 0 ? '' : ` [${tags.join(', ')}]`;
|
|
36
|
+
this.logger.info(
|
|
37
|
+
`${this.config.get('appName')}: creating ${name}${labels}`,
|
|
38
|
+
);
|
|
39
|
+
return this.repository.create(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async summary(): Promise<string> {
|
|
43
|
+
const users = await this.repository.findAll(50);
|
|
44
|
+
return `${users.length} users: ${users.map((user) => user.name).join(', ')}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# The one line that makes constructor injection work. The compiler plugin records
|
|
2
|
+
# each class's constructor parameter types so the container can resolve them.
|
|
3
|
+
# Without it, providers are built with no arguments and boot fails saying so.
|
|
4
|
+
preload = ["@dunx/transform/preload"]
|
|
5
|
+
|
|
6
|
+
[test]
|
|
7
|
+
preload = ["@dunx/transform/preload"]
|