create-projx 1.7.0 → 1.7.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 +13 -35
- package/dist/{baseline-FHOZNS4D.js → baseline-FKCXQFRD.js} +2 -2
- package/dist/{chunk-IMZKHDIL.js → chunk-N4WD4VN3.js} +15 -18
- package/dist/{chunk-HAT7D4G2.js → chunk-OLPF7FAN.js} +1 -1
- package/dist/index.js +166 -385
- package/dist/{utils-BZGSJ7XZ.js → utils-4G3HNHES.js} +1 -1
- package/package.json +2 -3
- package/src/templates/README.md.ejs +1 -1
- package/src/addons/orms/drizzle/express/src/app.ts +0 -81
- package/src/addons/orms/drizzle/express/src/modules/_base/auto-routes.ts +0 -278
- package/src/addons/orms/drizzle/express/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/drizzle/express/src/server.ts +0 -32
- package/src/addons/orms/drizzle/express/tests/app.test.ts +0 -24
- package/src/addons/orms/drizzle/express/vitest.config.ts +0 -20
- package/src/addons/orms/drizzle/fastify/src/app.ts +0 -90
- package/src/addons/orms/drizzle/fastify/src/modules/_base/auto-routes.ts +0 -268
- package/src/addons/orms/drizzle/fastify/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/drizzle/fastify/tests/modules/app.test.ts +0 -20
- package/src/addons/orms/drizzle/fastify/vitest.config.ts +0 -31
- package/src/addons/orms/drizzle/gen-entity/express-router.ts +0 -21
- package/src/addons/orms/drizzle/gen-entity/express-test.ts +0 -61
- package/src/addons/orms/drizzle/gen-entity/fastify-router.ts +0 -19
- package/src/addons/orms/drizzle/gen-entity/fastify-test.ts +0 -87
- package/src/addons/orms/drizzle/manifest.json +0 -52
- package/src/addons/orms/drizzle/shared/drizzle.config.ts +0 -12
- package/src/addons/orms/drizzle/shared/src/db/client.ts +0 -17
- package/src/addons/orms/drizzle/shared/src/db/schema.ts +0 -14
- package/src/addons/orms/drizzle/shared/src/modules/_base/query-engine.ts +0 -115
- package/src/addons/orms/drizzle/shared/src/modules/_base/registry.ts +0 -15
- package/src/addons/orms/sequelize/express/src/app.ts +0 -82
- package/src/addons/orms/sequelize/express/src/modules/_base/auto-routes.ts +0 -226
- package/src/addons/orms/sequelize/express/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/sequelize/express/src/server.ts +0 -32
- package/src/addons/orms/sequelize/express/tests/app.test.ts +0 -24
- package/src/addons/orms/sequelize/express/vitest.config.ts +0 -20
- package/src/addons/orms/sequelize/fastify/src/app.ts +0 -83
- package/src/addons/orms/sequelize/fastify/src/modules/_base/auto-routes.ts +0 -216
- package/src/addons/orms/sequelize/fastify/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/sequelize/fastify/tests/modules/app.test.ts +0 -20
- package/src/addons/orms/sequelize/fastify/vitest.config.ts +0 -31
- package/src/addons/orms/sequelize/gen-entity/express-router.ts +0 -17
- package/src/addons/orms/sequelize/gen-entity/express-test.ts +0 -65
- package/src/addons/orms/sequelize/gen-entity/fastify-router.ts +0 -19
- package/src/addons/orms/sequelize/gen-entity/fastify-test.ts +0 -89
- package/src/addons/orms/sequelize/gen-entity/model.ts +0 -21
- package/src/addons/orms/sequelize/manifest.json +0 -53
- package/src/addons/orms/sequelize/shared/scripts/db-sync.ts +0 -14
- package/src/addons/orms/sequelize/shared/src/db/client.ts +0 -19
- package/src/addons/orms/sequelize/shared/src/models/index.ts +0 -9
- package/src/addons/orms/sequelize/shared/src/modules/_base/query-engine.ts +0 -101
- package/src/addons/orms/sequelize/shared/src/modules/_base/registry.ts +0 -15
- package/src/addons/orms/typeorm/express/src/app.ts +0 -82
- package/src/addons/orms/typeorm/express/src/modules/_base/auto-routes.ts +0 -249
- package/src/addons/orms/typeorm/express/src/modules/_base/index.ts +0 -19
- package/src/addons/orms/typeorm/express/src/server.ts +0 -43
- package/src/addons/orms/typeorm/express/tests/app.test.ts +0 -24
- package/src/addons/orms/typeorm/express/vitest.config.ts +0 -20
- package/src/addons/orms/typeorm/fastify/src/app.ts +0 -86
- package/src/addons/orms/typeorm/fastify/src/modules/_base/auto-routes.ts +0 -239
- package/src/addons/orms/typeorm/fastify/src/modules/_base/index.ts +0 -19
- package/src/addons/orms/typeorm/fastify/tests/modules/app.test.ts +0 -20
- package/src/addons/orms/typeorm/fastify/vitest.config.ts +0 -31
- package/src/addons/orms/typeorm/gen-entity/entity.ts +0 -21
- package/src/addons/orms/typeorm/gen-entity/express-router.ts +0 -17
- package/src/addons/orms/typeorm/gen-entity/express-test.ts +0 -66
- package/src/addons/orms/typeorm/gen-entity/fastify-router.ts +0 -19
- package/src/addons/orms/typeorm/gen-entity/fastify-test.ts +0 -89
- package/src/addons/orms/typeorm/manifest.json +0 -53
- package/src/addons/orms/typeorm/shared/scripts/db-sync.ts +0 -14
- package/src/addons/orms/typeorm/shared/src/db/data-source.ts +0 -21
- package/src/addons/orms/typeorm/shared/src/entities/index.ts +0 -8
- package/src/addons/orms/typeorm/shared/src/modules/_base/query-engine.ts +0 -94
- package/src/addons/orms/typeorm/shared/src/modules/_base/registry.ts +0 -15
- package/src/addons/orms/typeorm/shared/tsconfig.json +0 -16
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { Op, type Order, type WhereOptions } from 'sequelize';
|
|
2
|
-
|
|
3
|
-
export interface ParsedQuery {
|
|
4
|
-
page: number;
|
|
5
|
-
page_size: number;
|
|
6
|
-
order_by?: string;
|
|
7
|
-
search?: string;
|
|
8
|
-
filters: Record<string, string>;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const RESERVED = new Set(['page', 'page_size', 'order_by', 'search']);
|
|
12
|
-
|
|
13
|
-
export function parseRawQuery(rawQs: string): ParsedQuery {
|
|
14
|
-
const params = new URLSearchParams(rawQs);
|
|
15
|
-
const page = Math.max(1, Number(params.get('page')) || 1);
|
|
16
|
-
const page_size = Math.min(
|
|
17
|
-
100,
|
|
18
|
-
Math.max(1, Number(params.get('page_size')) || 10),
|
|
19
|
-
);
|
|
20
|
-
const filters: Record<string, string> = {};
|
|
21
|
-
for (const [key, value] of params.entries()) {
|
|
22
|
-
if (RESERVED.has(key)) continue;
|
|
23
|
-
filters[key] = value;
|
|
24
|
-
}
|
|
25
|
-
return {
|
|
26
|
-
page,
|
|
27
|
-
page_size,
|
|
28
|
-
order_by: params.get('order_by') ?? undefined,
|
|
29
|
-
search: params.get('search') ?? undefined,
|
|
30
|
-
filters,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function buildWhere(
|
|
35
|
-
attributes: Set<string>,
|
|
36
|
-
filters: Record<string, string>,
|
|
37
|
-
): WhereOptions {
|
|
38
|
-
const where: Record<string, unknown> = {};
|
|
39
|
-
for (const [key, value] of Object.entries(filters)) {
|
|
40
|
-
if (!attributes.has(key)) continue;
|
|
41
|
-
where[key] = value;
|
|
42
|
-
}
|
|
43
|
-
return where as WhereOptions;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function buildSearchWhere(
|
|
47
|
-
searchableFields: string[],
|
|
48
|
-
term: string | undefined,
|
|
49
|
-
): WhereOptions | undefined {
|
|
50
|
-
if (!term) return undefined;
|
|
51
|
-
const trimmed = term.trim();
|
|
52
|
-
if (!trimmed || searchableFields.length === 0) return undefined;
|
|
53
|
-
const pattern = `%${trimmed}%`;
|
|
54
|
-
return {
|
|
55
|
-
[Op.or]: searchableFields.map((field) => ({
|
|
56
|
-
[field]: { [Op.iLike]: pattern },
|
|
57
|
-
})),
|
|
58
|
-
} as WhereOptions;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function combineWhere(a: WhereOptions, b?: WhereOptions): WhereOptions {
|
|
62
|
-
if (!b) return a;
|
|
63
|
-
return { [Op.and]: [a, b] } as WhereOptions;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function buildOrder(
|
|
67
|
-
attributes: Set<string>,
|
|
68
|
-
orderBy: string | undefined,
|
|
69
|
-
): Order | undefined {
|
|
70
|
-
if (!orderBy) return undefined;
|
|
71
|
-
const out: [string, 'ASC' | 'DESC'][] = [];
|
|
72
|
-
for (const raw of orderBy.split(',')) {
|
|
73
|
-
const term = raw.trim();
|
|
74
|
-
if (!term) continue;
|
|
75
|
-
const descOrder = term.startsWith('-');
|
|
76
|
-
const fieldName = descOrder ? term.slice(1) : term;
|
|
77
|
-
if (!attributes.has(fieldName)) continue;
|
|
78
|
-
out.push([fieldName, descOrder ? 'DESC' : 'ASC']);
|
|
79
|
-
}
|
|
80
|
-
return out.length > 0 ? (out as Order) : undefined;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface PaginationMeta {
|
|
84
|
-
current_page: number;
|
|
85
|
-
page_size: number;
|
|
86
|
-
total_records: number;
|
|
87
|
-
total_pages: number;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function buildPagination(
|
|
91
|
-
page: number,
|
|
92
|
-
page_size: number,
|
|
93
|
-
total: number,
|
|
94
|
-
): PaginationMeta {
|
|
95
|
-
return {
|
|
96
|
-
current_page: page,
|
|
97
|
-
page_size,
|
|
98
|
-
total_records: total,
|
|
99
|
-
total_pages: Math.max(1, Math.ceil(total / page_size)),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export interface RegisteredEntity {
|
|
2
|
-
name: string;
|
|
3
|
-
apiPrefix: string;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
const entityList: RegisteredEntity[] = [];
|
|
7
|
-
|
|
8
|
-
export function registerInRegistry(entry: RegisteredEntity): void {
|
|
9
|
-
if (entityList.some((existing) => existing.name === entry.name)) return;
|
|
10
|
-
entityList.push(entry);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function listEntities(): RegisteredEntity[] {
|
|
14
|
-
return [...entityList].sort((a, b) => a.name.localeCompare(b.name));
|
|
15
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import 'reflect-metadata';
|
|
2
|
-
import crypto from 'node:crypto';
|
|
3
|
-
import compression from 'compression';
|
|
4
|
-
import cors from 'cors';
|
|
5
|
-
import express, { type RequestHandler } from 'express';
|
|
6
|
-
import rateLimit from 'express-rate-limit';
|
|
7
|
-
import helmet from 'helmet';
|
|
8
|
-
import pinoHttp from 'pino-http';
|
|
9
|
-
import { allowedOrigins, config } from './config.js';
|
|
10
|
-
import { ApiError, errorHandler, notFoundHandler } from './errors.js';
|
|
11
|
-
import { checkDatabase, dataSource } from './db/data-source.js';
|
|
12
|
-
import { listEntities } from './modules/_base/index.js';
|
|
13
|
-
// projx-anchor: entity-imports
|
|
14
|
-
|
|
15
|
-
const requestId: RequestHandler = (req, res, next) => {
|
|
16
|
-
const incoming = req.headers['x-request-id'];
|
|
17
|
-
const value =
|
|
18
|
-
typeof incoming === 'string' && incoming.trim()
|
|
19
|
-
? incoming
|
|
20
|
-
: crypto.randomUUID();
|
|
21
|
-
res.locals.requestId = value;
|
|
22
|
-
res.setHeader('x-request-id', value);
|
|
23
|
-
next();
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
function corsOrigin(
|
|
27
|
-
origin: string | undefined,
|
|
28
|
-
callback: (err: Error | null, allow?: boolean) => void,
|
|
29
|
-
): void {
|
|
30
|
-
const origins = allowedOrigins();
|
|
31
|
-
if (!origin || origins.includes('*') || origins.includes(origin)) {
|
|
32
|
-
callback(null, true);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
callback(new ApiError(403, 'Origin not allowed', 'origin_not_allowed'));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function buildApp(): express.Express {
|
|
39
|
-
const app = express();
|
|
40
|
-
|
|
41
|
-
app.disable('x-powered-by');
|
|
42
|
-
app.locals.dataSource = dataSource;
|
|
43
|
-
app.use(requestId);
|
|
44
|
-
app.use(pinoHttp({ level: config.LOG_LEVEL }));
|
|
45
|
-
app.use(helmet());
|
|
46
|
-
app.use(cors({ origin: corsOrigin, credentials: true }));
|
|
47
|
-
app.use(compression());
|
|
48
|
-
app.use(express.json({ limit: '1mb' }));
|
|
49
|
-
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
|
|
50
|
-
app.use(
|
|
51
|
-
rateLimit({
|
|
52
|
-
windowMs: config.RATE_LIMIT_WINDOW_MS,
|
|
53
|
-
limit: config.RATE_LIMIT_MAX,
|
|
54
|
-
standardHeaders: 'draft-8',
|
|
55
|
-
legacyHeaders: false,
|
|
56
|
-
}),
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
app.get('/api/health', async (_req, res) => {
|
|
60
|
-
const checks: Record<string, string> = { app: 'ok' };
|
|
61
|
-
try {
|
|
62
|
-
await checkDatabase();
|
|
63
|
-
checks.database = 'ok';
|
|
64
|
-
} catch (e) {
|
|
65
|
-
checks.database = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
66
|
-
res.status(503).json({ status: 'unhealthy', checks });
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
res.json({ status: 'healthy', checks });
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
app.get('/api/v1/_meta', (_req, res) => {
|
|
73
|
-
res.json({ entities: listEntities(), orm: 'typeorm' });
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// projx-anchor: entity-registrations
|
|
77
|
-
|
|
78
|
-
app.use(notFoundHandler);
|
|
79
|
-
app.use(errorHandler);
|
|
80
|
-
|
|
81
|
-
return app;
|
|
82
|
-
}
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
import express, {
|
|
2
|
-
type NextFunction,
|
|
3
|
-
type Request,
|
|
4
|
-
type Response,
|
|
5
|
-
} from 'express';
|
|
6
|
-
import type {
|
|
7
|
-
DeepPartial,
|
|
8
|
-
EntityTarget,
|
|
9
|
-
ObjectLiteral,
|
|
10
|
-
Repository,
|
|
11
|
-
} from 'typeorm';
|
|
12
|
-
import { dataSource } from '../../db/data-source.js';
|
|
13
|
-
import { ApiError } from '../../errors.js';
|
|
14
|
-
import { registerInRegistry } from './registry.js';
|
|
15
|
-
import {
|
|
16
|
-
buildOrder,
|
|
17
|
-
buildPagination,
|
|
18
|
-
buildSearchWheres,
|
|
19
|
-
buildWhere,
|
|
20
|
-
parseRawQuery,
|
|
21
|
-
} from './query-engine.js';
|
|
22
|
-
|
|
23
|
-
export type BeforeCreateHook = (
|
|
24
|
-
request: Request,
|
|
25
|
-
data: Record<string, unknown>,
|
|
26
|
-
) => void | Promise<void>;
|
|
27
|
-
export type AfterCreateHook = (
|
|
28
|
-
request: Request,
|
|
29
|
-
record: Record<string, unknown>,
|
|
30
|
-
) => void | Promise<void>;
|
|
31
|
-
export type BeforeUpdateHook = (
|
|
32
|
-
request: Request,
|
|
33
|
-
response: Response,
|
|
34
|
-
data: Record<string, unknown>,
|
|
35
|
-
) => void | Promise<void>;
|
|
36
|
-
export type AfterUpdateHook = (
|
|
37
|
-
request: Request,
|
|
38
|
-
before: Record<string, unknown>,
|
|
39
|
-
after: Record<string, unknown>,
|
|
40
|
-
) => void | Promise<void>;
|
|
41
|
-
export type BeforeDeleteHook = (
|
|
42
|
-
request: Request,
|
|
43
|
-
recordId: string,
|
|
44
|
-
) => void | Promise<void>;
|
|
45
|
-
|
|
46
|
-
export interface TypeormEntityConfig<T extends ObjectLiteral> {
|
|
47
|
-
name: string;
|
|
48
|
-
apiPrefix: string;
|
|
49
|
-
tag: string;
|
|
50
|
-
entity: EntityTarget<T>;
|
|
51
|
-
primaryKey?: string;
|
|
52
|
-
searchableFields?: string[];
|
|
53
|
-
readonly?: boolean;
|
|
54
|
-
bulkOperations?: boolean;
|
|
55
|
-
beforeCreate?: BeforeCreateHook;
|
|
56
|
-
afterCreate?: AfterCreateHook;
|
|
57
|
-
beforeUpdate?: BeforeUpdateHook;
|
|
58
|
-
afterUpdate?: AfterUpdateHook;
|
|
59
|
-
beforeDelete?: BeforeDeleteHook;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type AsyncHandler = (
|
|
63
|
-
req: Request,
|
|
64
|
-
res: Response,
|
|
65
|
-
next: NextFunction,
|
|
66
|
-
) => Promise<void>;
|
|
67
|
-
|
|
68
|
-
function asyncHandler(handler: AsyncHandler) {
|
|
69
|
-
return (req: Request, res: Response, next: NextFunction): void => {
|
|
70
|
-
handler(req, res, next).catch(next);
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function columnNames<T extends ObjectLiteral>(
|
|
75
|
-
repo: Repository<T>,
|
|
76
|
-
): Set<string> {
|
|
77
|
-
return new Set(repo.metadata.columns.map((c) => c.propertyName));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function registerEntityRoutes<T extends ObjectLiteral>(
|
|
81
|
-
config: TypeormEntityConfig<T>,
|
|
82
|
-
): express.Router {
|
|
83
|
-
registerInRegistry({ name: config.name, apiPrefix: config.apiPrefix });
|
|
84
|
-
const router = express.Router();
|
|
85
|
-
const pk = config.primaryKey ?? 'id';
|
|
86
|
-
|
|
87
|
-
function repo(): Repository<T> {
|
|
88
|
-
return dataSource.getRepository(config.entity);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
router.get(
|
|
92
|
-
'/',
|
|
93
|
-
asyncHandler(async (req, res) => {
|
|
94
|
-
const rawQs = req.originalUrl.split('?')[1] ?? '';
|
|
95
|
-
const query = parseRawQuery(rawQs);
|
|
96
|
-
const r = repo();
|
|
97
|
-
const cols = columnNames(r);
|
|
98
|
-
const filterWhere = buildWhere<T>(cols, query.filters);
|
|
99
|
-
const searchWheres = buildSearchWheres<T>(
|
|
100
|
-
config.searchableFields ?? [],
|
|
101
|
-
query.search,
|
|
102
|
-
);
|
|
103
|
-
const where =
|
|
104
|
-
searchWheres.length > 0
|
|
105
|
-
? searchWheres.map((s) => ({ ...filterWhere, ...s }))
|
|
106
|
-
: filterWhere;
|
|
107
|
-
const order = buildOrder<T>(cols, query.order_by);
|
|
108
|
-
const [rows, count] = await r.findAndCount({
|
|
109
|
-
where,
|
|
110
|
-
order,
|
|
111
|
-
skip: (query.page - 1) * query.page_size,
|
|
112
|
-
take: query.page_size,
|
|
113
|
-
});
|
|
114
|
-
res.json({
|
|
115
|
-
data: rows,
|
|
116
|
-
pagination: buildPagination(query.page, query.page_size, count),
|
|
117
|
-
});
|
|
118
|
-
}),
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
router.get(
|
|
122
|
-
'/:id',
|
|
123
|
-
asyncHandler(async (req, res) => {
|
|
124
|
-
const record = await repo().findOne({
|
|
125
|
-
where: { [pk]: String(req.params.id) } as never,
|
|
126
|
-
});
|
|
127
|
-
if (!record) throw new ApiError(404, 'Not found', 'not_found');
|
|
128
|
-
res.json(record);
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
if (config.readonly) return router;
|
|
133
|
-
|
|
134
|
-
router.post(
|
|
135
|
-
'/',
|
|
136
|
-
asyncHandler(async (req, res) => {
|
|
137
|
-
const data = req.body as Record<string, unknown>;
|
|
138
|
-
await config.beforeCreate?.(req, data);
|
|
139
|
-
const r = repo();
|
|
140
|
-
const entity = r.create(data as DeepPartial<T>);
|
|
141
|
-
const record = (await r.save(entity)) as unknown as Record<
|
|
142
|
-
string,
|
|
143
|
-
unknown
|
|
144
|
-
>;
|
|
145
|
-
if (config.afterCreate) {
|
|
146
|
-
try {
|
|
147
|
-
await config.afterCreate(req, record);
|
|
148
|
-
} catch (err) {
|
|
149
|
-
req.log?.error?.(
|
|
150
|
-
{
|
|
151
|
-
err,
|
|
152
|
-
entity: config.name,
|
|
153
|
-
record_id: (record as { id?: string }).id,
|
|
154
|
-
},
|
|
155
|
-
'afterCreate hook failed (record persisted; hook is best-effort)',
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
res.status(201).json(record);
|
|
160
|
-
}),
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
router.patch(
|
|
164
|
-
'/:id',
|
|
165
|
-
asyncHandler(async (req, res) => {
|
|
166
|
-
const id = String(req.params.id);
|
|
167
|
-
const data = req.body as Record<string, unknown>;
|
|
168
|
-
if (!data || Object.keys(data).length === 0) {
|
|
169
|
-
throw new ApiError(400, 'Request body cannot be empty', 'empty_body');
|
|
170
|
-
}
|
|
171
|
-
if (config.beforeUpdate) {
|
|
172
|
-
await config.beforeUpdate(req, res, data);
|
|
173
|
-
if (res.headersSent) return;
|
|
174
|
-
}
|
|
175
|
-
const r = repo();
|
|
176
|
-
const existing = await r.findOne({ where: { [pk]: id } as never });
|
|
177
|
-
if (!existing) throw new ApiError(404, 'Not found', 'not_found');
|
|
178
|
-
const before = { ...(existing as Record<string, unknown>) };
|
|
179
|
-
Object.assign(existing, data);
|
|
180
|
-
const saved = (await r.save(existing)) as Record<string, unknown>;
|
|
181
|
-
if (config.afterUpdate) {
|
|
182
|
-
try {
|
|
183
|
-
await config.afterUpdate(req, before, saved);
|
|
184
|
-
} catch (err) {
|
|
185
|
-
req.log?.error?.(
|
|
186
|
-
{ err, entity: config.name, record_id: id },
|
|
187
|
-
'afterUpdate hook failed (record persisted; hook is best-effort)',
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
res.json(saved);
|
|
192
|
-
}),
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
router.delete(
|
|
196
|
-
'/:id',
|
|
197
|
-
asyncHandler(async (req, res) => {
|
|
198
|
-
const id = String(req.params.id);
|
|
199
|
-
if (config.beforeDelete) await config.beforeDelete(req, id);
|
|
200
|
-
const result = await repo().delete({ [pk]: id } as never);
|
|
201
|
-
if (!result.affected) throw new ApiError(404, 'Not found', 'not_found');
|
|
202
|
-
res.status(204).send();
|
|
203
|
-
}),
|
|
204
|
-
);
|
|
205
|
-
|
|
206
|
-
if (!config.bulkOperations) return router;
|
|
207
|
-
|
|
208
|
-
router.post(
|
|
209
|
-
'/bulk',
|
|
210
|
-
asyncHandler(async (req, res) => {
|
|
211
|
-
const { items } = req.body as { items: Record<string, unknown>[] };
|
|
212
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
213
|
-
throw new ApiError(
|
|
214
|
-
400,
|
|
215
|
-
'items must be a non-empty array',
|
|
216
|
-
'validation_error',
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
for (const item of items) {
|
|
220
|
-
await config.beforeCreate?.(req, item);
|
|
221
|
-
}
|
|
222
|
-
const r = repo();
|
|
223
|
-
const entities = r.create(items as DeepPartial<T>[]);
|
|
224
|
-
const rows = (await r.save(entities)) as unknown as Record<
|
|
225
|
-
string,
|
|
226
|
-
unknown
|
|
227
|
-
>[];
|
|
228
|
-
res.status(201).json({ data: rows, count: rows.length });
|
|
229
|
-
}),
|
|
230
|
-
);
|
|
231
|
-
|
|
232
|
-
router.delete(
|
|
233
|
-
'/bulk',
|
|
234
|
-
asyncHandler(async (req, res) => {
|
|
235
|
-
const { ids } = req.body as { ids: string[] };
|
|
236
|
-
if (!Array.isArray(ids) || ids.length === 0) {
|
|
237
|
-
throw new ApiError(
|
|
238
|
-
400,
|
|
239
|
-
'ids must be a non-empty array',
|
|
240
|
-
'validation_error',
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
await repo().delete(ids as never);
|
|
244
|
-
res.status(204).send();
|
|
245
|
-
}),
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
return router;
|
|
249
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
export { registerEntityRoutes } from './auto-routes.js';
|
|
2
|
-
export type {
|
|
3
|
-
TypeormEntityConfig,
|
|
4
|
-
BeforeCreateHook,
|
|
5
|
-
AfterCreateHook,
|
|
6
|
-
BeforeUpdateHook,
|
|
7
|
-
AfterUpdateHook,
|
|
8
|
-
BeforeDeleteHook,
|
|
9
|
-
} from './auto-routes.js';
|
|
10
|
-
export { listEntities, registerInRegistry } from './registry.js';
|
|
11
|
-
export type { RegisteredEntity } from './registry.js';
|
|
12
|
-
export {
|
|
13
|
-
buildOrder,
|
|
14
|
-
buildPagination,
|
|
15
|
-
buildSearchWheres,
|
|
16
|
-
buildWhere,
|
|
17
|
-
parseRawQuery,
|
|
18
|
-
} from './query-engine.js';
|
|
19
|
-
export type { ParsedQuery, PaginationMeta } from './query-engine.js';
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import 'reflect-metadata';
|
|
2
|
-
import { createServer } from 'node:http';
|
|
3
|
-
import { buildApp } from './app.js';
|
|
4
|
-
import { config } from './config.js';
|
|
5
|
-
import { closeDatabase, dataSource } from './db/data-source.js';
|
|
6
|
-
|
|
7
|
-
async function main(): Promise<void> {
|
|
8
|
-
if (!dataSource.isInitialized) await dataSource.initialize();
|
|
9
|
-
const app = buildApp();
|
|
10
|
-
const server = createServer(app);
|
|
11
|
-
|
|
12
|
-
server.listen(config.PORT, config.HOST, () => {
|
|
13
|
-
console.log(
|
|
14
|
-
`Express API listening on http://${config.HOST}:${config.PORT}`,
|
|
15
|
-
);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const shutdown = (signal: string): void => {
|
|
19
|
-
console.log(`${signal} received, closing HTTP server`);
|
|
20
|
-
server.close((err) => {
|
|
21
|
-
closeDatabase()
|
|
22
|
-
.catch((closeErr: unknown) => {
|
|
23
|
-
console.error(closeErr);
|
|
24
|
-
})
|
|
25
|
-
.finally(() => {
|
|
26
|
-
if (err) {
|
|
27
|
-
console.error(err);
|
|
28
|
-
process.exit(1);
|
|
29
|
-
}
|
|
30
|
-
process.exit(0);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
setTimeout(() => process.exit(1), 10_000).unref();
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
process.on('SIGTERM', shutdown);
|
|
37
|
-
process.on('SIGINT', shutdown);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
main().catch((err: unknown) => {
|
|
41
|
-
console.error('Failed to start server:', err);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import request from 'supertest';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { buildApp } from '../src/app.js';
|
|
4
|
-
|
|
5
|
-
describe('Express TypeORM app', () => {
|
|
6
|
-
it('exposes empty generated metadata until entities are added', async () => {
|
|
7
|
-
const res = await request(buildApp()).get('/api/v1/_meta');
|
|
8
|
-
|
|
9
|
-
expect(res.status).toBe(200);
|
|
10
|
-
expect(res.body).toEqual({ entities: [], orm: 'typeorm' });
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('returns structured errors with request id', async () => {
|
|
14
|
-
const res = await request(buildApp())
|
|
15
|
-
.get('/missing')
|
|
16
|
-
.set('x-request-id', 'req-missing');
|
|
17
|
-
|
|
18
|
-
expect(res.status).toBe(404);
|
|
19
|
-
expect(res.body.error).toMatchObject({
|
|
20
|
-
code: 'not_found',
|
|
21
|
-
request_id: 'req-missing',
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
});
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
test: {
|
|
5
|
-
globals: true,
|
|
6
|
-
environment: 'node',
|
|
7
|
-
include: ['tests/**/*.test.ts'],
|
|
8
|
-
coverage: {
|
|
9
|
-
provider: 'v8',
|
|
10
|
-
include: ['src/**/*.ts'],
|
|
11
|
-
exclude: ['src/server.ts', 'src/config.ts'],
|
|
12
|
-
thresholds: {
|
|
13
|
-
statements: 80,
|
|
14
|
-
branches: 80,
|
|
15
|
-
functions: 80,
|
|
16
|
-
lines: 80,
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
});
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import 'reflect-metadata';
|
|
2
|
-
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
|
3
|
-
import cors from '@fastify/cors';
|
|
4
|
-
import helmet from '@fastify/helmet';
|
|
5
|
-
import rateLimit from '@fastify/rate-limit';
|
|
6
|
-
import { config } from './config.js';
|
|
7
|
-
import errorHandler from './plugins/error-handler.js';
|
|
8
|
-
import authPlugin from './plugins/auth.js';
|
|
9
|
-
import authzPlugin from './plugins/authz.js';
|
|
10
|
-
import requestIdPlugin from './plugins/request-id.js';
|
|
11
|
-
import swaggerPlugin from './plugins/swagger.js';
|
|
12
|
-
import { checkDatabase, closeDatabase, dataSource } from './db/data-source.js';
|
|
13
|
-
import { listEntities } from './modules/_base/index.js';
|
|
14
|
-
// projx-anchor: entity-imports
|
|
15
|
-
|
|
16
|
-
export interface BuildAppOptions {
|
|
17
|
-
logger?: boolean | object;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function buildApp(
|
|
21
|
-
options: BuildAppOptions = {},
|
|
22
|
-
): Promise<FastifyInstance> {
|
|
23
|
-
const app = Fastify({
|
|
24
|
-
logger: options.logger ?? { level: config.LOG_LEVEL },
|
|
25
|
-
genReqId: (req) =>
|
|
26
|
-
(req.headers['x-request-id'] as string) || crypto.randomUUID(),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
if (!dataSource.isInitialized) {
|
|
30
|
-
await dataSource.initialize();
|
|
31
|
-
}
|
|
32
|
-
app.decorate('dataSource', dataSource);
|
|
33
|
-
await app.register(helmet, { contentSecurityPolicy: false });
|
|
34
|
-
await app.register(cors, {
|
|
35
|
-
origin: config.CORS_ALLOW_ORIGINS.split(',').map((o) => o.trim()),
|
|
36
|
-
credentials: true,
|
|
37
|
-
});
|
|
38
|
-
await app.register(rateLimit, {
|
|
39
|
-
max: config.RATE_LIMIT_MAX,
|
|
40
|
-
timeWindow: config.RATE_LIMIT_WINDOW,
|
|
41
|
-
keyGenerator: (request: FastifyRequest) =>
|
|
42
|
-
request.authUser?.sub ?? request.ip,
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
await app.register(swaggerPlugin);
|
|
46
|
-
await app.register(errorHandler);
|
|
47
|
-
await app.register(requestIdPlugin);
|
|
48
|
-
await app.register(authPlugin);
|
|
49
|
-
await app.register(authzPlugin);
|
|
50
|
-
|
|
51
|
-
app.get(
|
|
52
|
-
'/api/health',
|
|
53
|
-
{ config: { public: true }, schema: { tags: ['health'] } },
|
|
54
|
-
async (_request, reply) => {
|
|
55
|
-
const checks: Record<string, string> = { app: 'ok' };
|
|
56
|
-
try {
|
|
57
|
-
await checkDatabase();
|
|
58
|
-
checks.database = 'ok';
|
|
59
|
-
} catch (e) {
|
|
60
|
-
checks.database = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
61
|
-
return reply.status(503).send({ status: 'unhealthy', checks });
|
|
62
|
-
}
|
|
63
|
-
return reply.send({ status: 'healthy', checks });
|
|
64
|
-
},
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
app.get(
|
|
68
|
-
'/api/v1/_meta',
|
|
69
|
-
{ config: { public: true }, schema: { tags: ['meta'] } },
|
|
70
|
-
async () => ({ entities: listEntities(), orm: 'typeorm' }),
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
// projx-anchor: entity-registrations
|
|
74
|
-
|
|
75
|
-
app.addHook('onClose', async () => {
|
|
76
|
-
await closeDatabase();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
return app;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
declare module 'fastify' {
|
|
83
|
-
interface FastifyInstance {
|
|
84
|
-
dataSource: typeof dataSource;
|
|
85
|
-
}
|
|
86
|
-
}
|