@avijs/cli 0.1.2 → 0.2.1
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 +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +15 -5
- package/dist/cli.js.map +1 -1
- package/dist/generate.d.ts +1 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +205 -10
- package/dist/generate.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/templates.d.ts +3 -0
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +258 -8
- package/dist/templates.js.map +1 -1
- package/dist/validators.d.ts +27 -0
- package/dist/validators.d.ts.map +1 -0
- package/dist/validators.js +56 -0
- package/dist/validators.js.map +1 -0
- package/package.json +6 -2
- package/src/cli.ts +16 -5
- package/src/generate.ts +226 -8
- package/src/index.ts +1 -0
- package/src/templates.ts +271 -13
- package/src/validators.ts +85 -0
package/src/generate.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
5
|
import ts from 'typescript';
|
|
6
|
+
import {
|
|
7
|
+
SYNTAX,
|
|
8
|
+
VALIDATORS,
|
|
9
|
+
detectValidator,
|
|
10
|
+
type SchemaSyntax,
|
|
11
|
+
type Validator,
|
|
12
|
+
} from './validators.js';
|
|
6
13
|
|
|
7
14
|
type Kind = 'module' | 'controller' | 'service' | 'resource';
|
|
8
15
|
type Layout = 'modular' | 'minimal';
|
|
@@ -23,7 +30,8 @@ export const GENERATE_USAGE = ` avi generate <module|controller|service|resourc
|
|
|
23
30
|
Create a module, controller or service and register it. Short: avi g co admin/posts.
|
|
24
31
|
Modular projects get src/modules/<path>/<name>.<kind>.ts, registered in the nearest
|
|
25
32
|
module; minimal projects get src/routes/<path>.ts or src/services/<path>.ts, registered
|
|
26
|
-
in src/app.ts. A resource (avi g res posts) creates all of them with CRUD routes
|
|
33
|
+
in src/app.ts. A resource (avi g res posts) creates all of them with CRUD routes, validated
|
|
34
|
+
with the project's valibot or zod (--validator valibot|zod|none to choose).`;
|
|
27
35
|
|
|
28
36
|
interface Names {
|
|
29
37
|
/** kebab-case file name, e.g. `user-profile`. */
|
|
@@ -79,6 +87,7 @@ export async function generate(args: readonly string[]): Promise<number> {
|
|
|
79
87
|
options: {
|
|
80
88
|
root: { type: 'string', default: '.' },
|
|
81
89
|
'dry-run': { type: 'boolean', default: false },
|
|
90
|
+
validator: { type: 'string' },
|
|
82
91
|
},
|
|
83
92
|
});
|
|
84
93
|
const [kindArg, target] = positionals;
|
|
@@ -110,12 +119,20 @@ export async function generate(args: readonly string[]): Promise<number> {
|
|
|
110
119
|
return 1;
|
|
111
120
|
}
|
|
112
121
|
|
|
122
|
+
const validator = (values.validator ??
|
|
123
|
+
detectValidator(readJson(path.join(root, 'package.json')))) as Validator;
|
|
124
|
+
if (!VALIDATORS.includes(validator)) {
|
|
125
|
+
console.error(`Unknown validator "${validator}". Use ${VALIDATORS.join(', ')}.`);
|
|
126
|
+
return 2;
|
|
127
|
+
}
|
|
128
|
+
const syntax = validator === 'none' ? undefined : SYNTAX[validator];
|
|
129
|
+
|
|
113
130
|
const names = toNames(segments.at(-1) as string);
|
|
114
131
|
const dirs = segments.slice(0, -1);
|
|
115
132
|
const plan = new Plan();
|
|
116
133
|
try {
|
|
117
|
-
if (layout === 'modular') await modular(plan, src, kind, dirs, names);
|
|
118
|
-
else await minimal(plan, src, kind, dirs, names);
|
|
134
|
+
if (layout === 'modular') await modular(plan, src, kind, dirs, names, syntax);
|
|
135
|
+
else await minimal(plan, src, kind, dirs, names, syntax);
|
|
119
136
|
} catch (error) {
|
|
120
137
|
if (!(error instanceof GenerateError)) throw error;
|
|
121
138
|
console.error(error.message);
|
|
@@ -131,14 +148,29 @@ async function modular(
|
|
|
131
148
|
kind: Kind,
|
|
132
149
|
dirs: readonly string[],
|
|
133
150
|
names: Names,
|
|
151
|
+
syntax: SchemaSyntax | undefined,
|
|
134
152
|
): Promise<void> {
|
|
135
153
|
const folder = path.join(src, 'modules', ...dirs, names.file);
|
|
136
154
|
|
|
137
155
|
if (kind === 'resource') {
|
|
138
156
|
const module = path.join(folder, `${names.file}.module.ts`);
|
|
139
157
|
plan.create(module, resourceModule(names));
|
|
140
|
-
|
|
141
|
-
|
|
158
|
+
if (syntax) {
|
|
159
|
+
const schemas = `./${names.file}.schemas.js`;
|
|
160
|
+
plan.create(path.join(folder, `${names.file}.schemas.ts`), resourceSchemas(names, syntax));
|
|
161
|
+
plan.create(
|
|
162
|
+
path.join(folder, `${names.file}.service.ts`),
|
|
163
|
+
typedService(names, true, schemas),
|
|
164
|
+
);
|
|
165
|
+
plan.create(
|
|
166
|
+
path.join(folder, `${names.file}.controller.ts`),
|
|
167
|
+
typedController(names, schemas),
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
plan.create(path.join(folder, `${names.file}.service.ts`), crudService(names, true));
|
|
171
|
+
plan.create(path.join(folder, `${names.file}.controller.ts`), crudController(names));
|
|
172
|
+
plan.notes.push(UNVALIDATED);
|
|
173
|
+
}
|
|
142
174
|
const parent = nearestModule(plan, src, path.dirname(folder));
|
|
143
175
|
await register(plan, parent, 'imports', `${names.pascal}Module`, module);
|
|
144
176
|
return;
|
|
@@ -219,6 +251,7 @@ async function minimal(
|
|
|
219
251
|
kind: Kind,
|
|
220
252
|
dirs: readonly string[],
|
|
221
253
|
names: Names,
|
|
254
|
+
syntax: SchemaSyntax | undefined,
|
|
222
255
|
): Promise<void> {
|
|
223
256
|
if (kind === 'module') {
|
|
224
257
|
throw new GenerateError(
|
|
@@ -231,8 +264,19 @@ async function minimal(
|
|
|
231
264
|
|
|
232
265
|
if (kind === 'resource') {
|
|
233
266
|
const routes = path.join(src, 'routes', ...dirs, `${names.file}.ts`);
|
|
234
|
-
|
|
235
|
-
|
|
267
|
+
if (syntax) {
|
|
268
|
+
const schemas = path.join(src, 'schemas', ...dirs, `${names.file}.ts`);
|
|
269
|
+
plan.create(schemas, resourceSchemas(names, syntax));
|
|
270
|
+
plan.create(service, typedService(names, false, importPath(service, schemas)));
|
|
271
|
+
plan.create(
|
|
272
|
+
routes,
|
|
273
|
+
typedRoutes(names, importPath(routes, service), importPath(routes, schemas)),
|
|
274
|
+
);
|
|
275
|
+
} else {
|
|
276
|
+
plan.create(service, crudService(names, false));
|
|
277
|
+
plan.create(routes, crudRoutes(names, importPath(routes, service)));
|
|
278
|
+
plan.notes.push(UNVALIDATED);
|
|
279
|
+
}
|
|
236
280
|
await register(plan, app, 'providers', serviceClass, service);
|
|
237
281
|
await register(plan, app, 'controllers', `${names.camel}Routes`, routes);
|
|
238
282
|
return;
|
|
@@ -569,3 +613,177 @@ export const ${field}Routes = defineController({
|
|
|
569
613
|
});
|
|
570
614
|
`;
|
|
571
615
|
}
|
|
616
|
+
|
|
617
|
+
function readJson(file: string): unknown {
|
|
618
|
+
try {
|
|
619
|
+
return JSON.parse(readFileSync(file, 'utf8')) as unknown;
|
|
620
|
+
} catch {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const UNVALIDATED =
|
|
626
|
+
'NOTE No validation library in package.json: request bodies are stored as sent. Install valibot\n or zod and generate with --validator to get schemas that reject unknown fields.';
|
|
627
|
+
|
|
628
|
+
/** Names of a resource's schemas: `posts` → `PostId`, `CreatePost`, `UpdatePost`. */
|
|
629
|
+
function schemaNames(names: Names) {
|
|
630
|
+
const item = singular(names.pascal);
|
|
631
|
+
return { item, id: `${item}Id`, create: `Create${item}`, update: `Update${item}` };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function resourceSchemas(names: Names, syntax: SchemaSyntax): string {
|
|
635
|
+
const { id, create, update } = schemaNames(names);
|
|
636
|
+
return `${syntax.import}
|
|
637
|
+
|
|
638
|
+
export const ${id} = ${syntax.id};
|
|
639
|
+
|
|
640
|
+
// Add your fields here. Fields not listed are dropped from request bodies.
|
|
641
|
+
export const ${create} = ${syntax.object([['title', syntax.text(200, 'title is required')]])};
|
|
642
|
+
export type ${create} = ${syntax.infer(create)};
|
|
643
|
+
|
|
644
|
+
export const ${update} = ${syntax.partialNonEmpty(create)};
|
|
645
|
+
export type ${update} = ${syntax.infer(update)};
|
|
646
|
+
`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function typedService(names: Names, decorated: boolean, schemas: string): string {
|
|
650
|
+
const { item, create, update } = schemaNames(names);
|
|
651
|
+
const imports = decorated ? 'HttpError, Injectable' : 'HttpError';
|
|
652
|
+
return `import { ${imports} } from '@avijs/core';
|
|
653
|
+
import type { ${create}, ${update} } from '${schemas}';
|
|
654
|
+
|
|
655
|
+
export type ${item} = { id: number } & ${create};
|
|
656
|
+
|
|
657
|
+
/** Keeps ${names.file} in memory. Swap the Map for your database; the routes stay the same. */
|
|
658
|
+
${decorated ? '@Injectable()\n' : ''}export class ${names.pascal}Service {
|
|
659
|
+
readonly #items = new Map<number, ${item}>();
|
|
660
|
+
#nextId = 1;
|
|
661
|
+
|
|
662
|
+
findAll(): ${item}[] {
|
|
663
|
+
return [...this.#items.values()];
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
findOne(id: number): ${item} {
|
|
667
|
+
const item = this.#items.get(id);
|
|
668
|
+
if (!item) throw new HttpError(404, \`${item} \${String(id)} not found\`);
|
|
669
|
+
return item;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
create(data: ${create}): ${item} {
|
|
673
|
+
const item = { ...data, id: this.#nextId++ };
|
|
674
|
+
this.#items.set(item.id, item);
|
|
675
|
+
return item;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
update(id: number, data: ${update}): ${item} {
|
|
679
|
+
const item = { ...this.findOne(id), ...data, id };
|
|
680
|
+
this.#items.set(id, item);
|
|
681
|
+
return item;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
remove(id: number): void {
|
|
685
|
+
this.findOne(id);
|
|
686
|
+
this.#items.delete(id);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function typedController(names: Names, schemas: string): string {
|
|
693
|
+
const { id, create, update } = schemaNames(names);
|
|
694
|
+
const service = `${names.pascal}Service`;
|
|
695
|
+
const field = names.camel;
|
|
696
|
+
return `import {
|
|
697
|
+
Controller,
|
|
698
|
+
Delete,
|
|
699
|
+
Get,
|
|
700
|
+
Patch,
|
|
701
|
+
Post,
|
|
702
|
+
reply,
|
|
703
|
+
type RouteContext,
|
|
704
|
+
type RouteSchema,
|
|
705
|
+
} from '@avijs/core';
|
|
706
|
+
import { ${create}, ${id}, ${update} } from '${schemas}';
|
|
707
|
+
import { ${service} } from './${names.file}.service.js';
|
|
708
|
+
|
|
709
|
+
const byId = { params: ${id} } satisfies RouteSchema;
|
|
710
|
+
const create = { body: ${create} } satisfies RouteSchema;
|
|
711
|
+
const update = { params: ${id}, body: ${update} } satisfies RouteSchema;
|
|
712
|
+
|
|
713
|
+
@Controller('/${names.file}')
|
|
714
|
+
export class ${names.pascal}Controller {
|
|
715
|
+
static inject = [${service}] as const;
|
|
716
|
+
|
|
717
|
+
constructor(private readonly ${field}: ${service}) {}
|
|
718
|
+
|
|
719
|
+
@Get()
|
|
720
|
+
findAll() {
|
|
721
|
+
return this.${field}.findAll();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
@Get('/:id', { schema: byId })
|
|
725
|
+
findOne(ctx: RouteContext<typeof byId>) {
|
|
726
|
+
return this.${field}.findOne(ctx.input.params.id);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
@Post('/', { schema: create })
|
|
730
|
+
create(ctx: RouteContext<typeof create>) {
|
|
731
|
+
return reply(this.${field}.create(ctx.input.body), { status: 201 });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
@Patch('/:id', { schema: update })
|
|
735
|
+
update(ctx: RouteContext<typeof update>) {
|
|
736
|
+
return this.${field}.update(ctx.input.params.id, ctx.input.body);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
@Delete('/:id', { schema: byId })
|
|
740
|
+
remove(ctx: RouteContext<typeof byId>) {
|
|
741
|
+
this.${field}.remove(ctx.input.params.id);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function typedRoutes(names: Names, serviceImport: string, schemas: string): string {
|
|
748
|
+
const { id, create, update } = schemaNames(names);
|
|
749
|
+
const service = `${names.pascal}Service`;
|
|
750
|
+
const field = names.camel;
|
|
751
|
+
return `import { defineController, reply, route } from '@avijs/core';
|
|
752
|
+
import { ${create}, ${id}, ${update} } from '${schemas}';
|
|
753
|
+
import { ${service} } from '${serviceImport}';
|
|
754
|
+
|
|
755
|
+
export const ${field}Routes = defineController({
|
|
756
|
+
prefix: '/${names.file}',
|
|
757
|
+
inject: [${service}],
|
|
758
|
+
routes: (${field}) => [
|
|
759
|
+
route({ method: 'GET', path: '/', handler: () => ${field}.findAll() }),
|
|
760
|
+
route({
|
|
761
|
+
method: 'GET',
|
|
762
|
+
path: '/:id',
|
|
763
|
+
schema: { params: ${id} },
|
|
764
|
+
handler: (ctx) => ${field}.findOne(ctx.input.params.id),
|
|
765
|
+
}),
|
|
766
|
+
route({
|
|
767
|
+
method: 'POST',
|
|
768
|
+
path: '/',
|
|
769
|
+
schema: { body: ${create} },
|
|
770
|
+
handler: (ctx) => reply(${field}.create(ctx.input.body), { status: 201 }),
|
|
771
|
+
}),
|
|
772
|
+
route({
|
|
773
|
+
method: 'PATCH',
|
|
774
|
+
path: '/:id',
|
|
775
|
+
schema: { params: ${id}, body: ${update} },
|
|
776
|
+
handler: (ctx) => ${field}.update(ctx.input.params.id, ctx.input.body),
|
|
777
|
+
}),
|
|
778
|
+
route({
|
|
779
|
+
method: 'DELETE',
|
|
780
|
+
path: '/:id',
|
|
781
|
+
schema: { params: ${id} },
|
|
782
|
+
handler: (ctx) => {
|
|
783
|
+
${field}.remove(ctx.input.params.id);
|
|
784
|
+
},
|
|
785
|
+
}),
|
|
786
|
+
],
|
|
787
|
+
});
|
|
788
|
+
`;
|
|
789
|
+
}
|
package/src/index.ts
CHANGED
package/src/templates.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SYNTAX, VALIDATOR_VERSIONS, type SchemaSyntax, type Validator } from './validators.js';
|
|
2
|
+
|
|
1
3
|
export type TemplateName = 'modular' | 'minimal';
|
|
2
4
|
|
|
3
5
|
export interface TemplateOptions {
|
|
@@ -11,22 +13,29 @@ export interface TemplateOptions {
|
|
|
11
13
|
* each other resolve locally too.
|
|
12
14
|
*/
|
|
13
15
|
localPackages?: Readonly<Record<string, string>>;
|
|
16
|
+
/** How the users routes validate input. Default: `valibot`. */
|
|
17
|
+
validator?: Validator;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
/** Every file of a new project, keyed by its path relative to the project folder. */
|
|
17
21
|
export function templateFiles(options: TemplateOptions): Record<string, string> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
const validator = options.validator ?? 'valibot';
|
|
23
|
+
const syntax = validator === 'none' ? undefined : SYNTAX[validator];
|
|
24
|
+
const files = options.template === 'modular' ? modular() : minimal();
|
|
25
|
+
if (syntax) {
|
|
26
|
+
Object.assign(
|
|
27
|
+
files,
|
|
28
|
+
options.template === 'modular' ? modularSchemas(syntax) : minimalSchemas(syntax),
|
|
29
|
+
);
|
|
30
|
+
if (options.template === 'modular') delete files['src/modules/users/dto/create-user.dto.ts'];
|
|
31
|
+
}
|
|
32
|
+
return { ...shared(options, validator), ...files };
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
function shared(
|
|
25
|
-
name,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
localPackages,
|
|
29
|
-
}: TemplateOptions): Record<string, string> {
|
|
35
|
+
function shared(
|
|
36
|
+
{ name, template, aviVersion, localPackages }: TemplateOptions,
|
|
37
|
+
validator: Validator,
|
|
38
|
+
): Record<string, string> {
|
|
30
39
|
const spec = (pkg: string) => localPackages?.[pkg] ?? aviVersion;
|
|
31
40
|
const pkg = {
|
|
32
41
|
name,
|
|
@@ -40,7 +49,10 @@ function shared({
|
|
|
40
49
|
check: 'avi check',
|
|
41
50
|
test: 'tsc && node --test "dist/**/*.test.js"',
|
|
42
51
|
},
|
|
43
|
-
dependencies: {
|
|
52
|
+
dependencies: {
|
|
53
|
+
'@avijs/core': spec('@avijs/core'),
|
|
54
|
+
...(validator === 'none' ? {} : { [validator]: VALIDATOR_VERSIONS[validator] }),
|
|
55
|
+
},
|
|
44
56
|
devDependencies: {
|
|
45
57
|
'@avijs/cli': spec('@avijs/cli'),
|
|
46
58
|
'@avijs/testing': spec('@avijs/testing'),
|
|
@@ -68,7 +80,7 @@ function shared({
|
|
|
68
80
|
return {
|
|
69
81
|
'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
|
|
70
82
|
'tsconfig.json': `${JSON.stringify(tsconfig, null, 2)}\n`,
|
|
71
|
-
'.gitignore': 'node_modules/\ndist/\n',
|
|
83
|
+
'.gitignore': 'node_modules/\ndist/\n.env\n',
|
|
72
84
|
'README.md': `# ${name}
|
|
73
85
|
|
|
74
86
|
An [Avi.js](https://github.com/chrischase011/avi-js) API (${template} template).
|
|
@@ -85,7 +97,10 @@ Then open http://localhost:3000/health and http://localhost:3000/users.
|
|
|
85
97
|
};
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
const MAIN_LISTEN =
|
|
100
|
+
const MAIN_LISTEN = listenOn("Number(process.env['PORT'] ?? 3000)");
|
|
101
|
+
|
|
102
|
+
function listenOn(port: string): string {
|
|
103
|
+
return `const { port } = await app.listen(${port});
|
|
89
104
|
console.log(\`Listening on http://localhost:\${String(port)}\`);
|
|
90
105
|
|
|
91
106
|
const shutdown = () => {
|
|
@@ -94,6 +109,19 @@ const shutdown = () => {
|
|
|
94
109
|
process.once('SIGINT', shutdown);
|
|
95
110
|
process.once('SIGTERM', shutdown);
|
|
96
111
|
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `src/config.ts`: environment variables checked at startup. */
|
|
115
|
+
function configFile(syntax: SchemaSyntax): string {
|
|
116
|
+
return `import { defineConfig } from '@avijs/core';
|
|
117
|
+
${syntax.import}
|
|
118
|
+
|
|
119
|
+
// Environment variables, checked when the app starts. A .env file is read too, if present.
|
|
120
|
+
const env = ${syntax.object([['PORT', syntax.port]])};
|
|
121
|
+
|
|
122
|
+
export const Config = defineConfig(env, { envFile: '.env' });
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
97
125
|
|
|
98
126
|
function modular(): Record<string, string> {
|
|
99
127
|
return {
|
|
@@ -331,3 +359,233 @@ test('answers 404 for an unknown user', async () => {
|
|
|
331
359
|
`,
|
|
332
360
|
};
|
|
333
361
|
}
|
|
362
|
+
|
|
363
|
+
const REJECTS_NAMELESS = `test('rejects a user without a name', async () => {
|
|
364
|
+
const res = await client.post('/users', {});
|
|
365
|
+
assert.equal(res.status, 400);
|
|
366
|
+
const body = res.body as { details: { issues: { in: string; path: string[] }[] } };
|
|
367
|
+
assert.deepEqual(
|
|
368
|
+
body.details.issues.map((issue) => \`\${issue.in}.\${issue.path.join('.')}\`),
|
|
369
|
+
['body.name'],
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
/** The users feature with route schemas instead of a handwritten parser. */
|
|
375
|
+
function modularSchemas(syntax: SchemaSyntax): Record<string, string> {
|
|
376
|
+
return {
|
|
377
|
+
'src/config.ts': configFile(syntax),
|
|
378
|
+
'src/main.ts': `import { AviFactory } from '@avijs/core';
|
|
379
|
+
import { AppModule } from './app.module.js';
|
|
380
|
+
import { Config } from './config.js';
|
|
381
|
+
|
|
382
|
+
const app = await AviFactory.create(AppModule);
|
|
383
|
+
${listenOn('app.get(Config).PORT')}`,
|
|
384
|
+
'src/app.module.ts': `import { Module } from '@avijs/core';
|
|
385
|
+
import { HealthController } from './common/health.controller.js';
|
|
386
|
+
import { Config } from './config.js';
|
|
387
|
+
import { UsersModule } from './modules/users/users.module.js';
|
|
388
|
+
|
|
389
|
+
@Module({ imports: [Config.module, UsersModule], controllers: [HealthController] })
|
|
390
|
+
export class AppModule {}
|
|
391
|
+
`,
|
|
392
|
+
'src/modules/users/dto/user.dto.ts': `${syntax.import}
|
|
393
|
+
|
|
394
|
+
export const UserId = ${syntax.id};
|
|
395
|
+
|
|
396
|
+
export const CreateUserDto = ${syntax.object([['name', syntax.text(100, 'name is required')]])};
|
|
397
|
+
export type CreateUserDto = ${syntax.infer('CreateUserDto')};
|
|
398
|
+
|
|
399
|
+
// Responses are checked against these and lose any field not listed, so internals cannot leak.
|
|
400
|
+
export const User = ${syntax.object([
|
|
401
|
+
['id', syntax.number],
|
|
402
|
+
['name', syntax.string],
|
|
403
|
+
])};
|
|
404
|
+
export type User = ${syntax.infer('User')};
|
|
405
|
+
|
|
406
|
+
export const UserList = ${syntax.array('User')};
|
|
407
|
+
`,
|
|
408
|
+
'src/modules/users/users.service.ts': `import { Injectable } from '@avijs/core';
|
|
409
|
+
import type { CreateUserDto, User } from './dto/user.dto.js';
|
|
410
|
+
|
|
411
|
+
@Injectable()
|
|
412
|
+
export class UsersService {
|
|
413
|
+
readonly #users: User[] = [{ id: 1, name: 'Ada' }];
|
|
414
|
+
|
|
415
|
+
findAll(): User[] {
|
|
416
|
+
return this.#users;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
findOne(id: number): User | undefined {
|
|
420
|
+
return this.#users.find((user) => user.id === id);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
create(dto: CreateUserDto): User {
|
|
424
|
+
const user = { id: this.#users.length + 1, name: dto.name };
|
|
425
|
+
this.#users.push(user);
|
|
426
|
+
return user;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
`,
|
|
430
|
+
'src/modules/users/users.controller.ts': `import {
|
|
431
|
+
Controller,
|
|
432
|
+
Get,
|
|
433
|
+
HttpError,
|
|
434
|
+
Post,
|
|
435
|
+
reply,
|
|
436
|
+
type RouteContext,
|
|
437
|
+
type RouteSchema,
|
|
438
|
+
} from '@avijs/core';
|
|
439
|
+
import { CreateUserDto, User, UserId, UserList } from './dto/user.dto.js';
|
|
440
|
+
import { UsersService } from './users.service.js';
|
|
441
|
+
|
|
442
|
+
const findOne = { params: UserId, response: User } satisfies RouteSchema;
|
|
443
|
+
const create = { body: CreateUserDto, response: User } satisfies RouteSchema;
|
|
444
|
+
|
|
445
|
+
@Controller('/users')
|
|
446
|
+
export class UsersController {
|
|
447
|
+
static inject = [UsersService] as const;
|
|
448
|
+
|
|
449
|
+
constructor(private readonly users: UsersService) {}
|
|
450
|
+
|
|
451
|
+
@Get('/', { schema: { response: UserList } })
|
|
452
|
+
findAll() {
|
|
453
|
+
return this.users.findAll();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@Get('/:id', { schema: findOne })
|
|
457
|
+
findOne(ctx: RouteContext<typeof findOne>) {
|
|
458
|
+
const user = this.users.findOne(ctx.input.params.id);
|
|
459
|
+
if (!user) throw new HttpError(404, 'User not found');
|
|
460
|
+
return user;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@Post('/', { schema: create })
|
|
464
|
+
create(ctx: RouteContext<typeof create>) {
|
|
465
|
+
return reply(this.users.create(ctx.input.body), { status: 201 });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
`,
|
|
469
|
+
'src/modules/users/users.test.ts': `import assert from 'node:assert/strict';
|
|
470
|
+
import { after, before, test } from 'node:test';
|
|
471
|
+
import { createTestClient, type TestClient } from '@avijs/testing';
|
|
472
|
+
import { AppModule } from '../../app.module.js';
|
|
473
|
+
|
|
474
|
+
let client: TestClient;
|
|
475
|
+
before(async () => {
|
|
476
|
+
client = await createTestClient(AppModule);
|
|
477
|
+
});
|
|
478
|
+
after(() => client.close());
|
|
479
|
+
|
|
480
|
+
test('lists users', async () => {
|
|
481
|
+
const res = await client.get('/users');
|
|
482
|
+
assert.equal(res.status, 200);
|
|
483
|
+
assert.deepEqual(res.body, [{ id: 1, name: 'Ada' }]);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('creates a user', async () => {
|
|
487
|
+
const res = await client.post('/users', { name: ' Grace ', role: 'admin' });
|
|
488
|
+
assert.equal(res.status, 201);
|
|
489
|
+
assert.deepEqual(res.body, { id: 2, name: 'Grace' });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
${REJECTS_NAMELESS}`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function minimalSchemas(syntax: SchemaSyntax): Record<string, string> {
|
|
497
|
+
return {
|
|
498
|
+
'src/config.ts': configFile(syntax),
|
|
499
|
+
'src/main.ts': `import { createApp } from './app.js';
|
|
500
|
+
import { Config } from './config.js';
|
|
501
|
+
|
|
502
|
+
const app = createApp();
|
|
503
|
+
await app.init();
|
|
504
|
+
${listenOn('app.get(Config).PORT')}`,
|
|
505
|
+
'src/app.ts': `import { createAvi } from '@avijs/core';
|
|
506
|
+
import { Config } from './config.js';
|
|
507
|
+
import { healthRoutes } from './routes/health.js';
|
|
508
|
+
import { usersRoutes } from './routes/users.js';
|
|
509
|
+
import { UsersService } from './services/users.js';
|
|
510
|
+
|
|
511
|
+
export function createApp() {
|
|
512
|
+
return createAvi().register({
|
|
513
|
+
imports: [Config.module],
|
|
514
|
+
providers: [UsersService],
|
|
515
|
+
controllers: [healthRoutes, usersRoutes],
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
`,
|
|
519
|
+
'src/routes/users.ts': `import { HttpError, defineController, reply, route } from '@avijs/core';
|
|
520
|
+
${syntax.import}
|
|
521
|
+
import { UsersService } from '../services/users.js';
|
|
522
|
+
|
|
523
|
+
const UserId = ${syntax.id};
|
|
524
|
+
|
|
525
|
+
const CreateUser = ${syntax.object([['name', syntax.text(100, 'name is required')]])};
|
|
526
|
+
|
|
527
|
+
// Responses are checked against this and lose any field not listed, so internals cannot leak.
|
|
528
|
+
const User = ${syntax.object([
|
|
529
|
+
['id', syntax.number],
|
|
530
|
+
['name', syntax.string],
|
|
531
|
+
])};
|
|
532
|
+
|
|
533
|
+
export const usersRoutes = defineController({
|
|
534
|
+
prefix: '/users',
|
|
535
|
+
inject: [UsersService],
|
|
536
|
+
routes: (users) => [
|
|
537
|
+
route({
|
|
538
|
+
method: 'GET',
|
|
539
|
+
path: '/',
|
|
540
|
+
schema: { response: ${syntax.array('User')} },
|
|
541
|
+
handler: () => users.findAll(),
|
|
542
|
+
}),
|
|
543
|
+
route({
|
|
544
|
+
method: 'GET',
|
|
545
|
+
path: '/:id',
|
|
546
|
+
schema: { params: UserId, response: User },
|
|
547
|
+
handler: (ctx) => {
|
|
548
|
+
const user = users.findOne(ctx.input.params.id);
|
|
549
|
+
if (!user) throw new HttpError(404, 'User not found');
|
|
550
|
+
return user;
|
|
551
|
+
},
|
|
552
|
+
}),
|
|
553
|
+
route({
|
|
554
|
+
method: 'POST',
|
|
555
|
+
path: '/',
|
|
556
|
+
schema: { body: CreateUser, response: User },
|
|
557
|
+
handler: (ctx) => reply(users.create(ctx.input.body.name), { status: 201 }),
|
|
558
|
+
}),
|
|
559
|
+
],
|
|
560
|
+
});
|
|
561
|
+
`,
|
|
562
|
+
'src/app.test.ts': `import assert from 'node:assert/strict';
|
|
563
|
+
import { after, before, test } from 'node:test';
|
|
564
|
+
import { createTestClient, type TestClient } from '@avijs/testing';
|
|
565
|
+
import { createApp } from './app.js';
|
|
566
|
+
|
|
567
|
+
let client: TestClient;
|
|
568
|
+
before(async () => {
|
|
569
|
+
client = await createTestClient(createApp());
|
|
570
|
+
});
|
|
571
|
+
after(() => client.close());
|
|
572
|
+
|
|
573
|
+
test('lists users', async () => {
|
|
574
|
+
const res = await client.get('/users');
|
|
575
|
+
assert.equal(res.status, 200);
|
|
576
|
+
assert.deepEqual(res.body, [{ id: 1, name: 'Ada' }]);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('creates a user', async () => {
|
|
580
|
+
const res = await client.post('/users', { name: ' Grace ', role: 'admin' });
|
|
581
|
+
assert.equal(res.status, 201);
|
|
582
|
+
assert.deepEqual(res.body, { id: 2, name: 'Grace' });
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test('answers 404 for an unknown user', async () => {
|
|
586
|
+
assert.equal((await client.get('/users/99')).status, 404);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
${REJECTS_NAMELESS}`,
|
|
590
|
+
};
|
|
591
|
+
}
|