@fuzionx/framework 0.1.2
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/bin/fx.js +12 -0
- package/index.js +64 -0
- package/lib/core/AppError.js +46 -0
- package/lib/core/Application.js +553 -0
- package/lib/core/AutoLoader.js +162 -0
- package/lib/core/Base.js +64 -0
- package/lib/core/Config.js +122 -0
- package/lib/core/Context.js +429 -0
- package/lib/database/ConnectionManager.js +192 -0
- package/lib/database/MariaModel.js +29 -0
- package/lib/database/Model.js +247 -0
- package/lib/database/ModelRegistry.js +72 -0
- package/lib/database/MongoModel.js +232 -0
- package/lib/database/Pagination.js +37 -0
- package/lib/database/PostgreModel.js +29 -0
- package/lib/database/QueryBuilder.js +172 -0
- package/lib/database/SQLiteModel.js +27 -0
- package/lib/database/SqlModel.js +252 -0
- package/lib/database/SqlQueryBuilder.js +309 -0
- package/lib/helpers/CryptoHelper.js +48 -0
- package/lib/helpers/FileHelper.js +61 -0
- package/lib/helpers/HashHelper.js +39 -0
- package/lib/helpers/I18nHelper.js +170 -0
- package/lib/helpers/Logger.js +105 -0
- package/lib/helpers/MediaHelper.js +38 -0
- package/lib/http/Controller.js +34 -0
- package/lib/http/ErrorHandler.js +135 -0
- package/lib/http/Middleware.js +43 -0
- package/lib/http/Router.js +109 -0
- package/lib/http/Validation.js +124 -0
- package/lib/middleware/index.js +286 -0
- package/lib/realtime/RoomManager.js +85 -0
- package/lib/realtime/WsHandler.js +107 -0
- package/lib/schedule/Job.js +34 -0
- package/lib/schedule/Queue.js +90 -0
- package/lib/schedule/Scheduler.js +161 -0
- package/lib/schedule/Task.js +39 -0
- package/lib/schedule/WorkerPool.js +225 -0
- package/lib/services/EventBus.js +94 -0
- package/lib/services/Service.js +261 -0
- package/lib/services/Storage.js +112 -0
- package/lib/view/OpenAPI.js +231 -0
- package/lib/view/View.js +72 -0
- package/package.json +52 -0
- package/testing/index.js +232 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger — Bridge tracing 통합 로거
|
|
3
|
+
*
|
|
4
|
+
* Bridge(Rust) logInfo/logWarn/logError/logDebug N-API에 위임.
|
|
5
|
+
* Bridge 없으면 JS console 폴백.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/framework/13-logging.md
|
|
8
|
+
* @see packages/fuzionx/lib/logger.js (Core 래퍼 참조)
|
|
9
|
+
*/
|
|
10
|
+
import { format } from 'node:util';
|
|
11
|
+
|
|
12
|
+
export default class Logger {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} [opts]
|
|
15
|
+
* @param {string} [opts.level='info'] - 최소 출력 레벨
|
|
16
|
+
* @param {object} [opts.bridge] - Bridge N-API 인스턴스
|
|
17
|
+
* @param {boolean} [opts.json=false] - JSON 포맷 (Bridge 없을 때)
|
|
18
|
+
* @param {string} [opts.prefix='app'] - Bridge target 이름
|
|
19
|
+
*/
|
|
20
|
+
constructor(opts = {}) {
|
|
21
|
+
this.level = opts.level || 'info';
|
|
22
|
+
this._bridge = opts.bridge || null;
|
|
23
|
+
this._json = opts.json || false;
|
|
24
|
+
this._prefix = opts.prefix || 'app';
|
|
25
|
+
this._levels = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
26
|
+
this._minLevel = this._levels[this.level] ?? 2;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} message
|
|
31
|
+
* @param {object} [context]
|
|
32
|
+
*/
|
|
33
|
+
error(message, context) { this._log('error', message, context); }
|
|
34
|
+
warn(message, context) { this._log('warn', message, context); }
|
|
35
|
+
info(message, context) { this._log('info', message, context); }
|
|
36
|
+
debug(message, context) { this._log('debug', message, context); }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 자식 로거 생성 (prefix 상속)
|
|
40
|
+
* @param {string} prefix
|
|
41
|
+
* @returns {Logger}
|
|
42
|
+
*/
|
|
43
|
+
child(prefix) {
|
|
44
|
+
return new Logger({
|
|
45
|
+
level: this.level,
|
|
46
|
+
bridge: this._bridge,
|
|
47
|
+
json: this._json,
|
|
48
|
+
prefix: this._prefix ? `${this._prefix}:${prefix}` : prefix,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @private */
|
|
53
|
+
_log(level, message, context) {
|
|
54
|
+
if (this._levels[level] > this._minLevel) return;
|
|
55
|
+
|
|
56
|
+
const msg = context
|
|
57
|
+
? `${message} ${JSON.stringify(context)}`
|
|
58
|
+
: message;
|
|
59
|
+
|
|
60
|
+
// ── Bridge N-API 위임 ──
|
|
61
|
+
// Core logger.js 참조: bridge.logInfo(target, msg), bridge.logWarn(target, msg, location?)
|
|
62
|
+
if (this._bridge) {
|
|
63
|
+
try {
|
|
64
|
+
if (level === 'info' && typeof this._bridge.logInfo === 'function') {
|
|
65
|
+
this._bridge.logInfo(this._prefix, msg);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (level === 'warn' && typeof this._bridge.logWarn === 'function') {
|
|
69
|
+
this._bridge.logWarn(this._prefix, msg);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (level === 'error' && typeof this._bridge.logError === 'function') {
|
|
73
|
+
this._bridge.logError(this._prefix, msg);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (level === 'debug' && typeof this._bridge.logDebug === 'function') {
|
|
77
|
+
this._bridge.logDebug(this._prefix, msg);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
} catch {} // Bridge 호출 실패 시 JS 폴백
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── JS 폴백 ──
|
|
84
|
+
const timestamp = new Date().toISOString();
|
|
85
|
+
|
|
86
|
+
if (this._json) {
|
|
87
|
+
const entry = {
|
|
88
|
+
timestamp,
|
|
89
|
+
level,
|
|
90
|
+
target: this._prefix,
|
|
91
|
+
message,
|
|
92
|
+
...(context || {}),
|
|
93
|
+
};
|
|
94
|
+
console[level === 'debug' ? 'log' : level](JSON.stringify(entry));
|
|
95
|
+
} else {
|
|
96
|
+
const levelTag = level.toUpperCase().padEnd(5);
|
|
97
|
+
const fullMsg = `${timestamp} ${levelTag} [${this._prefix}] ${message}`;
|
|
98
|
+
if (context && Object.keys(context).length > 0) {
|
|
99
|
+
console[level === 'debug' ? 'log' : level](fullMsg, context);
|
|
100
|
+
} else {
|
|
101
|
+
console[level === 'debug' ? 'log' : level](fullMsg);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaHelper — Bridge media N-API 래퍼
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/19-utilities.md — "3. Media (app.media)"
|
|
5
|
+
* @see packages/fuzionx/lib/media.js (Core 래퍼)
|
|
6
|
+
*/
|
|
7
|
+
export default class MediaHelper {
|
|
8
|
+
constructor(bridge) { this._bridge = bridge; }
|
|
9
|
+
|
|
10
|
+
resize(input, output, width, height, format = 'webp', quality = 80) {
|
|
11
|
+
if (this._bridge?.mediaResize) return this._bridge.mediaResize(input, output, width, height, format, quality);
|
|
12
|
+
throw new Error('Media operations require Bridge (Rust). Install @fuzionx/bridge.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
resizeMultiple(input, outputDir, baseName, specs) {
|
|
16
|
+
if (this._bridge?.mediaResizeMultiple) return this._bridge.mediaResizeMultiple(input, outputDir, baseName, JSON.stringify(specs));
|
|
17
|
+
throw new Error('Media operations require Bridge (Rust).');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
imageInfo(filePath) {
|
|
21
|
+
if (this._bridge?.mediaGetImageInfo) return this._bridge.mediaGetImageInfo(filePath);
|
|
22
|
+
throw new Error('Image info requires Bridge (Rust).');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toWebp(input, output, quality = 80) {
|
|
26
|
+
return this.resize(input, output, 0, 0, 'webp', quality);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
videoThumbnail(input, output, atSeconds = 1, width = 640, format = 'jpeg') {
|
|
30
|
+
if (this._bridge?.mediaVideoThumbnail) return this._bridge.mediaVideoThumbnail(input, output, atSeconds, width, format);
|
|
31
|
+
throw new Error('Video operations require ffmpeg + Bridge.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
videoInfo(filePath) {
|
|
35
|
+
if (this._bridge?.mediaVideoInfo) return this._bridge.mediaVideoInfo(filePath);
|
|
36
|
+
throw new Error('Video info requires ffprobe + Bridge.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controller — HTTP 요청 핸들러 기본 클래스
|
|
3
|
+
*
|
|
4
|
+
* 싱글톤: 부트 시 1회 생성. 핸들러 메서드는 ctx만으로 동작.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/framework/01-routing-controllers.md
|
|
7
|
+
* @see docs/framework/class-design.mm.md (Controller)
|
|
8
|
+
*/
|
|
9
|
+
import Base from '../core/Base.js';
|
|
10
|
+
|
|
11
|
+
export default class Controller extends Base {
|
|
12
|
+
/** @type {string[]} 이 컨트롤러 전체에 적용할 미들웨어 */
|
|
13
|
+
static middleware = [];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 프레임워크 내부 — 컨트롤러 스캔 시 프로토타입 메서드를 static 레퍼런스로 자동 등록
|
|
17
|
+
* @param {typeof Controller} ControllerClass
|
|
18
|
+
*/
|
|
19
|
+
static register(ControllerClass) {
|
|
20
|
+
const proto = ControllerClass.prototype;
|
|
21
|
+
const baseNames = new Set(Object.getOwnPropertyNames(Base.prototype));
|
|
22
|
+
for (const method of Object.getOwnPropertyNames(proto)) {
|
|
23
|
+
if (method === 'constructor') continue;
|
|
24
|
+
// Base 메서드/getter 전체 스킵 (service, emit, dispatch, ws, crypto, i18n)
|
|
25
|
+
if (baseNames.has(method)) continue;
|
|
26
|
+
|
|
27
|
+
ControllerClass[method] = {
|
|
28
|
+
__handler__: true,
|
|
29
|
+
controller: ControllerClass,
|
|
30
|
+
method,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ErrorHandler — 기본 에러 핸들러 (JSON/HTML 분기 + 테마 에러 페이지)
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/08-error-handling.md
|
|
5
|
+
* @see docs/framework/03-views-templates.md (에러 페이지)
|
|
6
|
+
*/
|
|
7
|
+
import AppError, { ValidationError } from '../core/AppError.js';
|
|
8
|
+
|
|
9
|
+
export default class ErrorHandler {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {boolean} [opts.isDev=false] - dev 모드에서 스택 노출
|
|
13
|
+
* @param {Function} [opts.logger] - 에러 로깅 함수
|
|
14
|
+
* @param {import('./View.js').default} [opts.view] - 뷰 렌더러 (테마 에러 페이지용)
|
|
15
|
+
*/
|
|
16
|
+
constructor(opts = {}) {
|
|
17
|
+
this.isDev = opts.isDev ?? false;
|
|
18
|
+
this.logger = opts.logger || console.error;
|
|
19
|
+
this._view = opts.view || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 에러 처리 — ctx에 응답 세팅
|
|
24
|
+
* @param {Error} err
|
|
25
|
+
* @param {import('./Context.js').default} ctx
|
|
26
|
+
*/
|
|
27
|
+
handle(err, ctx) {
|
|
28
|
+
const status = err.status || 500;
|
|
29
|
+
|
|
30
|
+
// 500 에러는 로깅
|
|
31
|
+
if (status >= 500) {
|
|
32
|
+
this.logger(err);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// JSON 응답 요청이면 JSON으로
|
|
36
|
+
const wantsJson = ctx.get('accept')?.includes('application/json')
|
|
37
|
+
|| ctx.is('json')
|
|
38
|
+
|| ctx.path?.startsWith('/api');
|
|
39
|
+
|
|
40
|
+
if (wantsJson) {
|
|
41
|
+
return this._jsonResponse(err, ctx, status);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return this._htmlResponse(err, ctx, status);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @private */
|
|
48
|
+
_jsonResponse(err, ctx, status) {
|
|
49
|
+
const body = {
|
|
50
|
+
error: {
|
|
51
|
+
message: err.message,
|
|
52
|
+
status,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ValidationError → 필드 에러 포함
|
|
57
|
+
if (err instanceof ValidationError) {
|
|
58
|
+
body.error.fields = err.fields;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// dev 모드에서 스택 노출
|
|
62
|
+
if (this.isDev && status >= 500) {
|
|
63
|
+
body.error.stack = err.stack;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 추가 데이터
|
|
67
|
+
if (err.data && !(err instanceof ValidationError)) {
|
|
68
|
+
body.error.data = err.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
ctx.status(status).json(body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* HTML 에러 응답 — 테마 에러 페이지 지원
|
|
76
|
+
*
|
|
77
|
+
* 검색 순서 (03-views-templates.md):
|
|
78
|
+
* 1. views/{theme}/errors/{code}.html
|
|
79
|
+
* 2. views/{theme}/errors/default.html
|
|
80
|
+
* 3. 프레임워크 내장 에러 페이지
|
|
81
|
+
*
|
|
82
|
+
* @private
|
|
83
|
+
*/
|
|
84
|
+
_htmlResponse(err, ctx, status) {
|
|
85
|
+
const errorData = {
|
|
86
|
+
error: {
|
|
87
|
+
code: status,
|
|
88
|
+
message: err.message,
|
|
89
|
+
stack: this.isDev ? err.stack : null,
|
|
90
|
+
},
|
|
91
|
+
request: { url: ctx.url, method: ctx.method },
|
|
92
|
+
config: { debug: this.isDev },
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// 테마 에러 페이지 시도 (View 렌더러가 있을 때)
|
|
96
|
+
const view = this._view || ctx.app?._view;
|
|
97
|
+
if (view) {
|
|
98
|
+
const theme = ctx.theme || ctx.app?.config?.get('themes.default', 'default') || 'default';
|
|
99
|
+
// 1. views/{theme}/errors/{code}.html
|
|
100
|
+
try {
|
|
101
|
+
const html = view.render(`errors/${status}`, errorData);
|
|
102
|
+
if (html && !html.startsWith('<!-- template:')) {
|
|
103
|
+
ctx.status(status).html(html);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
// 2. views/{theme}/errors/default.html
|
|
108
|
+
try {
|
|
109
|
+
const html = view.render('errors/default', errorData);
|
|
110
|
+
if (html && !html.startsWith('<!-- template:')) {
|
|
111
|
+
ctx.status(status).html(html);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. 프레임워크 내장 에러 페이지 (XSS 방지)
|
|
118
|
+
const esc = (s) => String(s)
|
|
119
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
120
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
121
|
+
|
|
122
|
+
const title = status >= 500 ? 'Server Error' : esc(err.message);
|
|
123
|
+
|
|
124
|
+
ctx.status(status).html(
|
|
125
|
+
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${status}</title>` +
|
|
126
|
+
`<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:80px auto;` +
|
|
127
|
+
`padding:0 20px;color:#333}h1{color:#e74c3c;}pre{background:#f5f5f5;padding:16px;border-radius:8px;` +
|
|
128
|
+
`overflow-x:auto;font-size:13px;}</style></head>` +
|
|
129
|
+
`<body><h1>${status} — ${title}</h1>` +
|
|
130
|
+
`<p>요청: ${esc(ctx.method)} ${esc(ctx.url)}</p>` +
|
|
131
|
+
`${this.isDev && err.stack ? `<pre>${esc(err.stack)}</pre>` : ''}` +
|
|
132
|
+
`</body></html>`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware — 기본 미들웨어 클래스
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/12-middleware.md
|
|
5
|
+
* @see docs/framework/class-design.mm.md (Middleware)
|
|
6
|
+
*/
|
|
7
|
+
import Base from '../core/Base.js';
|
|
8
|
+
|
|
9
|
+
export default class Middleware extends Base {
|
|
10
|
+
/** @type {string} 미들웨어 등록 이름 (라우트에서 참조) */
|
|
11
|
+
static name = '';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 미들웨어 핸들러 (서브클래스에서 오버라이드)
|
|
15
|
+
* @param {import('./Context.js').default} ctx
|
|
16
|
+
* @param {Function} next
|
|
17
|
+
* @param {...*} params - 파라미터화 미들웨어 인자 ('name:param1:param2')
|
|
18
|
+
*/
|
|
19
|
+
async handle(ctx, next, ...params) {
|
|
20
|
+
await next();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 미들웨어 체인 실행기
|
|
26
|
+
*
|
|
27
|
+
* @param {Array<Function>} middlewares - [(ctx, next) => ...] 배열
|
|
28
|
+
* @param {import('./Context.js').default} ctx
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
export async function runMiddlewareChain(middlewares, ctx) {
|
|
32
|
+
let idx = 0;
|
|
33
|
+
|
|
34
|
+
async function next(err) {
|
|
35
|
+
if (err) throw err;
|
|
36
|
+
if (idx >= middlewares.length) return;
|
|
37
|
+
|
|
38
|
+
const fn = middlewares[idx++];
|
|
39
|
+
await fn(ctx, next);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await next();
|
|
43
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router / RouteGroup — 라우트 등록 DSL
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/01-routing-controllers.md
|
|
5
|
+
* @see docs/framework/class-design.mm.md (Router/RouteGroup)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class RouteGroup {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} prefix - 그룹 접두사 ('', '/api')
|
|
11
|
+
* @param {object} [groupOpts] - 그룹 옵션 { middleware }
|
|
12
|
+
*/
|
|
13
|
+
constructor(prefix = '', groupOpts = {}) {
|
|
14
|
+
this._prefix = prefix;
|
|
15
|
+
this._groupMiddleware = groupOpts.middleware || [];
|
|
16
|
+
this._routes = [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} method
|
|
21
|
+
* @param {string} path
|
|
22
|
+
* @param {Function|object} handler
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
*/
|
|
25
|
+
_addRoute(method, path, handler, opts = {}) {
|
|
26
|
+
const fullPath = this._prefix + path;
|
|
27
|
+
this._routes.push({
|
|
28
|
+
method,
|
|
29
|
+
path: fullPath,
|
|
30
|
+
handler,
|
|
31
|
+
middleware: [...this._groupMiddleware, ...(opts.middleware || [])],
|
|
32
|
+
validate: opts.validate || null,
|
|
33
|
+
upload: opts.upload || null,
|
|
34
|
+
docs: opts.docs || null,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(path, handler, opts) { this._addRoute('GET', path, handler, opts); }
|
|
39
|
+
post(path, handler, opts) { this._addRoute('POST', path, handler, opts); }
|
|
40
|
+
put(path, handler, opts) { this._addRoute('PUT', path, handler, opts); }
|
|
41
|
+
patch(path, handler, opts) { this._addRoute('PATCH', path, handler, opts); }
|
|
42
|
+
delete(path, handler, opts) { this._addRoute('DELETE', path, handler, opts); }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 그룹 — 중첩 라우트
|
|
46
|
+
* 인자 순서: (prefix, opts, callback) 또는 (prefix, callback, opts)
|
|
47
|
+
* @param {string} prefix
|
|
48
|
+
* @param {object|Function} optsOrCallback
|
|
49
|
+
* @param {Function|object} [callbackOrOpts]
|
|
50
|
+
*/
|
|
51
|
+
group(prefix, optsOrCallback, callbackOrOpts) {
|
|
52
|
+
let opts, callback;
|
|
53
|
+
if (typeof optsOrCallback === 'function') {
|
|
54
|
+
// group('/api', (r) => {}, opts?)
|
|
55
|
+
callback = optsOrCallback;
|
|
56
|
+
opts = callbackOrOpts || {};
|
|
57
|
+
} else {
|
|
58
|
+
// group('/api', { middleware: [...] }, (r) => {})
|
|
59
|
+
opts = optsOrCallback || {};
|
|
60
|
+
callback = callbackOrOpts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const subGroup = new RouteGroup(this._prefix + prefix, {
|
|
64
|
+
middleware: [...this._groupMiddleware, ...(opts.middleware || [])],
|
|
65
|
+
});
|
|
66
|
+
callback(subGroup);
|
|
67
|
+
this._routes.push(...subGroup._routes);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* RESTful resource 자동 등록
|
|
72
|
+
* @param {string} name - 리소스 이름 (복수형, e.g. 'users')
|
|
73
|
+
* @param {object} controller - Controller 클래스 (메서드 레퍼런스)
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
*/
|
|
76
|
+
resource(name, controller, opts = {}) {
|
|
77
|
+
const basePath = `/${name}`;
|
|
78
|
+
const middleware = opts.middleware || [];
|
|
79
|
+
const base = { middleware };
|
|
80
|
+
|
|
81
|
+
if (controller.index) this.get(basePath, controller.index, base);
|
|
82
|
+
if (controller.store) this.post(basePath, controller.store, base);
|
|
83
|
+
if (controller.show) this.get(`${basePath}/:id`, controller.show, base);
|
|
84
|
+
if (controller.update) this.put(`${basePath}/:id`, controller.update, base);
|
|
85
|
+
if (controller.destroy) this.delete(`${basePath}/:id`, controller.destroy, base);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default class Router extends RouteGroup {
|
|
90
|
+
constructor() {
|
|
91
|
+
super('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 모든 등록된 라우트 반환
|
|
96
|
+
* @returns {Array<{method, path, handler, middleware, validate, upload, docs}>}
|
|
97
|
+
*/
|
|
98
|
+
getRoutes() {
|
|
99
|
+
return this._routes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 라우트 파일 로드 (routes/ callback 등록)
|
|
104
|
+
* @param {Function} routeCallback - (r: RouteGroup) => void
|
|
105
|
+
*/
|
|
106
|
+
load(routeCallback) {
|
|
107
|
+
routeCallback(this);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation — 간단문법 → Joi 변환 파서
|
|
3
|
+
*
|
|
4
|
+
* 'required|min:2|max:50' → Joi.string().required().min(2).max(50)
|
|
5
|
+
*
|
|
6
|
+
* @see docs/framework/09-validation.md
|
|
7
|
+
*/
|
|
8
|
+
import { ValidationError } from '../core/AppError.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 간단 규칙 문자열을 Joi 스키마로 변환
|
|
12
|
+
* @param {object} rules - { name: 'required|min:2', email: 'required|email' }
|
|
13
|
+
* @param {object} Joi - Joi 모듈 참조
|
|
14
|
+
* @returns {import('joi').ObjectSchema}
|
|
15
|
+
*/
|
|
16
|
+
export function parseRules(rules, Joi) {
|
|
17
|
+
const shape = {};
|
|
18
|
+
|
|
19
|
+
for (const [field, rule] of Object.entries(rules)) {
|
|
20
|
+
const parts = typeof rule === 'string' ? rule.split('|') : [];
|
|
21
|
+
let schema = Joi.any();
|
|
22
|
+
|
|
23
|
+
for (const part of parts) {
|
|
24
|
+
const [name, ...args] = part.split(':');
|
|
25
|
+
|
|
26
|
+
switch (name) {
|
|
27
|
+
case 'string':
|
|
28
|
+
schema = Joi.string(); break;
|
|
29
|
+
case 'number':
|
|
30
|
+
schema = Joi.number(); break;
|
|
31
|
+
case 'boolean':
|
|
32
|
+
schema = Joi.boolean(); break;
|
|
33
|
+
case 'required':
|
|
34
|
+
schema = schema.required(); break;
|
|
35
|
+
case 'optional':
|
|
36
|
+
schema = schema.optional(); break;
|
|
37
|
+
case 'min':
|
|
38
|
+
schema = schema.min(Number(args[0])); break;
|
|
39
|
+
case 'max':
|
|
40
|
+
schema = schema.max(Number(args[0])); break;
|
|
41
|
+
case 'email':
|
|
42
|
+
schema = Joi.string().email(); break;
|
|
43
|
+
case 'url':
|
|
44
|
+
schema = Joi.string().uri(); break;
|
|
45
|
+
case 'in':
|
|
46
|
+
schema = schema.valid(...args[0].split(',')); break;
|
|
47
|
+
case 'integer':
|
|
48
|
+
schema = schema.integer ? schema.integer() : Joi.number().integer(); break;
|
|
49
|
+
case 'positive':
|
|
50
|
+
schema = schema.positive ? schema.positive() : Joi.number().positive(); break;
|
|
51
|
+
case 'date':
|
|
52
|
+
schema = Joi.date(); break;
|
|
53
|
+
case 'alpha':
|
|
54
|
+
schema = Joi.string().alphanum(); break;
|
|
55
|
+
default:
|
|
56
|
+
// 알 수 없는 규칙은 무시
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
shape[field] = schema;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Joi.object(shape);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validation 클래스 — 라우트 검증 실행기
|
|
69
|
+
*/
|
|
70
|
+
export default class Validation {
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} Joi - Joi 인스턴스
|
|
73
|
+
*/
|
|
74
|
+
constructor(Joi) {
|
|
75
|
+
this._Joi = Joi;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 라우트 validate 옵션 실행
|
|
80
|
+
* @param {object} ctx
|
|
81
|
+
* @param {object} validateOpts - { body: schema, query: schema, params: schema }
|
|
82
|
+
* @returns {object} - { body, query, params } 정제된 값
|
|
83
|
+
*/
|
|
84
|
+
run(ctx, validateOpts) {
|
|
85
|
+
const result = {};
|
|
86
|
+
|
|
87
|
+
if (validateOpts.body) {
|
|
88
|
+
result.body = this._validate(ctx.body, validateOpts.body);
|
|
89
|
+
}
|
|
90
|
+
if (validateOpts.query) {
|
|
91
|
+
result.query = this._validate(ctx.query, validateOpts.query);
|
|
92
|
+
}
|
|
93
|
+
if (validateOpts.params) {
|
|
94
|
+
result.params = this._validate(ctx.params, validateOpts.params);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @private
|
|
102
|
+
*/
|
|
103
|
+
_validate(data, schema) {
|
|
104
|
+
// 간단문법 객체 → Joi 변환
|
|
105
|
+
if (schema && typeof schema === 'object' && !schema.validate) {
|
|
106
|
+
schema = parseRules(schema, this._Joi);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { error, value } = schema.validate(data, {
|
|
110
|
+
abortEarly: false,
|
|
111
|
+
stripUnknown: true,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (error) {
|
|
115
|
+
const fields = {};
|
|
116
|
+
for (const detail of error.details) {
|
|
117
|
+
fields[detail.path.join('.')] = detail.message;
|
|
118
|
+
}
|
|
119
|
+
throw new ValidationError('Validation failed', fields);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
}
|