@toktokhan-dev/cli-plugin-gen-api-react-query 0.1.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +15 -0
- package/dist/index.js +423 -58
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -58,11 +58,26 @@ type GenerateSwaggerApiConfig = SwaggerSchemaOption & {
|
|
|
58
58
|
* infiniteQuery 를 생성할 함수 필터 목록 입니다.
|
|
59
59
|
* */
|
|
60
60
|
paginationSets: PaginationConfig[];
|
|
61
|
+
/**
|
|
62
|
+
* SSL 인증서 검증 무시 여부입니다. (self-signed certificate 등 사용 시 true 로 설정)
|
|
63
|
+
* @default false
|
|
64
|
+
*/
|
|
65
|
+
ignoreTlsError?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* data-contracts.ts를 모듈별로 분할할지 여부입니다.
|
|
68
|
+
* true일 때 각 모듈 폴더에 <module>.contracts.ts가 생성되고,
|
|
69
|
+
* 공유 타입은 @types/common-contracts.ts에 생성됩니다.
|
|
70
|
+
* @default false
|
|
71
|
+
*/
|
|
72
|
+
splitDataContracts?: boolean;
|
|
61
73
|
};
|
|
62
74
|
/**
|
|
63
75
|
* @category Commands
|
|
64
76
|
*/
|
|
65
77
|
declare const genApi: _toktokhan_dev_cli.MyCommand<GenerateSwaggerApiConfig, "gen:api">;
|
|
78
|
+
/**
|
|
79
|
+
* 스마트 타입 병합 함수들
|
|
80
|
+
*/
|
|
66
81
|
declare function mergeTypeScriptContent(existing: string, newContent: string): string;
|
|
67
82
|
|
|
68
83
|
export { genApi, mergeTypeScriptContent };
|
package/dist/index.js
CHANGED
|
@@ -74,10 +74,288 @@ const parseSwagger = (config) => generateApi({
|
|
|
74
74
|
},
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* data-contracts.ts의 렌더링된 내용을 타입 블록 단위로 파싱합니다.
|
|
79
|
+
* 각 export type/interface/enum/const를 독립된 블록으로 분리합니다.
|
|
80
|
+
*
|
|
81
|
+
* lookahead로 `\nexport\s`를 사용하여 export function/class 등
|
|
82
|
+
* 비표준 export도 블록 경계로 인식합니다.
|
|
83
|
+
*/
|
|
84
|
+
const TYPE_BLOCK_REGEX = /(export\s+(?:type|interface|enum|const)\s+(\w+)[\s\S]*?)(?=\nexport\s|$)/g;
|
|
85
|
+
function parseTypeDefinitions(content) {
|
|
86
|
+
const types = {};
|
|
87
|
+
const typeRegex = new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags);
|
|
88
|
+
let match;
|
|
89
|
+
while ((match = typeRegex.exec(content)) !== null) {
|
|
90
|
+
const typeName = match[2];
|
|
91
|
+
const typeContent = match[1].trim();
|
|
92
|
+
types[typeName] = typeContent;
|
|
93
|
+
}
|
|
94
|
+
return types;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 코드에서 주석과 문자열 리터럴을 제거합니다.
|
|
99
|
+
* false positive 의존성 감지를 방지합니다. (예: 주석 내 타입명 언급)
|
|
100
|
+
*/
|
|
101
|
+
function stripNonCode(code) {
|
|
102
|
+
return code
|
|
103
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
104
|
+
.replace(/\/\/.*$/gm, '')
|
|
105
|
+
.replace(/'[^']*'/g, '""')
|
|
106
|
+
.replace(/"[^"]*"/g, '""')
|
|
107
|
+
.replace(/`[^`]*`/g, '""');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 렌더링된 타입 블록들 간의 의존성 그래프를 구축합니다.
|
|
111
|
+
*
|
|
112
|
+
* 각 타입 블록의 TypeScript 코드에서 다른 타입 이름이 참조되는지를
|
|
113
|
+
* 단어 경계 기반 정규식으로 감지합니다.
|
|
114
|
+
* 주석과 문자열 리터럴은 제거 후 매칭하여 false positive를 줄입니다.
|
|
115
|
+
*
|
|
116
|
+
* @param parsedTypes - parseTypeDefinitions의 결과 (typeName -> renderedBlock)
|
|
117
|
+
* @returns Map<typeName, Set<referencedTypeName>> - 직접 의존성 그래프
|
|
118
|
+
*/
|
|
119
|
+
function buildDependencyGraph(parsedTypes) {
|
|
120
|
+
const knownTypes = Object.keys(parsedTypes);
|
|
121
|
+
const graph = new Map();
|
|
122
|
+
// Pre-compile regexes once (O(n) instead of O(n²) compilations)
|
|
123
|
+
const compiledPatterns = new Map();
|
|
124
|
+
for (const known of knownTypes) {
|
|
125
|
+
const escaped = known.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
126
|
+
compiledPatterns.set(known, new RegExp(`\\b${escaped}\\b`));
|
|
127
|
+
}
|
|
128
|
+
for (const [typeName, typeBlock] of Object.entries(parsedTypes)) {
|
|
129
|
+
const deps = new Set();
|
|
130
|
+
const cleanBlock = stripNonCode(typeBlock);
|
|
131
|
+
for (const known of knownTypes) {
|
|
132
|
+
if (known !== typeName && compiledPatterns.get(known).test(cleanBlock)) {
|
|
133
|
+
deps.add(known);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
graph.set(typeName, deps);
|
|
137
|
+
}
|
|
138
|
+
return graph;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 직접 의존성 그래프에서 전이적 의존성을 포함한 전체 의존성을 계산합니다.
|
|
142
|
+
* A -> B -> C면, A의 의존성은 {B, C}가 됩니다.
|
|
143
|
+
*/
|
|
144
|
+
function getTransitiveDependencies(graph, typeName, visited = new Set()) {
|
|
145
|
+
if (visited.has(typeName))
|
|
146
|
+
return new Set();
|
|
147
|
+
visited.add(typeName);
|
|
148
|
+
const directDeps = graph.get(typeName) || new Set();
|
|
149
|
+
const allDeps = new Set(directDeps);
|
|
150
|
+
for (const dep of directDeps) {
|
|
151
|
+
const transitiveDeps = getTransitiveDependencies(graph, dep, visited);
|
|
152
|
+
for (const td of transitiveDeps) {
|
|
153
|
+
allDeps.add(td);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return allDeps;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* route의 type 문자열에서 알려진 타입 이름들을 추출합니다.
|
|
161
|
+
* 제네릭, 유니온, 인터섹션, 배열 등에서 타입 이름을 추출합니다.
|
|
162
|
+
*
|
|
163
|
+
* 예: "PaginatedResponse<UserType>" -> ["PaginatedResponse", "UserType"]
|
|
164
|
+
*/
|
|
165
|
+
function extractTypeNames(typeString, knownTypes) {
|
|
166
|
+
if (!typeString)
|
|
167
|
+
return [];
|
|
168
|
+
const identifiers = typeString.match(/\b[A-Z]\w+/g) || [];
|
|
169
|
+
return identifiers.filter((id) => knownTypes.has(id));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* swagger-typescript-api의 routes.combined에서 타입-모듈 매핑을 구축합니다.
|
|
173
|
+
*
|
|
174
|
+
* 각 route의 response.type, response.errorType, request.payload.type,
|
|
175
|
+
* request.query.type에서 타입 이름을 추출하여, 어느 모듈에서 사용하는지 매핑합니다.
|
|
176
|
+
*
|
|
177
|
+
* @returns Map<typeName, Set<moduleName>> (moduleName은 PascalCase로 정규화)
|
|
178
|
+
*/
|
|
179
|
+
function buildTypeToModuleMap(combined, knownTypeNames) {
|
|
180
|
+
const typeToModules = new Map();
|
|
181
|
+
if (!combined)
|
|
182
|
+
return typeToModules;
|
|
183
|
+
for (const moduleGroup of combined) {
|
|
184
|
+
// moduleName을 PascalCase로 정규화 (write-swagger.ts의 filename과 일치시키기 위해)
|
|
185
|
+
const normalizedModuleName = upperFirst(camelCase(moduleGroup.moduleName));
|
|
186
|
+
for (const route of moduleGroup.routes) {
|
|
187
|
+
// ParsedRoute의 .d.ts는 request: Request, response: Response (DOM 타입)으로 선언되어 있으나
|
|
188
|
+
// 런타임에는 swagger-typescript-api 내부 객체임. as any 캐스팅 필요.
|
|
189
|
+
const r = route;
|
|
190
|
+
const typeStrings = [
|
|
191
|
+
r.response?.type,
|
|
192
|
+
r.response?.errorType,
|
|
193
|
+
r.request?.payload?.type,
|
|
194
|
+
r.request?.query?.type,
|
|
195
|
+
];
|
|
196
|
+
// request.parameters에서도 타입 추출 (path params 등)
|
|
197
|
+
if (r.request?.parameters) {
|
|
198
|
+
for (const param of Object.values(r.request.parameters)) {
|
|
199
|
+
if (param?.type) {
|
|
200
|
+
typeStrings.push(param.type);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const typeStr of typeStrings) {
|
|
205
|
+
const extracted = extractTypeNames(typeStr, knownTypeNames);
|
|
206
|
+
for (const typeName of extracted) {
|
|
207
|
+
if (!typeToModules.has(typeName)) {
|
|
208
|
+
typeToModules.set(typeName, new Set());
|
|
209
|
+
}
|
|
210
|
+
typeToModules.get(typeName).add(normalizedModuleName);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return typeToModules;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* data-contracts.ts의 타입들을 모듈별/공유로 분류합니다.
|
|
220
|
+
*
|
|
221
|
+
* 분류 알고리즘:
|
|
222
|
+
* 1. 직접 매핑: route에서 참조하는 타입 → 해당 모듈에 소속
|
|
223
|
+
* 2. 의존성 전파: 타입이 참조하는 다른 타입도 같은 모듈에 소속
|
|
224
|
+
* 3. 공유 판별: 2개 이상 모듈에 소속된 타입 → shared
|
|
225
|
+
* 4. Orphan 처리: 어디에도 소속되지 않은 타입 → shared
|
|
226
|
+
* 5. 전이적 공유: shared 타입이 참조하는 타입도 shared로 격상
|
|
227
|
+
* 6. enumMap 동반 이동: *Map const는 base type과 동일 파일에 배치
|
|
228
|
+
*/
|
|
229
|
+
function classifyTypes(dataContractsContent, routesCombined) {
|
|
230
|
+
const parsedTypes = parseTypeDefinitions(dataContractsContent);
|
|
231
|
+
const allTypeNames = new Set(Object.keys(parsedTypes));
|
|
232
|
+
// Step 1: 타입-모듈 직접 매핑
|
|
233
|
+
const typeToModules = buildTypeToModuleMap(routesCombined, allTypeNames);
|
|
234
|
+
// Step 2: 의존성 그래프 구축 + 의존성 전파
|
|
235
|
+
const depGraph = buildDependencyGraph(parsedTypes);
|
|
236
|
+
// 의존성 전파: 각 타입의 전이적 의존성도 같은 모듈에 추가
|
|
237
|
+
for (const [typeName, modules] of typeToModules.entries()) {
|
|
238
|
+
const transitiveDeps = getTransitiveDependencies(depGraph, typeName);
|
|
239
|
+
for (const dep of transitiveDeps) {
|
|
240
|
+
if (!typeToModules.has(dep)) {
|
|
241
|
+
typeToModules.set(dep, new Set());
|
|
242
|
+
}
|
|
243
|
+
for (const mod of modules) {
|
|
244
|
+
typeToModules.get(dep).add(mod);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Step 3: 분류 (module-exclusive vs shared vs orphan)
|
|
249
|
+
const moduleTypesMap = new Map();
|
|
250
|
+
const sharedTypesSet = new Set();
|
|
251
|
+
for (const typeName of allTypeNames) {
|
|
252
|
+
const modules = typeToModules.get(typeName);
|
|
253
|
+
if (!modules || modules.size === 0) {
|
|
254
|
+
// Orphan: 어디서도 참조되지 않음 → shared (안전 기본값)
|
|
255
|
+
sharedTypesSet.add(typeName);
|
|
256
|
+
}
|
|
257
|
+
else if (modules.size === 1) {
|
|
258
|
+
// Module-exclusive: 한 모듈에서만 사용
|
|
259
|
+
const moduleName = [...modules][0];
|
|
260
|
+
if (!moduleTypesMap.has(moduleName)) {
|
|
261
|
+
moduleTypesMap.set(moduleName, new Set());
|
|
262
|
+
}
|
|
263
|
+
moduleTypesMap.get(moduleName).add(typeName);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Shared: 2개 이상 모듈에서 사용
|
|
267
|
+
sharedTypesSet.add(typeName);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Step 5: 전이적 공유 — shared 타입이 참조하는 타입도 shared로 격상
|
|
271
|
+
// 순회 중 Set에 추가하지 않고 별도 배열에 수집 후 일괄 적용
|
|
272
|
+
let changed = true;
|
|
273
|
+
while (changed) {
|
|
274
|
+
changed = false;
|
|
275
|
+
const toPromote = [];
|
|
276
|
+
for (const sharedType of sharedTypesSet) {
|
|
277
|
+
const deps = depGraph.get(sharedType) || new Set();
|
|
278
|
+
for (const dep of deps) {
|
|
279
|
+
if (!sharedTypesSet.has(dep)) {
|
|
280
|
+
toPromote.push(dep);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const dep of toPromote) {
|
|
285
|
+
sharedTypesSet.add(dep);
|
|
286
|
+
for (const [, moduleSet] of moduleTypesMap) {
|
|
287
|
+
moduleSet.delete(dep);
|
|
288
|
+
}
|
|
289
|
+
changed = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Step 6: enumMap 동반 이동 — *Map const는 base type과 동일 위치에 배치
|
|
293
|
+
for (const typeName of allTypeNames) {
|
|
294
|
+
if (typeName.endsWith('Map')) {
|
|
295
|
+
const baseTypeName = typeName.slice(0, -3); // FooTypeMap -> FooType
|
|
296
|
+
if (!allTypeNames.has(baseTypeName))
|
|
297
|
+
continue;
|
|
298
|
+
// base type이 있는 위치를 찾아서 Map도 같은 곳으로 이동
|
|
299
|
+
if (sharedTypesSet.has(baseTypeName)) {
|
|
300
|
+
// base가 shared → Map도 shared
|
|
301
|
+
sharedTypesSet.add(typeName);
|
|
302
|
+
for (const [, moduleSet] of moduleTypesMap) {
|
|
303
|
+
moduleSet.delete(typeName);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
// base가 특정 모듈에 있음 → Map도 같은 모듈로
|
|
308
|
+
for (const [moduleName, moduleSet] of moduleTypesMap) {
|
|
309
|
+
if (moduleSet.has(baseTypeName)) {
|
|
310
|
+
moduleSet.add(typeName);
|
|
311
|
+
// 다른 모듈이나 shared에서 제거
|
|
312
|
+
sharedTypesSet.delete(typeName);
|
|
313
|
+
for (const [otherMod, otherSet] of moduleTypesMap) {
|
|
314
|
+
if (otherMod !== moduleName) {
|
|
315
|
+
otherSet.delete(typeName);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// 빈 모듈 제거
|
|
325
|
+
for (const [moduleName, typeSet] of moduleTypesMap) {
|
|
326
|
+
if (typeSet.size === 0) {
|
|
327
|
+
moduleTypesMap.delete(moduleName);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// Set → Array 변환
|
|
331
|
+
const moduleTypes = new Map();
|
|
332
|
+
for (const [moduleName, typeSet] of moduleTypesMap) {
|
|
333
|
+
moduleTypes.set(moduleName, [...typeSet]);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
moduleTypes,
|
|
337
|
+
sharedTypes: [...sharedTypesSet],
|
|
338
|
+
parsedTypes,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
77
342
|
const { TYPE_FILE, UTIL_FILE, QUERY_HOOK_INDICATOR, USE_SUSPENSE_QUERY_HOOK_INDICATOR, } = GENERATE_SWAGGER_DATA;
|
|
78
|
-
const writeSwaggerApiFile = (params) => {
|
|
343
|
+
const writeSwaggerApiFile = async (params) => {
|
|
79
344
|
const { input, output, spinner, config } = params;
|
|
80
|
-
|
|
345
|
+
// === Pre-analysis (splitDataContracts 모드) ===
|
|
346
|
+
// for...of 전에 classification을 완료하여 import 후처리에 사용
|
|
347
|
+
let classificationResult = null;
|
|
348
|
+
if (config.splitDataContracts) {
|
|
349
|
+
const dataContractsFile = input.files.find((f) => f.fileName + f.fileExtension === 'data-contracts.ts');
|
|
350
|
+
if (dataContractsFile?.fileContent) {
|
|
351
|
+
// GenerateApiOutput은 configuration을 런타임에 포함하지만 타입 정의에서 미노출
|
|
352
|
+
const configuration = input.configuration;
|
|
353
|
+
const routesCombined = configuration?.routes?.combined;
|
|
354
|
+
classificationResult = classifyTypes(dataContractsFile.fileContent, routesCombined);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// === Pass 1: for...of 루프 (기존 로직 + splitDataContracts 분기) ===
|
|
358
|
+
for (const { fileName, fileContent: content, fileExtension } of input.files) {
|
|
81
359
|
const name = fileName + fileExtension;
|
|
82
360
|
try {
|
|
83
361
|
const isTypeFile = TYPE_FILE.includes(name);
|
|
@@ -85,6 +363,13 @@ const writeSwaggerApiFile = (params) => {
|
|
|
85
363
|
const isHttpClient = name === 'http-client.ts';
|
|
86
364
|
const isApiFile = content?.includes(QUERY_HOOK_INDICATOR);
|
|
87
365
|
const filename = name.replace('.ts', '');
|
|
366
|
+
// splitDataContracts: data-contracts.ts 쓰기 억제
|
|
367
|
+
if (isTypeFile &&
|
|
368
|
+
name === 'data-contracts.ts' &&
|
|
369
|
+
config.splitDataContracts) {
|
|
370
|
+
// 디스크에 쓰지 않음 — pre-analysis에서 이미 content를 처리함
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
88
373
|
const getTargetFolder = () => {
|
|
89
374
|
if (isUtilFile)
|
|
90
375
|
return path.resolve(output, '@utils');
|
|
@@ -100,28 +385,120 @@ const writeSwaggerApiFile = (params) => {
|
|
|
100
385
|
spinner.info(`generated: ${targetFolder}`);
|
|
101
386
|
if (isHttpClient) {
|
|
102
387
|
generate(path.resolve(targetFolder, 'index.ts'), content);
|
|
103
|
-
|
|
388
|
+
continue;
|
|
104
389
|
}
|
|
105
390
|
if (isApiFile) {
|
|
106
|
-
|
|
107
|
-
|
|
391
|
+
// splitDataContracts: import 후처리 (splitHookContents 호출 전)
|
|
392
|
+
let processedContent = content;
|
|
393
|
+
if (config.splitDataContracts && classificationResult) {
|
|
394
|
+
processedContent = rewriteDataContractsImport(content, filename, classificationResult);
|
|
395
|
+
}
|
|
396
|
+
const { apiContents, hookParts } = splitHookContents(filename, processedContent);
|
|
397
|
+
await generatePretty(path.resolve(targetFolder, `${filename}.api.ts`), apiContents);
|
|
108
398
|
if (config.includeReactQuery) {
|
|
109
|
-
generatePretty(path.resolve(targetFolder, `${filename}.query.ts`), hookParts[0]);
|
|
399
|
+
await generatePretty(path.resolve(targetFolder, `${filename}.query.ts`), hookParts[0]);
|
|
110
400
|
}
|
|
111
401
|
if (config.includeReactSuspenseQuery) {
|
|
112
|
-
generatePretty(path.resolve(targetFolder, `${filename}.suspenseQuery.ts`), hookParts[1]);
|
|
402
|
+
await generatePretty(path.resolve(targetFolder, `${filename}.suspenseQuery.ts`), hookParts[1]);
|
|
113
403
|
}
|
|
114
|
-
|
|
404
|
+
continue;
|
|
115
405
|
}
|
|
116
406
|
generate(path.resolve(targetFolder, name), content);
|
|
117
407
|
}
|
|
118
408
|
catch (err) {
|
|
119
409
|
console.error(err);
|
|
120
410
|
}
|
|
121
|
-
}
|
|
411
|
+
}
|
|
412
|
+
// === Post-step: 분할 contracts 파일 생성 (동기 작업이므로 async forEach 영향 없음) ===
|
|
413
|
+
if (config.splitDataContracts && classificationResult) {
|
|
414
|
+
const { moduleTypes, sharedTypes, parsedTypes } = classificationResult;
|
|
415
|
+
// 모듈별 contracts 파일 생성
|
|
416
|
+
for (const [moduleName, typeNames] of moduleTypes.entries()) {
|
|
417
|
+
const moduleFolder = path.resolve(output, moduleName);
|
|
418
|
+
fs.mkdirSync(moduleFolder, { recursive: true });
|
|
419
|
+
const moduleContent = buildContractsFileContent(typeNames, parsedTypes, sharedTypes);
|
|
420
|
+
generate(path.resolve(moduleFolder, `${moduleName}.contracts.ts`), moduleContent);
|
|
421
|
+
}
|
|
422
|
+
// common-contracts 파일 생성 (공유 타입이 있을 때만)
|
|
423
|
+
if (sharedTypes.length > 0) {
|
|
424
|
+
const commonFolder = path.resolve(output, '@types');
|
|
425
|
+
fs.mkdirSync(commonFolder, { recursive: true });
|
|
426
|
+
const commonContent = buildContractsFileContent(sharedTypes, parsedTypes);
|
|
427
|
+
generate(path.resolve(commonFolder, 'common-contracts.ts'), commonContent);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
122
430
|
};
|
|
431
|
+
/**
|
|
432
|
+
* 타입 이름 목록과 파싱된 타입 블록으로 contracts 파일 내용을 생성합니다.
|
|
433
|
+
* @internal — exported for testing
|
|
434
|
+
*/
|
|
435
|
+
function buildContractsFileContent(typeNames, parsedTypes, sharedTypeNames) {
|
|
436
|
+
const header = `/* eslint-disable */
|
|
437
|
+
/* tslint:disable */
|
|
438
|
+
/**
|
|
439
|
+
* !DO NOT EDIT THIS FILE!
|
|
440
|
+
*
|
|
441
|
+
* This file was auto-generated by tok-cli.config.ts 에서 설정된 gen:api 명령어로 생성되었습니다.
|
|
442
|
+
*/\n`;
|
|
443
|
+
const blocks = typeNames.map((name) => parsedTypes[name]).filter(Boolean);
|
|
444
|
+
const bodyContent = blocks.join('\n\n');
|
|
445
|
+
// module contracts가 shared type을 참조하면 import 추가
|
|
446
|
+
let importSection = '';
|
|
447
|
+
if (sharedTypeNames && sharedTypeNames.length > 0) {
|
|
448
|
+
const referencedShared = sharedTypeNames.filter((sharedType) => {
|
|
449
|
+
const escaped = sharedType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
450
|
+
return new RegExp(`\\b${escaped}\\b`).test(bodyContent);
|
|
451
|
+
});
|
|
452
|
+
if (referencedShared.length > 0) {
|
|
453
|
+
importSection =
|
|
454
|
+
`import { ${referencedShared.join(', ')} } from '../@types/common-contracts';\n\n`;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return header + '\n' + importSection + bodyContent + '\n';
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* api 파일의 data-contracts import를 모듈별 contracts + common-contracts로 교체합니다.
|
|
461
|
+
*
|
|
462
|
+
* import의 `[^}]+`는 negated character class이므로 멀티라인도 매칭됩니다.
|
|
463
|
+
* (prettier가 줄바꿈해도 안전)
|
|
464
|
+
* @internal — exported for testing
|
|
465
|
+
*/
|
|
466
|
+
function rewriteDataContractsImport(content, filename, classification) {
|
|
467
|
+
const moduleTypeNames = classification.moduleTypes.get(filename) || [];
|
|
468
|
+
const sharedTypeNames = classification.sharedTypes;
|
|
469
|
+
// 현재 api 파일에서 실제로 사용하는 타입만 필터링
|
|
470
|
+
const allImportedTypes = extractImportedTypeNames(content);
|
|
471
|
+
const usedModuleTypes = moduleTypeNames.filter((t) => allImportedTypes.has(t));
|
|
472
|
+
const usedSharedTypes = sharedTypeNames.filter((t) => allImportedTypes.has(t));
|
|
473
|
+
// 기존 data-contracts import 라인을 찾아서 교체
|
|
474
|
+
const importRegex = /import\s*\{[^}]+\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/;
|
|
475
|
+
const newImports = [];
|
|
476
|
+
if (usedModuleTypes.length > 0) {
|
|
477
|
+
newImports.push(`import { ${usedModuleTypes.join(', ')} } from './${filename}.contracts';`);
|
|
478
|
+
}
|
|
479
|
+
if (usedSharedTypes.length > 0) {
|
|
480
|
+
newImports.push(`import { ${usedSharedTypes.join(', ')} } from '../@types/common-contracts';`);
|
|
481
|
+
}
|
|
482
|
+
// import가 하나도 없으면 빈 문자열로 교체 (사용하지 않는 타입만 있던 경우)
|
|
483
|
+
if (newImports.length === 0) {
|
|
484
|
+
return content.replace(importRegex, '');
|
|
485
|
+
}
|
|
486
|
+
return content.replace(importRegex, newImports.join('\n'));
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* api 파일 content에서 data-contracts import 구문의 타입 이름들을 추출합니다.
|
|
490
|
+
* @internal — exported for testing
|
|
491
|
+
*/
|
|
492
|
+
function extractImportedTypeNames(content) {
|
|
493
|
+
const importMatch = content.match(/import\s*\{([^}]+)\}\s*from\s*['"]\.\.\/[@]types\/data-contracts['"];?/);
|
|
494
|
+
if (!importMatch)
|
|
495
|
+
return new Set();
|
|
496
|
+
return new Set(importMatch[1]
|
|
497
|
+
.split(',')
|
|
498
|
+
.map((s) => s.trim())
|
|
499
|
+
.filter(Boolean));
|
|
500
|
+
}
|
|
123
501
|
async function generatePretty(path, contents) {
|
|
124
|
-
// prettier-plugin-organize-imports가 설치되어 있으면 사용, 없으면 스킵
|
|
125
502
|
let organized = contents;
|
|
126
503
|
try {
|
|
127
504
|
organized = await prettierString(contents, {
|
|
@@ -130,7 +507,6 @@ async function generatePretty(path, contents) {
|
|
|
130
507
|
});
|
|
131
508
|
}
|
|
132
509
|
catch (err) {
|
|
133
|
-
// 플러그인이 없거나 에러 발생 시 원본 사용
|
|
134
510
|
console.warn('⚠️ prettier-plugin-organize-imports not found, skipping import organization');
|
|
135
511
|
}
|
|
136
512
|
const formatted = await prettierString(organized, {
|
|
@@ -140,32 +516,42 @@ async function generatePretty(path, contents) {
|
|
|
140
516
|
generate(path, formatted);
|
|
141
517
|
}
|
|
142
518
|
function generate(path, contents) {
|
|
143
|
-
// 기존 파일이 있으면 읽어서 병합
|
|
144
519
|
let existingContent = '';
|
|
145
520
|
try {
|
|
146
521
|
if (fs.existsSync(path)) {
|
|
147
522
|
existingContent = fs.readFileSync(path, 'utf8');
|
|
148
|
-
console.log('🔧 [SMART-MERGE] Found existing file, merging:', path);
|
|
149
523
|
}
|
|
150
524
|
}
|
|
151
525
|
catch (err) {
|
|
152
|
-
|
|
526
|
+
// no existing file
|
|
153
527
|
}
|
|
154
|
-
// 기존 내용이 있으면 병합
|
|
155
528
|
if (existingContent) {
|
|
156
|
-
// 스마트 병합: 중복 타입 제거
|
|
157
529
|
const mergedContent = mergeTypeScriptContent(existingContent, contents);
|
|
158
530
|
fs.writeFileSync(path, mergedContent);
|
|
159
|
-
console.log('🔧 [SMART-MERGE] Smart merged content for:', path);
|
|
160
531
|
}
|
|
161
532
|
else {
|
|
162
533
|
fs.writeFileSync(path, contents);
|
|
163
|
-
console.log('🔧 [SMART-MERGE] Created new file:', path);
|
|
164
534
|
}
|
|
165
535
|
}
|
|
166
536
|
function splitHookContents(filename, content) {
|
|
167
|
-
const
|
|
168
|
-
|
|
537
|
+
const indicatorIdx = content.indexOf(QUERY_HOOK_INDICATOR);
|
|
538
|
+
if (indicatorIdx === -1) {
|
|
539
|
+
throw new Error(`[splitHookContents] QUERY_HOOK_INDICATOR not found in ${filename}. ` +
|
|
540
|
+
`Ensure the template includes the indicator comment.`);
|
|
541
|
+
}
|
|
542
|
+
const _apiContent = content.slice(0, indicatorIdx);
|
|
543
|
+
const _hookContent = content.slice(indicatorIdx + QUERY_HOOK_INDICATOR.length);
|
|
544
|
+
const suspenseIdx = _hookContent.indexOf(USE_SUSPENSE_QUERY_HOOK_INDICATOR);
|
|
545
|
+
let _hookParts;
|
|
546
|
+
if (suspenseIdx === -1) {
|
|
547
|
+
_hookParts = [_hookContent, ''];
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
_hookParts = [
|
|
551
|
+
_hookContent.slice(0, suspenseIdx),
|
|
552
|
+
_hookContent.slice(suspenseIdx + USE_SUSPENSE_QUERY_HOOK_INDICATOR.length),
|
|
553
|
+
];
|
|
554
|
+
}
|
|
169
555
|
const lastImport = getLastImportLine(content);
|
|
170
556
|
const lines = content.split('\n');
|
|
171
557
|
const importArea = [
|
|
@@ -177,12 +563,16 @@ function splitHookContents(filename, content) {
|
|
|
177
563
|
hookParts: _hookParts.map((d) => importArea + d),
|
|
178
564
|
};
|
|
179
565
|
}
|
|
566
|
+
/** @internal — exported for testing */
|
|
180
567
|
function getLastImportLine(content) {
|
|
181
|
-
|
|
568
|
+
const importLines = content
|
|
182
569
|
.split('\n')
|
|
183
570
|
.map((line, idx) => ({ idx, has: /from ('|").*('|");/.test(line) }))
|
|
184
571
|
.filter(({ has }) => has)
|
|
185
|
-
.map(({ idx }) => idx)
|
|
572
|
+
.map(({ idx }) => idx);
|
|
573
|
+
if (importLines.length === 0)
|
|
574
|
+
return 0;
|
|
575
|
+
return Math.max(...importLines) + 1;
|
|
186
576
|
}
|
|
187
577
|
|
|
188
578
|
/**
|
|
@@ -205,8 +595,13 @@ const genApi = defineCommand({
|
|
|
205
595
|
nextKey: 'cursor',
|
|
206
596
|
},
|
|
207
597
|
],
|
|
598
|
+
ignoreTlsError: false,
|
|
599
|
+
splitDataContracts: false,
|
|
208
600
|
},
|
|
209
601
|
run: async (config) => {
|
|
602
|
+
if (config.ignoreTlsError) {
|
|
603
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
604
|
+
}
|
|
210
605
|
if (config.swaggerSchemaUrls && config.swaggerSchemaUrls.length === 0) {
|
|
211
606
|
throw new Error('No URLs provided');
|
|
212
607
|
}
|
|
@@ -221,7 +616,6 @@ const genApi = defineCommand({
|
|
|
221
616
|
}
|
|
222
617
|
return [];
|
|
223
618
|
})();
|
|
224
|
-
console.log('🔧 [MULTI-URL] Processing URLs:', urls);
|
|
225
619
|
const coverPath = (config, url) => {
|
|
226
620
|
const { httpClientType, output } = config;
|
|
227
621
|
const { AXIOS_DEFAULT_INSTANCE_PATH, FETCH_DEFAULT_INSTANCE_PATH } = GENERATE_SWAGGER_DATA;
|
|
@@ -239,7 +633,6 @@ const genApi = defineCommand({
|
|
|
239
633
|
// 각 URL별로 순차 처리
|
|
240
634
|
for (let i = 0; i < urls.length; i++) {
|
|
241
635
|
const url = urls[i];
|
|
242
|
-
console.log(`🔧 [MULTI-URL] Processing URL ${i + 1}/${urls.length}: ${url}`);
|
|
243
636
|
const covered = coverPath(config, url);
|
|
244
637
|
const parsed = await withLoading(`Parse Swagger ${i + 1}/${urls.length}`, 'swaggerSchemaUrl' in covered ? covered.swaggerSchemaUrl : '', () => {
|
|
245
638
|
return parseSwagger(omit(covered, 'swaggerSchemaUrls'));
|
|
@@ -248,9 +641,9 @@ const genApi = defineCommand({
|
|
|
248
641
|
console.error(`Failed to generate api for URL ${i + 1}: swagger parse error.`);
|
|
249
642
|
continue;
|
|
250
643
|
}
|
|
251
|
-
withLoading('Write Swagger API', //
|
|
644
|
+
await withLoading('Write Swagger API', //
|
|
252
645
|
covered.output, (spinner) => {
|
|
253
|
-
writeSwaggerApiFile({
|
|
646
|
+
return writeSwaggerApiFile({
|
|
254
647
|
input: parsed,
|
|
255
648
|
output: covered.output,
|
|
256
649
|
spinner,
|
|
@@ -297,20 +690,6 @@ const genApi = defineCommand({
|
|
|
297
690
|
/**
|
|
298
691
|
* 스마트 타입 병합 함수들
|
|
299
692
|
*/
|
|
300
|
-
// 타입 정의 파싱 함수
|
|
301
|
-
function parseTypeDefinitions(content) {
|
|
302
|
-
const types = {};
|
|
303
|
-
// export type, interface, enum, const 패턴 매칭
|
|
304
|
-
const typeRegex = /(export\s+(?:type|interface|enum|const)\s+(\w+)[\s\S]*?)(?=export\s+(?:type|interface|enum|const)\s+\w+|$)/g;
|
|
305
|
-
let match;
|
|
306
|
-
while ((match = typeRegex.exec(content)) !== null) {
|
|
307
|
-
const typeName = match[2];
|
|
308
|
-
const typeContent = match[1].trim();
|
|
309
|
-
types[typeName] = typeContent;
|
|
310
|
-
}
|
|
311
|
-
return types;
|
|
312
|
-
}
|
|
313
|
-
// (deprecated) 타입 문자열 변환 로직은 병합 로직 내에서 직접 조립합니다.
|
|
314
693
|
// 스마트 타입 병합 함수
|
|
315
694
|
function mergeTypeScriptContent(existing, newContent) {
|
|
316
695
|
// 1) import 구문 보존 및 병합 (양쪽 모두에서 수집)
|
|
@@ -333,25 +712,11 @@ function mergeTypeScriptContent(existing, newContent) {
|
|
|
333
712
|
existingImports.forEach((imp) => mergedImportSet.add(imp));
|
|
334
713
|
const mergedImports = Array.from(mergedImportSet);
|
|
335
714
|
// 2) 타입 선언 병합 (중복 제거)
|
|
336
|
-
const typeBlockRegex = /(export\s+(?:type|interface|enum|const)\s+\w+[\s\S]*?)(?=export\s+(?:type|interface|enum|const)\s+\w+|$)/g;
|
|
337
715
|
const existingTypes = parseTypeDefinitions(existingBody);
|
|
338
716
|
const newTypes = parseTypeDefinitions(newBody);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const mergedTypes = { ...existingTypes };
|
|
342
|
-
let addedCount = 0;
|
|
343
|
-
let skippedCount = 0;
|
|
344
|
-
for (const [typeName, typeContent] of Object.entries(newTypes)) {
|
|
345
|
-
if (mergedTypes[typeName]) {
|
|
346
|
-
console.log('🔧 [SMART-MERGE] Skipping duplicate type:', typeName);
|
|
347
|
-
skippedCount++;
|
|
348
|
-
}
|
|
349
|
-
else {
|
|
350
|
-
mergedTypes[typeName] = typeContent;
|
|
351
|
-
addedCount++;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
console.log('🔧 [SMART-MERGE] Added types:', addedCount, 'Skipped duplicates:', skippedCount);
|
|
717
|
+
// 새 타입 기준으로 병합: 새 버전이 source of truth (swagger 스키마)
|
|
718
|
+
// 기존에만 있는 타입은 보존 (사용자가 수동 추가한 것)
|
|
719
|
+
const mergedTypes = { ...existingTypes, ...newTypes };
|
|
355
720
|
const mergedTypesString = Object.values(mergedTypes).join('\n\n');
|
|
356
721
|
// 3) 기타 코드(타입/임포트 외)는 "새로운 내용"을 기준으로 유지
|
|
357
722
|
const removeHeaderComment = (content) => {
|
|
@@ -368,7 +733,7 @@ function mergeTypeScriptContent(existing, newContent) {
|
|
|
368
733
|
return removeLineComment(removeBlockComment(content));
|
|
369
734
|
};
|
|
370
735
|
// 새 본문에서 타입 블록 제거 후 남은 코드
|
|
371
|
-
const newBodyWithoutTypes = (newBody || '').replace(
|
|
736
|
+
const newBodyWithoutTypes = (newBody || '').replace(new RegExp(TYPE_BLOCK_REGEX.source, TYPE_BLOCK_REGEX.flags), '');
|
|
372
737
|
let otherCodeFromNew = removeHeaderComment(newBodyWithoutTypes).trim();
|
|
373
738
|
// 본문 내에 남아있는 "!DO NOT EDIT THIS FILE" 주석 블록들을 모두 제거
|
|
374
739
|
otherCodeFromNew = otherCodeFromNew.replace(/\/\*\*?\s*\*\s*!DO NOT EDIT THIS FILE[\s\S]*?\*\//g, '');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toktokhan-dev/cli-plugin-gen-api-react-query",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A CLI plugin for generating API hooks with React Query built by TOKTOKHAN.DEV",
|
|
5
5
|
"author": "TOKTOKHAN.DEV <fe-system@toktokhan.dev>",
|
|
6
6
|
"license": "ISC",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"lodash": "^4.17.21",
|
|
39
39
|
"prettier-plugin-organize-imports": "^3.2.4",
|
|
40
40
|
"swagger-typescript-api": "13.0.22",
|
|
41
|
-
"@toktokhan-dev/
|
|
42
|
-
"@toktokhan-dev/
|
|
41
|
+
"@toktokhan-dev/node": "0.0.10",
|
|
42
|
+
"@toktokhan-dev/cli": "0.0.11"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/lodash": "^4.17.0",
|
|
@@ -50,6 +50,6 @@
|
|
|
50
50
|
"build:watch": "rollup -c --watch",
|
|
51
51
|
"api-extractor": "api-extractor run --local --verbose",
|
|
52
52
|
"play": "pnpm build:watch & node --watch play/playground.js",
|
|
53
|
-
"test": "
|
|
53
|
+
"test": "jest --passWithNoTests"
|
|
54
54
|
}
|
|
55
55
|
}
|