mock-fried 1.0.1 → 1.0.3
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 +2 -1
- package/dist/module.d.mts +0 -11
- package/dist/module.json +1 -1
- package/dist/module.mjs +7 -2
- package/dist/runtime/plugin.js +8 -1
- package/dist/runtime/server/handlers/openapi.d.ts +4 -0
- package/dist/runtime/server/handlers/openapi.js +13 -6
- package/dist/runtime/server/handlers/reset.d.ts +15 -0
- package/dist/runtime/server/handlers/reset.js +18 -0
- package/dist/runtime/server/handlers/rpc.d.ts +4 -0
- package/dist/runtime/server/handlers/rpc.js +8 -2
- package/dist/runtime/server/handlers/schema.d.ts +4 -0
- package/dist/runtime/server/handlers/schema.js +148 -9
- package/dist/runtime/server/utils/mock/client-generator.d.ts +14 -4
- package/dist/runtime/server/utils/mock/client-generator.js +106 -12
- package/dist/runtime/server/utils/mock/pagination/cursor-manager.d.ts +1 -0
- package/dist/runtime/server/utils/mock/pagination/cursor-manager.js +18 -9
- package/dist/runtime/server/utils/mock/pagination/page-manager.js +6 -4
- package/dist/runtime/server/utils/mock/pagination/snapshot-store.d.ts +12 -0
- package/dist/runtime/server/utils/mock/pagination/snapshot-store.js +16 -4
- package/dist/runtime/server/utils/mock/pagination/types.d.ts +6 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -160,8 +160,9 @@ const schema = await $api.getSchema()
|
|
|
160
160
|
| Endpoint | Method | Description |
|
|
161
161
|
|----------|--------|-------------|
|
|
162
162
|
| `/mock/__schema` | GET | API 스키마 메타데이터 |
|
|
163
|
-
| `/mock
|
|
163
|
+
| `/mock/__reset` | POST | 캐시 초기화 |
|
|
164
164
|
| `/mock/rpc/:service/:method` | POST | RPC Mock 핸들러 |
|
|
165
|
+
| `/mock/**` | * | OpenAPI Mock 핸들러 |
|
|
165
166
|
|
|
166
167
|
## Development
|
|
167
168
|
|
package/dist/module.d.mts
CHANGED
|
@@ -50,12 +50,6 @@ interface MockCursorConfig {
|
|
|
50
50
|
*/
|
|
51
51
|
includeSortInfo?: boolean;
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
54
|
-
* 응답 포맷 타입
|
|
55
|
-
* - 'auto': 기존 동작 유지 (스키마 기반 자동)
|
|
56
|
-
* - 'standardized': 표준화된 응답 형식
|
|
57
|
-
*/
|
|
58
|
-
type MockResponseFormat = 'auto' | 'standardized';
|
|
59
53
|
/**
|
|
60
54
|
* Mock Module Options
|
|
61
55
|
* nuxt.config.ts에서 mock 키로 설정
|
|
@@ -92,11 +86,6 @@ interface MockModuleOptions {
|
|
|
92
86
|
* Cursor 설정
|
|
93
87
|
*/
|
|
94
88
|
cursor?: MockCursorConfig;
|
|
95
|
-
/**
|
|
96
|
-
* 응답 포맷
|
|
97
|
-
* @default 'auto'
|
|
98
|
-
*/
|
|
99
|
-
responseFormat?: MockResponseFormat;
|
|
100
89
|
}
|
|
101
90
|
/**
|
|
102
91
|
* OpenAPI 클라이언트 패키지 설정
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -113,8 +113,7 @@ const module$1 = defineNuxtModule({
|
|
|
113
113
|
clientPackageConfig,
|
|
114
114
|
protoPath,
|
|
115
115
|
pagination: options.pagination,
|
|
116
|
-
cursor: options.cursor
|
|
117
|
-
responseFormat: options.responseFormat ?? "auto"
|
|
116
|
+
cursor: options.cursor
|
|
118
117
|
};
|
|
119
118
|
nuxt.options.runtimeConfig.public.mock = {
|
|
120
119
|
enable: options.enable,
|
|
@@ -126,6 +125,12 @@ const module$1 = defineNuxtModule({
|
|
|
126
125
|
handler: resolver.resolve("./runtime/server/handlers/schema")
|
|
127
126
|
});
|
|
128
127
|
logger.info(`Schema handler registered at GET ${prefix}/__schema`);
|
|
128
|
+
addServerHandler({
|
|
129
|
+
route: `${prefix}/__reset`,
|
|
130
|
+
method: "post",
|
|
131
|
+
handler: resolver.resolve("./runtime/server/handlers/reset")
|
|
132
|
+
});
|
|
133
|
+
logger.info(`Reset handler registered at POST ${prefix}/__reset`);
|
|
129
134
|
if (protoPath) {
|
|
130
135
|
addServerHandler({
|
|
131
136
|
route: `${prefix}/rpc/:service/:method`,
|
package/dist/runtime/plugin.js
CHANGED
|
@@ -17,9 +17,16 @@ export default defineNuxtPlugin(() => {
|
|
|
17
17
|
};
|
|
18
18
|
if (options?.body && fetchOptions.method !== "GET") {
|
|
19
19
|
fetchOptions.body = JSON.stringify(options.body);
|
|
20
|
+
fetchOptions.headers = {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
...fetchOptions.headers
|
|
23
|
+
};
|
|
20
24
|
}
|
|
21
25
|
if (options?.headers) {
|
|
22
|
-
fetchOptions.headers =
|
|
26
|
+
fetchOptions.headers = {
|
|
27
|
+
...fetchOptions.headers,
|
|
28
|
+
...options.headers
|
|
29
|
+
};
|
|
23
30
|
}
|
|
24
31
|
let finalUrl = url;
|
|
25
32
|
if (options?.params) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineEventHandler, getQuery, readBody, createError
|
|
1
|
+
import { defineEventHandler, getQuery, readBody, createError } from "h3";
|
|
2
2
|
import { useRuntimeConfig } from "#imports";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import yaml from "js-yaml";
|
|
@@ -78,6 +78,15 @@ let cachedClientPath = null;
|
|
|
78
78
|
let mockGenerator = null;
|
|
79
79
|
let cursorPaginationManager = null;
|
|
80
80
|
let pagePaginationManager = null;
|
|
81
|
+
export function clearOpenApiCache() {
|
|
82
|
+
apiInstance = null;
|
|
83
|
+
cachedSpecPath = null;
|
|
84
|
+
cachedClientPackage = null;
|
|
85
|
+
cachedClientPath = null;
|
|
86
|
+
mockGenerator = null;
|
|
87
|
+
cursorPaginationManager = null;
|
|
88
|
+
pagePaginationManager = null;
|
|
89
|
+
}
|
|
81
90
|
function getClientPackageData(packagePath, config, paginationConfig, cursorConfig) {
|
|
82
91
|
if (cachedClientPackage && cachedClientPath === packagePath && mockGenerator && cursorPaginationManager && pagePaginationManager) {
|
|
83
92
|
return {
|
|
@@ -127,7 +136,7 @@ function findMatchingEndpoint(endpoints, path, method) {
|
|
|
127
136
|
}
|
|
128
137
|
return null;
|
|
129
138
|
}
|
|
130
|
-
function handleClientPackageRequest(pkg, generator, cursorManager, pageManager, path, method, query
|
|
139
|
+
function handleClientPackageRequest(pkg, generator, cursorManager, pageManager, path, method, query) {
|
|
131
140
|
const match = findMatchingEndpoint(pkg.endpoints, path, method);
|
|
132
141
|
if (!match) {
|
|
133
142
|
return {
|
|
@@ -281,9 +290,8 @@ function handleClientPackageRequest(pkg, generator, cursorManager, pageManager,
|
|
|
281
290
|
export default defineEventHandler(async (event) => {
|
|
282
291
|
const config = useRuntimeConfig(event);
|
|
283
292
|
const mockConfig = config.mock;
|
|
284
|
-
const requestUrl = getRequestURL(event);
|
|
285
293
|
const prefix = mockConfig?.prefix || "/mock";
|
|
286
|
-
let path =
|
|
294
|
+
let path = event.path;
|
|
287
295
|
if (path.startsWith(prefix)) {
|
|
288
296
|
path = path.substring(prefix.length) || "/";
|
|
289
297
|
}
|
|
@@ -310,8 +318,7 @@ export default defineEventHandler(async (event) => {
|
|
|
310
318
|
pageManager,
|
|
311
319
|
path,
|
|
312
320
|
event.method,
|
|
313
|
-
query
|
|
314
|
-
mockConfig.responseFormat ?? "auto"
|
|
321
|
+
query
|
|
315
322
|
);
|
|
316
323
|
if (result.statusCode) {
|
|
317
324
|
event.node.res.statusCode = result.statusCode;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 캐시 초기화 핸들러
|
|
3
|
+
* POST {prefix}/__reset
|
|
4
|
+
*
|
|
5
|
+
* 모든 캐시를 초기화하여 설정 변경사항을 즉시 반영할 수 있게 합니다.
|
|
6
|
+
* 개발 환경에서 핫리로드 후 캐시 문제 해결에 유용합니다.
|
|
7
|
+
*
|
|
8
|
+
* POST를 사용하는 이유: GET은 브라우저/CDN 프리페치로 의도치 않게 호출될 수 있음
|
|
9
|
+
*/
|
|
10
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, {
|
|
11
|
+
success: boolean;
|
|
12
|
+
message: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
}>;
|
|
15
|
+
export default _default;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { clearOpenApiCache } from "./openapi.js";
|
|
3
|
+
import { clearProtoCache } from "./rpc.js";
|
|
4
|
+
import { clearSchemaCache } from "./schema.js";
|
|
5
|
+
import { clearClientPackageCache } from "../utils/client-parser.js";
|
|
6
|
+
import { resetSnapshotStore } from "../utils/mock/pagination/index.js";
|
|
7
|
+
export default defineEventHandler(() => {
|
|
8
|
+
clearOpenApiCache();
|
|
9
|
+
clearProtoCache();
|
|
10
|
+
clearSchemaCache();
|
|
11
|
+
clearClientPackageCache();
|
|
12
|
+
resetSnapshotStore();
|
|
13
|
+
return {
|
|
14
|
+
success: true,
|
|
15
|
+
message: "All caches have been reset",
|
|
16
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
17
|
+
};
|
|
18
|
+
});
|
|
@@ -3,7 +3,7 @@ import { useRuntimeConfig } from "#imports";
|
|
|
3
3
|
import * as protoLoader from "@grpc/proto-loader";
|
|
4
4
|
import * as grpc from "@grpc/grpc-js";
|
|
5
5
|
import { readdirSync, statSync } from "node:fs";
|
|
6
|
-
import { join, extname } from "pathe";
|
|
6
|
+
import { join, extname, dirname } from "pathe";
|
|
7
7
|
import { generateMockMessage, deriveSeedFromRequest } from "../utils/mock/index.js";
|
|
8
8
|
let protoCache = null;
|
|
9
9
|
let cachedProtoPath = null;
|
|
@@ -47,13 +47,15 @@ async function loadProto(protoPath) {
|
|
|
47
47
|
if (protoFiles.length === 0) {
|
|
48
48
|
throw new Error(`No .proto files found in ${protoPath}`);
|
|
49
49
|
}
|
|
50
|
+
const stat = statSync(protoPath);
|
|
51
|
+
const includeDir = stat.isFile() ? dirname(protoPath) : protoPath;
|
|
50
52
|
const packageDefinition = await protoLoader.load(protoFiles, {
|
|
51
53
|
keepCase: true,
|
|
52
54
|
longs: String,
|
|
53
55
|
enums: String,
|
|
54
56
|
defaults: true,
|
|
55
57
|
oneofs: true,
|
|
56
|
-
includeDirs: [
|
|
58
|
+
includeDirs: [includeDir]
|
|
57
59
|
});
|
|
58
60
|
const grpcObject = grpc.loadPackageDefinition(packageDefinition);
|
|
59
61
|
const services = /* @__PURE__ */ new Map();
|
|
@@ -66,6 +68,10 @@ async function loadProto(protoPath) {
|
|
|
66
68
|
cachedProtoPath = protoPath;
|
|
67
69
|
return protoCache;
|
|
68
70
|
}
|
|
71
|
+
export function clearProtoCache() {
|
|
72
|
+
protoCache = null;
|
|
73
|
+
cachedProtoPath = null;
|
|
74
|
+
}
|
|
69
75
|
function getResponseTypeInfo(methodDef) {
|
|
70
76
|
const responseType = methodDef.responseType;
|
|
71
77
|
if (responseType?.type) {
|
|
@@ -1,7 +1,92 @@
|
|
|
1
1
|
import { defineEventHandler, createError } from "h3";
|
|
2
2
|
import { useRuntimeConfig } from "#imports";
|
|
3
|
+
import { consola } from "consola";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
3
5
|
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import yaml from "js-yaml";
|
|
7
|
+
import { getClientPackage } from "../utils/client-parser.js";
|
|
8
|
+
const logger = consola.withTag("mock-fried");
|
|
9
|
+
const _require = createRequire(import.meta.url);
|
|
10
|
+
function convertEndpointToPathItem(endpoint) {
|
|
11
|
+
const parameters = [
|
|
12
|
+
...(endpoint.pathParams || []).map((p) => ({
|
|
13
|
+
name: p.name,
|
|
14
|
+
in: "path",
|
|
15
|
+
required: p.required,
|
|
16
|
+
schema: { type: p.type }
|
|
17
|
+
})),
|
|
18
|
+
...(endpoint.queryParams || []).map((p) => ({
|
|
19
|
+
name: p.name,
|
|
20
|
+
in: "query",
|
|
21
|
+
required: p.required,
|
|
22
|
+
schema: { type: p.type }
|
|
23
|
+
}))
|
|
24
|
+
];
|
|
25
|
+
return {
|
|
26
|
+
path: endpoint.path,
|
|
27
|
+
method: endpoint.method,
|
|
28
|
+
operationId: endpoint.operationId,
|
|
29
|
+
summary: endpoint.summary,
|
|
30
|
+
tags: endpoint.apiClassName ? [endpoint.apiClassName] : void 0,
|
|
31
|
+
parameters: parameters.length > 0 ? parameters : void 0,
|
|
32
|
+
requestBody: endpoint.requestBodyType ? {
|
|
33
|
+
content: {
|
|
34
|
+
"application/json": {
|
|
35
|
+
schema: { $ref: `#/components/schemas/${endpoint.requestBodyType}` }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} : void 0,
|
|
39
|
+
responses: {
|
|
40
|
+
200: {
|
|
41
|
+
description: "Success",
|
|
42
|
+
content: endpoint.responseType ? {
|
|
43
|
+
"application/json": {
|
|
44
|
+
schema: { $ref: `#/components/schemas/${endpoint.responseType}` }
|
|
45
|
+
}
|
|
46
|
+
} : void 0
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function parseClientPackageSchema(config) {
|
|
52
|
+
try {
|
|
53
|
+
const packagePath = _require.resolve(`${config.package}/package.json`);
|
|
54
|
+
const packageRoot = packagePath.replace("/package.json", "").replace("\\package.json", "");
|
|
55
|
+
const clientPackage = getClientPackage(packageRoot, config);
|
|
56
|
+
const apiGroups = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const endpoint of clientPackage.endpoints) {
|
|
58
|
+
const apiName = endpoint.apiClassName || "default";
|
|
59
|
+
if (!apiGroups.has(apiName)) {
|
|
60
|
+
apiGroups.set(apiName, []);
|
|
61
|
+
}
|
|
62
|
+
apiGroups.get(apiName).push(endpoint);
|
|
63
|
+
}
|
|
64
|
+
const pathItems = clientPackage.endpoints.map(convertEndpointToPathItem);
|
|
65
|
+
return {
|
|
66
|
+
info: {
|
|
67
|
+
title: clientPackage.info.title || clientPackage.info.name || "Unknown API",
|
|
68
|
+
version: clientPackage.info.version || "1.0.0",
|
|
69
|
+
description: `Generated from ${config.package}`
|
|
70
|
+
},
|
|
71
|
+
paths: pathItems,
|
|
72
|
+
// 추가 메타데이터: 모델 수, API 클래스 목록
|
|
73
|
+
_meta: {
|
|
74
|
+
source: "client-package",
|
|
75
|
+
package: config.package,
|
|
76
|
+
apiClasses: Array.from(apiGroups.keys()),
|
|
77
|
+
modelCount: clientPackage.models.size,
|
|
78
|
+
endpointCount: clientPackage.endpoints.length
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.error("Failed to parse client package:", error);
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
4
86
|
let cachedSchema = null;
|
|
87
|
+
export function clearSchemaCache() {
|
|
88
|
+
cachedSchema = null;
|
|
89
|
+
}
|
|
5
90
|
function parseOpenApiSpec(specPath) {
|
|
6
91
|
if (!existsSync(specPath)) {
|
|
7
92
|
return void 0;
|
|
@@ -10,7 +95,6 @@ function parseOpenApiSpec(specPath) {
|
|
|
10
95
|
const content = readFileSync(specPath, "utf-8");
|
|
11
96
|
let spec;
|
|
12
97
|
if (specPath.endsWith(".yaml") || specPath.endsWith(".yml")) {
|
|
13
|
-
const yaml = require("js-yaml");
|
|
14
98
|
spec = yaml.load(content);
|
|
15
99
|
} else {
|
|
16
100
|
spec = JSON.parse(content);
|
|
@@ -18,15 +102,28 @@ function parseOpenApiSpec(specPath) {
|
|
|
18
102
|
const info = spec.info || {};
|
|
19
103
|
const paths = spec.paths || {};
|
|
20
104
|
const pathItems = [];
|
|
21
|
-
for (const [path,
|
|
22
|
-
|
|
105
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
106
|
+
const pathLevelParams = [];
|
|
107
|
+
if (Array.isArray(pathItem.parameters)) {
|
|
108
|
+
for (const param of pathItem.parameters) {
|
|
109
|
+
const p = param;
|
|
110
|
+
pathLevelParams.push({
|
|
111
|
+
name: p.name,
|
|
112
|
+
in: p.in,
|
|
113
|
+
required: p.required,
|
|
114
|
+
description: p.description,
|
|
115
|
+
schema: p.schema
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const [method, operation] of Object.entries(pathItem)) {
|
|
23
120
|
if (["get", "post", "put", "delete", "patch"].includes(method.toLowerCase())) {
|
|
24
121
|
const op = operation;
|
|
25
|
-
const
|
|
122
|
+
const operationParams = [];
|
|
26
123
|
if (Array.isArray(op.parameters)) {
|
|
27
124
|
for (const param of op.parameters) {
|
|
28
125
|
const p = param;
|
|
29
|
-
|
|
126
|
+
operationParams.push({
|
|
30
127
|
name: p.name,
|
|
31
128
|
in: p.in,
|
|
32
129
|
required: p.required,
|
|
@@ -35,6 +132,17 @@ function parseOpenApiSpec(specPath) {
|
|
|
35
132
|
});
|
|
36
133
|
}
|
|
37
134
|
}
|
|
135
|
+
const mergedParams = [...pathLevelParams];
|
|
136
|
+
for (const opParam of operationParams) {
|
|
137
|
+
const existingIndex = mergedParams.findIndex(
|
|
138
|
+
(p) => p.name === opParam.name && p.in === opParam.in
|
|
139
|
+
);
|
|
140
|
+
if (existingIndex >= 0) {
|
|
141
|
+
mergedParams[existingIndex] = opParam;
|
|
142
|
+
} else {
|
|
143
|
+
mergedParams.push(opParam);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
38
146
|
pathItems.push({
|
|
39
147
|
path,
|
|
40
148
|
method: method.toUpperCase(),
|
|
@@ -42,7 +150,7 @@ function parseOpenApiSpec(specPath) {
|
|
|
42
150
|
summary: op.summary,
|
|
43
151
|
description: op.description,
|
|
44
152
|
tags: op.tags,
|
|
45
|
-
parameters:
|
|
153
|
+
parameters: mergedParams.length > 0 ? mergedParams : void 0,
|
|
46
154
|
requestBody: op.requestBody,
|
|
47
155
|
responses: op.responses
|
|
48
156
|
});
|
|
@@ -57,7 +165,8 @@ function parseOpenApiSpec(specPath) {
|
|
|
57
165
|
},
|
|
58
166
|
paths: pathItems
|
|
59
167
|
};
|
|
60
|
-
} catch {
|
|
168
|
+
} catch (error) {
|
|
169
|
+
logger.error("Failed to parse OpenAPI spec:", specPath, error);
|
|
61
170
|
return void 0;
|
|
62
171
|
}
|
|
63
172
|
}
|
|
@@ -112,7 +221,8 @@ async function parseProtoSpec(protoPath) {
|
|
|
112
221
|
package: packageName,
|
|
113
222
|
services
|
|
114
223
|
};
|
|
115
|
-
} catch {
|
|
224
|
+
} catch (error) {
|
|
225
|
+
logger.error("Failed to parse Proto spec:", protoPath, error);
|
|
116
226
|
return void 0;
|
|
117
227
|
}
|
|
118
228
|
}
|
|
@@ -167,7 +277,36 @@ export default defineEventHandler(async () => {
|
|
|
167
277
|
return cachedSchema;
|
|
168
278
|
}
|
|
169
279
|
const schema = {};
|
|
170
|
-
if (mockConfig.
|
|
280
|
+
if (mockConfig.clientPackageConfig?.package) {
|
|
281
|
+
const openapi = parseClientPackageSchema(mockConfig.clientPackageConfig);
|
|
282
|
+
if (openapi) {
|
|
283
|
+
schema.openapi = openapi;
|
|
284
|
+
}
|
|
285
|
+
} else if (mockConfig.clientPackagePath) {
|
|
286
|
+
const clientPkg = getClientPackage(mockConfig.clientPackagePath);
|
|
287
|
+
const apiGroups = /* @__PURE__ */ new Map();
|
|
288
|
+
for (const endpoint of clientPkg.endpoints) {
|
|
289
|
+
const apiName = endpoint.apiClassName || "default";
|
|
290
|
+
if (!apiGroups.has(apiName)) {
|
|
291
|
+
apiGroups.set(apiName, []);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const pathItems = clientPkg.endpoints.map(convertEndpointToPathItem);
|
|
295
|
+
schema.openapi = {
|
|
296
|
+
info: {
|
|
297
|
+
title: clientPkg.info.title || clientPkg.info.name || "Unknown API",
|
|
298
|
+
version: clientPkg.info.version || "1.0.0",
|
|
299
|
+
description: `Generated from client package`
|
|
300
|
+
},
|
|
301
|
+
paths: pathItems,
|
|
302
|
+
_meta: {
|
|
303
|
+
source: "client-package",
|
|
304
|
+
apiClasses: Array.from(apiGroups.keys()),
|
|
305
|
+
modelCount: clientPkg.models.size,
|
|
306
|
+
endpointCount: clientPkg.endpoints.length
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
} else if (mockConfig.openapiPath) {
|
|
171
310
|
const openapi = parseOpenApiSpec(mockConfig.openapiPath);
|
|
172
311
|
if (openapi) {
|
|
173
312
|
schema.openapi = openapi;
|
|
@@ -14,6 +14,7 @@ export declare function inferValueByFieldName(fieldName: string, rng: SeededRand
|
|
|
14
14
|
export declare function generateValueByType(type: string, fieldName: string, rng: SeededRandom, index?: number, idConfig?: MockIdConfig): unknown;
|
|
15
15
|
/**
|
|
16
16
|
* 필드명에서 타입을 추측하여 적절한 값 생성
|
|
17
|
+
* (unknown/any/object 타입인 경우 사용)
|
|
17
18
|
*/
|
|
18
19
|
export declare function inferTypeFromFieldName(fieldName: string, rng: SeededRandom, index: number, idConfig?: MockIdConfig): unknown;
|
|
19
20
|
/**
|
|
@@ -46,23 +47,32 @@ export declare class SchemaMockGenerator {
|
|
|
46
47
|
* 모델 스키마 기반 단일 객체 생성
|
|
47
48
|
*/
|
|
48
49
|
generateOne(modelName: string, seed?: string | number, index?: number): Record<string, unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* 내부 구현: 순환 참조 감지를 위한 재귀 생성
|
|
52
|
+
*/
|
|
53
|
+
private generateOneInternal;
|
|
49
54
|
/**
|
|
50
55
|
* ID 부여된 단일 객체 생성 (Pagination용)
|
|
51
56
|
* 주어진 ID를 모델의 ID 필드에 설정하여 cursor와 응답 ID가 일치하도록 함
|
|
57
|
+
* @param modelName 모델명
|
|
58
|
+
* @param itemId 아이템 ID (string 또는 number)
|
|
59
|
+
* @param seed 생성 seed
|
|
60
|
+
* @param index 인덱스
|
|
52
61
|
*/
|
|
53
|
-
generateOneWithId(modelName: string, itemId: string, seed?: string | number, index?: number): Record<string, unknown>;
|
|
62
|
+
generateOneWithId(modelName: string, itemId: string | number, seed?: string | number, index?: number): Record<string, unknown>;
|
|
54
63
|
/**
|
|
55
64
|
* 모델의 ID 필드명 찾기 (MockIdConfig 기반)
|
|
65
|
+
* public으로 변경하여 CursorPaginationManager에서 사용 가능
|
|
56
66
|
*/
|
|
57
|
-
|
|
67
|
+
findIdFieldName(modelName: string): string | null;
|
|
58
68
|
/**
|
|
59
69
|
* 필드의 출력 키 가져오기 (jsonKey 또는 name)
|
|
60
70
|
*/
|
|
61
71
|
private getOutputKey;
|
|
62
72
|
/**
|
|
63
|
-
* 필드 값 생성
|
|
73
|
+
* 필드 값 생성 (순환 참조 감지 포함)
|
|
64
74
|
*/
|
|
65
|
-
private
|
|
75
|
+
private generateFieldInternal;
|
|
66
76
|
/**
|
|
67
77
|
* 리스트 데이터 생성 (캐시 지원 - Pagination)
|
|
68
78
|
* @deprecated Use pagination/page-manager.ts instead for enhanced pagination
|
|
@@ -33,22 +33,43 @@ export function inferValueByFieldName(fieldName, rng, index = 0, idConfig = DEFA
|
|
|
33
33
|
if (name.includes("title") || name.includes("subject") || name.includes("headline")) {
|
|
34
34
|
return `\uC0D8\uD50C ${fieldName} #${rng.nextInt(1, 100)}`;
|
|
35
35
|
}
|
|
36
|
-
if (name.includes("status")) {
|
|
37
|
-
return rng.pick(["ACTIVE", "INACTIVE", "PENDING", "COMPLETED"]);
|
|
36
|
+
if (name.includes("status") || name.includes("state") || name.includes("stage")) {
|
|
37
|
+
return rng.pick(["ACTIVE", "INACTIVE", "PENDING", "COMPLETED", "APPROVED", "REJECTED"]);
|
|
38
|
+
}
|
|
39
|
+
if (name.includes("yearmonth") || name.includes("month") && !name.includes("months")) {
|
|
40
|
+
const year = 2024 + rng.nextInt(0, 1);
|
|
41
|
+
const month = rng.nextInt(1, 12);
|
|
42
|
+
return year * 100 + month;
|
|
43
|
+
}
|
|
44
|
+
if (name.includes("note") || name.includes("memo") || name.includes("remark") || name.includes("comment") || name.includes("message")) {
|
|
45
|
+
return `${fieldName} \uB0B4\uC6A9\uC785\uB2C8\uB2E4. #${rng.nextInt(1, 100)}`;
|
|
38
46
|
}
|
|
39
|
-
if (name.includes("count") || name.includes("quantity") || name.includes("
|
|
47
|
+
if (name.includes("count") || name.includes("quantity") || name.includes("num")) {
|
|
40
48
|
return rng.nextInt(0, 100);
|
|
41
49
|
}
|
|
42
|
-
if (name.includes("
|
|
50
|
+
if (name.includes("rate") || name.includes("ratio") || name.includes("percent")) {
|
|
51
|
+
return `${rng.nextInt(1, 100)}`;
|
|
52
|
+
}
|
|
53
|
+
if (name.includes("amount") || name.includes("price") || name.includes("cost") || name.includes("fee") || name.includes("commission")) {
|
|
43
54
|
return rng.nextInt(1e3, 1e5);
|
|
44
55
|
}
|
|
45
56
|
if (name === "page") return 1;
|
|
46
57
|
if (name === "limit" || name === "size" || name === "pagesize") return 20;
|
|
47
58
|
if (name === "total" || name === "totalcount" || name === "totalitems") return rng.nextInt(50, 500);
|
|
48
59
|
if (name === "totalpages") return rng.nextInt(3, 25);
|
|
60
|
+
if (name.includes("sequence") || name.includes("order") || name.includes("priority") || name.includes("rank") || name === "sort") {
|
|
61
|
+
return rng.nextInt(1, 100);
|
|
62
|
+
}
|
|
49
63
|
if (name.startsWith("is") || name.startsWith("has") || name.startsWith("can") || name.startsWith("should") || name.startsWith("will")) {
|
|
50
64
|
return rng.next() > 0.5;
|
|
51
65
|
}
|
|
66
|
+
if (name === "result" || name === "success" || name.includes("enabled") || name.includes("valid") || name.includes("complete")) {
|
|
67
|
+
return rng.next() > 0.5;
|
|
68
|
+
}
|
|
69
|
+
if (name.includes("owner") || name.includes("manager") || name.includes("author") || name.includes("writer") || name.includes("creator")) {
|
|
70
|
+
const names = ["\uAE40\uCCA0\uC218", "\uC774\uC601\uD76C", "\uBC15\uBBFC\uC218", "John", "Jane"];
|
|
71
|
+
return rng.pick(names);
|
|
72
|
+
}
|
|
52
73
|
if (name.includes("address") || name.includes("street")) {
|
|
53
74
|
return `\uC11C\uC6B8\uC2DC \uAC15\uB0A8\uAD6C \uD14C\uD5E4\uB780\uB85C ${rng.nextInt(1, 500)}\uBC88\uAE38`;
|
|
54
75
|
}
|
|
@@ -61,18 +82,42 @@ export function inferValueByFieldName(fieldName, rng, index = 0, idConfig = DEFA
|
|
|
61
82
|
if (name.includes("zipcode") || name.includes("postal")) {
|
|
62
83
|
return `${rng.nextInt(1e4, 99999)}`;
|
|
63
84
|
}
|
|
85
|
+
if (name.includes("bizreg") || name.includes("regno")) {
|
|
86
|
+
return `${rng.nextInt(100, 999)}-${rng.nextInt(10, 99)}-${rng.nextInt(1e4, 99999)}`;
|
|
87
|
+
}
|
|
64
88
|
if (name.includes("code")) {
|
|
65
89
|
return `CODE-${rng.nextInt(1e3, 9999)}`;
|
|
66
90
|
}
|
|
67
91
|
if (name.includes("type") || name.includes("category")) {
|
|
68
92
|
return rng.pick(["TYPE_A", "TYPE_B", "TYPE_C"]);
|
|
69
93
|
}
|
|
94
|
+
if (name.includes("tag") || name.includes("label")) {
|
|
95
|
+
return rng.pick(["\uD0DC\uADF81", "\uD0DC\uADF82", "\uD0DC\uADF83", "Tag A", "Tag B"]);
|
|
96
|
+
}
|
|
97
|
+
if (name.includes("scale") || name.includes("level") || name.includes("grade") || name.includes("tier")) {
|
|
98
|
+
return rng.pick(["SMALL", "MEDIUM", "LARGE", "LEVEL_1", "LEVEL_2", "LEVEL_3"]);
|
|
99
|
+
}
|
|
100
|
+
if (name.includes("days") || name.includes("months") || name.includes("weeks") || name.includes("years")) {
|
|
101
|
+
return rng.nextInt(1, 30);
|
|
102
|
+
}
|
|
103
|
+
if (name.includes("day") && !name.includes("days")) {
|
|
104
|
+
return rng.nextInt(1, 28);
|
|
105
|
+
}
|
|
106
|
+
if (name.includes("reason") || name.includes("cause")) {
|
|
107
|
+
return rng.pick(["\uC0AC\uC6A9\uC790 \uC694\uCCAD", "\uC2DC\uC2A4\uD15C \uCC98\uB9AC", "\uAE30\uAC04 \uB9CC\uB8CC", "\uC815\uCC45 \uBCC0\uACBD"]);
|
|
108
|
+
}
|
|
109
|
+
if (name.includes("password") || name.includes("pwd")) {
|
|
110
|
+
return "********";
|
|
111
|
+
}
|
|
70
112
|
if (name.includes("version")) {
|
|
71
113
|
return `${rng.nextInt(1, 10)}.${rng.nextInt(0, 9)}.${rng.nextInt(0, 99)}`;
|
|
72
114
|
}
|
|
73
115
|
if (name.includes("token") || name.includes("key") || name.includes("secret")) {
|
|
74
116
|
return rng.uuid();
|
|
75
117
|
}
|
|
118
|
+
if (name.includes("file") || name.includes("attachment") || name.includes("document")) {
|
|
119
|
+
return `https://storage.example.com/files/${rng.hashId(12)}.pdf`;
|
|
120
|
+
}
|
|
76
121
|
return null;
|
|
77
122
|
}
|
|
78
123
|
export function generateValueByType(type, fieldName, rng, index = 0, idConfig = DEFAULT_ID_CONFIG) {
|
|
@@ -103,23 +148,49 @@ export function inferTypeFromFieldName(fieldName, rng, index, idConfig = DEFAULT
|
|
|
103
148
|
if (isIdField(fieldName, idConfig)) {
|
|
104
149
|
return generateIdValue(fieldName, index, rng.hashId(16), idConfig);
|
|
105
150
|
}
|
|
106
|
-
if (name.includes("
|
|
151
|
+
if (name.includes("yearmonth")) {
|
|
152
|
+
const year = 2024 + rng.nextInt(0, 1);
|
|
153
|
+
const month = rng.nextInt(1, 12);
|
|
154
|
+
return year * 100 + month;
|
|
155
|
+
}
|
|
156
|
+
if (name.includes("count") || name.includes("num") || name.includes("amount") || name.includes("size") || name.includes("total") || name.includes("views") || name.includes("likes") || name.includes("price") || name.includes("age") || name.includes("quantity") || name.includes("index") || name.includes("order") || name.includes("sequence") || name.includes("priority") || name.includes("rank") || name.includes("fee") || name.includes("commission")) {
|
|
107
157
|
return rng.nextInt(0, 1e3);
|
|
108
158
|
}
|
|
109
|
-
if (name.
|
|
159
|
+
if (name.includes("rate") || name.includes("ratio") || name.includes("percent")) {
|
|
160
|
+
return `${rng.nextInt(1, 100)}`;
|
|
161
|
+
}
|
|
162
|
+
if (name.startsWith("is") || name.startsWith("has") || name.startsWith("can") || name.startsWith("should") || name.startsWith("will") || name.includes("enabled") || name.includes("active") || name.includes("visible") || name.includes("valid") || name === "result" || name === "success" || name.includes("complete")) {
|
|
110
163
|
return rng.next() > 0.5;
|
|
111
164
|
}
|
|
112
|
-
if (name.includes("date") || name.endsWith("at") || name.includes("time") || name.includes("created") || name.includes("updated") || name.includes("modified")) {
|
|
165
|
+
if (name.includes("date") || name.endsWith("at") || name.includes("time") || name.includes("created") || name.includes("updated") || name.includes("modified") || name.includes("deadline")) {
|
|
113
166
|
const now = Date.now();
|
|
114
167
|
const offset = rng.nextInt(-365, 30) * 24 * 60 * 60 * 1e3;
|
|
115
168
|
return new Date(now + offset).toISOString();
|
|
116
169
|
}
|
|
170
|
+
if (name.includes("days") || name.includes("months") || name.includes("weeks") || name.includes("years")) {
|
|
171
|
+
return rng.nextInt(1, 30);
|
|
172
|
+
}
|
|
173
|
+
if (name.includes("day") && !name.includes("days")) {
|
|
174
|
+
return rng.nextInt(1, 28);
|
|
175
|
+
}
|
|
117
176
|
if (name.includes("url") || name.includes("link") || name.includes("href")) {
|
|
118
177
|
return `https://example.com/${fieldName}/${rng.nextInt(1, 1e3)}`;
|
|
119
178
|
}
|
|
120
179
|
if (name.includes("image") || name.includes("thumbnail") || name.includes("avatar") || name.includes("photo") || name.includes("picture")) {
|
|
121
180
|
return `https://picsum.photos/seed/${rng.nextInt(1, 1e3)}/200/200`;
|
|
122
181
|
}
|
|
182
|
+
if (name.includes("file") || name.includes("attachment") || name.includes("document")) {
|
|
183
|
+
return `https://storage.example.com/files/${rng.hashId(12)}.pdf`;
|
|
184
|
+
}
|
|
185
|
+
if (name.includes("status") || name.includes("state") || name.includes("stage")) {
|
|
186
|
+
return rng.pick(["ACTIVE", "INACTIVE", "PENDING", "COMPLETED"]);
|
|
187
|
+
}
|
|
188
|
+
if (name.includes("scale") || name.includes("level") || name.includes("grade") || name.includes("tier")) {
|
|
189
|
+
return rng.pick(["SMALL", "MEDIUM", "LARGE"]);
|
|
190
|
+
}
|
|
191
|
+
if (name.includes("reason") || name.includes("cause")) {
|
|
192
|
+
return rng.pick(["\uC0AC\uC6A9\uC790 \uC694\uCCAD", "\uC2DC\uC2A4\uD15C \uCC98\uB9AC", "\uAE30\uAC04 \uB9CC\uB8CC"]);
|
|
193
|
+
}
|
|
123
194
|
return `mock-${fieldName}-${rng.nextInt(1, 1e3)}`;
|
|
124
195
|
}
|
|
125
196
|
export function extractDataModelName(responseType, models) {
|
|
@@ -168,6 +239,7 @@ export function extractDataModelName(responseType, models) {
|
|
|
168
239
|
}
|
|
169
240
|
return { modelName: responseType, isList: false };
|
|
170
241
|
}
|
|
242
|
+
const MAX_RECURSION_DEPTH = 5;
|
|
171
243
|
export class SchemaMockGenerator {
|
|
172
244
|
models;
|
|
173
245
|
dataStore = /* @__PURE__ */ new Map();
|
|
@@ -180,18 +252,32 @@ export class SchemaMockGenerator {
|
|
|
180
252
|
* 모델 스키마 기반 단일 객체 생성
|
|
181
253
|
*/
|
|
182
254
|
generateOne(modelName, seed, index = 0) {
|
|
255
|
+
return this.generateOneInternal(modelName, seed, index, /* @__PURE__ */ new Set(), 0);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* 내부 구현: 순환 참조 감지를 위한 재귀 생성
|
|
259
|
+
*/
|
|
260
|
+
generateOneInternal(modelName, seed, index, visitedModels, depth) {
|
|
183
261
|
const schema = this.models.get(modelName);
|
|
184
262
|
if (!schema) {
|
|
185
263
|
return {};
|
|
186
264
|
}
|
|
265
|
+
if (visitedModels.has(modelName) && depth > 1) {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
if (depth >= MAX_RECURSION_DEPTH) {
|
|
269
|
+
return {};
|
|
270
|
+
}
|
|
187
271
|
if (schema.enumValues && schema.enumValues.length > 0) {
|
|
188
272
|
const rng2 = new SeededRandom(seed ?? modelName);
|
|
189
273
|
return { value: rng2.pick(schema.enumValues) };
|
|
190
274
|
}
|
|
275
|
+
const newVisited = new Set(visitedModels);
|
|
276
|
+
newVisited.add(modelName);
|
|
191
277
|
const rng = new SeededRandom(seed ?? `${modelName}-${index}`);
|
|
192
278
|
const result = {};
|
|
193
279
|
for (const field of schema.fields) {
|
|
194
|
-
const value = this.
|
|
280
|
+
const value = this.generateFieldInternal(field, rng, index, newVisited, depth);
|
|
195
281
|
if (value !== void 0) {
|
|
196
282
|
const outputKey = field.jsonKey || field.name;
|
|
197
283
|
result[outputKey] = value;
|
|
@@ -202,6 +288,10 @@ export class SchemaMockGenerator {
|
|
|
202
288
|
/**
|
|
203
289
|
* ID 부여된 단일 객체 생성 (Pagination용)
|
|
204
290
|
* 주어진 ID를 모델의 ID 필드에 설정하여 cursor와 응답 ID가 일치하도록 함
|
|
291
|
+
* @param modelName 모델명
|
|
292
|
+
* @param itemId 아이템 ID (string 또는 number)
|
|
293
|
+
* @param seed 생성 seed
|
|
294
|
+
* @param index 인덱스
|
|
205
295
|
*/
|
|
206
296
|
generateOneWithId(modelName, itemId, seed, index = 0) {
|
|
207
297
|
const item = this.generateOne(modelName, seed ?? `${modelName}-${itemId}`, index);
|
|
@@ -218,6 +308,7 @@ export class SchemaMockGenerator {
|
|
|
218
308
|
}
|
|
219
309
|
/**
|
|
220
310
|
* 모델의 ID 필드명 찾기 (MockIdConfig 기반)
|
|
311
|
+
* public으로 변경하여 CursorPaginationManager에서 사용 가능
|
|
221
312
|
*/
|
|
222
313
|
findIdFieldName(modelName) {
|
|
223
314
|
const schema = this.models.get(modelName);
|
|
@@ -239,9 +330,9 @@ export class SchemaMockGenerator {
|
|
|
239
330
|
return field?.jsonKey || fieldName;
|
|
240
331
|
}
|
|
241
332
|
/**
|
|
242
|
-
* 필드 값 생성
|
|
333
|
+
* 필드 값 생성 (순환 참조 감지 포함)
|
|
243
334
|
*/
|
|
244
|
-
|
|
335
|
+
generateFieldInternal(field, rng, index, visitedModels, depth) {
|
|
245
336
|
if (!field.required && rng.next() > 0.7) {
|
|
246
337
|
return void 0;
|
|
247
338
|
}
|
|
@@ -251,14 +342,17 @@ export class SchemaMockGenerator {
|
|
|
251
342
|
const value = rng.pick(refSchema.enumValues);
|
|
252
343
|
return field.isArray ? [value] : value;
|
|
253
344
|
}
|
|
345
|
+
if (visitedModels.has(field.refType)) {
|
|
346
|
+
return field.isArray ? [] : {};
|
|
347
|
+
}
|
|
254
348
|
if (field.isArray) {
|
|
255
349
|
const count = rng.nextInt(1, 3);
|
|
256
350
|
return Array.from(
|
|
257
351
|
{ length: count },
|
|
258
|
-
(_, i) => this.
|
|
352
|
+
(_, i) => this.generateOneInternal(field.refType, `${field.refType}-${index}-${i}`, i, visitedModels, depth + 1)
|
|
259
353
|
);
|
|
260
354
|
}
|
|
261
|
-
return this.
|
|
355
|
+
return this.generateOneInternal(field.refType, `${field.refType}-${index}`, index, visitedModels, depth + 1);
|
|
262
356
|
}
|
|
263
357
|
if (field.isArray) {
|
|
264
358
|
const count = rng.nextInt(1, 5);
|
|
@@ -14,6 +14,7 @@ export declare function encodeCursor(payload: CursorPayload): string;
|
|
|
14
14
|
* - base64url 인코딩된 CursorPayload JSON
|
|
15
15
|
* - Legacy base64 인코딩된 인덱스 번호
|
|
16
16
|
* - Raw UUID/ID 문자열 (직접 ID로 사용)
|
|
17
|
+
* - Raw 숫자 (numeric ID 직접 사용)
|
|
17
18
|
*/
|
|
18
19
|
export declare function decodeCursor(cursor: string): CursorPayload | null;
|
|
19
20
|
/**
|
|
@@ -8,7 +8,7 @@ export function decodeCursor(cursor) {
|
|
|
8
8
|
try {
|
|
9
9
|
const json = Buffer.from(cursor, "base64url").toString("utf-8");
|
|
10
10
|
const parsed = JSON.parse(json);
|
|
11
|
-
if (parsed && typeof parsed.lastId === "string" && parsed.direction) {
|
|
11
|
+
if (parsed && (typeof parsed.lastId === "string" || typeof parsed.lastId === "number") && parsed.direction) {
|
|
12
12
|
return parsed;
|
|
13
13
|
}
|
|
14
14
|
} catch {
|
|
@@ -25,6 +25,14 @@ export function decodeCursor(cursor) {
|
|
|
25
25
|
}
|
|
26
26
|
} catch {
|
|
27
27
|
}
|
|
28
|
+
const numericId = Number(cursor);
|
|
29
|
+
if (!Number.isNaN(numericId) && cursor === String(numericId)) {
|
|
30
|
+
return {
|
|
31
|
+
lastId: numericId,
|
|
32
|
+
direction: "forward",
|
|
33
|
+
timestamp: Date.now()
|
|
34
|
+
};
|
|
35
|
+
}
|
|
28
36
|
if (cursor && cursor.length >= 8 && !cursor.includes(" ")) {
|
|
29
37
|
return {
|
|
30
38
|
lastId: cursor,
|
|
@@ -61,16 +69,17 @@ export class CursorPaginationManager {
|
|
|
61
69
|
cache = true,
|
|
62
70
|
ttl
|
|
63
71
|
} = options;
|
|
72
|
+
const idFieldName = this.generator.findIdFieldName(modelName) ?? "id";
|
|
64
73
|
let snapshot;
|
|
65
74
|
if (snapshotId) {
|
|
66
75
|
const existing = this.snapshotStore.getById(snapshotId);
|
|
67
76
|
if (existing) {
|
|
68
77
|
snapshot = existing;
|
|
69
78
|
} else {
|
|
70
|
-
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl });
|
|
79
|
+
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl, idFieldName });
|
|
71
80
|
}
|
|
72
81
|
} else {
|
|
73
|
-
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl });
|
|
82
|
+
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl, idFieldName });
|
|
74
83
|
}
|
|
75
84
|
let startIndex = 0;
|
|
76
85
|
let cursorPayload = null;
|
|
@@ -79,15 +88,15 @@ export class CursorPaginationManager {
|
|
|
79
88
|
if (cursorPayload) {
|
|
80
89
|
if (isCursorExpired(cursorPayload, this.cursorConfig)) {
|
|
81
90
|
startIndex = 0;
|
|
82
|
-
} else if (cursorPayload.lastId.startsWith("legacy-")) {
|
|
91
|
+
} else if (typeof cursorPayload.lastId === "string" && cursorPayload.lastId.startsWith("legacy-")) {
|
|
83
92
|
startIndex = Number.parseInt(cursorPayload.lastId.replace("legacy-", ""), 10);
|
|
84
93
|
} else {
|
|
85
|
-
const
|
|
94
|
+
const targetId = String(cursorPayload.lastId);
|
|
95
|
+
const anchorIndex = snapshot.itemIds.findIndex((id) => String(id) === targetId);
|
|
86
96
|
if (anchorIndex !== -1) {
|
|
87
97
|
startIndex = cursorPayload.direction === "forward" ? anchorIndex + 1 : Math.max(0, anchorIndex - limit);
|
|
88
98
|
} else {
|
|
89
|
-
|
|
90
|
-
startIndex = Math.floor(elapsedRatio * snapshot.total);
|
|
99
|
+
startIndex = 0;
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
}
|
|
@@ -109,7 +118,7 @@ export class CursorPaginationManager {
|
|
|
109
118
|
direction: "forward",
|
|
110
119
|
snapshotId: snapshot.id,
|
|
111
120
|
timestamp: Date.now(),
|
|
112
|
-
sortField: this.cursorConfig.includeSortInfo ?
|
|
121
|
+
sortField: this.cursorConfig.includeSortInfo ? snapshot.idFieldName : void 0,
|
|
113
122
|
sortOrder: this.cursorConfig.includeSortInfo ? "asc" : void 0
|
|
114
123
|
});
|
|
115
124
|
}
|
|
@@ -119,7 +128,7 @@ export class CursorPaginationManager {
|
|
|
119
128
|
direction: "backward",
|
|
120
129
|
snapshotId: snapshot.id,
|
|
121
130
|
timestamp: Date.now(),
|
|
122
|
-
sortField: this.cursorConfig.includeSortInfo ?
|
|
131
|
+
sortField: this.cursorConfig.includeSortInfo ? snapshot.idFieldName : void 0,
|
|
123
132
|
sortOrder: this.cursorConfig.includeSortInfo ? "asc" : void 0
|
|
124
133
|
});
|
|
125
134
|
}
|
|
@@ -22,16 +22,17 @@ export class PagePaginationManager {
|
|
|
22
22
|
cache = this.config.cache,
|
|
23
23
|
ttl = this.config.cacheTTL
|
|
24
24
|
} = options;
|
|
25
|
+
const idFieldName = this.generator.findIdFieldName(modelName) ?? "id";
|
|
25
26
|
let snapshot;
|
|
26
27
|
if (snapshotId) {
|
|
27
28
|
const existing = this.snapshotStore.getById(snapshotId);
|
|
28
29
|
if (existing) {
|
|
29
30
|
snapshot = existing;
|
|
30
31
|
} else {
|
|
31
|
-
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl });
|
|
32
|
+
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl, idFieldName });
|
|
32
33
|
}
|
|
33
34
|
} else {
|
|
34
|
-
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl });
|
|
35
|
+
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl, idFieldName });
|
|
35
36
|
}
|
|
36
37
|
const startIndex = (page - 1) * limit;
|
|
37
38
|
const endIndex = Math.min(startIndex + limit, snapshot.total);
|
|
@@ -66,16 +67,17 @@ export class PagePaginationManager {
|
|
|
66
67
|
cache = this.config.cache,
|
|
67
68
|
ttl = this.config.cacheTTL
|
|
68
69
|
} = options;
|
|
70
|
+
const idFieldName = this.generator.findIdFieldName(modelName) ?? "id";
|
|
69
71
|
let snapshot;
|
|
70
72
|
if (snapshotId) {
|
|
71
73
|
const existing = this.snapshotStore.getById(snapshotId);
|
|
72
74
|
if (existing) {
|
|
73
75
|
snapshot = existing;
|
|
74
76
|
} else {
|
|
75
|
-
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl });
|
|
77
|
+
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl, idFieldName });
|
|
76
78
|
}
|
|
77
79
|
} else {
|
|
78
|
-
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl });
|
|
80
|
+
snapshot = this.snapshotStore.getOrCreate(modelName, seed, total, { cache, ttl, idFieldName });
|
|
79
81
|
}
|
|
80
82
|
const endIndex = Math.min(offset + limit, snapshot.total);
|
|
81
83
|
const pageItemIds = snapshot.itemIds.slice(offset, endIndex);
|
|
@@ -20,14 +20,26 @@ export declare class SnapshotStore {
|
|
|
20
20
|
/**
|
|
21
21
|
* 아이템 ID 목록 생성 (MockIdConfig 기반)
|
|
22
22
|
* 실제 응답에서 사용될 ID와 동일한 값을 생성
|
|
23
|
+
* @param total 총 아이템 수
|
|
24
|
+
* @param seed 생성 seed
|
|
25
|
+
* @param modelName 모델명
|
|
26
|
+
* @param idFieldName ID 필드명 (모델의 실제 ID 필드)
|
|
23
27
|
*/
|
|
24
28
|
private generateItemIds;
|
|
25
29
|
/**
|
|
26
30
|
* 스냅샷 가져오기 또는 생성
|
|
31
|
+
* @param modelName 모델명
|
|
32
|
+
* @param seed 생성 seed
|
|
33
|
+
* @param total 총 아이템 수
|
|
34
|
+
* @param options 옵션
|
|
35
|
+
* @param options.ttl 캐시 TTL (ms)
|
|
36
|
+
* @param options.cache 캐싱 활성화 여부
|
|
37
|
+
* @param options.idFieldName ID 필드명 (모델의 실제 ID 필드)
|
|
27
38
|
*/
|
|
28
39
|
getOrCreate(modelName: string, seed: string, total: number, options?: {
|
|
29
40
|
ttl?: number;
|
|
30
41
|
cache?: boolean;
|
|
42
|
+
idFieldName?: string;
|
|
31
43
|
}): PaginationSnapshot;
|
|
32
44
|
/**
|
|
33
45
|
* 스냅샷 ID로 조회
|
|
@@ -21,19 +21,30 @@ export class SnapshotStore {
|
|
|
21
21
|
/**
|
|
22
22
|
* 아이템 ID 목록 생성 (MockIdConfig 기반)
|
|
23
23
|
* 실제 응답에서 사용될 ID와 동일한 값을 생성
|
|
24
|
+
* @param total 총 아이템 수
|
|
25
|
+
* @param seed 생성 seed
|
|
26
|
+
* @param modelName 모델명
|
|
27
|
+
* @param idFieldName ID 필드명 (모델의 실제 ID 필드)
|
|
24
28
|
*/
|
|
25
|
-
generateItemIds(total, seed, modelName) {
|
|
29
|
+
generateItemIds(total, seed, modelName, idFieldName) {
|
|
26
30
|
return Array.from({ length: total }, (_, i) => {
|
|
27
|
-
|
|
28
|
-
return String(id);
|
|
31
|
+
return generateIdValue(idFieldName, i, `${seed}-${modelName}-${i}`, this.idConfig);
|
|
29
32
|
});
|
|
30
33
|
}
|
|
31
34
|
/**
|
|
32
35
|
* 스냅샷 가져오기 또는 생성
|
|
36
|
+
* @param modelName 모델명
|
|
37
|
+
* @param seed 생성 seed
|
|
38
|
+
* @param total 총 아이템 수
|
|
39
|
+
* @param options 옵션
|
|
40
|
+
* @param options.ttl 캐시 TTL (ms)
|
|
41
|
+
* @param options.cache 캐싱 활성화 여부
|
|
42
|
+
* @param options.idFieldName ID 필드명 (모델의 실제 ID 필드)
|
|
33
43
|
*/
|
|
34
44
|
getOrCreate(modelName, seed, total, options) {
|
|
35
45
|
const key = this.getKey(modelName, seed);
|
|
36
46
|
const shouldCache = options?.cache ?? this.config.cache;
|
|
47
|
+
const idFieldName = options?.idFieldName ?? "id";
|
|
37
48
|
const existing = this.snapshots.get(key);
|
|
38
49
|
if (existing && !this.isExpired(existing)) {
|
|
39
50
|
existing.accessedAt = Date.now();
|
|
@@ -46,7 +57,8 @@ export class SnapshotStore {
|
|
|
46
57
|
modelName,
|
|
47
58
|
seed,
|
|
48
59
|
total,
|
|
49
|
-
itemIds: this.generateItemIds(total, seed, modelName),
|
|
60
|
+
itemIds: this.generateItemIds(total, seed, modelName, idFieldName),
|
|
61
|
+
idFieldName,
|
|
50
62
|
createdAt: now,
|
|
51
63
|
expiresAt: shouldCache ? now + ttl : void 0,
|
|
52
64
|
accessedAt: now
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Cursor 페이로드 - 연결성 있는 cursor 데이터
|
|
6
6
|
*/
|
|
7
7
|
export interface CursorPayload {
|
|
8
|
-
/** 마지막 아이템 ID (anchor) */
|
|
9
|
-
lastId: string;
|
|
8
|
+
/** 마지막 아이템 ID (anchor) - string 또는 number 타입 지원 */
|
|
9
|
+
lastId: string | number;
|
|
10
10
|
/** 방향: forward (다음) | backward (이전) */
|
|
11
11
|
direction: 'forward' | 'backward';
|
|
12
12
|
/** 스냅샷 ID (옵션) */
|
|
@@ -30,8 +30,10 @@ export interface PaginationSnapshot {
|
|
|
30
30
|
seed: string;
|
|
31
31
|
/** 총 아이템 수 */
|
|
32
32
|
total: number;
|
|
33
|
-
/** 아이템 ID 목록 (순서 보장) */
|
|
34
|
-
itemIds: string[];
|
|
33
|
+
/** 아이템 ID 목록 (순서 보장) - string 또는 number 타입 지원 */
|
|
34
|
+
itemIds: (string | number)[];
|
|
35
|
+
/** ID 필드명 (모델의 실제 ID 필드) */
|
|
36
|
+
idFieldName: string;
|
|
35
37
|
/** 생성 시간 */
|
|
36
38
|
createdAt: number;
|
|
37
39
|
/** 만료 시간 (옵션) */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mock-fried",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Nuxt3 Mock API Module - OpenAPI & Protobuf RPC Mock Server",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@grpc/grpc-js": "^1.12.0",
|
|
58
58
|
"@grpc/proto-loader": "^0.7.13",
|
|
59
59
|
"@nuxt/kit": "^4.2.2",
|
|
60
|
+
"consola": "^3.4.2",
|
|
60
61
|
"js-yaml": "^4.1.0",
|
|
61
62
|
"openapi-backend": "^5.10.6",
|
|
62
63
|
"pathe": "^2.0.2"
|