@avijs/cli 0.1.1 → 0.2.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/src/generate.ts CHANGED
@@ -1,10 +1,17 @@
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
-
7
- type Kind = 'module' | 'controller' | 'service';
6
+ import {
7
+ SYNTAX,
8
+ VALIDATORS,
9
+ detectValidator,
10
+ type SchemaSyntax,
11
+ type Validator,
12
+ } from './validators.js';
13
+
14
+ type Kind = 'module' | 'controller' | 'service' | 'resource';
8
15
  type Layout = 'modular' | 'minimal';
9
16
 
10
17
  const KINDS: Record<string, Kind> = {
@@ -14,14 +21,17 @@ const KINDS: Record<string, Kind> = {
14
21
  co: 'controller',
15
22
  service: 'service',
16
23
  s: 'service',
24
+ resource: 'resource',
25
+ res: 'resource',
17
26
  };
18
27
  const SEGMENT = /^[a-z][a-z0-9-]*$/;
19
28
 
20
- export const GENERATE_USAGE = ` avi generate <module|controller|service> <path> [--root .] [--dry-run]
29
+ export const GENERATE_USAGE = ` avi generate <module|controller|service|resource> <path> [--root .] [--dry-run]
21
30
  Create a module, controller or service and register it. Short: avi g co admin/posts.
22
31
  Modular projects get src/modules/<path>/<name>.<kind>.ts, registered in the nearest
23
32
  module; minimal projects get src/routes/<path>.ts or src/services/<path>.ts, registered
24
- in src/app.ts.`;
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).`;
25
35
 
26
36
  interface Names {
27
37
  /** kebab-case file name, e.g. `user-profile`. */
@@ -77,6 +87,7 @@ export async function generate(args: readonly string[]): Promise<number> {
77
87
  options: {
78
88
  root: { type: 'string', default: '.' },
79
89
  'dry-run': { type: 'boolean', default: false },
90
+ validator: { type: 'string' },
80
91
  },
81
92
  });
82
93
  const [kindArg, target] = positionals;
@@ -108,12 +119,20 @@ export async function generate(args: readonly string[]): Promise<number> {
108
119
  return 1;
109
120
  }
110
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
+
111
130
  const names = toNames(segments.at(-1) as string);
112
131
  const dirs = segments.slice(0, -1);
113
132
  const plan = new Plan();
114
133
  try {
115
- if (layout === 'modular') await modular(plan, src, kind, dirs, names);
116
- 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);
117
136
  } catch (error) {
118
137
  if (!(error instanceof GenerateError)) throw error;
119
138
  console.error(error.message);
@@ -129,8 +148,34 @@ async function modular(
129
148
  kind: Kind,
130
149
  dirs: readonly string[],
131
150
  names: Names,
151
+ syntax: SchemaSyntax | undefined,
132
152
  ): Promise<void> {
133
153
  const folder = path.join(src, 'modules', ...dirs, names.file);
154
+
155
+ if (kind === 'resource') {
156
+ const module = path.join(folder, `${names.file}.module.ts`);
157
+ plan.create(module, resourceModule(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
+ }
174
+ const parent = nearestModule(plan, src, path.dirname(folder));
175
+ await register(plan, parent, 'imports', `${names.pascal}Module`, module);
176
+ return;
177
+ }
178
+
134
179
  const file = path.join(folder, `${names.file}.${kind}.ts`);
135
180
  const className = `${names.pascal}${capitalize(kind)}`;
136
181
 
@@ -206,6 +251,7 @@ async function minimal(
206
251
  kind: Kind,
207
252
  dirs: readonly string[],
208
253
  names: Names,
254
+ syntax: SchemaSyntax | undefined,
209
255
  ): Promise<void> {
210
256
  if (kind === 'module') {
211
257
  throw new GenerateError(
@@ -216,6 +262,26 @@ async function minimal(
216
262
  const service = path.join(src, 'services', ...dirs, `${names.file}.ts`);
217
263
  const serviceClass = `${names.pascal}Service`;
218
264
 
265
+ if (kind === 'resource') {
266
+ const routes = path.join(src, 'routes', ...dirs, `${names.file}.ts`);
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
+ }
280
+ await register(plan, app, 'providers', serviceClass, service);
281
+ await register(plan, app, 'controllers', `${names.camel}Routes`, routes);
282
+ return;
283
+ }
284
+
219
285
  if (kind === 'service') {
220
286
  plan.create(
221
287
  service,
@@ -392,3 +458,332 @@ function toNames(file: string): Names {
392
458
  function capitalize(value: string): string {
393
459
  return value.charAt(0).toUpperCase() + value.slice(1);
394
460
  }
461
+
462
+ /** `posts` → `Post`, `categories` → `Category`; names without a plural `s` stay as they are. */
463
+ function singular(pascal: string): string {
464
+ if (pascal.endsWith('ies')) return `${pascal.slice(0, -3)}y`;
465
+ if (pascal.endsWith('s') && !pascal.endsWith('ss')) return pascal.slice(0, -1);
466
+ return pascal;
467
+ }
468
+
469
+ function resourceModule(names: Names): string {
470
+ return `import { Module } from '@avijs/core';
471
+ import { ${names.pascal}Controller } from './${names.file}.controller.js';
472
+ import { ${names.pascal}Service } from './${names.file}.service.js';
473
+
474
+ @Module({ controllers: [${names.pascal}Controller], providers: [${names.pascal}Service] })
475
+ export class ${names.pascal}Module {}
476
+ `;
477
+ }
478
+
479
+ function crudService(names: Names, decorated: boolean): string {
480
+ const item = singular(names.pascal);
481
+ const imports = decorated ? 'HttpError, Injectable' : 'HttpError';
482
+ return `import { ${imports} } from '@avijs/core';
483
+
484
+ export interface ${item} {
485
+ id: number;
486
+ [field: string]: unknown;
487
+ }
488
+
489
+ /** Keeps ${names.file} in memory. Swap the Map for your database; the routes stay the same. */
490
+ ${decorated ? '@Injectable()\n' : ''}export class ${names.pascal}Service {
491
+ readonly #items = new Map<number, ${item}>();
492
+ #nextId = 1;
493
+
494
+ findAll(): ${item}[] {
495
+ return [...this.#items.values()];
496
+ }
497
+
498
+ findOne(id: number): ${item} {
499
+ const item = this.#items.get(id);
500
+ if (!item) throw new HttpError(404, \`${item} \${String(id)} not found\`);
501
+ return item;
502
+ }
503
+
504
+ create(data: Record<string, unknown>): ${item} {
505
+ const item = { ...data, id: this.#nextId++ };
506
+ this.#items.set(item.id, item);
507
+ return item;
508
+ }
509
+
510
+ update(id: number, data: Record<string, unknown>): ${item} {
511
+ const item = { ...this.findOne(id), ...data, id };
512
+ this.#items.set(id, item);
513
+ return item;
514
+ }
515
+
516
+ remove(id: number): void {
517
+ this.findOne(id);
518
+ this.#items.delete(id);
519
+ }
520
+ }
521
+ `;
522
+ }
523
+
524
+ // Shared by the generated controller and routes. Replace them with route schemas
525
+ // (see the validation chapter) once the shape of the data is known.
526
+ const INPUT_HELPERS = `function idOf(value: string | undefined): number {
527
+ const id = Number(value);
528
+ if (!Number.isInteger(id)) throw new HttpError(400, 'id must be an integer');
529
+ return id;
530
+ }
531
+
532
+ function bodyOf(value: unknown): Record<string, unknown> {
533
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
534
+ throw new HttpError(400, 'Expected a JSON object');
535
+ }
536
+ return value as Record<string, unknown>;
537
+ }`;
538
+
539
+ function crudController(names: Names): string {
540
+ const service = `${names.pascal}Service`;
541
+ const field = names.camel;
542
+ return `import { Controller, Delete, Get, HttpError, Patch, Post, reply, type Context } from '@avijs/core';
543
+ import { ${service} } from './${names.file}.service.js';
544
+
545
+ ${INPUT_HELPERS}
546
+
547
+ @Controller('/${names.file}')
548
+ export class ${names.pascal}Controller {
549
+ static inject = [${service}] as const;
550
+
551
+ constructor(private readonly ${field}: ${service}) {}
552
+
553
+ @Get()
554
+ findAll() {
555
+ return this.${field}.findAll();
556
+ }
557
+
558
+ @Get('/:id')
559
+ findOne(ctx: Context) {
560
+ return this.${field}.findOne(idOf(ctx.params['id']));
561
+ }
562
+
563
+ @Post()
564
+ async create(ctx: Context) {
565
+ return reply(this.${field}.create(bodyOf(await ctx.json())), { status: 201 });
566
+ }
567
+
568
+ @Patch('/:id')
569
+ async update(ctx: Context) {
570
+ return this.${field}.update(idOf(ctx.params['id']), bodyOf(await ctx.json()));
571
+ }
572
+
573
+ @Delete('/:id')
574
+ remove(ctx: Context) {
575
+ this.${field}.remove(idOf(ctx.params['id']));
576
+ }
577
+ }
578
+ `;
579
+ }
580
+
581
+ function crudRoutes(names: Names, serviceImport: string): string {
582
+ const service = `${names.pascal}Service`;
583
+ const field = names.camel;
584
+ return `import { HttpError, defineController, reply, route } from '@avijs/core';
585
+ import { ${service} } from '${serviceImport}';
586
+
587
+ ${INPUT_HELPERS}
588
+
589
+ export const ${field}Routes = defineController({
590
+ prefix: '/${names.file}',
591
+ inject: [${service}],
592
+ routes: (${field}) => [
593
+ route({ method: 'GET', path: '/', handler: () => ${field}.findAll() }),
594
+ route({ method: 'GET', path: '/:id', handler: (ctx) => ${field}.findOne(idOf(ctx.params.id)) }),
595
+ route({
596
+ method: 'POST',
597
+ path: '/',
598
+ handler: async (ctx) => reply(${field}.create(bodyOf(await ctx.json())), { status: 201 }),
599
+ }),
600
+ route({
601
+ method: 'PATCH',
602
+ path: '/:id',
603
+ handler: async (ctx) => ${field}.update(idOf(ctx.params.id), bodyOf(await ctx.json())),
604
+ }),
605
+ route({
606
+ method: 'DELETE',
607
+ path: '/:id',
608
+ handler: (ctx) => {
609
+ ${field}.remove(idOf(ctx.params.id));
610
+ },
611
+ }),
612
+ ],
613
+ });
614
+ `;
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';