diff --git a/openapi/oepnapi.json b/openapi/openapi.json similarity index 100% rename from openapi/oepnapi.json rename to openapi/openapi.json diff --git a/package.json b/package.json index a440b13..b641523 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "biome:lint": "biome lint .", "biome:fix": "biome check --write .", "biome:format": "biome format . --write", - "icons": "node scripts/generate-icons.js" + "icons": "node scripts/generate-icons.js", + "generate:api": "node scripts/generate-api.js" }, "dependencies": { "@expo/vector-icons": "^15.0.3", diff --git a/scripts/generate-api.js b/scripts/generate-api.js new file mode 100644 index 0000000..8ebcec9 --- /dev/null +++ b/scripts/generate-api.js @@ -0,0 +1,450 @@ +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); + +const ROOT = path.resolve(__dirname, ".."); +const SPEC_PATH = path.join(ROOT, "openapi/openapi.json"); + +const args = process.argv.slice(2); +const opsArgIndex = args.indexOf("--operations"); +const opsArg = opsArgIndex !== -1 ? args[opsArgIndex + 1] : null; +const targetOperationIds = opsArg + ? opsArg.split(",").map((s) => s.trim()) + : null; + +const featureArgIndex = args.indexOf("--feature"); +const featureArg = featureArgIndex !== -1 ? args[featureArgIndex + 1] : null; + +function collectSchemaRefs(node, allSchemas, visited = new Set()) { + if (!node || typeof node !== "object") return visited; + if (Array.isArray(node)) { + for (const item of node) { + collectSchemaRefs(item, allSchemas, visited); + } + return visited; + } + const ref = node["$ref"]; + if (ref) { + const name = ref.split("/").pop(); + if (!visited.has(name)) { + visited.add(name); + if (allSchemas[name]) { + collectSchemaRefs(allSchemas[name], allSchemas, visited); + } + } + } + for (const v of Object.values(node)) { + collectSchemaRefs(v, allSchemas, visited); + } + return visited; +} + +function buildFilteredSpec(spec, operationIds) { + const allSchemas = spec.components?.schemas ?? {}; + const filteredPaths = {}; + const usedSchemaNames = new Set(); + + for (const [pathKey, pathItem] of Object.entries(spec.paths ?? {})) { + const filteredMethods = {}; + for (const [method, operation] of Object.entries(pathItem)) { + if ( + typeof operation === "object" && + operationIds.includes(operation.operationId) + ) { + filteredMethods[method] = operation; + collectSchemaRefs(operation, allSchemas, usedSchemaNames); + } + } + if (Object.keys(filteredMethods).length > 0) { + filteredPaths[pathKey] = filteredMethods; + } + } + + if (Object.keys(filteredPaths).length === 0) { + console.error(`❌ 찾을 수 없는 operationId: ${operationIds.join(", ")}`); + console.error("사용 가능한 목록: node scripts/generate-api.js --list"); + process.exit(1); + } + + const filteredSchemas = {}; + for (const name of usedSchemaNames) { + if (allSchemas[name]) filteredSchemas[name] = allSchemas[name]; + } + + return { + ...spec, + paths: filteredPaths, + components: { ...spec.components, schemas: filteredSchemas }, + }; +} + +function listOperations(spec) { + console.log("\n사용 가능한 operationId 목록:\n"); + for (const [pathKey, pathItem] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if (typeof operation === "object" && operation.operationId) { + console.log( + ` ${operation.operationId.padEnd(45)} ${method.toUpperCase()} ${pathKey}`, + ); + } + } + } +} + +function getOperationMethods(spec, operationIds) { + const methods = {}; + for (const [, pathItem] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if ( + typeof operation === "object" && + operationIds.includes(operation.operationId) + ) { + methods[operation.operationId] = method.toUpperCase(); + } + } + } + return methods; +} + +function runOrval(inputSpecPath, outputTarget) { + const tmpConfigPath = path.join(ROOT, ".orval.tmp.config.js"); + const config = { + api: { + input: { target: inputSpecPath }, + output: { + target: outputTarget, + mode: "single", + client: "axios", + mock: false, + override: { + mutator: { + path: path.join(ROOT, "src/shared/api/orvalMutator.ts"), + name: "customInstance", + }, + }, + }, + }, + }; + + fs.writeFileSync( + tmpConfigPath, + `module.exports = ${JSON.stringify(config, null, 2)}`, + ); + + try { + execSync(`npx orval --config ${tmpConfigPath}`, { stdio: "inherit" }); + } finally { + fs.unlinkSync(tmpConfigPath); + } +} + +function toPascalCase(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +function toApiAliasName(operationId) { + return `get${toPascalCase(operationId)}Api`; +} + +function toHookName(operationId, method) { + const suffix = method === "GET" ? "Query" : "Mutation"; + return `use${toPascalCase(operationId)}${suffix}`; +} + +function parseGeneratedFile(filePath) { + const content = fs.readFileSync(filePath, "utf-8"); + + const interfaces = [...content.matchAll(/^export interface (\w+)/gm)].map( + (m) => m[1], + ); + + // "export type X = typeof X[keyof typeof X]" 패턴이 enum + const enumNames = [ + ...content.matchAll( + /^export type (\w+) = typeof \1\[keyof typeof \1\]/gm, + ), + ].map((m) => m[1]); + + const allTypeAliases = [...content.matchAll(/^export type (\w+) =/gm)].map( + (m) => m[1], + ); + + // enum과 orval 내부 Result 타입 제외 + const typeAliases = allTypeAliases.filter( + (name) => !enumNames.includes(name) && !name.endsWith("Result"), + ); + + const typeNames = [...interfaces, ...typeAliases]; + + // "return {operationFnName}};" 에서 함수명 추출 + const returnMatch = content.match(/return \{(\w+)\}\}/); + const operationFnName = returnMatch ? returnMatch[1] : null; + + // 함수 파라미터 타입 추출 (GET useQuery 템플릿에 사용) + let paramType = null; + if (operationFnName) { + const paramPattern = new RegExp( + `const ${operationFnName} = \\(\\s*\\w+:\\s*(\\w+)`, + "s", + ); + const paramMatch = content.match(paramPattern); + if (paramMatch) { + paramType = paramMatch[1]; + } + } + + return { typeNames, enumNames, operationFnName, paramType }; +} + +// index.ts에 이미 export된 타입/enum 이름 수집 (중복 방지용) +function getAlreadyExportedNames(indexContent) { + const names = new Set(); + + // export type { A, B, C } from (멀티라인 포함) + for (const match of indexContent.matchAll(/export type \{([^}]+)\}/gs)) { + for (const part of match[1].split(",")) { + const name = part.trim(); + if (name && /^\w+$/.test(name)) names.add(name); + } + } + + // export { A, B as C, ... } from (멀티라인 + as 별칭 포함) + for (const match of indexContent.matchAll(/export \{([^}]+)\} from/gs)) { + for (const part of match[1].split(",")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const asMatch = trimmed.match(/^(\w+)\s+as\s+(\w+)$/); + if (asMatch) { + names.add(asMatch[1]); // 원본 이름 + names.add(asMatch[2]); // 별칭 + } else if (/^\w+$/.test(trimmed)) { + names.add(trimmed); + } + } + } + + return names; +} + +function updateIndexTs(operationId, generatedFilePath) { + const indexPath = path.join(ROOT, "src/shared/api/index.ts"); + let indexContent = fs.readFileSync(indexPath, "utf-8"); + + if (indexContent.includes(`_generated/auth/${operationId}`)) { + console.log(`ℹ️ index.ts에 이미 등록됨: ${operationId}`); + return; + } + + const { typeNames, enumNames } = parseGeneratedFile(generatedFilePath); + const alreadyExported = getAlreadyExportedNames(indexContent); + + // 이미 다른 API에서 export된 타입은 제외 + const newTypeNames = typeNames.filter((name) => !alreadyExported.has(name)); + const newEnumNames = enumNames.filter((name) => !alreadyExported.has(name)); + + const skipped = [ + ...typeNames.filter((n) => alreadyExported.has(n)), + ...enumNames.filter((n) => alreadyExported.has(n)), + ]; + if (skipped.length > 0) { + console.log(`ℹ️ 중복 타입 스킵: ${skipped.join(", ")}`); + } + + const aliasName = toApiAliasName(operationId); + const relativePath = `./_generated/auth/${operationId}`; + + let newExports = `\n// ${operationId}\n`; + newExports += `export { getAssuApi as ${aliasName} } from "${relativePath}";\n`; + + if (newTypeNames.length > 0) { + newExports += `export type {\n`; + for (const name of newTypeNames) { + newExports += `\t${name},\n`; + } + newExports += `} from "${relativePath}";\n`; + } + + for (const name of newEnumNames) { + newExports += `export { ${name} } from "${relativePath}";\n`; + } + + indexContent = indexContent.trimEnd() + "\n" + newExports; + fs.writeFileSync(indexPath, indexContent); + console.log(`✅ index.ts 등록 완료`); +} + +function createHookFile(operationId, generatedFilePath, method, feature) { + const { operationFnName, paramType } = parseGeneratedFile(generatedFilePath); + if (!operationFnName) { + console.warn(`⚠️ operationFnName을 찾지 못해 훅 생성을 건너뜁니다.`); + return; + } + + const hookName = toHookName(operationId, method); + const aliasName = toApiAliasName(operationId); + const isQuery = method === "GET"; + + const featureDir = path.join(ROOT, `src/features/${feature}/api`); + fs.mkdirSync(featureDir, { recursive: true }); + + const fileName = `${hookName}.ts`; + const filePath = path.join(featureDir, fileName); + + if (fs.existsSync(filePath)) { + console.log( + `ℹ️ 훅 파일 이미 존재: src/features/${feature}/api/${fileName}`, + ); + return; + } + + let content; + + if (isQuery && paramType) { + // GET + 파라미터 있음 + content = `import { useQuery } from "@tanstack/react-query"; +import { ${aliasName} } from "@/shared/api"; +import type { ${paramType} } from "@/shared/api"; + +const { ${operationFnName} } = ${aliasName}(); + +export function ${hookName}(params: ${paramType}) { +\treturn useQuery({ +\t\tqueryKey: ["${operationFnName}", params], +\t\tqueryFn: () => ${operationFnName}(params), +\t}); +} +`; + } else if (isQuery) { + // GET + 파라미터 없음 + content = `import { useQuery } from "@tanstack/react-query"; +import { ${aliasName} } from "@/shared/api"; + +const { ${operationFnName} } = ${aliasName}(); + +export function ${hookName}() { +\treturn useQuery({ +\t\tqueryKey: ["${operationFnName}"], +\t\tqueryFn: ${operationFnName}, +\t}); +} +`; + } else { + // POST / PUT / DELETE + content = `import { useMutation } from "@tanstack/react-query"; +import { ${aliasName} } from "@/shared/api"; + +const { ${operationFnName} } = ${aliasName}(); + +export function ${hookName}() { +\treturn useMutation({ mutationFn: ${operationFnName} }); +} +`; + } + + fs.writeFileSync(filePath, content); + console.log(`✅ 훅 생성: src/features/${feature}/api/${fileName}`); +} + +function getAvailableFeatures() { + const featuresDir = path.join(ROOT, "src/features"); + return fs + .readdirSync(featuresDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); +} + +function promptFeatureSelection(features) { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log("\n❓ --feature 가 없습니다. 어느 feature에 훅을 생성할까요?\n"); + features.forEach((name, i) => { + console.log(` ${i + 1}. ${name}`); + }); + console.log(` ${features.length + 1}. 훅 생성 건너뜀\n`); + + rl.question(`선택 (1-${features.length + 1}): `, (answer) => { + rl.close(); + const index = parseInt(answer, 10) - 1; + if (index >= 0 && index < features.length) { + resolve(features[index]); + } else { + resolve(null); + } + }); + }); +} + +async function run() { + const spec = JSON.parse(fs.readFileSync(SPEC_PATH, "utf-8")); + + if (args.includes("--list")) { + listOperations(spec); + return; + } + + const operationsToProcess = targetOperationIds + ? targetOperationIds + : Object.values(spec.paths ?? {}).flatMap((pathItem) => + Object.values(pathItem) + .filter((op) => typeof op === "object" && op.operationId) + .map((op) => op.operationId), + ); + + console.log(`\n🔍 생성 대상: ${operationsToProcess.join(", ")}`); + + const operationMethods = getOperationMethods(spec, operationsToProcess); + + // --feature 없으면 대화형 선택 + let selectedFeature = featureArg; + if (!selectedFeature) { + const features = getAvailableFeatures(); + selectedFeature = await promptFeatureSelection(features); + } + + console.log(""); + const failed = []; + + for (const operationId of operationsToProcess) { + const tmpSpecPath = path.join(ROOT, `.orval.tmp.${operationId}.json`); + const outputTarget = path.join( + ROOT, + `src/shared/api/_generated/auth/${operationId}.ts`, + ); + + try { + const filteredSpec = buildFilteredSpec(spec, [operationId]); + fs.mkdirSync(path.dirname(outputTarget), { recursive: true }); + fs.writeFileSync(tmpSpecPath, JSON.stringify(filteredSpec, null, 2)); + + console.log(`⚙️ ${operationId} → _generated/auth/${operationId}.ts`); + runOrval(tmpSpecPath, outputTarget); + + updateIndexTs(operationId, outputTarget); + + if (selectedFeature) { + const method = operationMethods[operationId] ?? "POST"; + createHookFile(operationId, outputTarget, method, selectedFeature); + } + } catch (err) { + console.error(`❌ ${operationId} 처리 실패: ${err.message}`); + failed.push(operationId); + } finally { + if (fs.existsSync(tmpSpecPath)) { + fs.unlinkSync(tmpSpecPath); + } + } + } + + if (failed.length > 0) { + console.log(`\n⚠️ 실패한 항목: ${failed.join(", ")}\n`); + } else { + console.log("\n✅ 완료\n"); + } +} + +run(); diff --git a/scripts/generate-api.md b/scripts/generate-api.md new file mode 100644 index 0000000..bd0d82a --- /dev/null +++ b/scripts/generate-api.md @@ -0,0 +1,212 @@ +# generate-api.js + +openapi 스펙에서 지정한 API만 골라 타입·axios 함수·React Query 훅까지 자동생성하는 스크립트. + +orval을 직접 실행하면 스펙 전체(API 200개, DTO 200개)가 생성되는 문제를 해결하기 위해 +필요한 API만 필터링한 미니 스펙을 만들어 orval에 넘기는 방식으로 동작한다. + +--- + +## 실행 방법 + +프로젝트 루트(`ASSU_FE_RN/`)에서 실행한다. + +```bash +# 사용 가능한 operationId 목록 확인 +yarn generate:api --list + +# 파일 생성 + index.ts 등록 (훅 제외) +yarn generate:api --operations ssuAuth + +# 파일 생성 + index.ts 등록 + 훅 파일 생성 (권장) +yarn generate:api --operations ssuAuth --feature signup-user-flow + +# 여러 API 한 번에 (콤마로 구분) +yarn generate:api --operations ssuAuth,signupStudent --feature signup-user-flow +``` + +> `node scripts/generate-api.js` 로 직접 실행해도 동일하다. + +--- + +## 자동 처리 범위 + +| 플래그 | 1. _generated 파일 생성 | 2. index.ts 등록 | 3. 훅 파일 생성 | +|---|:---:|:---:|:---:| +| `--operations ` | ✅ | ✅ | ❌ | +| `--operations --feature ` | ✅ | ✅ | ✅ | + +- **HTTP 메서드 자동 감지**: GET → `useQuery`, 나머지 → `useMutation` +- **이미 등록된 경우**: 중복 없이 건너뜀 +- **공통 DTO 중복 방지**: 여러 API가 같은 DTO를 참조하더라도 `index.ts`에 중복 export 없음 +- **GET 파라미터 자동 감지**: 함수 시그니처를 파싱해 파라미터가 있으면 훅에 자동 반영 +- **에러 격리**: 하나의 operationId 처리가 실패해도 나머지는 계속 진행 + +--- + +## 전체 흐름 (예시: getNotices 연동) + +### 1단계 — operationId 확인 + +```bash +yarn generate:api --list +``` + +출력 예시: +``` +ssuAuth POST /auth/students/ssu-verify +signupStudent POST /auth/students/signup +getNotices GET /notices +``` + +### 2단계 — 전체 자동생성 + +```bash +yarn generate:api --operations getNotices --feature notice +``` + +스크립트 내부 동작: +``` +openapi/oepnapi.json (전체 스펙) + ↓ +getNotices 하나만 추출 + ↓ +해당 API가 참조하는 DTO만 재귀 수집 ($ref 추적) + ↓ +미니 스펙 임시 파일 생성 (.orval.tmp.getNotices.json) + ↓ +orval 실행 → src/shared/api/_generated/auth/getNotices.ts 생성 + ↓ +임시 파일 삭제 (에러 발생 시에도 반드시 정리) + ↓ +생성 파일 파싱 → 타입명·enum명·함수명·파라미터 타입 추출 + ↓ +src/shared/api/index.ts에 export 자동 추가 (중복 타입 스킵) + ↓ +src/features/notice/api/useGetNoticesQuery.ts 생성 +``` + +### 생성되는 파일 + +```typescript +// src/shared/api/_generated/auth/getNotices.ts ← 수정 금지 +export interface NoticeDTO { ... } + +export const getAssuApi = () => { + const getNotices = (params: GetNoticesParams) => customInstance(...) + return { getNotices }; +}; +``` + +```typescript +// src/shared/api/index.ts ← 자동 추가됨 +// getNotices +export { getAssuApi as getGetNoticesApi } from "./_generated/auth/getNotices"; +export type { NoticeDTO, ... } from "./_generated/auth/getNotices"; +``` + +```typescript +// GET + 파라미터 있는 경우 +// src/features/notice/api/useGetNoticesQuery.ts +import { useQuery } from "@tanstack/react-query"; +import { getGetNoticesApi } from "@/shared/api"; +import type { GetNoticesParams } from "@/shared/api"; + +const { getNotices } = getGetNoticesApi(); + +export function useGetNoticesQuery(params: GetNoticesParams) { + return useQuery({ + queryKey: ["getNotices", params], + queryFn: () => getNotices(params), + }); +} +``` + +```typescript +// GET + 파라미터 없는 경우 +export function useGetNoticesQuery() { + return useQuery({ + queryKey: ["getNotices"], + queryFn: getNotices, + }); +} +``` + +```typescript +// POST / PUT / DELETE +export function useCreateNoticeMutation() { + return useMutation({ mutationFn: createNotice }); +} +``` + +### 3단계 — 컴포넌트에서 사용 + +```typescript +const { data, isLoading } = useGetNoticesQuery({ page: 1 }); +const mutation = useCreateNoticeMutation(); + +mutation.mutate({ title: "공지", content: "..." }); +``` + +--- + +## 폴더 구조 요약 + +``` +openapi/oepnapi.json ← 스펙 원본 (수정 금지) +scripts/generate-api.js ← 생성 스크립트 +src/shared/api/ + index.ts ← Public API (스크립트가 자동 등록) + orvalMutator.ts ← apiInstance 연결 (수정 불필요) + _generated/auth/.ts ← 자동생성 (수정 금지) +src/features//api/ + useQuery.ts / useMutation.ts ← 스크립트가 자동 생성 +``` + +--- + +## 함수 설명 + +### `collectSchemaRefs` +API 정의에서 `$ref`를 따라가며 필요한 DTO 이름을 재귀적으로 수집한다. +중첩 참조(DTO가 다른 DTO를 참조)도 빠짐없이 추적한다. + +### `buildFilteredSpec` +전체 스펙에서 지정한 operationId의 경로와 수집된 DTO만 남긴 미니 스펙을 반환한다. + +### `listOperations` +`--list` 옵션 실행 시 스펙의 모든 operationId와 HTTP 메서드/경로를 출력한다. + +### `getOperationMethods` +operationId별 HTTP 메서드를 추출한다. 훅 종류(Query/Mutation) 결정에 사용한다. + +### `runOrval` +미니 스펙을 입력으로 orval을 실행한다. +임시 config 파일(`.orval.tmp.config.js`)을 만들어 orval에 넘기고 실행 후 삭제한다. +생성된 파일은 `src/shared/api/orvalMutator.ts`의 `customInstance`를 통해 기존 axios 인스턴스를 사용한다. + +### `parseGeneratedFile` +생성된 `.ts` 파일을 파싱해 타입명·enum명·함수명·파라미터 타입을 추출한다. +- enum: `export type X = typeof X[keyof typeof X]` 패턴으로 감지 +- 파라미터 타입: 함수 시그니처에서 추출, GET 훅 템플릿 분기에 사용 +- orval 내부 `Result` 타입은 제외 + +### `getAlreadyExportedNames` +현재 `index.ts`에 이미 export된 타입·enum 이름을 수집한다. +여러 API가 동일한 공통 DTO(예: `PageDTO`)를 참조할 때 중복 export를 방지한다. + +### `updateIndexTs` +`src/shared/api/index.ts`에 새 API의 export를 추가한다. +- 이미 등록된 operationId면 건너뜀 +- 다른 API에서 이미 export된 타입은 스킵하고 로그 출력 + +### `createHookFile` +`src/features//api/` 아래에 React Query 훅 파일을 생성한다. +- GET + 파라미터 있음: `(params: ParamType)` 받아서 `queryKey`에 포함 +- GET + 파라미터 없음: 기본 `useQuery` 템플릿 +- POST/PUT/DELETE: `useMutation` 템플릿 + +### `run` +진입점. operationId별로 생성 → index.ts 등록 → 훅 생성을 순서대로 실행한다. +각 operationId는 독립적인 `try/catch/finally`로 감싸져 하나가 실패해도 나머지를 계속 처리한다. +`finally`에서 임시 파일(`.orval.tmp.*.json`)을 무조건 정리한다. diff --git a/src/shared/api/orvalMutator.ts b/src/shared/api/orvalMutator.ts new file mode 100644 index 0000000..5cae474 --- /dev/null +++ b/src/shared/api/orvalMutator.ts @@ -0,0 +1,6 @@ +import type { AxiosRequestConfig } from "axios"; +import { apiInstance } from "./instance"; + +export const customInstance = (config: AxiosRequestConfig): Promise => { + return apiInstance(config).then((res) => res.data); +};