@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/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
- plan.create(path.join(folder, `${names.file}.service.ts`), crudService(names, true));
141
- plan.create(path.join(folder, `${names.file}.controller.ts`), crudController(names));
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
- plan.create(service, crudService(names, false));
235
- plan.create(routes, crudRoutes(names, importPath(routes, service)));
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
@@ -1,3 +1,4 @@
1
1
  export { main } from './cli.js';
2
2
  export { templateFiles } from './templates.js';
3
3
  export type { TemplateName, TemplateOptions } from './templates.js';
4
+ export type { Validator } from './validators.js';
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
- return {
19
- ...shared(options),
20
- ...(options.template === 'modular' ? modular() : minimal()),
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
- template,
27
- aviVersion,
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: { '@avijs/core': spec('@avijs/core') },
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 = `const { port } = await app.listen(Number(process.env['PORT'] ?? 3000));
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
+ }