@kematjaya/crud-ui-generator 0.1.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/README.md +80 -0
- package/dist/cli.js +106 -0
- package/dist/naming.js +47 -0
- package/dist/spec.js +47 -0
- package/dist/templates/apiShapes.js +45 -0
- package/dist/templates/bffRoutes.js +175 -0
- package/dist/templates/csvLib.js +30 -0
- package/dist/templates/form.js +172 -0
- package/dist/templates/pages.js +38 -0
- package/dist/templates/queryLib.js +77 -0
- package/dist/templates/schemas.js +36 -0
- package/dist/templates/sharedComponents.js +415 -0
- package/dist/templates/table.js +499 -0
- package/dist/templates/typesApi.js +45 -0
- package/dist/templates/useExport.js +121 -0
- package/dist/write.js +70 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @kematjaya/crud-ui-generator
|
|
2
|
+
|
|
3
|
+
Next.js CRUD frontend generator that reads the `crud-specs/{Entity}.json` sidecar written by
|
|
4
|
+
[kematjaya/crud-maker-bundle](https://github.com/kematjaya0/crud-maker-bundle)'s
|
|
5
|
+
`make:kmj-api-crud`, and generates pages, components, and BFF routes matching the shape of a
|
|
6
|
+
hand-written CRUD feature (list/create/edit pages, table with search/pagination/bulk-delete/CSV
|
|
7
|
+
export, form, BFF proxy routes).
|
|
8
|
+
|
|
9
|
+
This is a dev-time code generator (like Plop/Hygen), not a runtime component library. It targets
|
|
10
|
+
projects that already follow this monorepo's Next.js conventions:
|
|
11
|
+
|
|
12
|
+
- `@kematjaya/bootstrap-ui-kit` for `ListPageCard`/`TextField`/`Button`/etc.
|
|
13
|
+
- `@kematjaya/access-control-ui` for `usePermissions()`
|
|
14
|
+
- `src/lib/http.ts`, `src/lib/bff.ts` (BFF proxy helpers — `authedBackend`, `validateOrigin`, `parseJson`, `jsonProblem`)
|
|
15
|
+
- `src/lib/permissions.ts` exporting `requirePermission()`
|
|
16
|
+
- `src/types/api.ts` + `src/types/api.generated.ts` (OpenAPI types via `openapi-typescript`)
|
|
17
|
+
|
|
18
|
+
It is not a general-purpose Next.js scaffolder — running it against a project that doesn't already
|
|
19
|
+
have those pieces will produce files that don't compile until you add them.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
npx @kematjaya/crud-ui-generator <spec-path> [--src <dir>]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `<spec-path>` — path to the `crud-specs/{Entity}.json` file written by `make:kmj-api-crud`.
|
|
28
|
+
- `--src <dir>` — the frontend project's `src/` directory to generate into (default: `src`).
|
|
29
|
+
|
|
30
|
+
Example, run from the frontend project root, with the backend as a sibling directory:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
npx @kematjaya/crud-ui-generator ../backend/crud-specs/Note.json --src src
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What it generates
|
|
37
|
+
|
|
38
|
+
Per entity (skipped if the file already exists — safe to re-run):
|
|
39
|
+
|
|
40
|
+
- `app/dashboard/{entities}/page.tsx`, `new/page.tsx`, `[id]/edit/page.tsx`
|
|
41
|
+
- `components/{entities}/{Entity}Table.tsx`, `{Entity}Form.tsx`, `use{Entities}Export.ts`
|
|
42
|
+
- `lib/{entities}-query.ts`, `lib/{entities}-csv.ts`
|
|
43
|
+
- `app/api/{entities}/route.ts`, `[id]/route.ts`, `export/route.ts` (BFF proxy)
|
|
44
|
+
|
|
45
|
+
Shared, entity-agnostic UI primitives (written once, reused by every entity):
|
|
46
|
+
|
|
47
|
+
- `components/crud/DeleteConfirmModal.tsx`, `BulkActionsBar.tsx`, `PaginationBar.tsx`,
|
|
48
|
+
`SearchPanel.tsx`, `ExportAllButton.tsx`
|
|
49
|
+
|
|
50
|
+
Appended to (multi-entity, idempotent — each entity gets one marker-guarded block):
|
|
51
|
+
|
|
52
|
+
- `lib/api-shapes.ts` — `is{Entity}`/`is{Entity}Collection` type guards
|
|
53
|
+
- `lib/schemas.ts` — a Zod schema per entity
|
|
54
|
+
- `types/api.ts` — types derived from the OpenAPI-generated `paths`/`components`
|
|
55
|
+
|
|
56
|
+
## Assumptions / caveats
|
|
57
|
+
|
|
58
|
+
- **Ids are UUID strings.** `validId()` in the generated `[id]/route.ts` checks a 36-char
|
|
59
|
+
hex-with-dashes pattern. Adjust by hand if an entity uses a different id type.
|
|
60
|
+
- **The list search box only searches one field.** If more than one field is marked
|
|
61
|
+
`searchable` in the spec, the list view (backed by ApiPlatform's `SearchFilter`, one query
|
|
62
|
+
param per property) only wires up the first one. The export endpoint ORs across all of them.
|
|
63
|
+
- **The table/CSV export skip `textarea` fields** (long text), mirroring the hand-written Notes
|
|
64
|
+
feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`) becomes a
|
|
65
|
+
column.
|
|
66
|
+
- **Getters are assumed on the entity/generated types** in the conventional `get{Field}()` /
|
|
67
|
+
camelCase-property shape used throughout this boilerplate.
|
|
68
|
+
- **`npm run api:types` must be run first** (after adding the backend's `#[ApiResource]`/
|
|
69
|
+
`#[ApiFilter]` attributes — see `make:kmj-api-crud`'s printed next-steps) so
|
|
70
|
+
`src/types/api.ts`'s `paths['/api/{entities}']` / `components['schemas'][...]` lookups resolve.
|
|
71
|
+
If the entity's `#[ApiResource]` uses a custom `uriTemplate` that doesn't match
|
|
72
|
+
`permissionPrefix`, fix those lookups by hand.
|
|
73
|
+
- Generated files aren't run through Prettier — run `npm run format` afterwards.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
npm install
|
|
79
|
+
npm run build # tsc -> dist/
|
|
80
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { loadSpec } from './spec.js';
|
|
4
|
+
import { namesFromSpec, searchField, searchableFields } from './naming.js';
|
|
5
|
+
import { appendBlock, appendBlockWithImport, newLog, writeIfMissing, writeNewFile } from './write.js';
|
|
6
|
+
import * as shared from './templates/sharedComponents.js';
|
|
7
|
+
import { editPage, listPage, newPage } from './templates/pages.js';
|
|
8
|
+
import { table } from './templates/table.js';
|
|
9
|
+
import { form } from './templates/form.js';
|
|
10
|
+
import { useExportHook } from './templates/useExport.js';
|
|
11
|
+
import { queryLib } from './templates/queryLib.js';
|
|
12
|
+
import { csvLib } from './templates/csvLib.js';
|
|
13
|
+
import { apiShapesBlock, apiShapesFileHeader, apiShapesImport, apiShapesMarker } from './templates/apiShapes.js';
|
|
14
|
+
import { schemasBlock, schemasFileHeader, schemasMarker } from './templates/schemas.js';
|
|
15
|
+
import { typesApiBlock, typesApiFileHeader, typesApiMarker } from './templates/typesApi.js';
|
|
16
|
+
import { exportRoute, itemRoute, listRoute } from './templates/bffRoutes.js';
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
const positional = [];
|
|
19
|
+
let srcDir = 'src';
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const arg = argv[i];
|
|
22
|
+
if (arg === '--src') {
|
|
23
|
+
const next = argv[++i];
|
|
24
|
+
if (!next)
|
|
25
|
+
throw new Error('--src requires a value');
|
|
26
|
+
srcDir = next;
|
|
27
|
+
}
|
|
28
|
+
else if (arg === '--help' || arg === '-h') {
|
|
29
|
+
printUsage();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
positional.push(arg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const specPath = positional[0];
|
|
37
|
+
if (!specPath) {
|
|
38
|
+
printUsage();
|
|
39
|
+
throw new Error('Missing required <spec-path> argument.');
|
|
40
|
+
}
|
|
41
|
+
return { specPath, srcDir };
|
|
42
|
+
}
|
|
43
|
+
function printUsage() {
|
|
44
|
+
console.log(`Usage: crud-ui-generate <spec-path> [--src <dir>]
|
|
45
|
+
|
|
46
|
+
<spec-path> Path to the crud-specs/{Entity}.json sidecar written by
|
|
47
|
+
kematjaya/crud-maker-bundle's "make:kmj-api-crud".
|
|
48
|
+
--src <dir> Frontend src/ directory to generate into (default: "src").
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
npx @kematjaya/crud-ui-generator ../backend/crud-specs/Note.json --src src
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
function report(log) {
|
|
55
|
+
for (const path of log.created)
|
|
56
|
+
console.log(` created ${path}`);
|
|
57
|
+
for (const path of log.appended)
|
|
58
|
+
console.log(` appended ${path}`);
|
|
59
|
+
for (const path of log.skipped)
|
|
60
|
+
console.log(` skip ${path} (already exists)`);
|
|
61
|
+
}
|
|
62
|
+
function main() {
|
|
63
|
+
const { specPath, srcDir } = parseArgs(process.argv.slice(2));
|
|
64
|
+
const spec = loadSpec(resolve(specPath));
|
|
65
|
+
const names = namesFromSpec(spec);
|
|
66
|
+
const src = resolve(srcDir);
|
|
67
|
+
const log = newLog();
|
|
68
|
+
const cruddir = join(src, 'components', 'crud');
|
|
69
|
+
writeIfMissing(join(cruddir, 'DeleteConfirmModal.tsx'), shared.deleteConfirmModal, log);
|
|
70
|
+
writeIfMissing(join(cruddir, 'BulkActionsBar.tsx'), shared.bulkActionsBar, log);
|
|
71
|
+
writeIfMissing(join(cruddir, 'PaginationBar.tsx'), shared.paginationBar, log);
|
|
72
|
+
writeIfMissing(join(cruddir, 'ExportAllButton.tsx'), shared.exportAllButton, log);
|
|
73
|
+
if (null !== searchField(spec)) {
|
|
74
|
+
writeIfMissing(join(cruddir, 'SearchPanel.tsx'), shared.searchPanel, log);
|
|
75
|
+
}
|
|
76
|
+
const dashDir = join(src, 'app', 'dashboard', names.entitiesKebab);
|
|
77
|
+
writeNewFile(join(dashDir, 'page.tsx'), listPage(names), log);
|
|
78
|
+
writeNewFile(join(dashDir, 'new', 'page.tsx'), newPage(names), log);
|
|
79
|
+
writeNewFile(join(dashDir, '[id]', 'edit', 'page.tsx'), editPage(names), log);
|
|
80
|
+
const compDir = join(src, 'components', names.entitiesKebab);
|
|
81
|
+
writeNewFile(join(compDir, `${names.entityPascal}Table.tsx`), table(spec, names), log);
|
|
82
|
+
writeNewFile(join(compDir, `${names.entityPascal}Form.tsx`), form(spec, names), log);
|
|
83
|
+
writeNewFile(join(compDir, `use${names.entitiesPascal}Export.ts`), useExportHook(spec, names), log);
|
|
84
|
+
const libDir = join(src, 'lib');
|
|
85
|
+
writeNewFile(join(libDir, `${names.entitiesKebab}-query.ts`), queryLib(spec, names), log);
|
|
86
|
+
writeNewFile(join(libDir, `${names.entitiesKebab}-csv.ts`), csvLib(spec, names), log);
|
|
87
|
+
appendBlockWithImport(join(libDir, 'api-shapes.ts'), apiShapesFileHeader, apiShapesImport(names), apiShapesMarker(names.entityPascal), apiShapesBlock(spec, names), log);
|
|
88
|
+
appendBlock(join(libDir, 'schemas.ts'), schemasFileHeader, schemasMarker(names.entityCamel), schemasBlock(spec, names), log);
|
|
89
|
+
appendBlock(join(src, 'types', 'api.ts'), typesApiFileHeader, typesApiMarker(names.entityPascal), typesApiBlock(spec, names), log);
|
|
90
|
+
const apiDir = join(src, 'app', 'api', names.entitiesKebab);
|
|
91
|
+
writeNewFile(join(apiDir, 'route.ts'), listRoute(spec, names), log);
|
|
92
|
+
writeNewFile(join(apiDir, '[id]', 'route.ts'), itemRoute(spec, names), log);
|
|
93
|
+
writeNewFile(join(apiDir, 'export', 'route.ts'), exportRoute(spec, names), log);
|
|
94
|
+
report(log);
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log(`Generated ${names.entityPascal} CRUD UI. Before it works end-to-end:`);
|
|
97
|
+
console.log(` 1. Run the backend maker's printed next-steps (ApiResource/ApiFilter attributes, permission keys, rate limiter config).`);
|
|
98
|
+
console.log(` 2. Run "npm run api:types" in the frontend project so src/types/api.ts's paths/components lookups resolve.`);
|
|
99
|
+
console.log(` 3. Confirm the OpenAPI collection path is "/api/${names.entitiesKebab}" — if the entity's #[ApiResource] uses a custom uriTemplate, fix the "paths[...]" lookups in the appended src/types/api.ts block by hand.`);
|
|
100
|
+
if (searchableFields(spec).length > 1) {
|
|
101
|
+
console.log(` 4. Note: multiple searchable fields were configured (${searchableFields(spec).join(', ')}); the list view's single search box only queries by "${searchField(spec)}" (ApiPlatform SearchFilter's per-property param convention doesn't support one box matching several properties). The export endpoint does OR across all of them.`);
|
|
102
|
+
}
|
|
103
|
+
console.log(` Assumes UUID-shaped ids (matches this boilerplate's convention) — see validId() in the generated app/api/${names.entitiesKebab}/[id]/route.ts if this entity uses a different id type.`);
|
|
104
|
+
console.log(` Run "npm run format" afterwards — generated files aren't pre-formatted to this project's Prettier config.`);
|
|
105
|
+
}
|
|
106
|
+
main();
|
package/dist/naming.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function kebabToPascal(kebab) {
|
|
2
|
+
return kebab
|
|
3
|
+
.split(/[-_]/)
|
|
4
|
+
.filter(Boolean)
|
|
5
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
6
|
+
.join('');
|
|
7
|
+
}
|
|
8
|
+
export function namesFromSpec(spec) {
|
|
9
|
+
const entityPascal = spec.entity;
|
|
10
|
+
const entityCamel = entityPascal.charAt(0).toLowerCase() + entityPascal.slice(1);
|
|
11
|
+
const entitiesKebab = spec.permissionPrefix;
|
|
12
|
+
const entitiesPascal = kebabToPascal(entitiesKebab);
|
|
13
|
+
const entitiesCamel = entitiesPascal.charAt(0).toLowerCase() + entitiesPascal.slice(1);
|
|
14
|
+
return { entityPascal, entityCamel, entitiesKebab, entitiesPascal, entitiesCamel };
|
|
15
|
+
}
|
|
16
|
+
/** The single field used for the search box / list-view SearchFilter query param, if any. */
|
|
17
|
+
export function searchField(spec) {
|
|
18
|
+
return spec.fields.find((f) => f.searchable)?.name ?? null;
|
|
19
|
+
}
|
|
20
|
+
/** All searchable field names, used by the export endpoint's OR-search. */
|
|
21
|
+
export function searchableFields(spec) {
|
|
22
|
+
return spec.fields.filter((f) => f.searchable).map((f) => f.name);
|
|
23
|
+
}
|
|
24
|
+
/** "createdAt" -> "Created At", "TestArticle" -> "Test Article". */
|
|
25
|
+
export function humanize(identifier) {
|
|
26
|
+
const words = identifier
|
|
27
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
28
|
+
.replace(/[-_]/g, ' ')
|
|
29
|
+
.trim()
|
|
30
|
+
.split(/\s+/);
|
|
31
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
32
|
+
}
|
|
33
|
+
/** "TestArticles" -> "test articles" — used for prose/aria copy. */
|
|
34
|
+
export function lowerWords(identifier) {
|
|
35
|
+
return humanize(identifier).toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fields shown as table columns / export CSV columns: everything except long-text (textarea)
|
|
39
|
+
* fields, mirroring how the hand-written Notes table shows `title` but not `body`.
|
|
40
|
+
*/
|
|
41
|
+
export function displayFields(spec) {
|
|
42
|
+
return spec.fields.filter((f) => f.type !== 'textarea');
|
|
43
|
+
}
|
|
44
|
+
/** Field used to label a single row in delete-confirmation copy / aria-labels. */
|
|
45
|
+
export function labelField(spec) {
|
|
46
|
+
return displayFields(spec)[0]?.name ?? spec.fields[0]?.name ?? 'id';
|
|
47
|
+
}
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
function isFieldSpec(value) {
|
|
3
|
+
if (typeof value !== 'object' || value === null)
|
|
4
|
+
return false;
|
|
5
|
+
const f = value;
|
|
6
|
+
return (typeof f.name === 'string' &&
|
|
7
|
+
['text', 'textarea', 'number', 'boolean'].includes(f.type) &&
|
|
8
|
+
typeof f.required === 'boolean' &&
|
|
9
|
+
(f.maxLength === null || typeof f.maxLength === 'number') &&
|
|
10
|
+
typeof f.searchable === 'boolean');
|
|
11
|
+
}
|
|
12
|
+
export function loadSpec(specPath) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = readFileSync(specPath, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`Could not read spec file: ${specPath}`);
|
|
19
|
+
}
|
|
20
|
+
let data;
|
|
21
|
+
try {
|
|
22
|
+
data = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`Spec file is not valid JSON: ${specPath}`);
|
|
26
|
+
}
|
|
27
|
+
if (typeof data !== 'object' || data === null) {
|
|
28
|
+
throw new Error(`Spec file must contain a JSON object: ${specPath}`);
|
|
29
|
+
}
|
|
30
|
+
const spec = data;
|
|
31
|
+
if (typeof spec.entity !== 'string' || spec.entity === '') {
|
|
32
|
+
throw new Error(`Spec file missing "entity": ${specPath}`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof spec.permissionPrefix !== 'string' || spec.permissionPrefix === '') {
|
|
35
|
+
throw new Error(`Spec file missing "permissionPrefix": ${specPath}`);
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(spec.fields) || !spec.fields.every(isFieldSpec)) {
|
|
38
|
+
throw new Error(`Spec file "fields" is missing or malformed: ${specPath}`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
entity: spec.entity,
|
|
42
|
+
permissionPrefix: spec.permissionPrefix,
|
|
43
|
+
ownerProperty: typeof spec.ownerProperty === 'string' ? spec.ownerProperty : null,
|
|
44
|
+
timestampField: typeof spec.timestampField === 'string' ? spec.timestampField : null,
|
|
45
|
+
fields: spec.fields,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function tsType(field) {
|
|
2
|
+
if (field.type === 'number')
|
|
3
|
+
return 'number';
|
|
4
|
+
if (field.type === 'boolean')
|
|
5
|
+
return 'boolean';
|
|
6
|
+
return 'string';
|
|
7
|
+
}
|
|
8
|
+
/** Fresh-file bootstrap, only used when src/lib/api-shapes.ts doesn't exist yet. */
|
|
9
|
+
export const apiShapesFileHeader = `export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === 'object' && value !== null;
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
export function apiShapesMarker(entityPascal) {
|
|
14
|
+
return `export function is${entityPascal}(`;
|
|
15
|
+
}
|
|
16
|
+
export function apiShapesImport(names) {
|
|
17
|
+
return `import type { ${names.entityPascal}, ${names.entitiesPascal}Collection } from '@/types/api';\n`;
|
|
18
|
+
}
|
|
19
|
+
export function apiShapesBlock(spec, names) {
|
|
20
|
+
const { entityPascal, entitiesPascal } = names;
|
|
21
|
+
const checks = ['isRecord(value)', "typeof value.id === 'string'"];
|
|
22
|
+
for (const field of spec.fields) {
|
|
23
|
+
checks.push(`typeof value.${field.name} === '${tsType(field)}'`);
|
|
24
|
+
}
|
|
25
|
+
if (null !== spec.timestampField) {
|
|
26
|
+
checks.push(`typeof value.${spec.timestampField} === 'string'`);
|
|
27
|
+
}
|
|
28
|
+
const checksBlock = checks.map((c, i) => ` ${c}${i < checks.length - 1 ? ' &&' : ''}`).join('\n');
|
|
29
|
+
return `
|
|
30
|
+
export function is${entityPascal}(value: unknown): value is ${entityPascal} {
|
|
31
|
+
return (
|
|
32
|
+
${checksBlock}
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function is${entityPascal}Collection(value: unknown): value is ${entitiesPascal}Collection {
|
|
37
|
+
return (
|
|
38
|
+
isRecord(value) &&
|
|
39
|
+
typeof value.totalItems === 'number' &&
|
|
40
|
+
Array.isArray(value.member) &&
|
|
41
|
+
value.member.every(is${entityPascal})
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { displayFields, searchField } from '../naming.js';
|
|
2
|
+
function tsType(field) {
|
|
3
|
+
if (field.type === 'number')
|
|
4
|
+
return 'number';
|
|
5
|
+
if (field.type === 'boolean')
|
|
6
|
+
return 'boolean';
|
|
7
|
+
return 'string';
|
|
8
|
+
}
|
|
9
|
+
export function listRoute(_spec, names) {
|
|
10
|
+
const { entityCamel, entitiesKebab } = names;
|
|
11
|
+
return `import type { NextRequest } from 'next/server';
|
|
12
|
+
import { authedBackend } from '@/lib/bff';
|
|
13
|
+
import { parseJson, validateOrigin } from '@/lib/http';
|
|
14
|
+
import { build${names.entitiesPascal}ApiPath, parse${names.entitiesPascal}Query } from '@/lib/${entitiesKebab}-query';
|
|
15
|
+
import { ${entityCamel}Schema } from '@/lib/schemas';
|
|
16
|
+
|
|
17
|
+
export async function GET(request: NextRequest) {
|
|
18
|
+
return authedBackend(
|
|
19
|
+
build${names.entitiesPascal}ApiPath(parse${names.entitiesPascal}Query(request.nextUrl.searchParams))
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function POST(request: NextRequest) {
|
|
24
|
+
const badOrigin = validateOrigin(request);
|
|
25
|
+
if (badOrigin) return badOrigin;
|
|
26
|
+
const parsed = await parseJson(request, ${entityCamel}Schema);
|
|
27
|
+
if ('error' in parsed) return parsed.error;
|
|
28
|
+
return authedBackend('/api/${entitiesKebab}', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: JSON.stringify(parsed.data)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const dynamic = 'force-dynamic';
|
|
35
|
+
export const revalidate = 0;
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
export function itemRoute(_spec, names) {
|
|
39
|
+
const { entityCamel, entitiesKebab } = names;
|
|
40
|
+
return `import type { NextRequest } from 'next/server';
|
|
41
|
+
import { authedBackend } from '@/lib/bff';
|
|
42
|
+
import { jsonProblem, parseJson, validateOrigin } from '@/lib/http';
|
|
43
|
+
import { ${entityCamel}Schema } from '@/lib/schemas';
|
|
44
|
+
|
|
45
|
+
type Params = { params: Promise<{ id: string }> };
|
|
46
|
+
|
|
47
|
+
function validId(id: string) {
|
|
48
|
+
return /^[0-9a-fA-F-]{36}$/.test(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function GET(_request: NextRequest, context: Params) {
|
|
52
|
+
const { id } = await context.params;
|
|
53
|
+
if (!validId(id)) return jsonProblem(404, { title: 'Not Found' });
|
|
54
|
+
return authedBackend(\`/api/${entitiesKebab}/\${id}\`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function PATCH(request: NextRequest, context: Params) {
|
|
58
|
+
const badOrigin = validateOrigin(request);
|
|
59
|
+
if (badOrigin) return badOrigin;
|
|
60
|
+
const { id } = await context.params;
|
|
61
|
+
if (!validId(id)) return jsonProblem(404, { title: 'Not Found' });
|
|
62
|
+
const parsed = await parseJson(request, ${entityCamel}Schema);
|
|
63
|
+
if ('error' in parsed) return parsed.error;
|
|
64
|
+
// Backend only exposes a Put operation (full replacement) — no Patch operation exists,
|
|
65
|
+
// so the upstream call uses PUT even though the BFF's own contract to the browser stays PATCH.
|
|
66
|
+
return authedBackend(\`/api/${entitiesKebab}/\${id}\`, {
|
|
67
|
+
method: 'PUT',
|
|
68
|
+
body: JSON.stringify(parsed.data)
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function DELETE(request: NextRequest, context: Params) {
|
|
73
|
+
const badOrigin = validateOrigin(request);
|
|
74
|
+
if (badOrigin) return badOrigin;
|
|
75
|
+
const { id } = await context.params;
|
|
76
|
+
if (!validId(id)) return jsonProblem(404, { title: 'Not Found' });
|
|
77
|
+
return authedBackend(\`/api/${entitiesKebab}/\${id}\`, { method: 'DELETE' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const dynamic = 'force-dynamic';
|
|
81
|
+
export const revalidate = 0;
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
export function exportRoute(spec, names) {
|
|
85
|
+
const { entityPascal, entitiesPascal, entitiesKebab } = names;
|
|
86
|
+
const field = searchField(spec);
|
|
87
|
+
const cols = displayFields(spec).map((f) => f.name);
|
|
88
|
+
if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
|
|
89
|
+
cols.push(spec.timestampField);
|
|
90
|
+
}
|
|
91
|
+
const fieldsByName = new Map(spec.fields.map((f) => [f.name, f]));
|
|
92
|
+
const exportFieldsType = cols
|
|
93
|
+
.map((c) => ` ${c}: ${c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? { type: 'text' })};`)
|
|
94
|
+
.join('\n');
|
|
95
|
+
const guardLines = cols.flatMap((c) => {
|
|
96
|
+
const t = c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? { type: 'text' });
|
|
97
|
+
return [`'${c}' in value`, `typeof value.${c} === '${t}'`];
|
|
98
|
+
});
|
|
99
|
+
const guardChecks = guardLines.map((line, i) => ` ${line}${i < guardLines.length - 1 ? ' &&' : ''}`).join('\n');
|
|
100
|
+
return `import { type NextRequest, NextResponse } from 'next/server';
|
|
101
|
+
import { authedBackend } from '@/lib/bff';
|
|
102
|
+
import { jsonProblem } from '@/lib/http';
|
|
103
|
+
import { build${entitiesPascal}Csv } from '@/lib/${entitiesKebab}-csv';
|
|
104
|
+
import { parse${entitiesPascal}Query } from '@/lib/${entitiesKebab}-query';
|
|
105
|
+
|
|
106
|
+
type Export${entityPascal} = {
|
|
107
|
+
${exportFieldsType}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type Export${entitiesPascal}Collection = {
|
|
111
|
+
totalItems: number;
|
|
112
|
+
member: Export${entityPascal}[];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function isExport${entityPascal}(value: unknown): value is Export${entityPascal} {
|
|
116
|
+
return (
|
|
117
|
+
typeof value === 'object' &&
|
|
118
|
+
value !== null &&
|
|
119
|
+
${guardChecks}
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isExport${entitiesPascal}Collection(value: unknown): value is Export${entitiesPascal}Collection {
|
|
124
|
+
return (
|
|
125
|
+
typeof value === 'object' &&
|
|
126
|
+
value !== null &&
|
|
127
|
+
'totalItems' in value &&
|
|
128
|
+
typeof value.totalItems === 'number' &&
|
|
129
|
+
Number.isSafeInteger(value.totalItems) &&
|
|
130
|
+
0 <= value.totalItems &&
|
|
131
|
+
'member' in value &&
|
|
132
|
+
Array.isArray(value.member) &&
|
|
133
|
+
value.member.every(isExport${entityPascal})
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildExportPath(${field ? 'search: string' : ''}): string {
|
|
138
|
+
${field ? ` const params = new URLSearchParams();
|
|
139
|
+
if (search) params.set('search', search);
|
|
140
|
+
const query = params.toString();
|
|
141
|
+
return query
|
|
142
|
+
? \`/api/${entitiesKebab}/export-data?\${query}\`
|
|
143
|
+
: '/api/${entitiesKebab}/export-data';` : ` return '/api/${entitiesKebab}/export-data';`}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function GET(request: NextRequest): Promise<Response> {
|
|
147
|
+
try {
|
|
148
|
+
${field ? ` const { search } = parse${entitiesPascal}Query(request.nextUrl.searchParams);
|
|
149
|
+
const response = await authedBackend(buildExportPath(search));` : ` const response = await authedBackend(buildExportPath());`}
|
|
150
|
+
if (!response.ok) return response;
|
|
151
|
+
|
|
152
|
+
const data: unknown = await response.json();
|
|
153
|
+
if (!isExport${entitiesPascal}Collection(data)) throw new Error('Invalid export data');
|
|
154
|
+
|
|
155
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
156
|
+
return new NextResponse(build${entitiesPascal}Csv(data.member), {
|
|
157
|
+
status: 200,
|
|
158
|
+
headers: {
|
|
159
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
160
|
+
'Content-Disposition': \`attachment; filename="${entitiesKebab}-export-all-\${date}.csv"\`,
|
|
161
|
+
'Cache-Control': 'no-store'
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
return jsonProblem(502, {
|
|
166
|
+
title: 'Bad Gateway',
|
|
167
|
+
detail: 'Unexpected ${entitiesKebab} response.'
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const dynamic = 'force-dynamic';
|
|
173
|
+
export const revalidate = 0;
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { displayFields, humanize } from '../naming.js';
|
|
2
|
+
export function csvLib(spec, names) {
|
|
3
|
+
const { entityPascal, entitiesPascal, entitiesCamel } = names;
|
|
4
|
+
const fields = displayFields(spec).map((f) => f.name);
|
|
5
|
+
if (null !== spec.timestampField)
|
|
6
|
+
fields.push(spec.timestampField);
|
|
7
|
+
const pickList = fields.map((f) => `'${f}'`).join(' | ');
|
|
8
|
+
const header = fields.map((f) => humanize(f)).join(',');
|
|
9
|
+
const row = fields.map((f) => `toCsvField(String(item.${f}))`).join(', ');
|
|
10
|
+
return `import type { ${entityPascal} } from '@/types/api';
|
|
11
|
+
|
|
12
|
+
const CSV_FORMULA_PREFIX = /^[\\s\\p{Cc}]*[=+\\-@]/u;
|
|
13
|
+
const CSV_SPECIAL_CHARACTER = /[",\\r\\n]/;
|
|
14
|
+
|
|
15
|
+
type Csv${entityPascal} = Pick<${entityPascal}, ${pickList}>;
|
|
16
|
+
|
|
17
|
+
export function toCsvField(value: string): string {
|
|
18
|
+
const safeValue = CSV_FORMULA_PREFIX.test(value) ? \`'\${value}\` : value;
|
|
19
|
+
return CSV_SPECIAL_CHARACTER.test(safeValue)
|
|
20
|
+
? \`"\${safeValue.replaceAll('"', '""')}"\`
|
|
21
|
+
: safeValue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function build${entitiesPascal}Csv(${entitiesCamel}: readonly Csv${entityPascal}[]): string {
|
|
25
|
+
const rows = ${entitiesCamel}.map((item) => [${row}].join(','));
|
|
26
|
+
|
|
27
|
+
return \`\\uFEFF\${['${header}', ...rows].join('\\r\\n')}\`;
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
}
|