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 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/**` | * | OpenAPI 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
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.0.0"
6
6
  },
7
- "version": "1.0.1",
7
+ "version": "1.0.3",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
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`,
@@ -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 = options.headers;
26
+ fetchOptions.headers = {
27
+ ...fetchOptions.headers,
28
+ ...options.headers
29
+ };
23
30
  }
24
31
  let finalUrl = url;
25
32
  if (options?.params) {
@@ -1,2 +1,6 @@
1
+ /**
2
+ * OpenAPI 관련 캐시 초기화
3
+ */
4
+ export declare function clearOpenApiCache(): void;
1
5
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<any>>;
2
6
  export default _default;
@@ -1,4 +1,4 @@
1
- import { defineEventHandler, getQuery, readBody, createError, getRequestURL } from "h3";
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, _responseFormat = "auto") {
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 = requestUrl.pathname;
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
+ });
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Proto 캐시 초기화
3
+ */
4
+ export declare function clearProtoCache(): void;
1
5
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
6
  success: boolean;
3
7
  service: string;
@@ -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: [protoPath]
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,4 +1,8 @@
1
1
  import type { ApiSchema } from '../../../types.js';
2
+ /**
3
+ * 스키마 캐시 초기화
4
+ */
5
+ export declare function clearSchemaCache(): void;
2
6
  /**
3
7
  * 스키마 핸들러
4
8
  * GET /mock/__schema
@@ -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, methods] of Object.entries(paths)) {
22
- for (const [method, operation] of Object.entries(methods)) {
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 parameters = [];
122
+ const operationParams = [];
26
123
  if (Array.isArray(op.parameters)) {
27
124
  for (const param of op.parameters) {
28
125
  const p = param;
29
- parameters.push({
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: parameters.length > 0 ? parameters : void 0,
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.openapiPath) {
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
- private findIdFieldName;
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 generateField;
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("amount") || name.includes("num")) {
47
+ if (name.includes("count") || name.includes("quantity") || name.includes("num")) {
40
48
  return rng.nextInt(0, 100);
41
49
  }
42
- if (name.includes("price") || name.includes("cost") || name.includes("fee") || name.includes("amount")) {
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("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")) {
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.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")) {
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.generateField(field, rng, index);
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
- generateField(field, rng, index) {
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.generateOne(field.refType, `${field.refType}-${index}-${i}`, i)
352
+ (_, i) => this.generateOneInternal(field.refType, `${field.refType}-${index}-${i}`, i, visitedModels, depth + 1)
259
353
  );
260
354
  }
261
- return this.generateOne(field.refType, `${field.refType}-${index}`, index);
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 anchorIndex = snapshot.itemIds.findIndex((id) => id === cursorPayload.lastId);
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
- const elapsedRatio = Math.min(1, (cursorPayload.timestamp - snapshot.createdAt) / (snapshot.expiresAt || Date.now() - snapshot.createdAt));
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 ? "id" : void 0,
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 ? "id" : void 0,
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
- const id = generateIdValue("id", i, `${seed}-${modelName}-${i}`, this.idConfig);
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.1",
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"