@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service — 비즈니스 로직 기본 클래스
|
|
3
|
+
*
|
|
4
|
+
* 컨트롤러는 얇게, 비즈니스 로직은 서비스에.
|
|
5
|
+
* 트랜잭션, 캐싱, 병렬 실행, 재시도, 뮤텍스 지원.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/framework/07-service-layer.md
|
|
8
|
+
* @see docs/framework/class-design.mm.md (Service)
|
|
9
|
+
*/
|
|
10
|
+
import Base from '../core/Base.js';
|
|
11
|
+
import { ServiceError } from '../core/AppError.js';
|
|
12
|
+
|
|
13
|
+
/** @type {Map<string, { value: *, expires: number }>} 전역 캐시 스토어 */
|
|
14
|
+
const _cache = new Map();
|
|
15
|
+
|
|
16
|
+
/** @type {Map<string, Promise>} 전역 뮤텍스 락 */
|
|
17
|
+
const _locks = new Map();
|
|
18
|
+
|
|
19
|
+
export default class Service extends Base {
|
|
20
|
+
|
|
21
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
22
|
+
// 트랜잭션
|
|
23
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* SQL 트랜잭션 실행
|
|
27
|
+
* @param {Function} callback - (trx) => Promise<T>
|
|
28
|
+
* @returns {Promise<T>}
|
|
29
|
+
*/
|
|
30
|
+
async transaction(callback) {
|
|
31
|
+
const cm = this.app?._connectionManager;
|
|
32
|
+
if (!cm) throw new Error('Database not configured for transactions');
|
|
33
|
+
|
|
34
|
+
const conn = cm.get();
|
|
35
|
+
if (conn.type === 'knex' && conn.db) {
|
|
36
|
+
return conn.db.transaction(callback);
|
|
37
|
+
}
|
|
38
|
+
throw new Error('SQL database not configured for transactions');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* MongoDB 트랜잭션 실행
|
|
43
|
+
* @param {string} connName - MongoDB 연결명
|
|
44
|
+
* @param {Function} callback - (session) => Promise<T>
|
|
45
|
+
* @returns {Promise<T>}
|
|
46
|
+
*/
|
|
47
|
+
async mongoTransaction(connName, callback) {
|
|
48
|
+
const cm = this.app?._connectionManager;
|
|
49
|
+
if (!cm) throw new Error('Database not configured');
|
|
50
|
+
|
|
51
|
+
const conn = cm.get(connName);
|
|
52
|
+
if (conn.type !== 'mongo' || !conn.mongoose) {
|
|
53
|
+
throw new Error(`MongoDB connection '${connName}' not found`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const session = await conn.mongoose.startSession();
|
|
57
|
+
try {
|
|
58
|
+
session.startTransaction();
|
|
59
|
+
const result = await callback(session);
|
|
60
|
+
await session.commitTransaction();
|
|
61
|
+
return result;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
await session.abortTransaction();
|
|
64
|
+
throw err;
|
|
65
|
+
} finally {
|
|
66
|
+
session.endSession();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
71
|
+
// 캐시
|
|
72
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* TTL 기반 인메모리 캐시로 감싸기
|
|
76
|
+
*
|
|
77
|
+
* @param {string} key - 캐시 키
|
|
78
|
+
* @param {number} ttl - TTL (초)
|
|
79
|
+
* @param {Function} fn - 캐시 미스 시 실행할 함수
|
|
80
|
+
* @returns {Promise<*>}
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* const stats = await this.withCache('dashboard:stats', 300, async () => {
|
|
84
|
+
* return this.db.Order.raw('SELECT SUM(total) ...');
|
|
85
|
+
* });
|
|
86
|
+
*/
|
|
87
|
+
async withCache(key, ttl, fn) {
|
|
88
|
+
const cached = _cache.get(key);
|
|
89
|
+
if (cached && cached.expires > Date.now()) {
|
|
90
|
+
return cached.value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const value = await fn();
|
|
94
|
+
_cache.set(key, {
|
|
95
|
+
value,
|
|
96
|
+
expires: Date.now() + (ttl * 1000),
|
|
97
|
+
});
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 캐시 키 무효화
|
|
103
|
+
* @param {string} key
|
|
104
|
+
*/
|
|
105
|
+
invalidateCache(key) {
|
|
106
|
+
_cache.delete(key);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 전체 캐시 초기화
|
|
111
|
+
*/
|
|
112
|
+
clearCache() {
|
|
113
|
+
_cache.clear();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 캐시 존재 여부
|
|
118
|
+
* @param {string} key
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
hasCache(key) {
|
|
122
|
+
const cached = _cache.get(key);
|
|
123
|
+
return cached ? cached.expires > Date.now() : false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
127
|
+
// 병렬 실행
|
|
128
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 여러 함수를 병렬 실행
|
|
132
|
+
*
|
|
133
|
+
* @param {Array<Function>} tasks - () => Promise 형태의 함수 배열
|
|
134
|
+
* @param {object} [opts]
|
|
135
|
+
* @param {boolean} [opts.failFast=false] - true면 하나 실패 시 전체 reject
|
|
136
|
+
* @returns {Promise<Array>} 결과 배열 (failFast=false 시 에러도 포함)
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* const [users, orders, stats] = await this.runParallel([
|
|
140
|
+
* () => this.db.User.count(),
|
|
141
|
+
* () => this.db.Order.where('status', 'active').get(),
|
|
142
|
+
* () => this.calculateStats(),
|
|
143
|
+
* ]);
|
|
144
|
+
*/
|
|
145
|
+
async runParallel(tasks, opts = {}) {
|
|
146
|
+
const promises = tasks.map(fn => fn());
|
|
147
|
+
|
|
148
|
+
if (opts.failFast) {
|
|
149
|
+
return Promise.all(promises);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const results = await Promise.allSettled(promises);
|
|
153
|
+
return results.map(r => {
|
|
154
|
+
if (r.status === 'fulfilled') return r.value;
|
|
155
|
+
// 실패 시 에러 객체 반환 (throw하지 않음)
|
|
156
|
+
return r.reason;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
161
|
+
// 재시도
|
|
162
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 재시도 + 지수 백오프
|
|
166
|
+
*
|
|
167
|
+
* @param {Function} fn - 실행할 함수
|
|
168
|
+
* @param {object} [opts]
|
|
169
|
+
* @param {number} [opts.retries=3] - 재시도 횟수
|
|
170
|
+
* @param {number} [opts.delay=1000] - 초기 지연 (ms)
|
|
171
|
+
* @param {number} [opts.backoff=2] - 백오프 배수
|
|
172
|
+
* @param {Function} [opts.shouldRetry] - (err, attempt) => boolean
|
|
173
|
+
* @returns {Promise<*>}
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* const data = await this.retryable(
|
|
177
|
+
* () => fetch('https://api.external.com/data'),
|
|
178
|
+
* { retries: 3, delay: 1000, backoff: 2 }
|
|
179
|
+
* );
|
|
180
|
+
*/
|
|
181
|
+
async retryable(fn, opts = {}) {
|
|
182
|
+
const { retries = 3, delay = 1000, backoff = 2, shouldRetry } = opts;
|
|
183
|
+
let lastError;
|
|
184
|
+
let currentDelay = delay;
|
|
185
|
+
|
|
186
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
187
|
+
try {
|
|
188
|
+
return await fn();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
lastError = err;
|
|
191
|
+
|
|
192
|
+
if (attempt === retries) break;
|
|
193
|
+
if (shouldRetry && !shouldRetry(err, attempt)) break;
|
|
194
|
+
|
|
195
|
+
await new Promise(r => setTimeout(r, currentDelay));
|
|
196
|
+
currentDelay *= backoff;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
throw lastError;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
204
|
+
// 뮤텍스
|
|
205
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 동시 실행 방지 (in-process lock)
|
|
209
|
+
*
|
|
210
|
+
* 같은 key에 대해 동시에 1개만 실행.
|
|
211
|
+
* 이전 실행 완료 후 다음 실행.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} key - 락 키
|
|
214
|
+
* @param {Function} fn - 실행할 함수
|
|
215
|
+
* @returns {Promise<*>}
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* await this.mutex('order:process:' + orderId, async () => {
|
|
219
|
+
* const order = await this.db.Order.findOrFail(orderId);
|
|
220
|
+
* await order.update({ status: 'processing' });
|
|
221
|
+
* });
|
|
222
|
+
*/
|
|
223
|
+
async mutex(key, fn) {
|
|
224
|
+
// 이전 락이 있으면 대기
|
|
225
|
+
while (_locks.has(key)) {
|
|
226
|
+
await _locks.get(key);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 새 락 설정
|
|
230
|
+
let releaseLock;
|
|
231
|
+
const lockPromise = new Promise(resolve => { releaseLock = resolve; });
|
|
232
|
+
_locks.set(key, lockPromise);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return await fn();
|
|
236
|
+
} finally {
|
|
237
|
+
_locks.delete(key);
|
|
238
|
+
releaseLock();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
243
|
+
// 에러
|
|
244
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 에러 생성 헬퍼
|
|
248
|
+
* @param {string} message
|
|
249
|
+
* @param {number} [status=400]
|
|
250
|
+
* @returns {ServiceError}
|
|
251
|
+
*/
|
|
252
|
+
error(message, status = 400) {
|
|
253
|
+
return new ServiceError(message, status);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 캐시 스토어 접근 (테스트용)
|
|
259
|
+
* @returns {Map}
|
|
260
|
+
*/
|
|
261
|
+
export { _cache, _locks };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage — 파일 저장 추상화 (Local / S3)
|
|
3
|
+
*
|
|
4
|
+
* put(path, tempPath) — 임시파일 → 최종 위치 이동.
|
|
5
|
+
* Local: file.move(tempPath, fullPath)
|
|
6
|
+
* S3: Node.js 스트리밍 업로드 (큐 Task 권장)
|
|
7
|
+
*
|
|
8
|
+
* @see docs/framework/15-file-upload.md
|
|
9
|
+
* @see docs/framework/class-design.mm.md (Storage)
|
|
10
|
+
*/
|
|
11
|
+
import { promises as fs } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
export default class Storage {
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {string} [opts.driver='local'] - 'local' | 's3'
|
|
18
|
+
* @param {string} [opts.basePath='./storage'] - 로컬 저장 경로
|
|
19
|
+
* @param {object} [opts.s3] - S3 설정 { bucket, region, credentials }
|
|
20
|
+
* @param {import('./FileHelper.js').default} [opts.fileHelper] - FileHelper 인스턴스
|
|
21
|
+
*/
|
|
22
|
+
constructor(opts = {}) {
|
|
23
|
+
this.driver = opts.driver || 'local';
|
|
24
|
+
this.basePath = opts.basePath || './storage';
|
|
25
|
+
this._s3 = opts.s3 || null;
|
|
26
|
+
this._file = opts.fileHelper || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 임시파일 → 최종 위치 이동
|
|
31
|
+
* @param {string} filePath - 상대 경로 (e.g. 'uploads/avatar.jpg')
|
|
32
|
+
* @param {string} tempPath - 임시 파일 경로 (/tmp/fuzionx_upload_xxx)
|
|
33
|
+
* @returns {Promise<string>} - 저장된 URL 또는 경로
|
|
34
|
+
*/
|
|
35
|
+
async put(filePath, tempPath) {
|
|
36
|
+
if (this.driver === 's3') {
|
|
37
|
+
return this._s3Put(filePath, tempPath);
|
|
38
|
+
}
|
|
39
|
+
const fullPath = path.join(this.basePath, filePath);
|
|
40
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
41
|
+
// FileHelper로 이동 (Bridge file.move → Rust 직접)
|
|
42
|
+
if (this._file) {
|
|
43
|
+
await this._file.move(tempPath, fullPath);
|
|
44
|
+
} else {
|
|
45
|
+
// 폴백: fs.rename (cross-device 시 copy+unlink)
|
|
46
|
+
await fs.rename(tempPath, fullPath).catch(async () => {
|
|
47
|
+
await fs.copyFile(tempPath, fullPath);
|
|
48
|
+
await fs.unlink(tempPath);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return fullPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 파일 읽기
|
|
56
|
+
* @param {string} filePath
|
|
57
|
+
* @returns {Promise<Buffer>}
|
|
58
|
+
*/
|
|
59
|
+
async get(filePath) {
|
|
60
|
+
if (this.driver === 's3') {
|
|
61
|
+
return this._s3Get(filePath);
|
|
62
|
+
}
|
|
63
|
+
return fs.readFile(path.join(this.basePath, filePath));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 파일 삭제
|
|
68
|
+
* @param {string} filePath
|
|
69
|
+
* @returns {Promise<void>}
|
|
70
|
+
*/
|
|
71
|
+
async delete(filePath) {
|
|
72
|
+
if (this.driver === 's3') {
|
|
73
|
+
return this._s3Delete(filePath);
|
|
74
|
+
}
|
|
75
|
+
await fs.unlink(path.join(this.basePath, filePath));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 파일 존재 여부
|
|
80
|
+
* @param {string} filePath
|
|
81
|
+
* @returns {Promise<boolean>}
|
|
82
|
+
*/
|
|
83
|
+
async exists(filePath) {
|
|
84
|
+
try {
|
|
85
|
+
if (this.driver === 's3') {
|
|
86
|
+
return this._s3Exists(filePath);
|
|
87
|
+
}
|
|
88
|
+
await fs.access(path.join(this.basePath, filePath));
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 파일 URL (public 접근용)
|
|
97
|
+
* @param {string} filePath
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
url(filePath) {
|
|
101
|
+
if (this.driver === 's3' && this._s3) {
|
|
102
|
+
return `https://${this._s3.bucket}.s3.${this._s3.region}.amazonaws.com/${filePath}`;
|
|
103
|
+
}
|
|
104
|
+
return `/storage/${filePath}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── S3 스텁 (Phase 5+ 구현) ──
|
|
108
|
+
async _s3Put(filePath, tempPath) { throw new Error('S3 driver not implemented'); }
|
|
109
|
+
async _s3Get(filePath) { throw new Error('S3 driver not implemented'); }
|
|
110
|
+
async _s3Delete(filePath) { throw new Error('S3 driver not implemented'); }
|
|
111
|
+
async _s3Exists(filePath) { throw new Error('S3 driver not implemented'); }
|
|
112
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI — OpenAPI 3.0 spec 빌더 + Swagger UI
|
|
3
|
+
*
|
|
4
|
+
* 라우트 정의 + Joi 스키마에서 OpenAPI spec 자동 생성.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/framework/21-openapi.md
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Joi 스키마를 JSON Schema로 변환 (간단 버전)
|
|
11
|
+
* @param {object} joiSchema
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
function joiToJsonSchema(joiSchema) {
|
|
15
|
+
if (!joiSchema) return {};
|
|
16
|
+
|
|
17
|
+
// Joi는 describe()로 메타 추출 가능
|
|
18
|
+
if (typeof joiSchema.describe === 'function') {
|
|
19
|
+
return convertJoiDescribe(joiSchema.describe());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 이미 plain object면 그대로
|
|
23
|
+
return joiSchema;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function convertJoiDescribe(desc) {
|
|
27
|
+
if (!desc) return {};
|
|
28
|
+
|
|
29
|
+
if (desc.type === 'object') {
|
|
30
|
+
const properties = {};
|
|
31
|
+
const required = [];
|
|
32
|
+
|
|
33
|
+
if (desc.keys) {
|
|
34
|
+
for (const [key, child] of Object.entries(desc.keys)) {
|
|
35
|
+
properties[key] = convertJoiDescribe(child);
|
|
36
|
+
if (child.flags?.presence === 'required') {
|
|
37
|
+
required.push(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties,
|
|
45
|
+
...(required.length ? { required } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = { type: desc.type || 'string' };
|
|
50
|
+
|
|
51
|
+
if (desc.flags?.description) result.description = desc.flags.description;
|
|
52
|
+
if (desc.rules) {
|
|
53
|
+
for (const rule of desc.rules) {
|
|
54
|
+
if (rule.name === 'min') result.minLength = rule.args.limit;
|
|
55
|
+
if (rule.name === 'max') result.maxLength = rule.args.limit;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (desc.allow) result.enum = desc.allow;
|
|
59
|
+
if (desc.flags?.default !== undefined) result.default = desc.flags.default;
|
|
60
|
+
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default class OpenAPI {
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} opts
|
|
67
|
+
* @param {string} [opts.title='FuzionX API']
|
|
68
|
+
* @param {string} [opts.version='1.0.0']
|
|
69
|
+
* @param {string} [opts.description]
|
|
70
|
+
* @param {Array} [opts.servers]
|
|
71
|
+
*/
|
|
72
|
+
constructor(opts = {}) {
|
|
73
|
+
this.title = opts.title || 'FuzionX API';
|
|
74
|
+
this.version = opts.version || '1.0.0';
|
|
75
|
+
this.description = opts.description || '';
|
|
76
|
+
this.servers = opts.servers || [];
|
|
77
|
+
this._spec = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 라우트 목록에서 OpenAPI spec 빌드
|
|
82
|
+
* @param {Array} routes - Router.getRoutes() 결과
|
|
83
|
+
* @returns {object} - OpenAPI 3.0 spec
|
|
84
|
+
*/
|
|
85
|
+
build(routes) {
|
|
86
|
+
const paths = {};
|
|
87
|
+
|
|
88
|
+
for (const route of routes) {
|
|
89
|
+
if (!route.docs) continue;
|
|
90
|
+
|
|
91
|
+
const openApiPath = route.path.replace(/:(\w+)/g, '{$1}');
|
|
92
|
+
|
|
93
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
94
|
+
|
|
95
|
+
const method = route.method.toLowerCase();
|
|
96
|
+
const operation = {
|
|
97
|
+
summary: route.docs.summary || '',
|
|
98
|
+
tags: route.docs.tags || [],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (route.docs.description) {
|
|
102
|
+
operation.description = route.docs.description;
|
|
103
|
+
}
|
|
104
|
+
if (route.docs.deprecated) {
|
|
105
|
+
operation.deprecated = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// parameters (query + params)
|
|
109
|
+
const parameters = [];
|
|
110
|
+
|
|
111
|
+
if (route.validate?.params) {
|
|
112
|
+
const schema = joiToJsonSchema(route.validate.params);
|
|
113
|
+
if (schema.properties) {
|
|
114
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
115
|
+
parameters.push({
|
|
116
|
+
name,
|
|
117
|
+
in: 'path',
|
|
118
|
+
required: true,
|
|
119
|
+
schema: prop,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (route.validate?.query) {
|
|
126
|
+
const schema = joiToJsonSchema(route.validate.query);
|
|
127
|
+
if (schema.properties) {
|
|
128
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
129
|
+
parameters.push({
|
|
130
|
+
name,
|
|
131
|
+
in: 'query',
|
|
132
|
+
required: schema.required?.includes(name) || false,
|
|
133
|
+
schema: prop,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (parameters.length) operation.parameters = parameters;
|
|
140
|
+
|
|
141
|
+
// requestBody (body)
|
|
142
|
+
if (route.validate?.body) {
|
|
143
|
+
const schema = joiToJsonSchema(route.validate.body);
|
|
144
|
+
operation.requestBody = {
|
|
145
|
+
content: {
|
|
146
|
+
'application/json': { schema },
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// responses
|
|
152
|
+
operation.responses = route.docs.responses || { '200': { description: 'OK' } };
|
|
153
|
+
|
|
154
|
+
// security
|
|
155
|
+
if (route.middleware?.includes('auth') || route.middleware?.includes('apiAuth')) {
|
|
156
|
+
const scheme = route.middleware.includes('apiAuth') ? 'bearer' : 'session';
|
|
157
|
+
operation.security = [{ [scheme]: [] }];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
paths[openApiPath][method] = operation;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this._spec = {
|
|
164
|
+
openapi: '3.0.3',
|
|
165
|
+
info: {
|
|
166
|
+
title: this.title,
|
|
167
|
+
version: this.version,
|
|
168
|
+
...(this.description ? { description: this.description } : {}),
|
|
169
|
+
},
|
|
170
|
+
...(this.servers.length ? { servers: this.servers } : {}),
|
|
171
|
+
paths,
|
|
172
|
+
components: {
|
|
173
|
+
securitySchemes: {
|
|
174
|
+
bearer: {
|
|
175
|
+
type: 'http',
|
|
176
|
+
scheme: 'bearer',
|
|
177
|
+
bearerFormat: 'JWT',
|
|
178
|
+
},
|
|
179
|
+
session: {
|
|
180
|
+
type: 'apiKey',
|
|
181
|
+
in: 'cookie',
|
|
182
|
+
name: 'sid',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return this._spec;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 캐시된 spec 반환 (JSON)
|
|
193
|
+
*/
|
|
194
|
+
toJSON() {
|
|
195
|
+
return this._spec;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* YAML 포맷 (간단 변환)
|
|
200
|
+
*/
|
|
201
|
+
toYAML() {
|
|
202
|
+
return jsonToSimpleYaml(this._spec);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function jsonToSimpleYaml(obj, indent = 0) {
|
|
207
|
+
const spaces = ' '.repeat(indent);
|
|
208
|
+
let result = '';
|
|
209
|
+
|
|
210
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
211
|
+
if (value === null || value === undefined) continue;
|
|
212
|
+
|
|
213
|
+
if (Array.isArray(value)) {
|
|
214
|
+
result += `${spaces}${key}:\n`;
|
|
215
|
+
for (const item of value) {
|
|
216
|
+
if (typeof item === 'object') {
|
|
217
|
+
result += `${spaces} -\n${jsonToSimpleYaml(item, indent + 4)}`;
|
|
218
|
+
} else {
|
|
219
|
+
result += `${spaces} - ${item}\n`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} else if (typeof value === 'object') {
|
|
223
|
+
result += `${spaces}${key}:\n${jsonToSimpleYaml(value, indent + 2)}`;
|
|
224
|
+
} else {
|
|
225
|
+
const v = typeof value === 'string' ? `'${value}'` : value;
|
|
226
|
+
result += `${spaces}${key}: ${v}\n`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
package/lib/view/View.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View — 뷰 렌더링 (테마 + Tera SSR)
|
|
3
|
+
*
|
|
4
|
+
* ctx.theme에 따른 테마 경로 해석.
|
|
5
|
+
* Bridge Tera SSR 또는 JS 폴백.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/framework/03-views-templates.md
|
|
8
|
+
*/
|
|
9
|
+
export default class View {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {string} opts.viewsPath - 뷰 파일 루트 경로
|
|
13
|
+
* @param {string} [opts.theme='default'] - 기본 테마
|
|
14
|
+
* @param {object} [opts.bridge] - Bridge SSR 인스턴스 (Tera)
|
|
15
|
+
* @param {object} [opts.globals] - 모든 뷰에 주입되는 전역 변수
|
|
16
|
+
*/
|
|
17
|
+
constructor(opts = {}) {
|
|
18
|
+
this.viewsPath = opts.viewsPath || '';
|
|
19
|
+
this.theme = opts.theme || 'default';
|
|
20
|
+
this._bridge = opts.bridge || null;
|
|
21
|
+
this._globals = opts.globals || {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 전역 변수 추가
|
|
26
|
+
* @param {string} key
|
|
27
|
+
* @param {*} value
|
|
28
|
+
*/
|
|
29
|
+
share(key, value) {
|
|
30
|
+
this._globals[key] = value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 템플릿 렌더링
|
|
35
|
+
*
|
|
36
|
+
* 테마 경로 해석 (03-views-templates.md):
|
|
37
|
+
* 'pages/users/index' → {theme}/pages/users/index.html
|
|
38
|
+
*
|
|
39
|
+
* @param {string} template - 'users/index' 등
|
|
40
|
+
* @param {object} [data] - 템플릿 변수
|
|
41
|
+
* @param {string} [theme] - 테마 오버라이드 (ctx.theme에서 전달)
|
|
42
|
+
* @returns {string} - 렌더링된 HTML
|
|
43
|
+
*/
|
|
44
|
+
render(template, data = {}, theme) {
|
|
45
|
+
const activeTheme = theme || data.theme || this.theme;
|
|
46
|
+
const mergedData = { ...this._globals, ...data, theme: activeTheme };
|
|
47
|
+
|
|
48
|
+
// Bridge Tera SSR이 있으면 위임
|
|
49
|
+
if (this._bridge) {
|
|
50
|
+
const templatePath = `${activeTheme}/${template}.html`;
|
|
51
|
+
try {
|
|
52
|
+
return this._bridge.render(templatePath, mergedData);
|
|
53
|
+
} catch {
|
|
54
|
+
// SSR 실패 시 폴백
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 폴백: 간단한 변수 치환
|
|
59
|
+
return this._simpleRender(template, mergedData);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @private 간단 치환 (Bridge 없을 때)
|
|
64
|
+
*/
|
|
65
|
+
_simpleRender(template, data) {
|
|
66
|
+
let html = `<!-- template: ${template} -->`;
|
|
67
|
+
for (const [key, value] of Object.entries(data)) {
|
|
68
|
+
html += `\n<!-- ${key}: ${JSON.stringify(value)} -->`;
|
|
69
|
+
}
|
|
70
|
+
return html;
|
|
71
|
+
}
|
|
72
|
+
}
|