diff --git a/.gitignore b/.gitignore index 2adbc2f3..4d9ac051 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ **/build **/dist +# generated api schema +packages/api-schema/src/apis/ +packages/api-schema/.cache/ + # misc .DS_Store *.pem diff --git a/apps/web/package.json b/apps/web/package.json index ba64ae88..878c3f21 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,7 +16,7 @@ "analyze": "ANALYZE=true next build" }, "dependencies": { - "@hookform/resolvers": "^5.1.1", + "@hookform/resolvers": "^5.2.2", "@next/third-parties": "^14.2.4", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-label": "^2.1.2", @@ -44,7 +44,7 @@ "sockjs-client": "^1.6.1", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", - "zod": "^4.0.5", + "zod": "^4.0.0", "zustand": "^5.0.7" }, "devDependencies": { diff --git a/apps/web/public/images/univs/sungshin.jpg b/apps/web/public/images/univs/sungshin.jpg new file mode 100644 index 00000000..a95d01b3 Binary files /dev/null and b/apps/web/public/images/univs/sungshin.jpg differ diff --git a/apps/web/src/apis/mentor/getUnconfirmedMentoringCount.ts b/apps/web/src/apis/mentor/getUnconfirmedMentoringCount.ts index 5d55d649..3490970f 100644 --- a/apps/web/src/apis/mentor/getUnconfirmedMentoringCount.ts +++ b/apps/web/src/apis/mentor/getUnconfirmedMentoringCount.ts @@ -2,18 +2,15 @@ import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { type GetMentoringNewCountResponse, MentorQueryKeys, mentorApi } from "./api"; -/** - * @description 미확인 멘토링 수 조회 훅 - */ -const useGetMentoringUncheckedCount = (isEnable: boolean) => { +const useGetUnconfirmedMentoringCount = (enabled: boolean = true) => { return useQuery({ queryKey: [MentorQueryKeys.mentoringNewCount], queryFn: mentorApi.getMentoringUncheckedCount, - enabled: isEnable, + enabled, refetchInterval: 1000 * 60 * 10, // ⏱️ 10분마다 자동 재요청 staleTime: 1000 * 60 * 5, // fresh 상태 유지 select: (data) => data.uncheckedCount, }); }; -export default useGetMentoringUncheckedCount; +export default useGetUnconfirmedMentoringCount; diff --git a/apps/web/src/apis/mentor/index.ts b/apps/web/src/apis/mentor/index.ts index 7986dd51..df700ed1 100644 --- a/apps/web/src/apis/mentor/index.ts +++ b/apps/web/src/apis/mentor/index.ts @@ -9,15 +9,12 @@ export type { VerifyStatus, } from "./api"; export { MentorQueryKeys, mentorApi } from "./api"; -// Mentee (멘티) hooks export { default as useGetApplyMentoringList, usePrefetchApplyMentoringList } from "./getAppliedMentorings"; export { default as useGetMentorDetail } from "./getMentorDetail"; -// Mentors (멘토 목록) hooks export { default as useGetMentorList, usePrefetchMentorList } from "./getMentorList"; -// Mentor (멘토) hooks export { default as useGetMentorMyProfile } from "./getMyMentorPage"; export { default as useGetMentoringList } from "./getReceivedMentorings"; -export { default as useGetMentoringUncheckedCount } from "./getUnconfirmedMentoringCount"; +export { default as useGetUnconfirmedMentoringCount } from "./getUnconfirmedMentoringCount"; export { default as usePatchMentorCheckMentorings } from "./patchConfirmMentoring"; export { default as usePatchMenteeCheckMentorings } from "./patchMenteeCheckMentorings"; export { default as usePatchApprovalStatus } from "./patchMentoringStatus"; diff --git a/apps/web/src/app/university/(home)/_ui/HomeUniversityCard.tsx b/apps/web/src/app/university/(home)/_ui/HomeUniversityCard.tsx new file mode 100644 index 00000000..5c37567d --- /dev/null +++ b/apps/web/src/app/university/(home)/_ui/HomeUniversityCard.tsx @@ -0,0 +1,63 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; + +import type { HomeUniversityInfo } from "@/constants/university"; + +interface HomeUniversityCardProps { + university: HomeUniversityInfo; +} + +const HomeUniversityCard = ({ university }: HomeUniversityCardProps) => { + return ( + +
+ {`${university.name} { + // 이미지 로드 실패 시 기본 텍스트 표시 + const target = e.target as HTMLImageElement; + target.style.display = "none"; + }} + /> +
+ +
+ {university.name} + {university.description} +
+ +
+ + + +
+ + ); +}; + +export default HomeUniversityCard; diff --git a/apps/web/src/app/university/(home)/layout.tsx b/apps/web/src/app/university/(home)/layout.tsx new file mode 100644 index 00000000..26d3e9d7 --- /dev/null +++ b/apps/web/src/app/university/(home)/layout.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; + +interface LayoutProps { + children: ReactNode; +} + +const UniversityHomeLayout = ({ children }: LayoutProps) => { + return ( + <> + + {children} + + ); +}; + +export default UniversityHomeLayout; diff --git a/apps/web/src/app/university/(home)/page.tsx b/apps/web/src/app/university/(home)/page.tsx new file mode 100644 index 00000000..3b0fff91 --- /dev/null +++ b/apps/web/src/app/university/(home)/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; + +import { HOME_UNIVERSITY_LIST } from "@/constants/university"; + +import HomeUniversityCard from "./_ui/HomeUniversityCard"; + +export const revalidate = 3600; // 1시간마다 재검증 (ISR) + +export const metadata: Metadata = { + title: "대학 선택 | 솔리드커넥션", + description: "소속 대학교를 선택하여 교환학생 정보를 확인하세요.", +}; + +const UniversitySelectPage = () => { + return ( +
+
+

소속 대학교 선택

+

+ 소속 대학교를 선택하면 +
+ 해당 대학의 교환학생 정보를 확인할 수 있습니다. +

+
+ +
+ {HOME_UNIVERSITY_LIST.map((university) => ( + + ))} +
+
+ ); +}; + +export default UniversitySelectPage; diff --git a/apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx b/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx similarity index 100% rename from apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx rename to apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/InfoSection.tsx diff --git a/apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx b/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx similarity index 100% rename from apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx rename to apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/LanguageSection.tsx diff --git a/apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/MapSection.tsx b/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/MapSection.tsx similarity index 100% rename from apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/MapSection.tsx rename to apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/MapSection.tsx diff --git a/apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx b/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx similarity index 100% rename from apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx rename to apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/TitleSection.tsx diff --git a/apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx b/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx similarity index 100% rename from apps/web/src/app/university/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx rename to apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/_ui/UniversityBtns.tsx diff --git a/apps/web/src/app/university/[id]/_ui/UniversityDetail/index.tsx b/apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/index.tsx similarity index 100% rename from apps/web/src/app/university/[id]/_ui/UniversityDetail/index.tsx rename to apps/web/src/app/university/[homeUniversity]/[id]/_ui/UniversityDetail/index.tsx diff --git a/apps/web/src/app/university/[id]/page.tsx b/apps/web/src/app/university/[homeUniversity]/[id]/page.tsx similarity index 59% rename from apps/web/src/app/university/[id]/page.tsx rename to apps/web/src/app/university/[homeUniversity]/[id]/page.tsx index 90a4226c..bbb0333e 100644 --- a/apps/web/src/app/university/[id]/page.tsx +++ b/apps/web/src/app/university/[homeUniversity]/[id]/page.tsx @@ -1,54 +1,75 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; + import { getAllUniversities, getUniversityDetail } from "@/apis/universities/server"; import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; +import { getHomeUniversityBySlug, HOME_UNIVERSITY_SLUGS } from "@/constants/university"; +import type { HomeUniversitySlug } from "@/types/university"; + +// UniversityDetail 컴포넌트 import UniversityDetail from "./_ui/UniversityDetail"; -export const revalidate = false; +export const revalidate = false; // 완전 정적 생성 +// 모든 homeUniversity + id 조합에 대해 정적 경로 생성 export async function generateStaticParams() { const universities = await getAllUniversities(); - return universities.map((university) => ({ - id: String(university.id), - })); + const params: { homeUniversity: string; id: string }[] = []; + + // 각 대학에 대해 모든 homeUniversity 슬러그와 조합 + for (const slug of HOME_UNIVERSITY_SLUGS) { + const homeUniversityInfo = getHomeUniversityBySlug(slug); + if (!homeUniversityInfo) continue; + + // 해당 홈대학에 속하는 대학들만 필터링 + const filteredUniversities = universities.filter((uni) => uni.homeUniversityName === homeUniversityInfo.name); + + for (const university of filteredUniversities) { + params.push({ + homeUniversity: slug, + id: String(university.id), + }); + } + } + + return params; } -type MetadataProps = { - params: Promise<{ id: string }>; +type PageProps = { + params: Promise<{ homeUniversity: string; id: string }>; }; -export async function generateMetadata({ params }: MetadataProps): Promise { - const { id } = await params; +export async function generateMetadata({ params }: PageProps): Promise { + const { homeUniversity, id } = await params; + + // 유효한 슬러그인지 확인 + if (!HOME_UNIVERSITY_SLUGS.includes(homeUniversity as HomeUniversitySlug)) { + return { title: "파견 학교 상세" }; + } const universityData = await getUniversityDetail(Number(id)); if (!universityData) { - return { - title: "파견 학교 상세", - }; + return { title: "파견 학교 상세" }; } + const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity); const convertedKoreanName = universityData.term !== process.env.NEXT_PUBLIC_CURRENT_TERM ? `${universityData.koreanName}(${universityData.term})` : universityData.koreanName; const baseUrl = process.env.NEXT_PUBLIC_WEB_URL || "https://solid-connection.com"; - const pageUrl = `${baseUrl}/university/${id}`; + const pageUrl = `${baseUrl}/university/${homeUniversity}/${id}`; const imageUrl = universityData.backgroundImageUrl ? universityData.backgroundImageUrl.startsWith("http") ? universityData.backgroundImageUrl : `${baseUrl}${universityData.backgroundImageUrl}` : `${baseUrl}/images/article-thumb.png`; - // [나라] 교환학생 키워드 생성 const countryExchangeKeyword = `${universityData.country} 교환학생`; - - // Description 생성: 대학교 이름과 [나라] 교환학생 키워드 포함 - const description = `${convertedKoreanName}(${universityData.englishName}) ${countryExchangeKeyword} 프로그램. 모집인원 ${universityData.studentCapacity}명. 솔리드커넥션에서 ${convertedKoreanName} ${countryExchangeKeyword} 지원 정보 확인.`; - - // Title 생성: 대학교 이름과 [나라] 교환학생 키워드 포함 (검색 최적화) + const description = `${convertedKoreanName}(${universityData.englishName}) ${countryExchangeKeyword} 프로그램. 모집인원 ${universityData.studentCapacity}명. ${homeUniversityInfo?.shortName || ""} 학생을 위한 교환학생 정보.`; const title = `${convertedKoreanName} - ${countryExchangeKeyword} 정보 | 솔리드커넥션`; return { @@ -82,13 +103,20 @@ export async function generateMetadata({ params }: MetadataProps): Promise { + const { homeUniversity, id } = await params; -const CollegeDetailPage = async ({ params }: CollegeDetailPageProps) => { - const collegeId = Number(params.id); + // 유효한 슬러그인지 확인 + if (!HOME_UNIVERSITY_SLUGS.includes(homeUniversity as HomeUniversitySlug)) { + notFound(); + } + const homeUniversityInfo = getHomeUniversityBySlug(homeUniversity); + if (!homeUniversityInfo) { + notFound(); + } + + const collegeId = Number(id); const universityData = await getUniversityDetail(collegeId); if (!universityData) { @@ -101,21 +129,10 @@ const CollegeDetailPage = async ({ params }: CollegeDetailPageProps) => { : universityData.koreanName; const baseUrl = process.env.NEXT_PUBLIC_WEB_URL || "https://solid-connection.com"; - const pageUrl = `${baseUrl}/university/${collegeId}`; - - // [나라] 교환학생 키워드 생성 + const pageUrl = `${baseUrl}/university/${homeUniversity}/${collegeId}`; const countryExchangeKeyword = `${universityData.country} 교환학생`; - // Structured Data (JSON-LD) for SEO - 검색 엔진이 대학 정보를 더 잘 이해하도록 - const structuredData: { - "@context": string; - "@type": string; - name: string; - alternateName?: string; - url: string; - description: string; - image: string; - } = { + const structuredData = { "@context": "https://schema.org", "@type": "EducationalOrganization", name: convertedKoreanName, @@ -132,7 +149,7 @@ const CollegeDetailPage = async ({ params }: CollegeDetailPageProps) => { return ( <> + + + EOF + + - name: GitHub Pages 배포 + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + publish_branch: gh-pages + + - name: 배포 완료 알림 + run: | + echo "✅ API 문서가 GitHub Pages에 배포되었습니다!" + echo "🔗 URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/" diff --git a/packages/bruno-api-typescript/.github/workflows/api-review.yml b/packages/bruno-api-typescript/.github/workflows/api-review.yml new file mode 100644 index 00000000..089644b8 --- /dev/null +++ b/packages/bruno-api-typescript/.github/workflows/api-review.yml @@ -0,0 +1,156 @@ +name: API 변경사항 리뷰 + +on: + pull_request: + paths: + - 'bruno/**' + +jobs: + review: + runs-on: ubuntu-latest + + steps: + - name: 체크아웃 + uses: actions/checkout@v3 + with: + fetch-depth: 0 # 전체 히스토리 + + - name: Node 설정 + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: 의존성 설치 + run: npm install + + - name: 빌드 + run: npm run build + + - name: 이전 버전 OpenAPI 생성 + run: | + # main 브랜치로 체크아웃하여 이전 버전 생성 + git checkout origin/${{ github.base_ref }} -- bruno/ || true + node dist/cli/index.js generate -i ./bruno -o ./openapi-old.json + + - name: 현재 PR의 Bruno로 복원 + run: | + git checkout ${{ github.head_ref }} -- bruno/ || true + git checkout HEAD -- bruno/ + + - name: 현재 버전 OpenAPI 생성 및 변경사항 감지 + run: | + node dist/cli/index.js generate \ + -i ./bruno \ + -o ./openapi-new.json \ + --title "우리팀 API" \ + --version "1.0.0" + + # 변경사항 감지 + if [ -f openapi-old.json ]; then + cp openapi-old.json openapi-new.json.old + node dist/cli/index.js generate \ + -i ./bruno \ + -o ./openapi-new.json \ + --diff \ + --changelog ./CHANGELOG.md \ + --changelog-format markdown + fi + + - name: Breaking Changes 확인 + id: breaking + run: | + if [ -f CHANGELOG.md ]; then + if grep -q "⚠️ Breaking Changes" CHANGELOG.md || grep -q "Breaking Changes" CHANGELOG.md; then + echo "has_breaking=true" >> $GITHUB_OUTPUT + echo "⚠️ Breaking changes 발견!" + else + echo "has_breaking=false" >> $GITHUB_OUTPUT + echo "✅ Breaking changes 없음" + fi + else + echo "has_breaking=false" >> $GITHUB_OUTPUT + fi + + - name: Bruno 파일 변경사항 확인 + id: bruno_changes + run: | + # 변경된 .bru 파일 목록 + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '\.bru$' || echo "") + + if [ -n "$CHANGED_FILES" ]; then + echo "changed_files<> $GITHUB_OUTPUT + echo "$CHANGED_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "changed_files=" >> $GITHUB_OUTPUT + fi + + - name: PR에 변경사항 코멘트 + uses: actions/github-script@v6 + with: + script: | + const fs = require('fs'); + + let comment = '## 🔄 API 변경사항\n\n'; + + // Breaking changes 경고 + const hasBreaking = '${{ steps.breaking.outputs.has_breaking }}' === 'true'; + if (hasBreaking) { + comment += '### ⚠️ **Breaking Changes 발견!**\n\n'; + comment += '> 기존 코드를 깨뜨릴 수 있는 변경사항이 있습니다. 프론트엔드 팀과 상의 후 머지해주세요.\n\n'; + } + + // 변경된 Bruno 파일 목록 + const changedFiles = `${{ steps.bruno_changes.outputs.changed_files }}`; + if (changedFiles) { + comment += '### 📝 변경된 Bruno 파일\n\n'; + comment += '```\n' + changedFiles + '\n```\n\n'; + } + + // Changelog 내용 + if (fs.existsSync('CHANGELOG.md')) { + const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); + comment += '### 📊 상세 변경사항\n\n'; + comment += changelog; + } else { + comment += '### ✨ 새로운 API가 추가되었습니다!\n\n'; + comment += '변경사항을 확인하려면 Bruno 파일을 참조하세요.\n'; + } + + // API 문서 링크 (배포 후) + comment += '\n---\n\n'; + comment += '### 🔗 유용한 링크\n\n'; + comment += '- 📖 [API 명세서 보기](https://' + context.repo.owner + '.github.io/' + context.repo.repo + '/api-viewer.html)\n'; + comment += '- 🔄 [변경사항 시각화](https://' + context.repo.owner + '.github.io/' + context.repo.repo + '/changelog.html)\n'; + comment += '- 📥 [OpenAPI 다운로드](https://' + context.repo.owner + '.github.io/' + context.repo.repo + '/openapi.json)\n'; + + // PR에 코멘트 작성 + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + + - name: Breaking이 있으면 리뷰 요청 + if: steps.breaking.outputs.has_breaking == 'true' + uses: actions/github-script@v6 + with: + script: | + // Breaking이 있으면 특정 팀원에게 리뷰 요청 + // 필요시 팀원 username으로 변경 + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + reviewers: [], // 예: ['frontend-lead', 'tech-lead'] + }); + + - name: 체크 상태 설정 + if: steps.breaking.outputs.has_breaking == 'true' + run: | + echo "⚠️ Breaking changes가 감지되었습니다." + echo "프론트엔드 팀의 승인 후 머지해주세요." + # exit 1을 주석 처리하여 PR을 차단하지 않음 + # 팀 정책에 따라 주석 해제 가능 + # exit 1 diff --git a/packages/bruno-api-typescript/.github/workflows/bruno-repo-notify-frontend.yml.template b/packages/bruno-api-typescript/.github/workflows/bruno-repo-notify-frontend.yml.template new file mode 100644 index 00000000..cd5430fe --- /dev/null +++ b/packages/bruno-api-typescript/.github/workflows/bruno-repo-notify-frontend.yml.template @@ -0,0 +1,49 @@ +# Bruno 저장소용 Workflow +# 이 파일을 Bruno 저장소의 .github/workflows/ 폴더에 복사하세요 + +name: Notify Frontend on Bruno Changes + +on: + push: + branches: + - main + paths: + - 'bruno/**' + +jobs: + notify: + runs-on: ubuntu-latest + + steps: + - name: Checkout Bruno Repository + uses: actions/checkout@v3 + + - name: Repository Dispatch to Frontend + run: | + curl -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.FRONTEND_REPO_TOKEN }}" \ + https://api.github.com/repos/YOUR-ORG/FRONTEND-REPO/dispatches \ + -d '{ + "event_type": "bruno_updated", + "client_payload": { + "bruno_repo": "${{ github.repository }}", + "commit_sha": "${{ github.sha }}", + "commit_message": "${{ github.event.head_commit.message }}", + "author": "${{ github.event.head_commit.author.name }}", + "timestamp": "${{ github.event.head_commit.timestamp }}" + } + }' + + - name: Notify Complete + run: | + echo "✅ Frontend repository has been notified!" + echo "🔗 Check Actions: https://github.com/YOUR-ORG/FRONTEND-REPO/actions" + echo "" + echo "📝 Commit: ${{ github.event.head_commit.message }}" + echo "👤 Author: ${{ github.event.head_commit.author.name }}" + +# 설정 방법: +# 1. YOUR-ORG/FRONTEND-REPO를 실제 프론트엔드 저장소 경로로 변경 +# 2. Bruno 저장소 Settings → Secrets → FRONTEND_REPO_TOKEN 추가 +# (GitHub Personal Access Token with repo, workflow permissions) diff --git a/packages/bruno-api-typescript/.github/workflows/frontend-sync-bruno.yml.template b/packages/bruno-api-typescript/.github/workflows/frontend-sync-bruno.yml.template new file mode 100644 index 00000000..64f98b8d --- /dev/null +++ b/packages/bruno-api-typescript/.github/workflows/frontend-sync-bruno.yml.template @@ -0,0 +1,210 @@ +# 프론트엔드 저장소용 Workflow +# 이 파일을 프론트엔드 저장소의 .github/workflows/ 폴더에 복사하세요 + +name: Sync Bruno API Changes + +on: + repository_dispatch: + types: [bruno_updated] + workflow_dispatch: + inputs: + bruno_repo: + description: 'Bruno Repository (org/repo)' + required: false + default: 'YOUR-ORG/BRUNO-REPO' + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout Frontend Repository + uses: actions/checkout@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Get Bruno Repository Info + id: bruno_info + run: | + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then + echo "repo=${{ github.event.client_payload.bruno_repo }}" >> $GITHUB_OUTPUT + echo "sha=${{ github.event.client_payload.commit_sha }}" >> $GITHUB_OUTPUT + echo "message=${{ github.event.client_payload.commit_message }}" >> $GITHUB_OUTPUT + else + echo "repo=${{ github.event.inputs.bruno_repo }}" >> $GITHUB_OUTPUT + echo "sha=main" >> $GITHUB_OUTPUT + echo "message=Manual sync" >> $GITHUB_OUTPUT + fi + + - name: Clone Bruno Repository + run: | + echo "📥 Cloning Bruno repository: ${{ steps.bruno_info.outputs.repo }}" + git clone https://github.com/${{ steps.bruno_info.outputs.repo }}.git /tmp/bruno + cd /tmp/bruno + git checkout ${{ steps.bruno_info.outputs.sha }} || git checkout main + echo "✅ Bruno repository cloned successfully" + + - name: Install Dependencies + run: npm install + + - name: Generate OpenAPI and Detect Changes + run: | + echo "🔄 Generating OpenAPI spec..." + + # 이전 버전 백업 (변경사항 감지용) + if [ -f public/openapi.json ]; then + cp public/openapi.json public/openapi.json.old + fi + + # OpenAPI 생성 + npx bruno-sync generate \ + -i /tmp/bruno/bruno \ + -o ./public/openapi.json \ + --title "우리팀 API" \ + --version "1.0.0" + + # 변경사항 감지 + if [ -f public/openapi.json.old ]; then + echo "🔍 Detecting changes..." + cp public/openapi.json.old public/openapi.json.old.bak + npx bruno-sync generate \ + -i /tmp/bruno/bruno \ + -o ./public/openapi.json \ + --diff \ + --changelog ./public/CHANGELOG.md \ + --changelog-format markdown + + # HTML Changelog도 생성 + npx bruno-sync generate \ + -i /tmp/bruno/bruno \ + -o ./public/openapi.json \ + --diff \ + --changelog ./public/changelog.html \ + --changelog-format html + + echo "✅ Changes detected and documented" + else + echo "ℹ️ No previous version found, skipping diff" + fi + + - name: Check for Changes + id: changes + run: | + git add public/ + if git diff --staged --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "ℹ️ No changes detected" + else + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "✅ Changes detected!" + + # Breaking changes 확인 + if [ -f public/CHANGELOG.md ]; then + if grep -q "Breaking Changes" public/CHANGELOG.md; then + echo "has_breaking=true" >> $GITHUB_OUTPUT + echo "⚠️ Breaking changes detected!" + else + echo "has_breaking=false" >> $GITHUB_OUTPUT + fi + fi + fi + + - name: Create Pull Request + if: steps.changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: | + chore: sync API spec from Bruno + + Bruno Repository: ${{ steps.bruno_info.outputs.repo }} + Commit: ${{ steps.bruno_info.outputs.sha }} + Message: ${{ steps.bruno_info.outputs.message }} + branch: api-sync-${{ steps.bruno_info.outputs.sha }} + delete-branch: true + title: "🔄 API 변경사항 동기화 (${{ steps.bruno_info.outputs.sha }})" + body: | + ## 🔄 Bruno API 변경사항 자동 동기화 + + **Bruno Repository**: [`${{ steps.bruno_info.outputs.repo }}`](https://github.com/${{ steps.bruno_info.outputs.repo }}) + **Commit**: `${{ steps.bruno_info.outputs.sha }}` + **Author**: ${{ github.event.client_payload.author || 'N/A' }} + **Message**: + ``` + ${{ steps.bruno_info.outputs.message }} + ``` + + --- + + ### 📊 변경사항 요약 + + ${{ steps.changes.outputs.has_breaking == 'true' && '### ⚠️ **Breaking Changes 발견!**\n\n> 기존 코드를 깨뜨릴 수 있는 변경사항이 있습니다. 주의 깊게 리뷰해주세요.\n\n' || '' }} + + 상세 변경사항은 아래 파일들을 확인하세요: + - 📄 [OpenAPI Spec](../blob/${{ github.ref_name }}/public/openapi.json) + - 📝 [Changelog (Markdown)](../blob/${{ github.ref_name }}/public/CHANGELOG.md) + - 🎨 [Changelog (HTML)](../blob/${{ github.ref_name }}/public/changelog.html) + + --- + + ### ✅ 리뷰 체크리스트 + + - [ ] Breaking changes 확인 및 대응 방안 수립 + - [ ] 영향받는 프론트엔드 코드 파악 + - [ ] Swagger UI에서 새 API 스펙 확인 + - [ ] 타입 에러 없는지 확인 (`npm run build`) + - [ ] 테스트 통과 확인 (`npm run test`) + + --- + + ### 🔗 유용한 링크 + + - 🌐 [Swagger UI로 보기](https://your-org.github.io/your-repo/api-viewer.html) + - 📊 [변경사항 대시보드](https://your-org.github.io/your-repo/changelog.html) + - 📚 [Bruno 저장소](https://github.com/${{ steps.bruno_info.outputs.repo }}) + + --- + + 🤖 이 PR은 자동으로 생성되었습니다. + labels: | + api-sync + autogenerated + ${{ steps.changes.outputs.has_breaking == 'true' && 'breaking-change' || '' }} + + - name: Summary + if: steps.changes.outputs.has_changes == 'true' + run: | + echo "## 🎉 API 동기화 완료!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 📝 정보" >> $GITHUB_STEP_SUMMARY + echo "- **Bruno Commit**: \`${{ steps.bruno_info.outputs.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Message**: ${{ steps.bruno_info.outputs.message }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.changes.outputs.has_breaking }}" = "true" ]; then + echo "### ⚠️ Breaking Changes" >> $GITHUB_STEP_SUMMARY + echo "Breaking changes가 감지되었습니다. PR을 주의깊게 리뷰해주세요." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "### 🔗 다음 단계" >> $GITHUB_STEP_SUMMARY + echo "1. [Pull Requests](../../pulls)에서 자동 생성된 PR 확인" >> $GITHUB_STEP_SUMMARY + echo "2. Changelog 확인" >> $GITHUB_STEP_SUMMARY + echo "3. 코드 리뷰 및 머지" >> $GITHUB_STEP_SUMMARY + + - name: No Changes + if: steps.changes.outputs.has_changes == 'false' + run: | + echo "ℹ️ 변경사항이 없습니다." + echo "Bruno repository가 업데이트되었지만 OpenAPI 스펙에는 변경이 없습니다." + +# 설정 방법: +# 1. YOUR-ORG/BRUNO-REPO를 실제 Bruno 저장소 경로로 변경 +# 2. public/ 경로를 프로젝트에 맞게 수정 (예: src/api/, docs/ 등) +# 3. Swagger UI 링크를 실제 배포 URL로 변경 diff --git a/packages/bruno-api-typescript/.github/workflows/frontend-workflow.example.yml b/packages/bruno-api-typescript/.github/workflows/frontend-workflow.example.yml new file mode 100644 index 00000000..b19f8747 --- /dev/null +++ b/packages/bruno-api-typescript/.github/workflows/frontend-workflow.example.yml @@ -0,0 +1,87 @@ +name: Update API Hooks (Frontend Repo) + +# 🎯 이 파일은 프론트엔드 리포에 추가하세요 +# Repository Dispatch 이벤트를 받아서 API 훅을 업데이트합니다 + +on: + repository_dispatch: + types: [bruno_updated] + workflow_dispatch: # 수동 실행도 가능 + +jobs: + update-hooks: + runs-on: ubuntu-latest + + steps: + # 프론트엔드 리포 체크아웃 + - name: Checkout Frontend Repo + uses: actions/checkout@v4 + + # Bruno 리포 체크아웃 + - name: Checkout Bruno Repo + uses: actions/checkout@v4 + with: + # ⚠️ 변경 필요: Bruno 리포 경로 + repository: YOUR_USERNAME/YOUR_BRUNO_REPO + path: bruno-repo + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + # bruno-api-typescript 설치 + - name: Install Dependencies + run: | + npm install -D github:manNomi/bruno-api-typescript + + # React Query 훅 생성 + - name: Generate React Query Hooks + run: | + npx bruno-api generate-hooks \ + -i bruno-repo/bruno \ + -o ./src/apis \ + --axios-path "@/utils/axiosInstance" + + # 변경사항 확인 + - name: Check for changes + id: git-check + run: | + if [[ -n $(git status -s) ]]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + else + echo "has_changes=false" >> $GITHUB_OUTPUT + fi + + # 브랜치 생성 및 커밋 + - name: Commit and Push + if: steps.git-check.outputs.has_changes == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + BRANCH_NAME="auto/api-hooks-$(date +%Y%m%d-%H%M%S)" + git checkout -b $BRANCH_NAME + + git add src/apis + git commit -m "chore: update API hooks + + 🤖 Triggered by Bruno repository update + Source SHA: ${{ github.event.client_payload.sha }}" + + git push origin $BRANCH_NAME + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + id: commit + + # PR 생성 + - name: Create Pull Request + if: steps.git-check.outputs.has_changes == 'true' + run: | + gh pr create \ + --title "🔄 Update API Hooks" \ + --body "Auto-generated from Bruno repository update" \ + --base main \ + --head ${{ steps.commit.outputs.branch_name }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/bruno-api-typescript/.github/workflows/repository-dispatch.example.yml b/packages/bruno-api-typescript/.github/workflows/repository-dispatch.example.yml new file mode 100644 index 00000000..0d7592ff --- /dev/null +++ b/packages/bruno-api-typescript/.github/workflows/repository-dispatch.example.yml @@ -0,0 +1,53 @@ +name: Repository Dispatch Method + +# 🔄 더 모듈화된 방식: Bruno 리포에서 이벤트만 보내고, 프론트엔드 리포에서 처리 +# +# 이 파일: Bruno 리포에 사용 +# 프론트엔드 리포: frontend-workflow.example.yml 사용 + +on: + push: + branches: + - main + paths: + - '**.bru' + +jobs: + trigger-frontend: + runs-on: ubuntu-latest + + steps: + # GitHub App 토큰 생성 + - name: Generate GitHub App Token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + # ⚠️ 변경 필요 + owner: YOUR_GITHUB_USERNAME + repositories: "YOUR_FRONTEND_REPO" + + # Repository Dispatch 이벤트 전송 + - name: Trigger Frontend Workflow + run: | + curl -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ steps.generate-token.outputs.token }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/YOUR_USERNAME/YOUR_FRONTEND_REPO/dispatches \ + -d '{ + "event_type": "bruno_updated", + "client_payload": { + "sha": "${{ github.sha }}", + "ref": "${{ github.ref }}", + "repository": "${{ github.repository }}", + "actor": "${{ github.actor }}" + } + }' + + - name: Log + run: | + echo "✅ Frontend workflow triggered successfully" + echo "Repository: YOUR_USERNAME/YOUR_FRONTEND_REPO" + echo "Event: bruno_updated" diff --git a/packages/bruno-api-typescript/.github/workflows/sync-to-frontend.example.yml b/packages/bruno-api-typescript/.github/workflows/sync-to-frontend.example.yml new file mode 100644 index 00000000..3d041160 --- /dev/null +++ b/packages/bruno-api-typescript/.github/workflows/sync-to-frontend.example.yml @@ -0,0 +1,161 @@ +name: Sync API Hooks to Frontend + +# 🔄 Bruno 리포의 .bru 파일이 변경되면 자동으로 프론트엔드 리포에 React Query 훅을 생성하고 PR을 만듭니다 +# +# 사용 전 설정: +# 1. GitHub App 생성 및 설치 (docs/GITHUB-APPS-SETUP.md 참고) +# 2. Secrets 설정: APP_ID, APP_PRIVATE_KEY +# 3. 아래 YOUR_* 부분을 실제 값으로 변경 + +on: + push: + branches: + - main # 또는 master + paths: + - '**.bru' # .bru 파일이 변경될 때만 실행 + workflow_dispatch: # 수동 실행 가능 + +jobs: + generate-and-sync: + runs-on: ubuntu-latest + + steps: + # 1. GitHub App 토큰 생성 + - name: Generate GitHub App Token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + # ⚠️ 변경 필요: 본인의 GitHub 사용자명 또는 조직명 + owner: YOUR_GITHUB_USERNAME + # ⚠️ 변경 필요: Bruno 리포와 프론트엔드 리포 이름 (쉼표로 구분) + repositories: "bruno-repo-name,frontend-repo-name" + + # 2. Bruno 리포 체크아웃 + - name: Checkout Bruno Repo + uses: actions/checkout@v4 + with: + path: bruno-repo + + # 3. 프론트엔드 리포 체크아웃 + - name: Checkout Frontend Repo + uses: actions/checkout@v4 + with: + # ⚠️ 변경 필요: 프론트엔드 리포 경로 (예: manNomi/my-frontend-app) + repository: YOUR_USERNAME/YOUR_FRONTEND_REPO + token: ${{ steps.generate-token.outputs.token }} + path: frontend-repo + + # 4. Node.js 설정 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + # 5. bruno-api-typescript 설치 + - name: Install bruno-api-typescript + run: | + cd frontend-repo + npm install -D github:manNomi/bruno-api-typescript + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + + # 6. React Query 훅 생성 + - name: Generate React Query Hooks + run: | + cd frontend-repo + npx bruno-api generate-hooks \ + -i ../bruno-repo/bruno \ + -o ./src/apis \ + --axios-path "@/utils/axiosInstance" + + # 7. 변경사항 확인 + - name: Check for changes + id: git-check + run: | + cd frontend-repo + if [[ -n $(git status -s) ]]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "✅ Changes detected" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "ℹ️ No changes detected" + fi + + # 8. 브랜치 생성 및 커밋 + - name: Commit and Push Changes + if: steps.git-check.outputs.has_changes == 'true' + run: | + cd frontend-repo + + # Git 설정 + git config user.name "bruno-api-sync-bot[bot]" + git config user.email "bruno-api-sync-bot[bot]@users.noreply.github.com" + + # 브랜치 생성 + BRANCH_NAME="auto/update-api-hooks-$(date +%Y%m%d-%H%M%S)" + git checkout -b $BRANCH_NAME + + # 커밋 + git add src/apis + git commit -m "chore: update API hooks from Bruno + + 🤖 Auto-generated by bruno-api-typescript + + Source: ${{ github.repository }}@${{ github.sha }} + Triggered by: ${{ github.actor }}" + + # 푸시 + git push origin $BRANCH_NAME + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + id: commit + + # 9. PR 생성 + - name: Create Pull Request + if: steps.git-check.outputs.has_changes == 'true' + working-directory: frontend-repo + run: | + gh pr create \ + --title "🔄 Update API Hooks from Bruno" \ + --body "## 🤖 Auto-generated PR + + React Query 훅이 Bruno API 변경사항을 반영하여 업데이트되었습니다. + + ### 📋 Source Information + - **Repository**: \`${{ github.repository }}\` + - **Commit**: [\`${GITHUB_SHA:0:7}\`](https://github.com/${{ github.repository }}/commit/${{ github.sha }}) + - **Triggered by**: @${{ github.actor }} + + ### 📦 Generated Files + - \`src/apis/**/*.ts\` - React Query hooks + - \`src/apis/queryKeys.ts\` - Query key constants + + ### ✅ Review Checklist + - [ ] Breaking changes 확인 + - [ ] 변경된 API 사용하는 컴포넌트 업데이트 + - [ ] \`npm run type-check\` 실행 + - [ ] 테스트 실행 (\`npm test\`) + + --- + *Generated by [bruno-api-typescript](https://github.com/manNomi/bruno-api-typescript)* + " \ + --base main \ + --head ${{ steps.commit.outputs.branch_name }} \ + --label "auto-generated" \ + --label "api-update" + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + + # 10. 결과 요약 + - name: Summary + run: | + echo "## 🎉 Workflow Completed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "${{ steps.git-check.outputs.has_changes }}" == "true" ]]; then + echo "✅ PR created successfully!" >> $GITHUB_STEP_SUMMARY + echo "- Branch: \`${{ steps.commit.outputs.branch_name }}\`" >> $GITHUB_STEP_SUMMARY + else + echo "ℹ️ No changes detected. PR not created." >> $GITHUB_STEP_SUMMARY + fi diff --git a/packages/bruno-api-typescript/.gitignore b/packages/bruno-api-typescript/.gitignore new file mode 100644 index 00000000..ba3de2d3 --- /dev/null +++ b/packages/bruno-api-typescript/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +*.log +.DS_Store +coverage/ +.env +*.tsbuildinfo +bruno +test-output +.bruno-cache/ \ No newline at end of file diff --git a/packages/bruno-api-typescript/API_DEFINITIONS_SPECIFICATION.md b/packages/bruno-api-typescript/API_DEFINITIONS_SPECIFICATION.md new file mode 100644 index 00000000..e6cbc6fb --- /dev/null +++ b/packages/bruno-api-typescript/API_DEFINITIONS_SPECIFICATION.md @@ -0,0 +1,277 @@ +# API Definitions Specification + +## Overview + +This document describes how Bruno files are transformed into typed API definitions and factory functions for type-safe API consumption in TypeScript applications. + +## Architecture + +### 1. Generated File Structure + +For each domain (based on Bruno folder structure), the following files are generated: + +``` +src/apis/ +├── users/ +│ ├── api.ts # API factory with all endpoints +│ ├── apiDefinitions.ts # Type metadata for each endpoint +│ └── index.ts # Exports +├── products/ +│ ├── api.ts +│ ├── apiDefinitions.ts +│ └── index.ts +``` + +### 2. API Factory (api.ts) + +**Purpose**: Provides typed API client functions grouped by domain. + +**Example**: +```typescript +export const usersApi = { + getProfile: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/users/profile`, { params: params?.params }); + return res.data; + }, + + updateProfile: async (params: { data: UpdateProfileRequest }): Promise => { + const res = await axiosInstance.put(`/users/profile`, params.data); + return res.data; + }, +}; +``` + +**Type Safety Features**: +- Full TypeScript type inference +- Compile-time parameter validation +- Response type checking + +### 3. API Definitions (apiDefinitions.ts) + +**Purpose**: Provides typed metadata about each API endpoint. + +**Example**: +```typescript +import type { GetProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from './api'; + +export const usersApiDefinitions = { + getProfile: { + method: 'GET' as const, + path: '/users/profile' as const, + pathParams: {} as Record, + queryParams: {} as Record, + body: {} as Record, + response: {} as GetProfileResponse, + }, + + updateProfile: { + method: 'PUT' as const, + path: '/users/profile' as const, + pathParams: {} as Record, + queryParams: {} as Record, + body: {} as UpdateProfileRequest, + response: {} as UpdateProfileResponse, + }, +} as const; + +export type UsersApiDefinitions = typeof usersApiDefinitions; +``` + +**Metadata Fields**: + +| Field | Type | Description | +|-------|------|-------------| +| `method` | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'` | HTTP method | +| `path` | `string` | URL path template | +| `pathParams` | Type object | Path parameter types | +| `queryParams` | Type object | Query parameter types | +| `body` | Type object | Request body type | +| `response` | Type object | Response type | + +## Type Generation Rules + +### 1. Path Parameters + +**Bruno File**: `GET /users/:userId/posts/:postId` + +**Generated Type**: +```typescript +pathParams: {} as { userId: string | number; postId: string | number } +``` + +### 2. Query Parameters + +**For GET requests**: +```typescript +queryParams: {} as Record +``` + +**For non-GET requests**: +```typescript +queryParams: {} as Record +``` + +### 3. Request Body + +**For POST/PUT/PATCH with body**: +```typescript +body: {} as CreateUserRequest +``` + +**For GET/DELETE or no body**: +```typescript +body: {} as Record +``` + +### 4. Response Types + +**Generated from Bruno docs block**: +```typescript +response: {} as GetUserResponse +``` + +**If no docs block**: +```typescript +response: {} as void +``` + +## Usage Examples + +### Direct API Calls + +```typescript +import { usersApi } from '@/apis/users'; + +// GET request +const profile = await usersApi.getProfile({ + params: { includeDetails: true } +}); + +// POST request +const newUser = await usersApi.createUser({ + data: { name: 'John', email: 'john@example.com' } +}); + +// PUT request with path params +const updated = await usersApi.updatePost({ + postId: 123, + data: { title: 'New Title' } +}); +``` + +### With Custom Hooks + +```typescript +import { usersApi } from '@/apis/users'; + +export async function fetchProfile() { + return usersApi.getProfile({}); +} +``` + +### Using Type Metadata + +```typescript +import { usersApiDefinitions } from '@/apis/users'; + +// Extract types +type ProfileResponse = typeof usersApiDefinitions.getProfile.response; +type UpdateRequest = typeof usersApiDefinitions.updateProfile.body; + +// Runtime metadata +console.log(usersApiDefinitions.getProfile.method); // 'GET' +console.log(usersApiDefinitions.getProfile.path); // '/users/profile' +``` + +## File Generation Process + +1. **Parse Bruno Files**: Extract API definitions from `.bru` files +2. **Extract Metadata**: Determine method, path, parameters, body, response +3. **Infer Types**: Generate TypeScript types from docs JSON examples +4. **Generate Factory**: Create typed API client functions +5. **Generate Definitions**: Create type metadata exports +6. **Generate Index**: Export all APIs from domain + +## Type Safety Guarantees + +### Compile-Time Validation + +```typescript +// ✅ Correct usage +await usersApi.getProfile({ params: { page: 1 } }); + +// ❌ Type error: missing data parameter +await usersApi.createUser({}); + +// ❌ Type error: wrong parameter type +await usersApi.getProfile({ data: {} }); +``` + +### Response Type Inference + +```typescript +const profile = await usersApi.getProfile({}); +// profile is automatically typed as GetProfileResponse + +profile.id; // ✅ OK +profile.username; // ✅ OK +profile.invalid; // ❌ Type error +``` + +## Best Practices + +### 1. Do Not Modify Generated Files + +Generated files (`api.ts`, `apiDefinitions.ts`) are overwritten on each generation. Do not add custom logic to these files. + +### 2. Keep Custom Logic Separate + +``` +src/ +├── apis/ # Generated (do not modify) +│ └── users/ +│ ├── api.ts +│ └── apiDefinitions.ts +└── hooks/ # Custom hooks (safe to modify) + └── useAuth.ts +``` + +### 3. Use Type Metadata for Generic Functions + +```typescript +import { usersApiDefinitions } from '@/apis/users'; + +function getEndpointPath( + endpoint: T +) { + return usersApiDefinitions[endpoint].path; +} +``` + +## Migration from Query Keys + +Previous version generated hooks and query keys. The new approach: + +**Before**: +```typescript +import { useGetProfile } from '@/apis/users'; +const { data } = useGetProfile(); +``` + +**After**: +```typescript +import { usersApi } from '@/apis/users'; +const data = await usersApi.getProfile({}); +``` + +**Benefits**: +- Framework-agnostic API clients +- Easier testing (mock API functions directly) +- Clear separation between data fetching and business logic + +## Related Files + +- `src/generator/apiFactoryGenerator.ts` - Generates API factory +- `src/generator/apiDefinitionGenerator.ts` - Generates type definitions +- `src/generator/typeGenerator.ts` - Infers TypeScript types +- `src/parser/bruParser.ts` - Parses Bruno files diff --git a/packages/bruno-api-typescript/CHANGELOG.md b/packages/bruno-api-typescript/CHANGELOG.md new file mode 100644 index 00000000..e258f84a --- /dev/null +++ b/packages/bruno-api-typescript/CHANGELOG.md @@ -0,0 +1,166 @@ +# 변경 이력 + +이 프로젝트의 모든 주요 변경사항을 기록합니다. + +형식은 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)를 기반으로 하며, +[Semantic Versioning](https://semver.org/spec/v2.0.0.html)을 따릅니다. + +## [Unreleased] + +### Added + +#### 🎭 MSW (Mock Service Worker) 자동 생성 기능 +- `.bru` 파일에서 MSW 핸들러 자동 생성 +- `--msw-output` 옵션으로 MSW 핸들러 출력 디렉토리 지정 +- 도메인별 MSW 핸들러 그룹화 및 index 파일 자동 생성 +- `meta.done` 필드로 MSW 생성 제어 + - `done: true`: MSW 생성 건너뛰기 (백엔드 완료시) + - `done` 없음 또는 `false`: MSW 핸들러 자동 생성 + +**파일 추가:** +- `src/generator/mswGenerator.ts`: MSW 핸들러 생성 로직 + +**사용 예시:** +```bash +npx bruno-api generate-hooks -i ./bruno -o ./src/apis --msw-output ./src/mocks +``` + +**생성되는 구조:** +``` +src/mocks/ +├── admin/ +│ ├── get-list.ts +│ ├── post-create.ts +│ └── index.ts +├── users/ +│ ├── get-profile.ts +│ └── index.ts +└── handlers.ts +``` + +#### 📁 한글 폴더명 지원 +- Bruno 폴더명에서 `한글명 [EnglishKey]` 형식 지원 +- 대괄호 `[]` 안의 영문 키만 추출하여 파일명 및 도메인으로 사용 +- 한글 폴더명으로 가독성 확보, 영문 키로 코드 생성 + +**예시:** +- `사용자 [admin]/get-list.bru` → 도메인: `admin` +- `지원서 [applications]/create.bru` → 도메인: `applications` +- `상품 [products]/update.bru` → 도메인: `products` + +**파일 수정:** +- `src/generator/index.ts:48-60`: extractDomain() 함수에 `[키]` 추출 로직 추가 +- `src/converter/openapiConverter.ts:131-143`: extractDomain() 함수 수정 +- `src/diff/changeDetector.ts:189-202`: extractDomain() 함수 수정 + +### Changed + +#### 📝 Parser 업데이트 +- `meta` 블록에 `done` 필드 추가 +- `done: true` 파싱 지원 + +**파일 수정:** +- `src/parser/bruParser.ts:27`: ParsedBrunoFile 인터페이스에 `done?: boolean` 추가 +- `src/parser/bruParser.ts:188-191`: parseMeta() 함수에 done 필드 파싱 로직 추가 + +#### 📚 문서 업데이트 +- `docs/bruno-guide.md`: MSW 생성 제어 및 한글 폴더명 사용 가이드 추가 +- `README.md`: MSW 기능 및 한글 폴더명 지원 추가 +- `README.ko.md`: 한국어 문서 업데이트 + +### Technical Details + +#### MSW 생성 로직 +1. `.bru` 파일 파싱 +2. `meta.done` 확인 + - `done: true`면 건너뛰기 + - 그 외의 경우 MSW 핸들러 생성 +3. `docs` 블록에서 JSON 추출 +4. MSW 핸들러 코드 생성 +5. 도메인별 디렉토리에 파일 저장 +6. 도메인별 index.ts 생성 +7. 전체 handlers.ts 생성 + +#### 폴더명 추출 로직 +```typescript +const match = folderName.match(/\[([^\]]+)\]/); +if (match) { + return match[1]; // 대괄호 안의 키만 반환 +} +return folderName; // 대괄호가 없으면 폴더명 그대로 반환 +``` + +## [0.3.0] - 2025-01-XX + +### Previous Features +- Bruno to OpenAPI conversion +- Change detection +- Changelog generation (MD/JSON/HTML) +- Breaking change identification +- CLI tool +- TypeScript type generation +- API client generation +- Typed API client generation + +--- + +## 마이그레이션 가이드 + +### 기존 프로젝트용 + +#### 1. 한글 폴더명 사용 (선택사항) +기존 영문 폴더명을 그대로 사용하거나, 한글 폴더명으로 변경할 수 있습니다. + +**변경 전:** +``` +bruno/ +└── applications/ + └── get-list.bru +``` + +**변경 후:** +``` +bruno/ +└── 지원서 [applications]/ + └── get-list.bru +``` + +생성되는 파일명은 동일합니다: `applications/useGetApplicationsList.ts` + +#### 2. MSW 핸들러 생성 (선택사항) +기존 API 클라이언트 생성에 `--msw-output` 옵션을 추가하면 됩니다. + +**package.json 업데이트:** +```json +{ + "scripts": { + "api:hooks": "bruno-api generate-hooks -i ./bruno -o ./src/apis", + "api:mocks": "bruno-api generate-hooks -i ./bruno -o ./src/apis --msw-output ./src/mocks" + } +} +``` + +#### 3. 백엔드 완료된 API에 done 필드 추가 (선택사항) +이미 백엔드가 완료된 API는 `meta.done: true`를 추가하여 MSW 생성을 건너뛸 수 있습니다. + +**예시:** +```bru +meta { + name: Get User Profile + type: http + seq: 1 + done: true # 백엔드 완료, MSW 불필요 +} +``` + +--- + +## Contributors + +- Claude Code AI Assistant + +## Links + +- [GitHub Repository](https://github.com/manNomi/bruno-api-typescript) +- [Documentation](./docs/) +- [Bruno Guide](./docs/bruno-guide.md) diff --git a/packages/bruno-api-typescript/README.md b/packages/bruno-api-typescript/README.md new file mode 100644 index 00000000..626afb05 --- /dev/null +++ b/packages/bruno-api-typescript/README.md @@ -0,0 +1,107 @@ +# bruno-api-typescript + +> **Bruno 파일을 작성하면, 나머지는 자동으로** + +이 프로젝트는 Bruno `.bru` 파일을 OpenAPI 스펙과 타입 안전한 API 클라이언트로 자동 변환하는 GitHub Apps 기반 자동화 도구입니다. + +백엔드 개발자는 Bruno 파일만 작성하면, GitHub Actions가 자동으로 프론트엔드 저장소에 타입 정의와 API 클라이언트를 생성하고 PR을 만들어줍니다. + +## 작동 방식 + +```mermaid +graph LR + A[백엔드: Bruno 파일 수정] --> B[GitHub Push] + B --> C[GitHub Actions 실행] + C --> D[OpenAPI 생성] + C --> E[API 클라이언트 생성] + E --> F[프론트엔드 저장소 PR 자동 생성] +``` + +## 설정 방법 (5단계) + +### 1단계: 저장소 준비 + +```bash +# Bruno API 저장소 클론 +git clone https://github.com/solid-connection/bruno-api-typescript.git +cd bruno-api-typescript + +# 의존성 설치 및 빌드 +npm install +npm run build +``` + +### 2단계: GitHub App 생성 + +GitHub Settings → Developer settings → GitHub Apps → New GitHub App + +필수 권한: + +- **Contents**: Read & Write +- **Pull requests**: Read & Write +- **Workflows**: Read & Write + +생성 후: + +- **App ID** 복사 +- **Private Key** 다운로드 + +### 3단계: Secrets 설정 + +Bruno 저장소와 프론트엔드 저장소 모두에 Secrets 추가: + +- `APP_ID`: GitHub App ID +- `APP_PRIVATE_KEY`: Private Key 전체 내용 +- `INSTALLATION_ID`: Installation ID + +### 4단계: Workflow 파일 추가 + +`.github/workflows/sync-to-frontend.yml` 파일 생성 (자세한 내용은 [설정 가이드](./docs/github-apps-simple.md) 참조) + +### 5단계: Bruno 파일 작성 및 푸시 + +Bruno 파일 작성 후 푸시하면 자동으로 프론트엔드에 PR 생성! + +## Bruno 파일 작성 예시 + +`````bru +meta { + name: Get User Profile + type: http +} + +get /users/profile + +headers { + Authorization: Bearer {{token}} +} + +docs { + ````json + { + "id": 1, + "username": "johndoe", + "email": "john@example.com", + "createdAt": "2025-01-01T00:00:00Z" + } + ```` +} +````` + +**핵심**: `docs` 블록에 실제 API 응답 JSON을 작성하면, 이를 기반으로 타입과 스키마가 자동 생성됩니다. + +## 추가 문서 + +- **[사용 방법 가이드](./docs/usage-guide.md)** - CLI 사용법과 프로젝트 통합 방법 +- **[GitHub Apps 설정 가이드](./docs/github-apps-simple.md)** - Workflow 파일 예시 포함 +- **[Bruno 파일 작성 튜토리얼](./docs/bruno-tutorial.md)** - 단계별 따라하기 +- **[Bruno 파일 작성 가이드](./docs/bruno-guide.md)** - 상세 레퍼런스 +- **[변경사항 처리 가이드](./docs/migration-guide.md)** - API 클라이언트 변경사항 처리 방법 + +## 라이선스 + +MIT + +--- + +**v0.3.0** | 문의: hanmw110@naver.com diff --git a/packages/bruno-api-typescript/agents.md b/packages/bruno-api-typescript/agents.md new file mode 100644 index 00000000..698643dc --- /dev/null +++ b/packages/bruno-api-typescript/agents.md @@ -0,0 +1,96 @@ +# Project: bruno-api-typescript + +## Overview + +The `bruno-api-typescript` project is an automation tool designed to streamline API synchronization between backend development (using Bruno `.bru` files) and frontend development. It automatically transforms Bruno request definitions into OpenAPI specifications and typed API clients (along with optional Mock Service Worker (MSW) handlers). This entire process is integrated with GitHub Apps and GitHub Actions, enabling automated generation of client code and Pull Requests in frontend repositories whenever backend API definitions in Bruno files change. + +**Core Goal:** To minimize manual effort in maintaining API client code and documentation by automating the generation process directly from Bruno API definitions. + +## Key Functionalities + +1. **OpenAPI Specification Generation:** Converts Bruno `.bru` collection files into a comprehensive OpenAPI 3.0.0 specification (`openapi.json`). This includes API paths, methods, request bodies, and inferred response schemas based on JSON examples provided in the Bruno `docs` blocks. +2. **Typed API Client Generation:** Generates TypeScript API factory functions from Bruno API definitions. These clients are type-safe, leveraging inferred schemas for request and response types. +3. **API Definitions Generation:** Creates typed metadata files (`apiDefinitions.ts`) that provide type information for each API endpoint including method, path, parameters, body, and response types. +4. **Mock Service Worker (MSW) Handlers Generation (Optional):** Can generate MSW handlers to mock API responses during frontend development and testing, based on the same Bruno definitions. +5. **Change Detection and Changelog Generation:** Detects breaking changes between different versions of the OpenAPI specification and can generate detailed changelogs (Markdown, JSON, HTML) to inform frontend teams about API modifications. +6. **GitHub Apps Integration:** Designed to run within GitHub Actions workflows, leveraging GitHub Apps for programmatic access to repositories (reading Bruno files, creating PRs in frontend repos). +7. **Incremental Generation:** Utilizes a hash-based caching mechanism (`BrunoHashCache`) to intelligently regenerate only the changed API clients, optimizing performance for large projects. + +## Architecture and Module Interaction + +The project follows a CLI-driven, pipeline-like architecture with a clear separation of concerns. + +### Main Components and Their Responsibilities: + +* **CLI (`src/cli/index.ts`)**: + * **Role:** The primary entry point for the tool. It parses command-line arguments (`generate` for OpenAPI, `generate-hooks` for API clients/MSW). + * **Interaction:** Orchestrates the entire process by invoking the appropriate internal modules based on the command and options provided. +* **Parser (`src/parser/bruParser.ts`)**: + * **Role:** Responsible for reading and parsing individual Bruno `.bru` files. It extracts structured data such as request metadata (`meta`), HTTP details (method, URL), headers, request body, and the crucial `docs` block content. + * **Interaction:** Provides the raw, structured representation of Bruno API definitions to the Converter and Generator modules. +* **Converter (`src/converter/openapiConverter.ts`, `src/converter/schemaBuilder.ts`)**: + * **`openapiConverter.ts` Role:** Takes the parsed Bruno data and constructs the OpenAPI Specification. It iterates through Bruno files, extracts domains for tagging, normalizes URLs, and builds operation objects for each API endpoint. + * **`schemaBuilder.ts` Role:** Infers JSON schemas (used in OpenAPI for request/response bodies) from example JSON data extracted from the Bruno `docs` blocks. It handles primitive types, arrays, and objects, marking properties as `required` if present in the example. + * **Interaction:** `bruParser` feeds data to `openapiConverter`, which in turn uses `schemaBuilder` to infer schemas for the OpenAPI spec. +* **Generator (`src/generator/index.ts`, `src/generator/*Generator.ts`)**: + * **`index.ts` Role:** The main orchestrator for generating frontend-specific code. It manages the `BrunoHashCache` for incremental updates, collects Bruno files, and then dispatches to specialized generators. + * **`apiClientGenerator.ts` (implied from `extractApiFunction`) Role:** Extracts API function metadata from parsed Bruno files. + * **`apiFactoryGenerator.ts` Role:** Generates API factory files, grouping related API functions by domain with full type safety. + * **`apiDefinitionGenerator.ts` Role:** Generates typed API metadata files with method, path, parameter, body, and response type information. + * **`mswGenerator.ts` Role:** Generates Mock Service Worker (MSW) request handlers from Bruno API definitions and `docs` examples. + * **`typeGenerator.ts` Role:** Infers TypeScript types from JSON examples in Bruno `docs` blocks. + * **Interaction:** `bruParser` feeds data to `index.ts`, which then uses various `*Generator.ts` modules to produce the final client-side code. `BrunoHashCache` is used throughout to manage regeneration efficiently. +* **Diffing (`src/diff/changeDetector.ts`, `src/diff/changelogGenerator.ts`)**: + * **`changeDetector.ts` Role:** Compares two OpenAPI specifications to identify differences, categorizing them (e.g., breaking changes, non-breaking changes). + * **`changelogGenerator.ts` Role:** Formats the detected changes into a human-readable changelog. + * **Interaction:** Used by the CLI `generate` command when the `--diff` option is enabled, operating on generated OpenAPI specs. + +## API Structure and Conventions + +This project does not define its own external API endpoints. Instead, it processes API definitions provided in Bruno `.bru` files. + +* **Bruno `.bru` Files:** Serve as the source of truth for API definitions. Each `.bru` file typically represents a single API request and includes: + * `meta` block: Defines request name, type, etc. + * HTTP method and URL (e.g., `get /users/profile`). + * `headers` block: HTTP headers. + * `body:json` block: JSON request body content. + * `docs` block: Crucially, this block can contain Markdown, including ````json` code blocks that provide example JSON responses. These examples are used for schema inference. +* **Generated OpenAPI Spec:** Conforms to the OpenAPI 3.0.0 standard, providing a machine-readable API contract. Paths are normalized (e.g., `/users/:id` becomes `/users/{id}`). +* **Generated API Clients:** Domain-based API factories and typed API definitions for each endpoint. +* **Generated MSW Handlers:** Designed to integrate seamlessly with the Mock Service Worker library. + +## Data Flow + +1. **Input:** Bruno `.bru` files (containing API request definitions and JSON examples in `docs` blocks). +2. **Parsing:** `bruParser` reads `.bru` files into structured `ParsedBrunoFile` objects. +3. **Schema Inference:** `schemaBuilder` infers JSON schemas from `docs` JSON examples within the parsed Bruno files. +4. **OpenAPI Generation:** `openapiConverter` uses parsed Bruno data and inferred schemas to build `OpenAPISpec`. +5. **Client Code Generation:** `generator/index.ts` uses parsed Bruno data and inferred schemas (implicitly via other generator modules) to produce: + * API factory functions (`apiFactoryGenerator`) + * API definitions (`apiDefinitionGenerator`) + * MSW handlers (`mswGenerator`) +6. **Output:** `openapi.json`, TypeScript files for API factories, API definitions, and MSW handlers. +7. **Automation Flow (via GitHub Actions):** + * Backend developer pushes changes to Bruno files. + * GitHub Action triggers `bruno-api-typescript` CLI. + * The tool generates updated OpenAPI spec and frontend client code. + * A Pull Request is automatically created against the frontend repository with the generated code. + +## Technologies Used + +* **TypeScript:** Primary development language. +* **Node.js:** Runtime environment. +* **Commander.js:** CLI framework. +* **Bruno:** API client for defining requests. +* **OpenAPI (Swagger):** Standard for API documentation and client generation. +* **Mock Service Worker (MSW):** API mocking library. +* **Mock Service Worker (MSW):** API mocking library. +* **GitHub Apps / GitHub Actions:** For automation and integration with repositories. +* **YAML:** For parsing Bruno files. + +## Development Environment Setup + +1. Clone the repository. +2. Install dependencies: `npm install`. +3. Build the project: `npm run build`. +4. Refer to `README.md` for detailed GitHub App and workflow configuration. diff --git a/packages/bruno-api-typescript/docs/bruno-guide.md b/packages/bruno-api-typescript/docs/bruno-guide.md new file mode 100644 index 00000000..e51f67db --- /dev/null +++ b/packages/bruno-api-typescript/docs/bruno-guide.md @@ -0,0 +1,651 @@ +# Bruno 파일 작성 가이드 (백엔드 개발자용) + +> **핵심**: `docs` 블록에 응답 JSON을 정확히 작성하면 끝입니다. + +## 기본 구조 + +`````bru +meta { + name: API 이름 + type: http +} + +get /api/endpoint + +headers { + Authorization: Bearer {{token}} +} + +body:json { + { + "key": "value" + } +} + +docs { + ````json + { + "id": 1, + "username": "johndoe" + } + ```` +} +````` + +## 필수 작성 규칙 + +### 1. `docs` 블록이 핵심 + +**`docs` 블록이 전부입니다!** 이 블록의 JSON으로 타입과 스키마가 자동 생성됩니다. + +**올바른 예시 (단일 응답):** + +`````bru +docs { + ````json + { + "id": 1, + "username": "johndoe", + "email": "john@example.com", + "createdAt": "2025-01-01T00:00:00Z", + "profile": { + "age": 25, + "city": "Seoul" + }, + "tags": ["developer", "backend"] + } + ```` +} +````` + +**올바른 예시 (상태 코드별 응답):** + +여러 상태 코드를 정의할 수 있지만, **200 OK만 사용**됩니다: + +````bru +docs { + ## 200 OK + ``` + { + "id": 1, + "username": "johndoe", + "email": "john@example.com" + } + ``` + + ## 404 Not Found + ``` + { + "message": "사용자를 찾을 수 없습니다." + } + ``` +} +```` + +**⚠️ 중요**: 현재는 **200 OK 응답만 타입 생성에 사용**됩니다. 다른 상태 코드(404, 500 등)는 문서화 목적으로만 작성할 수 있습니다. + +### 1-1. 200 OK 응답 필수 작성 + +**200 OK 응답이 없으면 타입 생성이 실패합니다!** + +**❌ 잘못된 예시 (200 OK 없음):** + +````bru +docs { + ## 404 Not Found + ``` + { + "message": "사용자를 찾을 수 없습니다." + } + ``` +} +```` + +**✅ 올바른 예시:** + +````bru +docs { + ## 200 OK + ``` + { + "id": 1, + "username": "johndoe" + } + ``` + + ## 404 Not Found + ``` + { + "message": "사용자를 찾을 수 없습니다." + } + ``` +} +```` + +**주의사항:** + +- 200 OK 응답이 여러 개 있으면 **첫 번째만** 사용됩니다 +- 200 OK 응답의 JSON이 실제 응답과 **정확히 일치**해야 합니다 +- 200 OK 응답이 없으면 단일 JSON 코드 블록을 찾지만, 그것도 없으면 타입 생성 실패 + +### 2. JSON 작성 규칙 + +- ✅ **실제 응답과 동일하게** 작성 +- ✅ **모든 필드를 포함** (옵셔널 필드도) +- ✅ **타입이 명확한 값 사용**: + - 문자열: `"hello"` + - 숫자: `123` 또는 `4.5` + - 불린: `true` / `false` + - 배열: `[1, 2, 3]` (최소 1개 요소 포함) + - 객체: `{ "key": "value" }` + - null: `null` +- ✅ **날짜는 ISO 8601 형식**: `"2025-01-01T00:00:00Z"` + +### 3. 자주 하는 실수 + +**❌ 잘못된 예시:** + +`````bru +docs { + ````json + { + id: 1, // 키에 따옴표 없음 + "name": '홍길동' // 작은따옴표 사용 + } + ```` +} +````` + +**❌ 빈 배열:** + +`````bru +docs { + ````json + { + "users": [] // 타입 추론 불가 → any[]로 추론됨 + } + ```` +} +````` + +**⚠️ 주의**: 빈 배열은 `any[]`로 추론되므로, 최소 1개 요소를 포함해야 합니다. + +**❌ null 값으로 인한 타입 오류:** + +null 값이 있으면 해당 필드의 타입이 `null`로 고정되어 실제 사용 시 타입 오류가 발생할 수 있습니다. + +`````bru +docs { + ````json + { + "optionalField": null // null로 추론되어 타입이 null로 고정됨 + } + ```` +} +````` + +**✅ 올바른 예시 (옵셔널 필드 - 배열에서 유니온 타입 생성):** + +배열 응답에서 여러 아이템을 확인하여 유니온 타입을 자동 생성합니다: + +`````bru +docs { + ````json + [ + { + "status": "active", + "data": "hello", + "optionalField": "value" + }, + { + "status": null, + "data": null, + "optionalField": null + } + ] + ```` +} +````` + +**생성되는 타입:** + +```typescript +export interface ResponseItem { + status: "active" | null; + data: "hello" | null; + optionalField: "value" | null; +} +``` + +**✅ 단일 객체에서 옵셔널 필드:** + +단일 객체의 경우 null 값은 `null` 타입으로만 추론됩니다. 옵셔널 필드를 표현하려면 배열로 작성하세요: + +`````bru +docs { + ````json + { + "requiredField": "value", + "optionalField": "example" // 실제 값으로 작성 + } + ```` +} +````` + +**✅ 올바른 예시:** + +`````bru +docs { + ````json + { + "users": [ + { + "id": 1, + "name": "예시" + } + ] + } + ```` +} +````` + +## 파일명 및 폴더명 가이드라인 + +파일명과 폴더명은 생성되는 코드의 구조와 이름에 직접 영향을 미칩니다. 일관된 네이밍을 위해 다음 규칙을 따르세요. + +### 폴더명 규칙 + +폴더명은 생성되는 도메인 디렉토리 이름이 됩니다. + +#### 지원하는 형식 + +1. **`숫자) 한글명 [영문키]` 형식** (권장) + + ``` + 1) 어드민 [Admin]/ → 생성 폴더: Admin + 7) 어드민 [Admin]/ → 생성 폴더: Admin + 8) 사용자 [Users]/ → 생성 폴더: Users + 9) 멘토 [Mentor]/ → 생성 폴더: Mentor + ``` + + - 대괄호 안의 `영문키`만 사용됩니다 + - 숫자와 한글명은 가독성을 위해 사용, 실제 폴더명에는 포함되지 않음 + +2. **`한글명 [영문키]` 형식** (기존 방식, 호환) + + ``` + 지원서 [applications]/ → 생성 폴더: applications + 사용자 [users]/ → 생성 폴더: users + ``` + + - 대괄호 안의 `영문키`만 사용됩니다 + - 기존 프로젝트와 호환됩니다 + +3. **영문 폴더명** (가장 단순) + ``` + applications/ → 생성 폴더: applications + users/ → 생성 폴더: users + ``` + - 패턴이 없으면 폴더명 그대로 사용 + +#### 폴더명 예시 + +| Bruno 폴더명 | 생성되는 폴더 | 설명 | +| ----------------------- | -------------- | --------------------- | +| `1) 어드민 [Admin]` | `Admin` | 대괄호 안의 키만 사용 | +| `7) 어드민 [Admin]` | `Admin` | 숫자는 무시됨 | +| `지원서 [applications]` | `applications` | 대괄호 안의 키만 사용 | +| `users` | `users` | 그대로 사용 | + +### 파일명 규칙 + +**파일명이 API 함수 이름에 직접 사용됩니다!** + +> ⚠️ **중요**: 파일명에 **HTTP 메서드를 포함하지 마세요**. 메서드는 `.bru` 파일 내부의 `get`, `post`, `put`, `patch`, `delete` 블록에서 자동으로 인식됩니다. + +#### 지원하는 형식 + +1. **`한글명 [영문키]` 형식** (권장, 폴더명과 동일한 패턴) + + ``` + 멘토 목록 조회 [mentor-list].bru → mentor-list → mentorList → getMentorList + 사용자 프로필 [user-profile].bru → user-profile → userProfile → getUserProfile + 지원서 생성 [create-application].bru → create-application → createApplication → postCreateApplication + ``` + + - 대괄호 안의 `영문키`만 사용됩니다 + - 한글명은 가독성을 위해 사용, 실제 코드 생성에는 포함되지 않음 + - **HTTP 메서드 prefix는 자동으로 제거됩니다** (예: `delete-account` → `account`) + +2. **영문 파일명** (기존 방식, 호환) + + ``` + competitors.bru → competitors → getCompetitors + user-profile.bru → user-profile → getUserProfile + create-application.bru → create-application → postCreateApplication + ``` + + - 패턴이 없으면 파일명 그대로 사용 + - **HTTP 메서드 prefix는 자동으로 제거됩니다** + +#### 권장 형식 + +**✅ 올바른 예시:** + +``` +멘토 목록 조회 [mentor-list].bru → mentorList → getMentorList +사용자 프로필 [user-profile].bru → userProfile → getUserProfile +지원서 생성 [create-application].bru → createApplication → postCreateApplication +프로필 수정 [update-profile].bru → updateProfile → putUpdateProfile +회원 탈퇴 [account].bru → account → deleteAccount +``` + +**❌ 잘못된 예시 (HTTP 메서드 포함):** + +``` +멘토 목록 조회 [get-mentor-list].bru → getMentorList → getGetMentorList (중복!) +사용자 삭제 [delete-user].bru → deleteUser → deleteDeleteUser (중복!) +``` + +#### 네이밍 규칙 + +1. **kebab-case 사용** (하이픈으로 단어 구분) + + - ✅ `user-profile.bru` + - ❌ `userProfile.bru` (camelCase) + - ❌ `user_profile.bru` (snake_case) + - ❌ `UserProfile.bru` (PascalCase) + +2. **HTTP 메서드 포함하지 않기** (필수) + + - ✅ `account.bru` (DELETE 메서드 → deleteAccount) + - ✅ `sign-up.bru` (POST 메서드 → postSignUp) + - ✅ `mentor-list.bru` (GET 메서드 → getMentorList) + - ❌ `delete-account.bru` (메서드 중복 → deleteDeleteAccount) + - ❌ `post-sign-up.bru` (메서드 중복 → postPostSignUp) + +3. **명확하고 간결한 이름** + + - ✅ `competitors.bru` (명확함) + - ✅ `create-application.bru` (명확함) + - ❌ `api1.bru` (불명확) + - ❌ `test.bru` (불명확) + +4. **한글 파일명 피하기** + - ❌ `멘토 목록 조회.bru` (함수 이름 생성 시 문제 가능) + - ✅ `mentor-list.bru` (영문 사용) + +#### 파일명 예시 + +| Bruno 파일명 | HTTP 메서드 | 추출된 키 | 함수 이름 | +| ---------------------------------- | ----------- | ---------------- | ----------------- | +| `멘토 목록 조회 [mentor-list].bru` | GET | `mentor-list` | `getMentorList` | +| `사용자 프로필 [user-profile].bru` | GET | `user-profile` | `getUserProfile` | +| `지원서 생성 [create].bru` | POST | `create` | `postCreate` | +| `회원 탈퇴 [account].bru` | DELETE | `account` | `deleteAccount` | +| `competitors.bru` | GET | `competitors` | `getCompetitors` | +| `멘토 목록 조회.bru` | GET | `멘토 목록 조회` | `get멘토목록조회` ❌ | + +**핵심**: + +- `한글명 [영문키]` 형식으로 작성하면 한글 설명과 영문 코드를 모두 활용할 수 있습니다 +- 대괄호 안의 영문키만 kebab-case로 작성하면, 자동으로 일관된 API 함수 이름이 생성됩니다! +- **HTTP 메서드는 파일명에 포함하지 마세요** - `.bru` 파일 내부에서 자동으로 인식됩니다! + +### 전체 구조 예시 + +``` +bruno/ +├── 7) 어드민 [Admin]/ # 폴더명: Admin +│ ├── 목록 조회 [list].bru # → adminApi.getList +│ ├── 생성 [create].bru # → adminApi.postCreate +│ └── 수정 [update].bru # → adminApi.putUpdate +├── 지원서 [applications]/ # 폴더명: applications +│ ├── 경쟁자 조회 [competitors].bru # → applicationsApi.getCompetitors +│ ├── 상세 조회 [details].bru # → applicationsApi.getDetails +│ └── 지원서 생성 [create].bru # → applicationsApi.postCreate +├── 사용자 [users]/ # 폴더명: users +│ ├── 프로필 조회 [profile].bru # → usersApi.getProfile +│ └── 프로필 수정 [update-profile].bru # → usersApi.putUpdateProfile +└── bruno.json +``` + +**생성되는 구조:** + +``` +src/apis/ +├── Admin/ +│ ├── api.ts # adminApi 팩토리 +│ ├── apiDefinitions.ts # 타입 정의 +│ └── index.ts +├── applications/ +│ ├── api.ts # applicationsApi 팩토리 +│ ├── apiDefinitions.ts # 타입 정의 +│ └── index.ts +├── users/ +│ ├── api.ts # usersApi 팩토리 +│ ├── apiDefinitions.ts # 타입 정의 +│ └── index.ts +``` + +## 실전 예시 + +### GET - 목록 조회 + +`````bru +meta { + name: Get Competitors + type: http +} + +get /applications/competitors + +headers { + Authorization: Bearer {{token}} +} + +docs { + ````json + { + "firstChoice": [ + { + "universityId": 1, + "koreanName": "데겐도르프대학", + "studentCapacity": 150, + "applicantCount": 120 + } + ], + "secondChoice": [], + "thirdChoice": [] + } + ```` +} +````` + +### POST - 생성 + +`````bru +meta { + name: Create Application + type: http +} + +post /applications + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +body:json { + { + "universityId": 1, + "choice": "first" + } +} + +docs { + ````json + { + "id": 123, + "status": "pending", + "submittedAt": "2025-11-12T05:30:00Z" + } + ```` +} +````` + +### GET - 상세 조회 (Path Parameter) + +`````bru +meta { + name: Get Application Detail + type: http +} + +get /applications/:id + +headers { + Authorization: Bearer {{token}} +} + +docs { + ````json + { + "id": 123, + "userId": 456, + "status": "approved", + "reviewer": { + "id": 789, + "name": "심사자" + } + } + ```` +} +````` + +## MSW 생성 제어 + +MSW 핸들러는 모든 API에 대해 생성되며, **프론트엔드에서 플래그로 활성/비활성을 제어**합니다. + +### 프론트엔드에서 MSW 제어 + +생성된 `handlers.ts` 파일에서 환경 변수나 설정으로 제어할 수 있습니다: + +**예시 1: 환경 변수로 제어** + +```typescript +// src/mocks/handlers.ts +import { authHandlers } from "./Auth"; +import { usersHandlers } from "./Users"; + +const ENABLE_MSW = process.env.NEXT_PUBLIC_ENABLE_MSW === "true"; + +export const handlers = ENABLE_MSW ? [...authHandlers, ...usersHandlers] : []; +``` + +**예시 2: 특정 도메인만 활성화** + +```typescript +export const handlers = [ + ...authHandlers, // Auth 도메인만 활성화 + // ...usersHandlers, // Users 도메인 비활성화 +]; +``` + +**예시 3: 조건부 필터링** + +```typescript +const enabledDomains = ["Auth", "Users"]; // 활성화할 도메인 목록 + +export const handlers = [ + ...(enabledDomains.includes("Auth") ? authHandlers : []), + ...(enabledDomains.includes("Users") ? usersHandlers : []), +]; +``` + +## 체크리스트 + +새 API 엔드포인트를 만들 때: + +- [ ] `meta` 블록 작성 (name 필수) +- [ ] HTTP 메서드와 경로 명확히 표기 +- [ ] 인증 필요시 `headers` 블록에 Authorization +- [ ] POST/PUT이면 `body:json` 블록 작성 +- [ ] **`docs` 블록 반드시 작성** (가장 중요!) +- [ ] JSON이 유효한가? (온라인 validator로 확인) +- [ ] 모든 필드가 포함되었나? +- [ ] 배열에 최소 1개 요소가 있나? +- [ ] 날짜는 ISO 8601 형식인가? + +## 빠른 템플릿 + +### GET 템플릿 + +````bru +meta { + name: [API 이름] + type: http +} + +get /[경로] + +headers { + Authorization: Bearer {{token}} +} + +docs { + ```json + { + "id": 1, + "field": "value" + } +```` + +} + +```` + +### POST 템플릿 + +```bru +meta { + name: [API 이름] + type: http +} + +post /[경로] + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +body:json { + { + "field": "value" + } +} + +docs { + ```json + { + "id": 1, + "status": "success" + } +```` + +} + +``` + +## 문제 해결 + +1. **파싱 에러**: docs 블록의 JSON을 복사해서 [JSONLint](https://jsonlint.com/)로 검증 +2. **타입이 이상함**: 값의 타입 확인 (숫자는 따옴표 없이, 문자열은 따옴표) +3. **필드가 안보임**: docs 블록에 해당 필드 추가했는지 확인 + +--- + +**핵심은 `docs` 블록을 정확하게 작성하는 것!** +``` diff --git a/packages/bruno-api-typescript/docs/bruno-tutorial.md b/packages/bruno-api-typescript/docs/bruno-tutorial.md new file mode 100644 index 00000000..18b0d594 --- /dev/null +++ b/packages/bruno-api-typescript/docs/bruno-tutorial.md @@ -0,0 +1,607 @@ +# Bruno 파일 작성 튜토리얼 (따라하기) + +> **단계별로 따라하면서 Bruno 파일 작성을 마스터하세요** + +## 시작하기 전에 + +### 필요한 도구 + +1. **텍스트 에디터** (VS Code, Cursor 등) +2. **Bruno 앱** (선택사항) - [다운로드](https://www.usebruno.com/downloads) + +### 폴더 구조 준비 + +```bash +mkdir -p bruno/users +mkdir -p bruno/products +``` + +--- + +## 튜토리얼 1: 첫 GET 요청 만들기 + +### 목표 + +사용자 프로필을 조회하는 API 작성 + +### 단계 1: 파일 생성 + +```bash +touch bruno/users/profile.bru +``` + +> ⚠️ **주의**: 파일명에 HTTP 메서드(`get-`, `post-` 등)를 포함하지 마세요. 메서드는 파일 내부에서 정의됩니다. + +### 단계 2: meta 블록 작성 + +파일을 열고 다음을 입력하세요: + +```bru +meta { + name: Get User Profile + type: http +} +``` + +**설명**: + +- `name`: API 이름 (자유롭게 작성) +- `type`: 항상 `http` + +### 단계 3: HTTP 메서드와 경로 추가 + +```bru +meta { + name: Get User Profile + type: http +} + +get /users/profile +``` + +**설명**: + +- `get`: HTTP 메서드 (소문자) +- `/users/profile`: API 경로 + +### 단계 4: 헤더 추가 + +```bru +meta { + name: Get User Profile + type: http +} + +get /users/profile + +headers { + Authorization: Bearer {{token}} +} +``` + +**설명**: + +- 인증이 필요한 API는 `Authorization` 헤더 추가 +- `{{token}}`: Bruno 변수 (나중에 설정) + +### 단계 5: docs 블록 추가 (가장 중요!) + +`````bru +meta { + name: Get User Profile + type: http +} + +get /users/profile + +headers { + Authorization: Bearer {{token}} +} + +docs { + ````json + { + "id": 1, + "username": "johndoe", + "email": "john@example.com", + "firstName": "John", + "lastName": "Doe", + "createdAt": "2025-01-01T00:00:00Z" + } + ```` +} +````` + +**설명**: + +- `docs` 블록에 **실제 API 응답 JSON** 작성 +- 이 JSON으로 타입이 자동 생성됩니다! + +### 완성! + +첫 Bruno 파일 완성입니다! + +**참고**: 이 프로젝트는 GitHub Actions에서 자동으로 실행됩니다. 로컬에서 테스트하려면 저장소를 클론하고 빌드한 후 사용하세요. + +**생성되는 코드**: +```typescript +// src/apis/users/api.ts +export const usersApi = { + getProfile: async (params: { params?: Record }): Promise => { + const res = await axiosInstance.get(`/users/profile`, { params: params?.params }); + return res.data; + } +}; +``` + +--- + +## 튜토리얼 2: POST 요청 만들기 + +### 목표 + +상품을 생성하는 API 작성 + +### 단계 1: 파일 생성 + +```bash +touch bruno/products/create-product.bru +``` + +### 단계 2: 기본 구조 작성 + +```bru +meta { + name: Create Product + type: http +} + +post /products + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} +``` + +### 단계 3: 요청 body 추가 + +```bru +meta { + name: Create Product + type: http +} + +post /products + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +body:json { + { + "name": "Laptop", + "price": 1200.50, + "category": "Electronics", + "inStock": true + } +} +``` + +**설명**: + +- `body:json`: POST/PUT 요청 시 필요 +- 중괄호 안에 JSON 작성 + +### 단계 4: 응답 docs 추가 + +`````bru +meta { + name: Create Product + type: http +} + +post /products + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +body:json { + { + "name": "Laptop", + "price": 1200.50, + "category": "Electronics", + "inStock": true + } +} + +docs { + ````json + { + "id": 101, + "name": "Laptop", + "price": 1200.50, + "category": "Electronics", + "inStock": true, + "createdAt": "2025-12-23T00:00:00Z", + "message": "Product created successfully" + } + ```` +} +````` + +### 완성! + +POST 요청이 완성되었습니다. + +--- + +## 튜토리얼 3: Path Parameter 사용하기 + +### 목표 + +특정 상품을 조회하는 API 작성 (`/products/:id`) + +### 단계 1: 파일 생성 + +```bash +touch bruno/products/product.bru +``` + +> ⚠️ **주의**: 파일명에 HTTP 메서드를 포함하지 마세요. + +### 단계 2: Path Parameter 포함 경로 작성 + +`````bru +meta { + name: Get Product by ID + type: http +} + +get /products/:id + +headers { + Authorization: Bearer {{token}} +} + +docs { + ````json + { + "id": 101, + "name": "Laptop", + "price": 1200.50, + "category": "Electronics", + "inStock": true, + "description": "High-performance laptop for professionals", + "createdAt": "2025-12-23T00:00:00Z" + } + ```` +} +````` + +**설명**: + +- `:id`: Path Parameter (동적 값) +- docs에는 실제 응답 예시 작성 + +### Bruno 앱에서 테스트하기 + +Bruno 앱에서: + +1. 파일 열기 +2. `:id` 부분에 `101` 입력 +3. Send 버튼 클릭 + +--- + +## 튜토리얼 4: Query Parameter 사용하기 + +### 목표 + +필터링이 가능한 상품 목록 조회 (`/products?category=Electronics&inStock=true`) + +`````bru +meta { + name: Get Products List + type: http +} + +get /products + +headers { + Authorization: Bearer {{token}} +} + +docs { + ````json + { + "products": [ + { + "id": 101, + "name": "Laptop", + "price": 1200.50, + "category": "Electronics", + "inStock": true + }, + { + "id": 102, + "name": "Mouse", + "price": 25.00, + "category": "Electronics", + "inStock": true + } + ], + "total": 2, + "page": 1, + "pageSize": 10 + } + ```` +} +````` + +**설명**: + +- Query Parameter는 경로에 적지 않아도 됨 +- docs에 배열 응답 예시 작성 (최소 1개 요소 필요!) + +--- + +## 튜토리얼 5: 한글 폴더명 및 파일명 사용하기 + +### 한글 폴더명 지원 + +**권장 형식: `숫자) 한글명 [영문키]`** + +```bash +mkdir "bruno/7) 어드민 [Admin]" +touch "bruno/7) 어드민 [Admin]/목록 조회 [list].bru" +``` + +**규칙**: + +- `숫자) 한글명 [영문키]` 형식 (권장) +- `한글명 [영문키]` 형식 (기존 호환) +- 대괄호 `[]` 안의 영문키만 사용됨 + +**예시**: + +``` +bruno/ +├── 7) 어드민 [Admin]/ → Admin으로 인식 +├── 8) 사용자 [Users]/ → Users로 인식 +├── 지원서 [applications]/ → applications로 인식 (기존 방식) +└── 상품 [products]/ → products로 인식 (기존 방식) +``` + +### 한글 파일명 지원 + +**권장 형식: `한글명 [영문키]`** + +> ⚠️ **중요**: 파일명에 **HTTP 메서드를 포함하지 마세요**. 메서드는 `.bru` 파일 내부에서 자동으로 인식됩니다. + +```bash +touch "bruno/7) 어드민 [Admin]/목록 조회 [list].bru" +touch "bruno/7) 어드민 [Admin]/생성 [create].bru" +``` + +**규칙**: + +- `한글명 [영문키]` 형식 +- 대괄호 `[]` 안의 영문키만 사용됨 +- **HTTP 메서드 prefix는 포함하지 않음** (예: `get-`, `post-` 등) +- 예: `목록 조회 [list].bru` → `list` → `getList` (GET 메서드인 경우) +- 예: `생성 [create].bru` → `create` → `postCreate` (POST 메서드인 경우) + +--- + +## 실전 연습 + +### 연습 1: 상품 삭제 API 만들기 + +**조건**: + +- DELETE 메서드 +- 경로: `/products/:id` +- 응답: `{ "success": true, "message": "Product deleted" }` + +
+정답 보기 + +`````bru +meta { + name: Delete Product + type: http +} + +delete /products/:id + +headers { + Authorization: Bearer {{token}} +} + +docs { + ````json + { + "success": true, + "message": "Product deleted", + "deletedId": 101 + } + ```` +} +````` + +
+ +### 연습 2: 상품 업데이트 API 만들기 + +**조건**: + +- PUT 메서드 +- 경로: `/products/:id` +- 요청 body: 가격과 재고 상태 업데이트 +- 응답: 업데이트된 상품 정보 + +
+정답 보기 + +`````bru +meta { + name: Update Product + type: http +} + +put /products/:id + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +body:json { + { + "price": 999.99, + "inStock": false + } +} + +docs { + ````json + { + "id": 101, + "name": "Laptop", + "price": 999.99, + "category": "Electronics", + "inStock": false, + "updatedAt": "2025-12-23T01:00:00Z" + } + ```` +} +````` + +
+ +--- + +## 일반적인 실수와 해결방법 + +### 실수 1: JSON 형식 오류 + +**❌ 잘못된 예시:** + +`````bru +docs { + ````json + { + id: 1, // 키에 따옴표 없음 + "name": '홍길동' // 작은따옴표 사용 + } + ```` +} +````` + +**✅ 올바른 예시:** + +`````bru +docs { + ````json + { + "id": 1, + "name": "홍길동" + } + ```` +} +````` + +**해결**: [JSONLint](https://jsonlint.com/)에서 JSON 검증 + +### 실수 2: 빈 배열 + +**❌ 잘못된 예시:** + +`````bru +docs { + ````json + { + "products": [] // 타입 추론 불가 + } + ```` +} +````` + +**✅ 올바른 예시:** + +`````bru +docs { + ````json + { + "products": [ + { + "id": 1, + "name": "예시" + } + ] + } + ```` +} +````` + +### 실수 3: docs 블록 누락 + +**❌ 잘못된 예시:** + +```bru +meta { + name: Get User + type: http +} + +get /users/profile + +headers { + Authorization: Bearer {{token}} +} + +# docs 블록이 없음! +``` + +**해결**: docs 블록은 필수입니다! + +--- + +## 체크리스트 + +새 Bruno 파일을 작성한 후: + +- [ ] `meta` 블록 작성 (name 필수) +- [ ] HTTP 메서드와 경로 명확히 표기 +- [ ] 인증 필요시 `headers` 블록에 Authorization +- [ ] POST/PUT이면 `body:json` 블록 작성 +- [ ] **`docs` 블록 반드시 작성** +- [ ] JSON이 유효한가? ([JSONLint](https://jsonlint.com/)로 확인) +- [ ] 모든 필드가 포함되었나? +- [ ] 배열에 최소 1개 요소가 있나? +- [ ] 날짜는 ISO 8601 형식인가? (`2025-01-01T00:00:00Z`) + +--- + +## 다음 단계 + +축하합니다! Bruno 파일 작성을 마스터했습니다. + +**다음으로 할 일**: + +1. 실제 API 엔드포인트로 Bruno 파일 작성 +2. OpenAPI 생성: `npm run api:generate` +3. API 클라이언트 생성: `npm run api:hooks` + +**더 알아보기**: + +- [Bruno 파일 작성 가이드](./bruno-guide.md) - 레퍼런스 +-- [빠른 시작](./quickstart.md) - 명령어 정리 + +--- + +**Happy Coding! 🚀** diff --git a/packages/bruno-api-typescript/docs/github-apps-simple.md b/packages/bruno-api-typescript/docs/github-apps-simple.md new file mode 100644 index 00000000..f77bfc92 --- /dev/null +++ b/packages/bruno-api-typescript/docs/github-apps-simple.md @@ -0,0 +1,279 @@ +# GitHub Apps 연결 (5분 가이드) + +> Bruno 저장소와 프론트엔드 저장소를 자동으로 연결하여 변경사항을 자동 PR로 생성 + +## 왜 필요한가? + +Bruno 저장소에서 `.bru` 파일이 변경되면, 프론트엔드 저장소에 자동으로: + +- API 클라이언트 생성 +- TypeScript 타입 생성 +- PR 자동 생성 + +**결과**: 백엔드 개발자가 Bruno 파일만 수정하면, 프론트엔드 개발자는 PR만 확인하면 끝! + +--- + +## 5분 설정 + +### 1단계: GitHub App 생성 (2분) + +#### 1-1. GitHub Settings 이동 + +1. GitHub 우측 상단 프로필 → **Settings** +2. 왼쪽 메뉴 하단 → **Developer settings** +3. **GitHub Apps** → **New GitHub App** + +#### 1-2. 앱 정보 입력 + +**필수 입력 사항**: + +- GitHub App name: `bruno-sync-app` (조직명 추가 가능) +- Homepage URL: `https://github.com/your-org/bruno-api` +- Webhook: **비활성화** (체크 해제) + +**권한 설정 (Repository permissions)**: + +- **Contents**: Read & Write +- **Pull requests**: Read & Write +- **Workflows**: Read & Write + +**Where can this GitHub App be installed?**: + +- **Only on this account** 선택 (조직 계정이면 조직 선택) + +#### 1-3. 생성 및 Private Key 다운로드 + +1. **Create GitHub App** 클릭 +2. 생성된 앱 페이지에서 스크롤 다운 +3. **Generate a private key** 클릭 +4. `bruno-sync-app.{date}.private-key.pem` 파일 다운로드 + +**App ID 복사**: + +- 페이지 상단에 표시된 **App ID** 복사 (예: `123456`) + +### 2단계: App 설치 (1분) + +#### 2-1. 앱 설치 + +1. GitHub App 설정 페이지에서 **Install App** (왼쪽 메뉴) +2. 조직 또는 개인 계정 선택 +3. **All repositories** 또는 **Only select repositories** 선택 + - Bruno 저장소와 프론트엔드 저장소 선택 +4. **Install** 클릭 + +#### 2-2. Installation ID 확인 + +설치 후 URL을 확인하세요: + +``` +https://github.com/settings/installations/{installation_id} +``` + +`{installation_id}` 숫자를 복사하세요 (예: `789012`) + +### 3단계: Secrets 설정 (1분) + +#### Bruno 저장소 Secrets + +Bruno 저장소 → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** + +다음 3개 Secret 추가: + +1. **APP_ID**: `123456` (1단계에서 복사한 App ID) +2. **APP_PRIVATE_KEY**: `bruno-sync-app.pem` 파일 전체 내용 복사 붙여넣기 +3. **INSTALLATION_ID**: `789012` (2단계에서 확인한 Installation ID) + +#### 프론트엔드 저장소 Secrets + +프론트엔드 저장소 → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** + +동일한 3개 Secret 추가 (값 동일) + +### 4단계: Workflow 파일 추가 (1분) + +#### Bruno 저장소 Workflow + +파일: `.github/workflows/sync-to-frontend.yml` + +```yaml +name: Sync to Frontend + +on: + push: + branches: + - main + paths: + - "**/*.bru" + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Generate App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "frontend-repo" # 프론트엔드 저장소 이름 + + - name: Trigger Frontend Workflow + run: | + curl -X POST \ + -H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/${{ github.repository_owner }}/frontend-repo/dispatches \ + -d '{"event_type":"bruno_updated","client_payload":{"sha":"${{ github.sha }}"}}' +``` + +**수정 필요**: + +- `frontend-repo`: 실제 프론트엔드 저장소 이름으로 변경 + +#### 프론트엔드 저장소 Workflow + +파일: `.github/workflows/sync-from-bruno.yml` + +```yaml +name: Sync from Bruno + +on: + repository_dispatch: + types: [bruno_updated] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Generate App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Clone Bruno Repo + run: | + git clone https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/${{ github.repository_owner }}/bruno-api.git /tmp/bruno + + - name: Clone bruno-api-typescript + run: | + git clone https://github.com/solid-connection/bruno-api-typescript.git /tmp/bruno-api-typescript + + - uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Build bruno-api-typescript + working-directory: /tmp/bruno-api-typescript + run: | + npm install + npm run build + + - name: Generate API Clients + run: | + node /tmp/bruno-api-typescript/dist/cli/index.js generate-hooks -i /tmp/bruno -o ./src/apis + + - name: Create PR + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ steps.app-token.outputs.token }} + commit-message: "chore: sync API from Bruno" + branch: api-sync-${{ github.run_number }} + title: "🔄 API Sync from Bruno" + body: | + ## Bruno API 자동 동기화 + + Bruno 저장소가 업데이트되어 자동으로 API 클라이언트를 생성했습니다. + + **변경사항**: + - API 클라이언트 업데이트 + - TypeScript 타입 업데이트 +``` + +**수정 필요**: + +- `bruno-api`: 실제 Bruno 저장소 이름으로 변경 +- `bruno-api-typescript`: 실제 bruno-api-typescript 저장소 이름으로 변경 (기본값: `solid-connection/bruno-api-typescript`) + +--- + +## 테스트 + +### Bruno 파일 수정 후 푸시 + +```bash +# Bruno 저장소에서 +vim users/get-profile.bru +git add . +git commit -m "feat: add email field" +git push origin main +``` + +### 확인 + +1. Bruno 저장소 Actions 탭 확인 +2. 프론트엔드 저장소 Actions 탭 확인 +3. 프론트엔드 저장소 Pull Requests 확인 + +**예상 결과**: 약 1-2분 후 프론트엔드 저장소에 PR 자동 생성! + +--- + +## 트러블슈팅 + +### 1. "Resource not accessible by integration" 에러 + +**원인**: App 권한 부족 + +**해결**: + +1. GitHub App 설정 → **Permissions** 확인 +2. Contents, Pull requests, Workflows가 **Read & Write**인지 확인 +3. 권한 변경 후 → **Install App** → 재설치 + +### 2. Private Key 오류 + +**원인**: Secret에 키가 잘못 입력됨 + +**해결**: + +1. `.pem` 파일을 텍스트 에디터로 열기 +2. `-----BEGIN RSA PRIVATE KEY-----`부터 `-----END RSA PRIVATE KEY-----`까지 전체 복사 +3. Secret 다시 생성 + +### 3. Workflow가 트리거 안됨 + +**원인**: `.bru` 파일이 변경되지 않음 + +**해결**: + +- Bruno 저장소에서 `.bru` 파일을 수정하고 푸시 +- Workflow 파일의 `paths` 필터 확인 + +### 4. "npm error 404 Not Found - bruno-api-typescript" 에러 + +**원인**: npm 패키지가 아니므로 `npx`로 실행 불가 + +**해결**: + +- Workflow에서 저장소를 클론하고 빌드한 후 사용 +- 위의 Workflow 예시처럼 `git clone` → `npm install` → `npm run build` → `node dist/cli/index.js` 순서로 실행 + +--- + +## 더 자세한 가이드 + +심화 내용은 [상세 GitHub Apps 가이드](./archived/github-apps.md)를 참조하세요. + +--- + +**설정 완료! 🎉** diff --git a/packages/bruno-api-typescript/docs/migration-guide.md b/packages/bruno-api-typescript/docs/migration-guide.md new file mode 100644 index 00000000..56f6fdf0 --- /dev/null +++ b/packages/bruno-api-typescript/docs/migration-guide.md @@ -0,0 +1,128 @@ +# API 클라이언트 변경사항 처리 가이드 + +## 개요 + +이 프로젝트는 Bruno 파일에서 타입 안전한 API 클라이언트를 자동 생성합니다. 변경사항은 다음과 같이 처리됩니다: + +1. **API 팩토리 (api.ts)**: 항상 덮어쓰기 (최신 API 시그니처 유지) +2. **API 정의 (apiDefinitions.ts)**: 항상 덮어쓰기 (타입 메타데이터 최신화) + +## 파일 구조 + +``` +src/apis/ +├── Auth/ +│ ├── api.ts # API 팩토리 (항상 덮어쓰기) +│ ├── apiDefinitions.ts # 타입 정의 (항상 덮어쓰기) +│ └── index.ts +``` + +## 동작 방식 + +### 1. 새 API 추가 시 + +새 API가 추가되면 팩토리와 정의 파일이 업데이트됩니다. + +``` +Auth/ +├── api.ts # authApi.postSignOut 추가됨 +├── apiDefinitions.ts # postSignOut 타입 정의 추가됨 +└── index.ts +``` + +### 2. 기존 API 변경 시 + +API 시그니처가 변경되면 자동으로 반영됩니다. + +``` +Auth/ +├── api.ts # authApi.postSignOut 업데이트됨 +├── apiDefinitions.ts # postSignOut 타입 정의 업데이트됨 +└── index.ts +``` + +## 사용 방법 + +### API 호출 + +생성된 API 클라이언트는 다음과 같이 사용합니다: + +```typescript +import { authApi } from '@/apis/Auth'; + +// GET 요청 +const profile = await authApi.getProfile({ params: { includeDetails: true } }); + +// POST 요청 +const result = await authApi.postSignOut({ data: {} }); +``` + + +## 타입 정의 활용 + +### API 메타데이터 확인 + +`apiDefinitions.ts`는 각 API의 타입 메타데이터를 제공합니다: + +```typescript +import { authApiDefinitions } from '@/apis/Auth'; + +// API 메타데이터 확인 +const signOutDef = authApiDefinitions.postSignOut; +console.log(signOutDef.method); // 'POST' +console.log(signOutDef.path); // '/auth/sign-out' + +// 타입 추론 +type SignOutRequest = typeof signOutDef.body; +type SignOutResponse = typeof signOutDef.response; +``` + +### 타입 안전성 + +모든 API 호출은 완전한 타입 안전성을 제공합니다: + +```typescript +// ✅ 올바른 사용 +await authApi.postSignOut({ + data: { /* PostSignOutRequest 타입 */ } +}); + +// ❌ 컴파일 에러 +await authApi.postSignOut({ + data: { invalidField: true } // 타입 에러 +}); +``` + +## 주의사항 + +### 생성 파일 커스터마이징 금지 + +`api.ts`와 `apiDefinitions.ts` 파일은 항상 덮어쓰기되므로, 이 파일들을 직접 수정하지 마세요. + +### 커스텀 로직 분리 + +비즈니스 로직이나 커스텀 훅은 별도 파일로 관리하세요: + +``` +src/ +├── apis/ +│ └── Auth/ +│ ├── api.ts # 자동 생성 (수정 금지) +│ └── apiDefinitions.ts # 자동 생성 (수정 금지) +└── hooks/ + └── useAuth.ts # 커스텀 훅 (자유롭게 수정) +``` + +## 자주 묻는 질문 + +### Q: API 팩토리 파일을 수정해도 되나요? + +A: 아니요. `api.ts`와 `apiDefinitions.ts`는 자동 생성 파일이므로 수정하지 마세요. 다음 생성 시 덮어쓰기됩니다. + +### Q: 커스텀 로직은 어디에 추가하나요? + +A: 별도의 훅 파일(`src/hooks/`)을 만들어 커스텀 로직을 관리하세요. 자동 생성된 API 클라이언트를 import하여 사용합니다. + +### Q: API 클라이언트를 직접 사용해도 되나요? + +A: 네, API 클라이언트와 API 정의는 독립적으로 사용할 수 있습니다. 필요하다면 별도의 레이어에서 커스텀 훅을 만들어 사용하세요. diff --git a/packages/bruno-api-typescript/docs/prd-react-query-removal.md b/packages/bruno-api-typescript/docs/prd-react-query-removal.md new file mode 100644 index 00000000..23b10670 --- /dev/null +++ b/packages/bruno-api-typescript/docs/prd-react-query-removal.md @@ -0,0 +1,120 @@ +# PRD: React Query 제거 및 타입/쿼리 동기화 강화 + +## 1. 배경 / 문제 정의 +현재 이 레포는 Bruno `.bru` 정의를 기반으로 OpenAPI와 React Query hooks를 생성하도록 설계되어 있습니다. 목표는 **React Query 의존을 제거**하고, **타입을 확실하게 보장**하며, **쿼리(요청) 정보가 완벽히 동기화된 산출물**을 제공하는 것입니다. 즉, React Query 훅 생성이 아닌 **정확한 타입/요청 정의 생성과 변경 동기화**가 핵심 목표입니다. + +## 2. 목표 (Goals) +1. **React Query 지원 제거**: 훅 생성 및 관련 문서/옵션/출력물을 제거 또는 대체. +2. **타입 정확도 강화**: Bruno docs의 JSON 예제 기반 타입 생성의 정확성을 강화하고, 불확실한 타입의 범위를 최소화. +3. **쿼리 정보 완전 동기화**: 요청 정보(메서드, URL, path/query params, request/response schema)가 항상 정확히 동기화된 산출물로 제공. + +## 3. 비목표 (Non-Goals) +- 외부 클라이언트 프레임워크(React Query 외) 추가 지원은 본 범위에 포함하지 않음. +- 런타임 SDK/HTTP 클라이언트 교체는 별도 범위로 분리. + +## 4. 사용자 스토리 +1. 백엔드 개발자는 Bruno 파일만 수정하면 **타입과 요청 정의가 자동으로 최신화**되길 원한다. +2. 프론트엔드 개발자는 **React Query 훅 없이도** 타입과 요청 정보가 완벽히 동기화된 산출물을 받길 원한다. +3. 변경 발생 시 **동기화 상태와 변경 내역을 명확히 파악**할 수 있길 원한다. + +## 5. 주요 요구사항 + +### 5.1 기능 요구사항 +1. **React Query 훅 생성 제거** + - `reactQueryGenerator.ts`, `queryKeyGenerator.ts`, hook 관련 CLI 옵션/문서에서 제거 또는 대체. + - `docs/migration-guide.md`의 React Query 훅 기반 워크플로우 제거 또는 재작성. + +2. **정확한 타입 산출** + - `typeGenerator.ts`의 타입 생성 로직 검증 강화. + - Bruno docs JSON 예제를 기반으로 **정확한 타입 선언 생성** (리터럴 → 일반 타입, 배열/객체/유니온에 대한 안정적 추론). + +3. **쿼리 정보 동기화 산출물 제공** + - 각 API 요청에 대해 **요청 메타데이터 + 타입 정보**를 함께 출력. + - 예: `apiDefinitions.ts` (또는 유사)로 모든 API 스펙을 타입 안전하게 노출. + - 변경 감지(캐시/디프) 결과가 이 산출물에도 적용되어 **변경 시 자동 동기화**. + +### 5.2 비기능 요구사항 +1. **완전 자동화**: 기존 CLI (`generate`, `generate-hooks`) 흐름에서 React Query 훅 제거 후에도 자동 생성 파이프라인이 유지되어야 함. +2. **정확성 우선**: 추론 실패 시 명확한 에러/경고 제공. +3. **호환성**: 기존 생성 파이프라인의 캐시/변경 감지 로직과 함께 동작. + +## 6. 산출물 정의 + +### 6.1 기존 산출물 제거 +- `src/apis/**` 내 React Query hooks +- `queryKeys.ts` + +### 6.2 신규 산출물 +1. **API 정의/타입 파일** (예: `src/apis/apiDefinitions.ts`) + - 각 API의 메서드/경로/파라미터/응답 타입을 구조적으로 표현 + - 예시: + ```ts + export const ApiDefinitions = { + users: { + getUser: { + method: "GET", + path: "/users/:userId", + params: { userId: "number" }, + response: {} as GetUserResponse, + }, + }, + } as const; + ``` + +2. **타입 정의 파일** + - 기존 `apiClientGenerator.ts` / `apiFactoryGenerator.ts`가 제공하는 타입 산출은 유지하되, React Query 의존이 없는 형태로 재구성. + +## 7. 변경 범위 (Scope) + +### 7.1 제거 대상 +- `src/generator/reactQueryGenerator.ts` +- `src/generator/queryKeyGenerator.ts` +- React Query 관련 문서 (README, migration-guide, querykey spec) +- CLI 명령 `generate-hooks` 혹은 해당 명령의 React Query 전용 옵션 + +### 7.2 수정 대상 +- `src/generator/index.ts` +- `src/cli/index.ts` +- 문서/가이드: README, docs/* + +### 7.3 유지 대상 +- OpenAPI 생성 로직 (`openapiConverter`, `schemaBuilder`) +- 타입 생성 로직 (`typeGenerator`, `apiClientGenerator`, `apiFactoryGenerator`) +- 캐시/변경 감지 로직 (`brunoHashCache`, `changeDetector`) + +## 8. 성공 기준 (Success Metrics) +1. React Query 관련 코드/문서/출력이 전부 제거되거나 대체됨. +2. 모든 API 정의가 타입 안전하게 산출됨. +3. 변경 감지/동기화 파이프라인이 정상 작동하며, 타입/정의 산출물이 최신 상태로 유지됨. + +## 9. 리스크 및 대응 +| 리스크 | 영향 | 대응 | +|-------|------|------| +| 기존 사용자/프론트엔드 워크플로우 영향 | 높음 | 마이그레이션 문서 제공, 레거시 출력 옵션 고려 | +| 타입 추론 정확도 한계 | 중간 | docs JSON 예제 규칙 강화, 실패 시 명확한 에러 제공 | +| 내부 CLI 변경에 따른 기존 스크립트 오류 | 중간 | 새로운 CLI 명령/옵션 명세 제공 | + +## 10. 실행 계획 (Phased Plan) + +### Phase 1: 구조 정리 및 React Query 제거 +- React Query generator 제거 +- CLI 옵션/명령 정리 +- 관련 문서 제거/업데이트 + +### Phase 2: 타입/정의 산출물 강화 +- 타입 생성 로직 보강 +- API 정의 메타데이터 파일 생성 +- 출력 구조 명세화 + +### Phase 3: 동기화 및 변경 감지 연계 +- 캐시/디프/변경 감지 결과와 산출물 동기화 +- 변경 리포트 문서 업데이트 + +### Phase 4: 문서 및 마이그레이션 +- README 및 docs 전면 정비 +- 기존 사용자 대상 마이그레이션 가이드 제공 + +## 11. 오픈 이슈 +- React Query 제거 후 CLI 명령 네이밍 (`generate-hooks` 유지 여부) +- 기존 훅 기반 프론트엔드와의 호환 전략 +- 타입 추론 실패 기준 및 에러 처리 정책 diff --git a/packages/bruno-api-typescript/docs/usage-guide.md b/packages/bruno-api-typescript/docs/usage-guide.md new file mode 100644 index 00000000..1252713e --- /dev/null +++ b/packages/bruno-api-typescript/docs/usage-guide.md @@ -0,0 +1,120 @@ +# 사용 방법 가이드 + +> 이 문서는 `bruno-api-typescript`를 실제 프로젝트에서 사용하는 방법을 설명합니다. + +## 목표 + +- Bruno `.bru` 정의로부터 **OpenAPI 스펙**과 **타입 안전한 API 클라이언트**를 생성합니다. +- 빌드/개발 과정에서 **항상 최신 스키마**를 바라보도록 실행 흐름을 구성합니다. + +## 사전 준비 + +1. **Node.js 18+** +2. Bruno `.bru` 파일이 있는 디렉토리 +3. `npm install` 및 `npm run build` 완료 + +## 핵심 명령어 + +### 1) OpenAPI 생성 + +```bash +node dist/cli/index.js generate -i ./bruno -o ./openapi.json +``` + +### 2) API 클라이언트/정의 생성 + +```bash +node dist/cli/index.js generate-hooks -i ./bruno -o ./src/apis +``` + +**옵션 예시**: + +```bash +node dist/cli/index.js generate-hooks \ + -i ./bruno \ + -o ./src/apis \ + --axios-path "@/utils/axiosInstance" \ + --msw-output ./src/mocks +``` + +## 권장 파일 구조 + +``` +bruno/ +├── users/ +│ └── profile.bru +└── applications/ + └── create-application.bru + +src/ +└── apis/ + ├── users/ + │ ├── api.ts + │ ├── apiDefinitions.ts + │ └── index.ts + └── applications/ + ├── api.ts + ├── apiDefinitions.ts + └── index.ts +``` + +## 생성 결과 사용법 + +### API 호출 + +```ts +import { usersApi } from '@/apis/users'; + +const profile = await usersApi.getGetProfile({ + params: { includeDetails: true }, +}); +``` + +### API 정의 메타데이터 + +```ts +import { usersApiDefinitions } from '@/apis/users'; + +const def = usersApiDefinitions.getGetProfile; +console.log(def.method); // 'GET' +console.log(def.path); // '/users/profile' + +type ProfileResponse = typeof def.response; +``` + +## 빌드/개발 파이프라인에 통합하기 + +### ✅ 목표: 항상 최신 스키마를 바라보도록 자동 생성 + +빌드 또는 개발 실행 전에 자동으로 OpenAPI/클라이언트를 생성하도록 **스크립트를 연결**합니다. + +#### 예시: `package.json` 스크립트 + +```json +{ + "scripts": { + "api:generate": "node dist/cli/index.js generate -i ./bruno -o ./openapi.json", + "api:clients": "node dist/cli/index.js generate-hooks -i ./bruno -o ./src/apis", + "build": "npm run api:generate && npm run api:clients && tsc", + "dev": "npm run api:generate && npm run api:clients && tsc --watch" + } +} +``` + +> 이렇게 구성하면 **빌드와 개발 실행 시 항상 최신 스키마/클라이언트를 보장**합니다. + +## 자주 묻는 질문 + +### Q. 생성 파일을 수정해도 되나요? + +A. 안 됩니다. `api.ts`, `apiDefinitions.ts`는 자동 생성 파일이며, 다음 실행 시 덮어쓰기됩니다. + +### Q. 커스텀 로직은 어디에 두나요? + +`src/hooks/` 또는 별도 레이어에서 API 클라이언트를 불러와 사용하는 것을 권장합니다. + +## 관련 문서 + +- [Bruno 파일 작성 가이드](./bruno-guide.md) +- [Bruno 파일 작성 튜토리얼](./bruno-tutorial.md) +- [변경사항 처리 가이드](./migration-guide.md) diff --git a/packages/bruno-api-typescript/package-lock.json b/packages/bruno-api-typescript/package-lock.json new file mode 100644 index 00000000..21f5f504 --- /dev/null +++ b/packages/bruno-api-typescript/package-lock.json @@ -0,0 +1,552 @@ +{ + "name": "bruno-api-typescript", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bruno-api-typescript", + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "glob": "^10.3.10", + "yaml": "^2.3.4" + }, + "bin": { + "bruno-api": "dist/cli/index.js" + }, + "devDependencies": { + "@types/node": "^20.10.6", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + } + } +} diff --git a/packages/bruno-api-typescript/package.json b/packages/bruno-api-typescript/package.json new file mode 100644 index 00000000..f2d390c4 --- /dev/null +++ b/packages/bruno-api-typescript/package.json @@ -0,0 +1,36 @@ +{ + "name": "bruno-api-typescript", + "version": "0.3.0", + "description": "Automate API sync between Bruno and Frontend via GitHub Apps", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "bruno-api": "./dist/cli/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "node --test" + }, + "private": true, + "keywords": [ + "bruno", + "openapi", + "github-apps", + "automation" + ], + "author": "MANWOOK", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "glob": "^10.3.10", + "yaml": "^2.3.4" + }, + "devDependencies": { + "@types/node": "^20.10.6", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/bruno-api-typescript/scripts/setup-cross-repo.sh b/packages/bruno-api-typescript/scripts/setup-cross-repo.sh new file mode 100755 index 00000000..6df6f12b --- /dev/null +++ b/packages/bruno-api-typescript/scripts/setup-cross-repo.sh @@ -0,0 +1,231 @@ +#!/bin/bash + +# Bruno-Frontend Cross-Repo 자동 연동 설정 스크립트 + +set -e + +echo "🚀 Bruno-Frontend Cross-Repo 자동 연동 설정" +echo "============================================" +echo "" + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 현재 저장소 타입 확인 +echo -e "${BLUE}현재 저장소 타입을 선택하세요:${NC}" +echo "1) Bruno 저장소 (백엔드)" +echo "2) 프론트엔드 저장소" +echo "" +read -p "선택 (1 또는 2): " REPO_TYPE + +if [ "$REPO_TYPE" = "1" ]; then + echo "" + echo -e "${GREEN}=== Bruno 저장소 설정 ===${NC}" + echo "" + + # 프론트엔드 저장소 정보 입력 + read -p "프론트엔드 저장소 (예: myorg/frontend-repo): " FRONTEND_REPO + + # Workflow 파일 생성 + WORKFLOW_DIR=".github/workflows" + mkdir -p "$WORKFLOW_DIR" + + echo "" + echo -e "${BLUE}Workflow 파일 생성 중...${NC}" + + cat > "$WORKFLOW_DIR/notify-frontend.yml" << EOF +name: Notify Frontend on Bruno Changes + +on: + push: + branches: + - main + paths: + - 'bruno/**' + +jobs: + notify: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Notify Frontend Repository + run: | + curl -X POST \\ + -H "Accept: application/vnd.github+json" \\ + -H "Authorization: Bearer \${{ secrets.FRONTEND_REPO_TOKEN }}" \\ + https://api.github.com/repos/${FRONTEND_REPO}/dispatches \\ + -d '{ + "event_type": "bruno_updated", + "client_payload": { + "bruno_repo": "\${{ github.repository }}", + "commit_sha": "\${{ github.sha }}", + "commit_message": "\${{ github.event.head_commit.message }}", + "author": "\${{ github.event.head_commit.author.name }}" + } + }' + + - name: Notify Complete + run: | + echo "✅ Frontend repository notified!" + echo "🔗 Check: https://github.com/${FRONTEND_REPO}/actions" +EOF + + echo -e "${GREEN}✅ Workflow 파일 생성 완료: $WORKFLOW_DIR/notify-frontend.yml${NC}" + echo "" + echo -e "${YELLOW}⚠️ 다음 단계:${NC}" + echo "" + echo "1. GitHub Personal Access Token 생성" + echo " - https://github.com/settings/tokens" + echo " - 권한: repo, workflow" + echo "" + echo "2. Bruno 저장소 Settings → Secrets → Actions" + echo " - New repository secret 클릭" + echo " - Name: FRONTEND_REPO_TOKEN" + echo " - Value: 생성한 Token 붙여넣기" + echo "" + echo "3. Git Commit & Push" + echo " git add .github/workflows/notify-frontend.yml" + echo " git commit -m 'chore: add frontend notification workflow'" + echo " git push" + echo "" + +elif [ "$REPO_TYPE" = "2" ]; then + echo "" + echo -e "${GREEN}=== 프론트엔드 저장소 설정 ===${NC}" + echo "" + + # Bruno 저장소 정보 입력 + read -p "Bruno 저장소 (예: myorg/bruno-repo): " BRUNO_REPO + read -p "OpenAPI 출력 경로 (예: public/openapi.json): " OPENAPI_PATH + read -p "Swagger UI URL (예: https://myorg.github.io/myrepo): " SWAGGER_URL + + # Workflow 파일 생성 + WORKFLOW_DIR=".github/workflows" + mkdir -p "$WORKFLOW_DIR" + + echo "" + echo -e "${BLUE}Workflow 파일 생성 중...${NC}" + + # 디렉토리 경로 추출 + OPENAPI_DIR=$(dirname "$OPENAPI_PATH") + + cat > "$WORKFLOW_DIR/sync-bruno.yml" << EOF +name: Sync Bruno API Changes + +on: + repository_dispatch: + types: [bruno_updated] + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + token: \${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Clone Bruno Repository + run: | + git clone https://github.com/${BRUNO_REPO}.git /tmp/bruno + + - name: Install Dependencies + run: npm install + + - name: Generate OpenAPI + run: | + mkdir -p ${OPENAPI_DIR} + + if [ -f ${OPENAPI_PATH} ]; then + cp ${OPENAPI_PATH} ${OPENAPI_PATH}.old + fi + + npx bruno-sync generate \\ + -i /tmp/bruno/bruno \\ + -o ${OPENAPI_PATH} \\ + --title "우리팀 API" \\ + --diff \\ + --changelog ${OPENAPI_DIR}/CHANGELOG.md + + npx bruno-sync generate \\ + -i /tmp/bruno/bruno \\ + -o ${OPENAPI_PATH} \\ + --diff \\ + --changelog ${OPENAPI_DIR}/changelog.html \\ + --changelog-format html + + - name: Check Changes + id: changes + run: | + git add ${OPENAPI_DIR}/ + if git diff --staged --quiet; then + echo "has_changes=false" >> \$GITHUB_OUTPUT + else + echo "has_changes=true" >> \$GITHUB_OUTPUT + if [ -f ${OPENAPI_DIR}/CHANGELOG.md ] && grep -q "Breaking" ${OPENAPI_DIR}/CHANGELOG.md; then + echo "has_breaking=true" >> \$GITHUB_OUTPUT + fi + fi + + - name: Create Pull Request + if: steps.changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v5 + with: + commit-message: "chore: sync API spec from Bruno" + branch: api-sync-\${{ github.event.client_payload.commit_sha || 'manual' }} + title: "🔄 API 변경사항 동기화" + body: | + ## 🔄 Bruno API 변경사항 + + **Bruno Repository**: ${BRUNO_REPO} + **Commit**: \${{ github.event.client_payload.commit_sha || 'manual' }} + + \${{ steps.changes.outputs.has_breaking == 'true' && '### ⚠️ Breaking Changes 발견!' || '' }} + + ### 📝 확인하기 + - [OpenAPI Spec](../blob/\${{ github.ref_name }}/${OPENAPI_PATH}) + - [Changelog](../blob/\${{ github.ref_name }}/${OPENAPI_DIR}/CHANGELOG.md) + - [Swagger UI](${SWAGGER_URL}/api-viewer.html) + + ### ✅ 체크리스트 + - [ ] Breaking changes 확인 + - [ ] 빌드 테스트 + labels: api-sync,autogenerated +EOF + + echo -e "${GREEN}✅ Workflow 파일 생성 완료: $WORKFLOW_DIR/sync-bruno.yml${NC}" + echo "" + echo -e "${YELLOW}⚠️ 다음 단계:${NC}" + echo "" + echo "1. Git Commit & Push" + echo " git add .github/workflows/sync-bruno.yml" + echo " git commit -m 'chore: add Bruno sync workflow'" + echo " git push" + echo "" + echo "2. Bruno 저장소에서 설정 완료되면 자동으로 동작합니다!" + echo "" + +else + echo -e "${RED}잘못된 선택입니다.${NC}" + exit 1 +fi + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}✅ 설정 완료!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "📚 자세한 내용: docs/CROSS-REPO-SYNC.md" diff --git a/packages/bruno-api-typescript/src/cli/index.ts b/packages/bruno-api-typescript/src/cli/index.ts new file mode 100644 index 00000000..dd4d2ae7 --- /dev/null +++ b/packages/bruno-api-typescript/src/cli/index.ts @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +/** + * bruno-api-typescript CLI + * Generate TypeScript API clients, typed definitions, and OpenAPI specs from Bruno files + */ + +import { Command } from 'commander'; +import { existsSync, writeFileSync, copyFileSync } from 'fs'; +import { resolve } from 'path'; +import { convertBrunoToOpenAPI } from '../converter/openapiConverter'; +import { detectChanges } from '../diff/changeDetector'; +import { generateChangelog, formatConsoleOutput, ChangelogFormat } from '../diff/changelogGenerator'; +import { generateHooks } from '../generator/index'; +import { BrunoHashCache } from '../generator/brunoHashCache'; + +const program = new Command(); + +program + .name('bruno-api') + .description('Generate TypeScript API clients, typed definitions, and OpenAPI specs from Bruno files') + .version('0.3.0'); + +program + .command('generate') + .description('Generate OpenAPI spec from Bruno collection') + .option('-i, --input ', 'Bruno collection directory', './bruno') + .option('-o, --output ', 'Output OpenAPI file', './openapi.json') + .option('--title ', 'API title', 'API Documentation') + .option('--version <version>', 'API version', '1.0.0') + .option('--description <description>', 'API description') + .option('--base-url <url>', 'Base URL for API') + .option('--diff', 'Detect changes from previous version', false) + .option('--changelog <path>', 'Generate changelog file') + .option( + '--changelog-format <format>', + 'Changelog format: markdown | json | html', + 'markdown' + ) + .option('--breaking-only', 'Show only breaking changes', false) + .action(async (options) => { + try { + const inputDir = resolve(process.cwd(), options.input); + const outputFile = resolve(process.cwd(), options.output); + + // 입력 디렉토리 확인 + if (!existsSync(inputDir)) { + console.error(`❌ Bruno directory not found: ${inputDir}`); + process.exit(1); + } + + console.log('🔄 Generating OpenAPI spec...'); + + // 이전 버전 백업 (diff 모드일 때) + let oldSpecPath: string | null = null; + if (options.diff && existsSync(outputFile)) { + oldSpecPath = outputFile + '.old'; + copyFileSync(outputFile, oldSpecPath); + } + + // OpenAPI 생성 + const spec = convertBrunoToOpenAPI(inputDir, { + title: options.title, + version: options.version, + description: options.description, + baseUrl: options.baseUrl, + }); + + // 파일 저장 + writeFileSync(outputFile, JSON.stringify(spec, null, 2), 'utf-8'); + console.log(`✅ OpenAPI spec generated: ${outputFile}`); + + // 변경사항 감지 + if (options.diff && oldSpecPath && existsSync(oldSpecPath)) { + console.log('\n🔍 Detecting changes...'); + + try { + const report = detectChanges(oldSpecPath, outputFile); + + // 콘솔 출력 + console.log(formatConsoleOutput(report, options.breakingOnly)); + + // Changelog 생성 + if (options.changelog) { + const changelogPath = resolve(process.cwd(), options.changelog); + const format = options.changelogFormat as ChangelogFormat; + + generateChangelog(report, { + format, + output: changelogPath, + breakingOnly: options.breakingOnly, + }); + } + + // Breaking changes가 있으면 exit code 1 + if (report.summary.breaking > 0 && options.breakingOnly) { + console.log( + '\n⚠️ Breaking changes detected! Please review the changes carefully.\n' + ); + process.exit(1); + } + } catch (error: any) { + console.warn(`⚠️ Failed to detect changes: ${error.message}`); + } + } + + console.log('\n✨ Done!\n'); + } catch (error: any) { + console.error(`❌ Error: ${error.message}`); + process.exit(1); + } + }); + +program + .command('generate-hooks') + .description('Generate typed API factories and definitions from Bruno collection') + .option('-i, --input <path>', 'Bruno collection directory', './bruno') + .option('-o, --output <path>', 'Output directory', './src/apis') + .option('--axios-path <path>', 'Axios instance import path', '@/utils/axiosInstance') + .option('--msw-output <path>', 'Output MSW handlers directory (optional)') + .option('--force', 'Force regenerate all clients (ignore hash cache)', false) + .option('--clear-cache', 'Clear hash cache before generation', false) + .action(async (options) => { + try { + const inputDir = resolve(process.cwd(), options.input); + const outputDir = resolve(process.cwd(), options.output); + const mswOutputDir = options.mswOutput ? resolve(process.cwd(), options.mswOutput) : undefined; + + if (!existsSync(inputDir)) { + console.error(`❌ Bruno directory not found: ${inputDir}`); + process.exit(1); + } + + if (options.clearCache) { + const cache = new BrunoHashCache(outputDir); + cache.clear(); + cache.save(); + console.log('🗑️ Hash cache cleared\n'); + } + + console.log('🏭 Generating typed API clients...\n'); + + await generateHooks({ + brunoDir: inputDir, + outputDir, + axiosInstancePath: options.axiosPath, + mswOutputDir, + force: options.force, + }); + + console.log('\n🎉 API clients generated successfully!'); + } catch (error: any) { + console.error(`❌ Error: ${error.message}`); + process.exit(1); + } + }); + +program.parse(process.argv); diff --git a/packages/bruno-api-typescript/src/converter/openapiConverter.ts b/packages/bruno-api-typescript/src/converter/openapiConverter.ts new file mode 100644 index 00000000..c0bf7734 --- /dev/null +++ b/packages/bruno-api-typescript/src/converter/openapiConverter.ts @@ -0,0 +1,245 @@ +/** + * Bruno 파일들을 OpenAPI 스펙으로 변환 + */ + +import { readdirSync, statSync } from 'fs'; +import { join, relative, dirname, basename } from 'path'; +import { parseBrunoFile, extractJsonFromDocs } from '../parser/bruParser'; +import { inferSchema } from './schemaBuilder'; + +export interface OpenAPISpec { + openapi: string; + info: { + title: string; + version: string; + description?: string; + }; + servers?: Array<{ + url: string; + description?: string; + }>; + paths: Record<string, any>; + components?: { + schemas?: Record<string, any>; + securitySchemes?: Record<string, any>; + }; + tags?: Array<{ + name: string; + description?: string; + }>; +} + +export interface ConversionOptions { + title?: string; + version?: string; + description?: string; + baseUrl?: string; +} + +/** + * Bruno 컬렉션을 OpenAPI로 변환 + */ +export function convertBrunoToOpenAPI( + brunoDir: string, + options: ConversionOptions = {} +): OpenAPISpec { + const spec: OpenAPISpec = { + openapi: '3.0.0', + info: { + title: options.title || 'API', + version: options.version || '1.0.0', + description: options.description, + }, + paths: {}, + components: { + schemas: {}, + }, + tags: [], + }; + + if (options.baseUrl) { + spec.servers = [{ url: options.baseUrl }]; + } + + // Bruno 파일들 수집 + const brunoFiles = collectBrunoFiles(brunoDir); + + // 도메인별로 그룹화 + const domainMap = new Map<string, any[]>(); + + for (const file of brunoFiles) { + try { + const parsed = parseBrunoFile(file); + const domain = extractDomain(file, brunoDir); + + if (!domainMap.has(domain)) { + domainMap.set(domain, []); + } + + domainMap.get(domain)!.push({ + file, + parsed, + }); + + // OpenAPI 경로 추가 + addPathToSpec(spec, parsed, domain); + } catch (error: any) { + console.warn(`Failed to parse ${file}:`, error.message); + } + } + + // 태그 추가 + for (const domain of domainMap.keys()) { + spec.tags!.push({ + name: domain, + description: `${domain} related endpoints`, + }); + } + + return spec; +} + +/** + * .bru 파일들 수집 + */ +function collectBrunoFiles(dir: string): string[] { + const files: string[] = []; + + function traverse(currentDir: string) { + const entries = readdirSync(currentDir); + + for (const entry of entries) { + const fullPath = join(currentDir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + traverse(fullPath); + } else if (entry.endsWith('.bru')) { + files.push(fullPath); + } + } + } + + traverse(dir); + return files; +} + +/** + * 파일 경로에서 도메인 추출 + * - "Solid Connection" 최상단 폴더를 제거하고 대괄호 패턴이 있는 첫 번째 폴더를 도메인으로 인식 + * - "숫자) 한글명 [영문키]" 형식: 1) 어드민 [Admin] → Admin + * - "한글명 [영문키]" 형식: 사용자 [users] → users + */ +function extractDomain(filePath: string, brunoDir: string): string { + const rel = relative(brunoDir, filePath); + const parts = rel.split('/'); + + // "Solid Connection" 폴더 제거 + const filteredParts = parts.filter(part => part !== 'Solid Connection'); + + // 대괄호 패턴이 있는 첫 번째 폴더 찾기 + const bracketPattern = /\[([^\]]+)\]/; + for (const part of filteredParts) { + const bracketMatch = part.match(bracketPattern); + if (bracketMatch) { + return bracketMatch[1].trim(); // 대괄호 안의 영문키 + } + } + + // 패턴이 없으면 파일이 있는 폴더명 사용 (마지막에서 두 번째) + return filteredParts[filteredParts.length - 2] || filteredParts[0] || 'default'; +} + +/** + * OpenAPI 스펙에 경로 추가 + */ +function addPathToSpec(spec: OpenAPISpec, parsed: any, domain: string): void { + const { method, url } = parsed.http; + const path = normalizeUrl(url); + + if (!spec.paths[path]) { + spec.paths[path] = {}; + } + + const operation: any = { + tags: [domain], + summary: parsed.meta.name || `${method} ${path}`, + operationId: generateOperationId(method, path, domain), + parameters: [], + responses: { + '200': { + description: 'Successful response', + }, + }, + }; + + // Headers + if (parsed.headers) { + for (const [key, value] of Object.entries(parsed.headers)) { + operation.parameters.push({ + name: key, + in: 'header', + required: false, + schema: { type: 'string' }, + example: value, + }); + } + } + + // Request body + if (parsed.body && parsed.body.content) { + try { + const bodyJson = JSON.parse(parsed.body.content); + operation.requestBody = { + required: true, + content: { + 'application/json': { + schema: inferSchema(bodyJson), + }, + }, + }; + } catch (error) { + // Invalid JSON + } + } + + // Response from docs + if (parsed.docs) { + const responseJson = extractJsonFromDocs(parsed.docs); + if (responseJson) { + operation.responses['200'] = { + description: 'Successful response', + content: { + 'application/json': { + schema: inferSchema(responseJson), + }, + }, + }; + } + } + + spec.paths[path][method.toLowerCase()] = operation; +} + +/** + * URL 정규화 (OpenAPI 경로 형식으로) + */ +function normalizeUrl(url: string): string { + // Query parameters 제거 + const withoutQuery = url.split('?')[0]; + + // :param -> {param} 변환 + return withoutQuery.replace(/:(\w+)/g, '{$1}'); +} + +/** + * Operation ID 생성 + */ +function generateOperationId(method: string, path: string, domain: string): string { + const cleanPath = path + .replace(/\{|\}/g, '') + .replace(/\//g, '-') + .replace(/^-|-$/g, ''); + + return `${method.toLowerCase()}-${domain}-${cleanPath}`; +} diff --git a/packages/bruno-api-typescript/src/converter/schemaBuilder.ts b/packages/bruno-api-typescript/src/converter/schemaBuilder.ts new file mode 100644 index 00000000..54abd6ab --- /dev/null +++ b/packages/bruno-api-typescript/src/converter/schemaBuilder.ts @@ -0,0 +1,75 @@ +/** + * JSON 샘플로부터 OpenAPI 스키마 생성 + */ + +export interface OpenAPISchema { + type: string; + properties?: Record<string, OpenAPISchema>; + items?: OpenAPISchema; + description?: string; + required?: string[]; + nullable?: boolean; + enum?: any[]; + example?: any; +} + +/** + * JSON 값으로부터 OpenAPI 스키마 생성 + */ +export function inferSchema(value: any): OpenAPISchema { + if (value === null) { + return { type: 'null', nullable: true }; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return { + type: 'array', + items: { type: 'object' }, + }; + } + return { + type: 'array', + items: inferSchema(value[0]), + }; + } + + const valueType = typeof value; + + switch (valueType) { + case 'string': + return { type: 'string', example: value }; + case 'number': + return { + type: Number.isInteger(value) ? 'integer' : 'number', + example: value, + }; + case 'boolean': + return { type: 'boolean', example: value }; + case 'object': + return inferObjectSchema(value); + default: + return { type: 'string' }; + } +} + +/** + * 객체로부터 스키마 생성 + */ +function inferObjectSchema(obj: Record<string, any>): OpenAPISchema { + const properties: Record<string, OpenAPISchema> = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + properties[key] = inferSchema(value); + if (value !== null && value !== undefined) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required: required.length > 0 ? required : undefined, + }; +} diff --git a/packages/bruno-api-typescript/src/diff/changeDetector.ts b/packages/bruno-api-typescript/src/diff/changeDetector.ts new file mode 100644 index 00000000..e887a730 --- /dev/null +++ b/packages/bruno-api-typescript/src/diff/changeDetector.ts @@ -0,0 +1,350 @@ +/** + * API 변경사항 자동 감지 + * 이전 버전과 현재 버전의 OpenAPI 스펙을 비교하여 변경사항을 추출 + */ + +import { readFileSync, existsSync } from 'fs'; +import { OpenAPISpec } from '../converter/openapiConverter'; + +export type ChangeType = 'added' | 'removed' | 'modified'; +export type ChangeSeverity = 'breaking' | 'minor' | 'patch'; + +export interface FieldChange { + type: 'added' | 'removed' | 'type-changed' | 'required-changed'; + field: string; + path: string; + oldValue?: any; + newValue?: any; +} + +export interface EndpointChange { + type: ChangeType; + severity: ChangeSeverity; + domain: string; + path: string; + method: string; + changes?: FieldChange[]; + description: string; +} + +export interface ChangeReport { + timestamp: string; + summary: { + added: number; + removed: number; + modified: number; + breaking: number; + }; + changes: EndpointChange[]; +} + +/** + * 두 OpenAPI 스펙 간 변경사항 감지 + */ +export function detectChanges(oldSpecPath: string, newSpecPath: string): ChangeReport { + // 파일 존재 확인 + if (!existsSync(oldSpecPath)) { + throw new Error(`Old spec file not found: ${oldSpecPath}`); + } + if (!existsSync(newSpecPath)) { + throw new Error(`New spec file not found: ${newSpecPath}`); + } + + const oldSpec: OpenAPISpec = JSON.parse(readFileSync(oldSpecPath, 'utf-8')); + const newSpec: OpenAPISpec = JSON.parse(readFileSync(newSpecPath, 'utf-8')); + + const changes: EndpointChange[] = []; + + // 모든 경로와 메서드 수집 + const allPaths = new Set([ + ...Object.keys(oldSpec.paths || {}), + ...Object.keys(newSpec.paths || {}), + ]); + + for (const path of allPaths) { + const oldPath = oldSpec.paths?.[path]; + const newPath = newSpec.paths?.[path]; + + // 경로가 추가됨 + if (!oldPath && newPath) { + for (const method of Object.keys(newPath)) { + if (isHttpMethod(method)) { + changes.push({ + type: 'added', + severity: 'minor', + domain: extractDomain(newPath[method]), + path, + method: method.toUpperCase(), + description: `New endpoint: ${method.toUpperCase()} ${path}`, + }); + } + } + continue; + } + + // 경로가 제거됨 + if (oldPath && !newPath) { + for (const method of Object.keys(oldPath)) { + if (isHttpMethod(method)) { + changes.push({ + type: 'removed', + severity: 'breaking', + domain: extractDomain(oldPath[method]), + path, + method: method.toUpperCase(), + description: `Endpoint removed: ${method.toUpperCase()} ${path}`, + }); + } + } + continue; + } + + // 경로가 수정됨 - 메서드별로 비교 + if (oldPath && newPath) { + const allMethods = new Set([...Object.keys(oldPath), ...Object.keys(newPath)]); + + for (const method of allMethods) { + if (!isHttpMethod(method)) continue; + + const oldMethod = oldPath[method]; + const newMethod = newPath[method]; + + // 메서드가 추가됨 + if (!oldMethod && newMethod) { + changes.push({ + type: 'added', + severity: 'minor', + domain: extractDomain(newMethod), + path, + method: method.toUpperCase(), + description: `New method: ${method.toUpperCase()} ${path}`, + }); + continue; + } + + // 메서드가 제거됨 + if (oldMethod && !newMethod) { + changes.push({ + type: 'removed', + severity: 'breaking', + domain: extractDomain(oldMethod), + path, + method: method.toUpperCase(), + description: `Method removed: ${method.toUpperCase()} ${path}`, + }); + continue; + } + + // 메서드가 수정됨 - 스키마 비교 + if (oldMethod && newMethod) { + const fieldChanges = compareSchemas(oldMethod, newMethod); + + if (fieldChanges.length > 0) { + const hasBreaking = fieldChanges.some( + (fc) => fc.type === 'removed' || fc.type === 'type-changed' + ); + + changes.push({ + type: 'modified', + severity: hasBreaking ? 'breaking' : 'minor', + domain: extractDomain(newMethod), + path, + method: method.toUpperCase(), + changes: fieldChanges, + description: `Schema changed: ${method.toUpperCase()} ${path}`, + }); + } + } + } + } + } + + // 요약 계산 + const summary = { + added: changes.filter((c) => c.type === 'added').length, + removed: changes.filter((c) => c.type === 'removed').length, + modified: changes.filter((c) => c.type === 'modified').length, + breaking: changes.filter((c) => c.severity === 'breaking').length, + }; + + return { + timestamp: new Date().toISOString(), + summary, + changes, + }; +} + +/** + * HTTP 메서드 확인 + */ +function isHttpMethod(method: string): boolean { + const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; + return methods.includes(method.toLowerCase()); +} + +/** + * 도메인 추출 + * "한글명 [EnglishKey]" 형식에서 EnglishKey만 추출 + */ +function extractDomain(operation: any): string { + if (operation.tags && operation.tags.length > 0) { + const tag = operation.tags[0]; + + // 대괄호 안의 영문키 추출: 1) 어드민 [Admin] → Admin, 사용자 [users] → users + const bracketPattern = /\[([^\]]+)\]/; + const bracketMatch = tag.match(bracketPattern); + if (bracketMatch) { + return bracketMatch[1].trim(); // 대괄호 안의 영문키 + } + + return tag; + } + return 'default'; +} + +/** + * 두 operation의 스키마 비교 + */ +function compareSchemas(oldOp: any, newOp: any): FieldChange[] { + const changes: FieldChange[] = []; + + // Response 스키마 비교 (200 응답) + const oldResponse = oldOp.responses?.['200']?.content?.['application/json']?.schema; + const newResponse = newOp.responses?.['200']?.content?.['application/json']?.schema; + + if (oldResponse && newResponse) { + compareObjectSchemas('response', oldResponse, newResponse, changes); + } else if (!oldResponse && newResponse) { + changes.push({ + type: 'added', + field: 'response', + path: 'response', + newValue: 'object', + }); + } else if (oldResponse && !newResponse) { + changes.push({ + type: 'removed', + field: 'response', + path: 'response', + oldValue: 'object', + }); + } + + // Request body 비교 + const oldRequestBody = oldOp.requestBody?.content?.['application/json']?.schema; + const newRequestBody = newOp.requestBody?.content?.['application/json']?.schema; + + if (oldRequestBody && newRequestBody) { + compareObjectSchemas('requestBody', oldRequestBody, newRequestBody, changes); + } + + return changes; +} + +/** + * 객체 스키마 재귀적 비교 + */ +function compareObjectSchemas( + basePath: string, + oldSchema: any, + newSchema: any, + changes: FieldChange[] +): void { + // 타입이 다른 경우 + if (oldSchema.type !== newSchema.type) { + changes.push({ + type: 'type-changed', + field: basePath.split('.').pop() || basePath, + path: basePath, + oldValue: oldSchema.type, + newValue: newSchema.type, + }); + return; + } + + // 배열인 경우 - items 비교 + if (oldSchema.type === 'array' && newSchema.type === 'array') { + if (oldSchema.items && newSchema.items) { + compareObjectSchemas(`${basePath}[]`, oldSchema.items, newSchema.items, changes); + } + return; + } + + // 객체인 경우 - properties 비교 + if (oldSchema.type === 'object' && newSchema.type === 'object') { + const oldProps = oldSchema.properties || {}; + const newProps = newSchema.properties || {}; + + const allKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]); + + for (const key of allKeys) { + const oldProp = oldProps[key]; + const newProp = newProps[key]; + const fieldPath = `${basePath}.${key}`; + + // 필드가 추가됨 + if (!oldProp && newProp) { + changes.push({ + type: 'added', + field: key, + path: fieldPath, + newValue: newProp.type, + }); + continue; + } + + // 필드가 제거됨 + if (oldProp && !newProp) { + changes.push({ + type: 'removed', + field: key, + path: fieldPath, + oldValue: oldProp.type, + }); + continue; + } + + // 필드가 수정됨 + if (oldProp && newProp) { + // 타입 변경 + if (oldProp.type !== newProp.type) { + changes.push({ + type: 'type-changed', + field: key, + path: fieldPath, + oldValue: oldProp.type, + newValue: newProp.type, + }); + } + + // 중첩 객체/배열 재귀 비교 + if (oldProp.type === 'object' || oldProp.type === 'array') { + compareObjectSchemas(fieldPath, oldProp, newProp, changes); + } + } + } + } +} + +/** + * 변경사항이 Breaking인지 확인 + */ +export function isBreakingChange(change: EndpointChange): boolean { + return change.severity === 'breaking'; +} + +/** + * 도메인별로 변경사항 그룹화 + */ +export function groupChangesByDomain(changes: EndpointChange[]): Map<string, EndpointChange[]> { + const grouped = new Map<string, EndpointChange[]>(); + + for (const change of changes) { + if (!grouped.has(change.domain)) { + grouped.set(change.domain, []); + } + grouped.get(change.domain)!.push(change); + } + + return grouped; +} diff --git a/packages/bruno-api-typescript/src/diff/changelogGenerator.ts b/packages/bruno-api-typescript/src/diff/changelogGenerator.ts new file mode 100644 index 00000000..3903d9be --- /dev/null +++ b/packages/bruno-api-typescript/src/diff/changelogGenerator.ts @@ -0,0 +1,503 @@ +/** + * Changelog 자동 생성 + * Markdown, JSON, HTML 형식 지원 + */ + +import { writeFileSync } from 'fs'; +import { ChangeReport, EndpointChange, FieldChange, groupChangesByDomain } from './changeDetector'; + +export type ChangelogFormat = 'markdown' | 'json' | 'html'; + +export interface ChangelogOptions { + format?: ChangelogFormat; + output: string; + breakingOnly?: boolean; +} + +/** + * Changelog 생성 + */ +export function generateChangelog(report: ChangeReport, options: ChangelogOptions): void { + const format = options.format || 'markdown'; + + let content: string; + + switch (format) { + case 'markdown': + content = generateMarkdown(report, options.breakingOnly); + break; + case 'json': + content = generateJson(report, options.breakingOnly); + break; + case 'html': + content = generateHtml(report, options.breakingOnly); + break; + default: + throw new Error(`Unsupported format: ${format}`); + } + + writeFileSync(options.output, content, 'utf-8'); + console.log(`✅ Changelog generated: ${options.output}`); +} + +/** + * Markdown 형식 생성 + */ +function generateMarkdown(report: ChangeReport, breakingOnly?: boolean): string { + const lines: string[] = []; + + lines.push('# API Changelog\n'); + lines.push(`**Generated**: ${new Date(report.timestamp).toLocaleString()}\n`); + + // 요약 + lines.push('## 📊 Summary\n'); + lines.push('| Type | Count |'); + lines.push('|------|-------|'); + lines.push(`| ✨ Added | ${report.summary.added} |`); + lines.push(`| 🗑️ Removed | ${report.summary.removed} |`); + lines.push(`| 🔄 Modified | ${report.summary.modified} |`); + lines.push(`| ⚠️ **Breaking Changes** | **${report.summary.breaking}** |\n`); + + // 변경사항 필터링 + let changes = report.changes; + if (breakingOnly) { + changes = changes.filter((c) => c.severity === 'breaking'); + } + + if (changes.length === 0) { + lines.push('_No changes detected._\n'); + return lines.join('\n'); + } + + // Breaking changes 섹션 + const breakingChanges = changes.filter((c) => c.severity === 'breaking'); + if (breakingChanges.length > 0) { + lines.push('## ⚠️ Breaking Changes\n'); + lines.push('> **주의**: 이 변경사항들은 기존 코드를 깨뜨릴 수 있습니다!\n'); + + for (const change of breakingChanges) { + lines.push(`#### ⚠️ \`${change.method} ${change.path}\`\n`); + + if (change.changes && change.changes.length > 0) { + lines.push('**변경사항**:\n'); + for (const fc of change.changes) { + lines.push(`- ${formatFieldChange(fc)}`); + } + lines.push(''); + + // 마이그레이션 가이드 + lines.push('**마이그레이션 가이드**:\n'); + lines.push('```typescript'); + lines.push('// Before'); + for (const fc of change.changes) { + if (fc.type === 'removed') { + lines.push(`// ${fc.path} // ❌ This field no longer exists`); + } else if (fc.type === 'type-changed') { + lines.push(`// ${fc.path}: ${fc.oldValue}`); + } + } + lines.push(''); + lines.push('// After'); + for (const fc of change.changes) { + if (fc.type === 'type-changed') { + lines.push(`// ${fc.path}: ${fc.newValue} // ⚠️ Type changed!`); + } + } + lines.push('```\n'); + } else { + lines.push(`${change.description}\n`); + } + } + } + + // 도메인별로 그룹화 + const grouped = groupChangesByDomain(changes); + + for (const [domain, domainChanges] of grouped.entries()) { + lines.push(`## 📁 ${domain.charAt(0).toUpperCase() + domain.slice(1)}\n`); + + // Added + const added = domainChanges.filter((c) => c.type === 'added'); + if (added.length > 0) { + lines.push('### ✨ Added\n'); + for (const change of added) { + lines.push(`#### ✨ \`${change.method} ${change.path}\`\n`); + lines.push(`${change.description}\n`); + } + } + + // Modified (non-breaking) + const modified = domainChanges.filter( + (c) => c.type === 'modified' && c.severity !== 'breaking' + ); + if (modified.length > 0) { + lines.push('### 🔄 Modified\n'); + for (const change of modified) { + lines.push(`#### 🔄 \`${change.method} ${change.path}\`\n`); + + if (change.changes && change.changes.length > 0) { + lines.push('**변경사항**:\n'); + for (const fc of change.changes) { + lines.push(`- ${formatFieldChange(fc)}`); + } + lines.push(''); + } + } + } + + // Removed + const removed = domainChanges.filter((c) => c.type === 'removed'); + if (removed.length > 0) { + lines.push('### 🗑️ Removed\n'); + for (const change of removed) { + lines.push(`#### 🗑️ \`${change.method} ${change.path}\`\n`); + lines.push(`${change.description}\n`); + } + } + } + + return lines.join('\n'); +} + +/** + * JSON 형식 생성 + */ +function generateJson(report: ChangeReport, breakingOnly?: boolean): string { + let changes = report.changes; + if (breakingOnly) { + changes = changes.filter((c) => c.severity === 'breaking'); + } + + return JSON.stringify( + { + ...report, + changes, + }, + null, + 2 + ); +} + +/** + * HTML 형식 생성 + */ +function generateHtml(report: ChangeReport, breakingOnly?: boolean): string { + let changes = report.changes; + if (breakingOnly) { + changes = changes.filter((c) => c.severity === 'breaking'); + } + + const html = `<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>API Changelog + + + +
+

🔍 API Changelog

+
Generated: ${new Date(report.timestamp).toLocaleString()}
+ +
+
+

✨ Added

+
${report.summary.added}
+
+
+

🔄 Modified

+
${report.summary.modified}
+
+
+

🗑️ Removed

+
${report.summary.removed}
+
+
+

⚠️ Breaking

+
${report.summary.breaking}
+
+
+ + ${generateHtmlChanges(changes)} +
+ +`; + + return html; +} + +/** + * HTML 변경사항 섹션 생성 + */ +function generateHtmlChanges(changes: EndpointChange[]): string { + const grouped = groupChangesByDomain(changes); + const sections: string[] = []; + + for (const [domain, domainChanges] of grouped.entries()) { + sections.push(`
`); + sections.push(`

📁 ${domain.charAt(0).toUpperCase() + domain.slice(1)}

`); + + for (const change of domainChanges) { + const className = change.severity === 'breaking' ? 'breaking' : change.type; + sections.push(`
`); + sections.push(`
`); + sections.push( + `${change.method}` + ); + sections.push(`${change.path}`); + sections.push(`
`); + + if (change.changes && change.changes.length > 0) { + sections.push(`
`); + for (const fc of change.changes) { + const fcClass = + fc.type === 'added' ? 'added' : fc.type === 'removed' ? 'removed' : 'modified'; + sections.push(`
`); + sections.push(formatFieldChange(fc)); + sections.push(`
`); + } + sections.push(`
`); + } + + sections.push(`
`); + } + + sections.push(`
`); + } + + return sections.join('\n'); +} + +/** + * 필드 변경사항 포맷팅 + */ +function formatFieldChange(fc: FieldChange): string { + switch (fc.type) { + case 'added': + return `✨ Added: ${fc.path} (${fc.newValue})`; + case 'removed': + return `🗑️ Removed: ${fc.path} (was ${fc.oldValue})`; + case 'type-changed': + return `🔄 Type changed: ${fc.path} from ${fc.oldValue} to ${fc.newValue}`; + case 'required-changed': + return `📝 Required changed: ${fc.path}`; + default: + return `Modified: ${fc.path}`; + } +} + +/** + * CLI 콘솔 출력용 포맷팅 + */ +export function formatConsoleOutput(report: ChangeReport, breakingOnly?: boolean): string { + const lines: string[] = []; + + lines.push('\n🔍 API Changes Detected\n'); + + // 요약 + lines.push('📊 Summary:'); + lines.push(` ✨ Added: ${report.summary.added}`); + lines.push(` 🗑️ Removed: ${report.summary.removed}`); + lines.push(` 🔄 Modified: ${report.summary.modified}`); + if (report.summary.breaking > 0) { + lines.push(` ⚠️ **BREAKING CHANGES**: ${report.summary.breaking}`); + } + lines.push(''); + + // 변경사항 필터링 + let changes = report.changes; + if (breakingOnly) { + changes = changes.filter((c) => c.severity === 'breaking'); + } + + if (changes.length === 0) { + lines.push('No changes detected.'); + return lines.join('\n'); + } + + lines.push('📝 Detailed Changes:\n'); + + // Breaking changes 먼저 + const breaking = changes.filter((c) => c.severity === 'breaking'); + if (breaking.length > 0) { + lines.push('⚠️ BREAKING CHANGES:'); + for (const change of breaking) { + lines.push(` ${change.method.padEnd(6)} ${change.path}`); + if (change.changes) { + for (const fc of change.changes) { + const symbol = fc.type === 'removed' ? '-' : fc.type === 'added' ? '+' : '~'; + lines.push(` ${symbol} ${fc.path}${formatFieldChangeShort(fc)}`); + } + } + } + lines.push(''); + } + + // Added + const added = changes.filter((c) => c.type === 'added'); + if (added.length > 0) { + lines.push('✨ Added:'); + for (const change of added) { + lines.push(` ${change.method.padEnd(6)} ${change.path}`); + } + lines.push(''); + } + + // Modified (non-breaking) + const modified = changes.filter((c) => c.type === 'modified' && c.severity !== 'breaking'); + if (modified.length > 0) { + lines.push('🔄 Modified:'); + for (const change of modified) { + lines.push(` ${change.method.padEnd(6)} ${change.path}`); + if (change.changes) { + for (const fc of change.changes) { + const symbol = fc.type === 'removed' ? '-' : fc.type === 'added' ? '+' : '~'; + lines.push(` ${symbol} ${fc.path}${formatFieldChangeShort(fc)}`); + } + } + } + lines.push(''); + } + + // Removed + const removed = changes.filter((c) => c.type === 'removed'); + if (removed.length > 0) { + lines.push('🗑️ Removed:'); + for (const change of removed) { + lines.push(` ${change.method.padEnd(6)} ${change.path}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * 필드 변경사항 짧은 포맷 + */ +function formatFieldChangeShort(fc: FieldChange): string { + if (fc.type === 'type-changed') { + return ` (${fc.oldValue} → ${fc.newValue})`; + } + return ''; +} diff --git a/packages/bruno-api-typescript/src/generator/apiClientGenerator.ts b/packages/bruno-api-typescript/src/generator/apiClientGenerator.ts new file mode 100644 index 00000000..229d0d8b --- /dev/null +++ b/packages/bruno-api-typescript/src/generator/apiClientGenerator.ts @@ -0,0 +1,121 @@ +/** + * Axios API 클라이언트 생성기 + * Bruno 파일로부터 axios API 호출 함수 생성 + */ + +import { ParsedBrunoFile } from '../parser/bruParser'; +import { extractJsonFromDocs } from '../parser/bruParser'; +import { generateTypeScriptInterface, urlToFunctionName, functionNameToTypeName, toCamelCase } from './typeGenerator'; + +export interface ApiFunction { + name: string; + method: string; + url: string; + responseType: string; + requestType?: string; + hasParams: boolean; + hasBody: boolean; +} + +/** + * Bruno 파일로부터 API 함수 정보 추출 + */ +export function extractApiFunction(parsed: ParsedBrunoFile, filePath: string): ApiFunction | null { + const { http, meta } = parsed; + + if (!http.method || !http.url) { + return null; + } + + // .bru 파일명에서 함수명 생성 + let fileName = filePath.split('/').pop()?.replace('.bru', '') || ''; + + // 한글명 [영문키] 패턴 추출: 멘토 목록 조회 [mentor-list] → mentor-list + const bracketPattern = /\[([^\]]+)\]/; + const bracketMatch = fileName.match(bracketPattern); + if (bracketMatch) { + fileName = bracketMatch[1].trim(); // 대괄호 안의 영문키만 사용 + } + + const baseFunctionName = toCamelCase(fileName); + // HTTP 메서드 prefix 추가: signOut → postSignOut + const methodPrefix = http.method.toLowerCase(); + const functionName = `${methodPrefix}${baseFunctionName.charAt(0).toUpperCase()}${baseFunctionName.slice(1)}`; + const responseType = functionNameToTypeName(baseFunctionName); + + // URL에 파라미터가 있는지 확인 + const hasParams = http.url.includes(':') || http.url.includes('{'); + + // POST, PUT, PATCH는 body를 가질 수 있음 + const hasBody = ['POST', 'PUT', 'PATCH'].includes(http.method.toUpperCase()); + + return { + name: functionName, + method: http.method.toUpperCase(), + url: http.url, + responseType, + hasParams, + hasBody, + }; +} + +/** + * API 함수 코드 생성 + */ +export function generateApiFunction(apiFunc: ApiFunction, domain: string): string { + const { name, method, url, responseType, hasParams, hasBody } = apiFunc; + + const lines: string[] = []; + + // 파라미터 인터페이스 생성 + const paramsList: string[] = []; + const urlParams: string[] = []; + + // URL 파라미터 추출 + const urlParamMatches = url.matchAll(/:(\w+)|\{(\w+)\}/g); + for (const match of urlParamMatches) { + const paramName = match[1] || match[2]; + urlParams.push(paramName); + paramsList.push(`${paramName}: string | number`); + } + + // Query 파라미터 + if (method === 'GET') { + paramsList.push('params?: Record'); + } + + // Body 파라미터 + if (hasBody) { + paramsList.push(`data?: ${responseType.replace('Response', 'Request')}`); + } + + // 함수 시그니처 + const paramsStr = paramsList.length > 0 ? `{ ${paramsList.join(', ')} }` : ''; + const paramsType = paramsList.length > 0 ? `params: ${paramsStr}` : ''; + + // URL 생성 로직 + let urlExpression = `\`${url}\``; + for (const param of urlParams) { + urlExpression = urlExpression.replace(`:${param}`, `\${params.${param}}`); + urlExpression = urlExpression.replace(`{${param}}`, `\${params.${param}}`); + } + + // 함수 생성 + lines.push(`const ${name} = async (${paramsType}): Promise<${responseType}> => {`); + + const configParts: string[] = []; + if (method === 'GET' && paramsList.some(p => p.includes('params?'))) { + configParts.push('params: params?.params'); + } + + const configStr = configParts.length > 0 ? `, { ${configParts.join(', ')} }` : ''; + const bodyStr = hasBody ? ', params?.data' : ''; + + lines.push(` const res = await axiosInstance.${method.toLowerCase()}<${responseType}>(`); + lines.push(` ${urlExpression}${bodyStr}${configStr}`); + lines.push(` );`); + lines.push(` return res.data;`); + lines.push(`};`); + + return lines.join('\n'); +} diff --git a/packages/bruno-api-typescript/src/generator/apiDefinitionGenerator.ts b/packages/bruno-api-typescript/src/generator/apiDefinitionGenerator.ts new file mode 100644 index 00000000..d9ed2795 --- /dev/null +++ b/packages/bruno-api-typescript/src/generator/apiDefinitionGenerator.ts @@ -0,0 +1,137 @@ +/** + * API Definition Generator + * Generates typed API metadata definitions from Bruno files + */ + +import { ParsedBrunoFile } from '../parser/bruParser'; +import { ApiFunction } from './apiClientGenerator'; +import { toCamelCase } from './typeGenerator'; + +export interface ApiDefinitionMeta { + method: string; + path: string; + pathParams: string; + queryParams: string; + body: string; + response: string; +} + +function toPascalCase(value: string): string { + const camel = toCamelCase(value); + return `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; +} + +/** + * Extract URL parameters from a path + */ +function extractPathParams(url: string): string[] { + const params: string[] = []; + + let processedUrl = url.replace(/\{\{URL\}\}/g, ''); + + const brunoVarPattern = /\{\{([^}]+)\}\}/g; + let match; + while ((match = brunoVarPattern.exec(processedUrl)) !== null) { + const varName = match[1]; + if (varName === 'URL') continue; + const camelVarName = varName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + if (!params.includes(camelVarName)) { + params.push(camelVarName); + } + } + + const urlParamMatches = processedUrl.matchAll(/:(\w+)|\{(\w+)\}/g); + for (const match of urlParamMatches) { + const paramName = match[1] || match[2]; + if (!params.includes(paramName)) { + params.push(paramName); + } + } + + return params; +} + +/** + * Generate API definition metadata for a single API function + */ +export function generateApiDefinitionMeta( + apiFunc: ApiFunction, + parsed: ParsedBrunoFile +): ApiDefinitionMeta { + const { method, url, responseType } = apiFunc; + const pathParams = extractPathParams(url); + + const pathParamsType = pathParams.length > 0 + ? `{ ${pathParams.map(p => `${p}: string | number`).join('; ')} }` + : 'Record'; + + const queryParamsType = method === 'GET' + ? 'Record' + : 'Record'; + + const requestType = responseType.replace('Response', 'Request'); + const hasBody = ['POST', 'PUT', 'PATCH'].includes(method) && Boolean(parsed.body?.content?.trim()); + const bodyType = hasBody ? requestType : 'Record'; + + return { + method, + path: url, + pathParams: pathParamsType, + queryParams: queryParamsType, + body: bodyType, + response: responseType, + }; +} + +/** + * Generate apiDefinitions.ts file content + */ +export function generateApiDefinitionsFile( + apiFunctions: Array<{ apiFunc: ApiFunction; parsed: ParsedBrunoFile }>, + domain: string +): string { + const lines: string[] = []; + + const typeNames = new Set(); + + for (const { apiFunc, parsed } of apiFunctions) { + const meta = generateApiDefinitionMeta(apiFunc, parsed); + + if (meta.response !== 'void') { + typeNames.add(meta.response); + } + if (meta.body !== 'Record') { + typeNames.add(meta.body); + } + } + + const sortedTypes = Array.from(typeNames).sort(); + if (sortedTypes.length > 0) { + lines.push(`import type { ${sortedTypes.join(', ')} } from './api';`); + lines.push(''); + } + + const definitionsConstName = `${toCamelCase(domain)}ApiDefinitions`; + const definitionsTypeName = `${toPascalCase(domain)}ApiDefinitions`; + lines.push(`export const ${definitionsConstName} = {`); + + for (const { apiFunc, parsed } of apiFunctions) { + const meta = generateApiDefinitionMeta(apiFunc, parsed); + + lines.push(` ${apiFunc.name}: {`); + lines.push(` method: '${meta.method}' as const,`); + lines.push(` path: '${meta.path}' as const,`); + lines.push(` pathParams: {} as ${meta.pathParams},`); + lines.push(` queryParams: {} as ${meta.queryParams},`); + lines.push(` body: {} as ${meta.body},`); + lines.push(` response: {} as ${meta.response},`); + lines.push(` },`); + } + + lines.push('} as const;'); + lines.push(''); + + lines.push(`export type ${definitionsTypeName} = typeof ${definitionsConstName};`); + + return lines.join('\n'); +} diff --git a/packages/bruno-api-typescript/src/generator/apiFactoryGenerator.ts b/packages/bruno-api-typescript/src/generator/apiFactoryGenerator.ts new file mode 100644 index 00000000..110df6e8 --- /dev/null +++ b/packages/bruno-api-typescript/src/generator/apiFactoryGenerator.ts @@ -0,0 +1,221 @@ +/** + * API 팩토리 생성기 + * 도메인별로 모든 API 함수를 객체로 묶어서 export + */ + +import { ParsedBrunoFile, extractJsonFromDocs } from '../parser/bruParser'; +import { ApiFunction } from './apiClientGenerator'; +import { generateTypeScriptInterface, toCamelCase, functionNameToTypeName } from './typeGenerator'; + +/** + * 빈 인터페이스를 Record 타입으로 변환 + */ +function convertEmptyInterfaceToType(content: string, typeName: string): string { + // 빈 인터페이스 패턴: export interface TypeName { } + const emptyInterfacePattern = new RegExp(`export interface ${typeName}\\s*\\{\\s*\\}`); + if (emptyInterfacePattern.test(content)) { + return `export type ${typeName} = Record;`; + } + return content; +} + +/** + * 팩토리용 API 함수 코드 생성 (객체 속성으로 사용) + */ +function generateApiFunctionForFactory(apiFunc: ApiFunction, parsed: ParsedBrunoFile): string { + const { name, method, url, responseType, hasParams, hasBody } = apiFunc; + + const lines: string[] = []; + + // 파라미터 인터페이스 생성 + const paramsList: string[] = []; + const urlParams: string[] = []; + + // URL 생성 로직 + // 1. {{URL}} 제거 + let processedUrl = url.replace(/\{\{URL\}\}/g, ''); + + // 2. 브루노 변수 {{변수명}} 처리 (URL 파라미터로 변환) + const brunoVarPattern = /\{\{([^}]+)\}\}/g; + let match; + const processedBrunoVars = new Set(); + + while ((match = brunoVarPattern.exec(processedUrl)) !== null) { + const varName = match[1]; + // URL 변수는 제외 + if (varName === 'URL') continue; + + const camelVarName = varName.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + if (!urlParams.includes(camelVarName) && !processedBrunoVars.has(camelVarName)) { + urlParams.push(camelVarName); + paramsList.push(`${camelVarName}: string | number`); + processedBrunoVars.add(camelVarName); + } + processedUrl = processedUrl.replace(match[0], `\${params.${camelVarName}}`); + } + + // 3. 기존 URL 파라미터 패턴 처리 (:param, {param}) + const urlParamMatches = processedUrl.matchAll(/:(\w+)|\{(\w+)\}/g); + for (const match of urlParamMatches) { + const paramName = match[1] || match[2]; + if (!urlParams.includes(paramName) && !processedBrunoVars.has(paramName)) { + urlParams.push(paramName); + paramsList.push(`${paramName}: string | number`); + } + processedUrl = processedUrl.replace(`:${paramName}`, `\${params.${paramName}}`); + processedUrl = processedUrl.replace(`{${paramName}}`, `\${params.${paramName}}`); + } + + // Query 파라미터 + if (method === 'GET') { + paramsList.push('params?: Record'); + } + + // Body 파라미터 + if (hasBody) { + paramsList.push(`data?: ${responseType.replace('Response', 'Request')}`); + } + + // 함수 시그니처 + const paramsStr = paramsList.length > 0 ? `{ ${paramsList.join(', ')} }` : ''; + const paramsType = paramsList.length > 0 ? `params: ${paramsStr}` : ''; + + let urlExpression = `\`${processedUrl}\``; + + // 함수 생성 (화살표 함수로) + lines.push(`async (${paramsType}): Promise<${responseType}> => {`); + + const configParts: string[] = []; + if (method === 'GET' && paramsList.some(p => p.includes('params?'))) { + configParts.push('params: params?.params'); + } + + const configStr = configParts.length > 0 ? `, { ${configParts.join(', ')} }` : ''; + const bodyStr = hasBody ? ', params?.data' : ''; + + lines.push(` const res = await axiosInstance.${method.toLowerCase()}<${responseType}>(`); + lines.push(` ${urlExpression}${bodyStr}${configStr}`); + lines.push(` );`); + lines.push(` return res.data;`); + lines.push(`}`); + + return lines.join('\n'); +} + +/** + * 도메인별 API 팩토리 파일 생성 + */ +export function generateApiFactory( + apiFunctions: Array<{ apiFunc: ApiFunction; parsed: ParsedBrunoFile }>, + domain: string, + axiosInstancePath: string +): string { + const lines: string[] = [ + `import { axiosInstance } from "${axiosInstancePath}";`, + '', + ]; + + // 모든 타입 정의 수집 + const typeDefinitions = new Set(); + + for (const { apiFunc, parsed } of apiFunctions) { + const { responseType } = apiFunc; + const requestType = responseType.replace('Response', 'Request'); + + // Response 타입 생성 + if (parsed.docs) { + const jsonData = extractJsonFromDocs(parsed.docs); + if (jsonData !== null && jsonData !== undefined) { + // 빈 객체 체크 + if (typeof jsonData === 'object' && !Array.isArray(jsonData) && Object.keys(jsonData).length === 0) { + // 빈 객체인 경우 Record 타입 생성 + const emptyType = `export type ${responseType} = Record;`; + if (!typeDefinitions.has(emptyType)) { + typeDefinitions.add(emptyType); + lines.push(emptyType); + lines.push(''); + } + } else { + const typeDefs = generateTypeScriptInterface(jsonData, responseType); + for (const typeDef of typeDefs) { + // 빈 인터페이스 체크 및 변환 + const processedContent = convertEmptyInterfaceToType(typeDef.content, responseType); + if (!typeDefinitions.has(processedContent)) { + typeDefinitions.add(processedContent); + lines.push(processedContent); + lines.push(''); + } + } + } + } else { + // JSON 추출 실패 시 void 타입 생성 + const defaultType = `export type ${responseType} = void;`; + if (!typeDefinitions.has(defaultType)) { + typeDefinitions.add(defaultType); + lines.push(defaultType); + lines.push(''); + } + } + } else { + // Response가 없으면 void 타입 사용 + const defaultType = `export type ${responseType} = void;`; + if (!typeDefinitions.has(defaultType)) { + typeDefinitions.add(defaultType); + lines.push(defaultType); + lines.push(''); + } + } + + // Request 타입 생성 (POST, PUT, PATCH인 경우) + if (['POST', 'PUT', 'PATCH'].includes(apiFunc.method)) { + if (parsed.body?.content) { + try { + const bodyData = JSON.parse(parsed.body.content); + const requestTypeDefs = generateTypeScriptInterface(bodyData, requestType); + for (const typeDef of requestTypeDefs) { + if (!typeDefinitions.has(typeDef.content)) { + typeDefinitions.add(typeDef.content); + lines.push(typeDef.content); + lines.push(''); + } + } + } catch { + // Request body 파싱 실패시 Record 사용 + const defaultRequestType = `export type ${requestType} = Record;`; + if (!typeDefinitions.has(defaultRequestType)) { + typeDefinitions.add(defaultRequestType); + lines.push(defaultRequestType); + lines.push(''); + } + } + } else { + // Request body가 없으면 Record 사용 + const defaultRequestType = `export type ${requestType} = Record;`; + if (!typeDefinitions.has(defaultRequestType)) { + typeDefinitions.add(defaultRequestType); + lines.push(defaultRequestType); + lines.push(''); + } + } + } + } + + // 팩토리 객체 생성 + const factoryName = `${toCamelCase(domain)}Api`; + lines.push(`export const ${factoryName} = {`); + + for (const { apiFunc, parsed } of apiFunctions) { + const functionCode = generateApiFunctionForFactory(apiFunc, parsed); + // 들여쓰기 추가 (2칸) + const indentedCode = functionCode.split('\n').map((line, index) => { + if (index === 0) return ` ${apiFunc.name}: ${line}`; + return ` ${line}`; + }).join('\n'); + lines.push(indentedCode + ','); + lines.push(''); + } + + lines.push(`};`); + + return lines.join('\n'); +} diff --git a/packages/bruno-api-typescript/src/generator/brunoHashCache.ts b/packages/bruno-api-typescript/src/generator/brunoHashCache.ts new file mode 100644 index 00000000..fa14e928 --- /dev/null +++ b/packages/bruno-api-typescript/src/generator/brunoHashCache.ts @@ -0,0 +1,173 @@ +import { createHash } from 'crypto'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +export interface HashEntry { + hash: string; + lastGenerated: string; + outputFiles: string[]; +} + +export interface HashCache { + version: string; + hashes: Record; +} + +export class BrunoHashCache { + private cachePath: string; + private cacheDir: string; + private cache: HashCache; + + constructor(outputDir: string) { + this.cacheDir = join(outputDir, '.bruno-cache'); + this.cachePath = join(this.cacheDir, 'hashes.json'); + this.cache = { version: '1.0', hashes: {} }; + } + + /** + * 캐시 파일 로드 + */ + load(): void { + if (existsSync(this.cachePath)) { + try { + const content = readFileSync(this.cachePath, 'utf-8'); + this.cache = JSON.parse(content); + } catch (error) { + console.warn('⚠️ Failed to load hash cache, using empty cache'); + this.cache = { version: '1.0', hashes: {} }; + } + } else { + this.cache = { version: '1.0', hashes: {} }; + } + } + + /** + * 캐시 파일 저장 + */ + save(): void { + try { + mkdirSync(this.cacheDir, { recursive: true }); + writeFileSync( + this.cachePath, + JSON.stringify(this.cache, null, 2), + 'utf-8' + ); + } catch (error) { + console.error('❌ Failed to save hash cache:', error); + } + } + + /** + * Bruno 파일의 SHA-256 해시 계산 + * 개행 문자를 정규화하여 OS 간 차이를 방지합니다. + * + * @param brunoFilePath - Bruno 파일의 절대 경로 + * @returns 64자 hex string SHA-256 해시 + */ + calculateHash(brunoFilePath: string): string { + const content = readFileSync(brunoFilePath, 'utf-8'); + // 개행 문자 정규화 (Windows/Unix 호환성) + const normalized = content.replace(/\r\n/g, '\n').trim(); + return createHash('sha256').update(normalized, 'utf-8').digest('hex'); + } + + /** + * 이전 해시 조회 + */ + getHash(brunoFilePath: string): string | null { + return this.cache.hashes[brunoFilePath]?.hash || null; + } + + /** + * 해시 저장 + */ + setHash(brunoFilePath: string, hash: string, outputFiles: string[]): void { + this.cache.hashes[brunoFilePath] = { + hash, + lastGenerated: new Date().toISOString(), + outputFiles, + }; + } + + /** + * Bruno 파일 변경 여부 확인 + */ + hasChanged(brunoFilePath: string): boolean { + // 1. 이전 해시 조회 + const previousHash = this.getHash(brunoFilePath); + + // 2. 캐시가 없으면 변경됨으로 간주 (첫 실행) + if (!previousHash) { + return true; + } + + // 3. 출력 파일 체크 (outputFiles가 빈 배열인 경우는 파싱 실패로 스킵) + const entry = this.cache.hashes[brunoFilePath]; + if (entry && entry.outputFiles && entry.outputFiles.length > 0) { + // 출력 파일이 있는 경우에만 존재 여부 확인 + if (!this.hasOutputFile(brunoFilePath)) { + return true; // 파일이 삭제됨 + } + } + // outputFiles가 빈 배열이면 파싱 실패 케이스이므로 파일 체크 스킵 + + // 4. 현재 해시 계산 및 비교 + const currentHash = this.calculateHash(brunoFilePath); + return currentHash !== previousHash; + } + + /** + * 출력 파일 존재 여부 확인 + */ + hasOutputFile(brunoFilePath: string): boolean { + const entry = this.cache.hashes[brunoFilePath]; + if (!entry || !entry.outputFiles || entry.outputFiles.length === 0) { + return false; + } + // 하나라도 존재하면 true + return entry.outputFiles.some(f => existsSync(f)); + } + + /** + * 캐시 초기화 + */ + clear(): void { + this.cache = { version: '1.0', hashes: {} }; + } + + /** + * 삭제된 Bruno 파일의 캐시 정리 + */ + cleanup(): void { + const brunoFiles = Object.keys(this.cache.hashes); + let cleanedCount = 0; + + for (const brunoPath of brunoFiles) { + if (!existsSync(brunoPath)) { + delete this.cache.hashes[brunoPath]; + cleanedCount++; + } + } + + if (cleanedCount > 0) { + console.log(`🗑️ Cleaned up ${cleanedCount} deleted file(s) from cache`); + } + } + + /** + * 캐시 경로 반환 (로깅용) + */ + getCachePath(): string { + return this.cachePath; + } + + /** + * 통계 정보 반환 + */ + getStats(): { total: number; cached: number } { + return { + total: 0, // generateHooks에서 설정 + cached: Object.keys(this.cache.hashes).length, + }; + } +} diff --git a/packages/bruno-api-typescript/src/generator/index.ts b/packages/bruno-api-typescript/src/generator/index.ts new file mode 100644 index 00000000..b0cb3d6c --- /dev/null +++ b/packages/bruno-api-typescript/src/generator/index.ts @@ -0,0 +1,326 @@ +/** + * API 생성 메인 로직 + * Bruno 파일들을 읽어서 API 팩토리 및 타입 정의를 생성 + */ + +import { readdirSync, statSync, mkdirSync, writeFileSync, existsSync } from 'fs'; +import { join, relative, dirname } from 'path'; +import { parseBrunoFile } from '../parser/bruParser'; +import { extractApiFunction } from './apiClientGenerator'; +import { generateMSWHandler, generateDomainHandlersIndex, generateMSWIndex } from './mswGenerator'; +import { generateApiFactory } from './apiFactoryGenerator'; +import { generateApiDefinitionsFile } from './apiDefinitionGenerator'; +import { BrunoHashCache } from './brunoHashCache'; +import { toCamelCase } from './typeGenerator'; + +export interface GenerateHooksOptions { + brunoDir: string; + outputDir: string; + axiosInstancePath?: string; + mswOutputDir?: string; + force?: boolean; +} + +/** + * Bruno 디렉토리에서 모든 .bru 파일 찾기 + */ +function findBrunoFiles(dir: string): string[] { + const files: string[] = []; + + function traverse(currentDir: string) { + const entries = readdirSync(currentDir); + + for (const entry of entries) { + const fullPath = join(currentDir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + traverse(fullPath); + } else if (entry.endsWith('.bru') && entry !== 'collection.bru') { + // collection.bru는 메타데이터 파일이므로 제외 + files.push(fullPath); + } + } + } + + traverse(dir); + return files; +} + +/** + * 파일 경로에서 도메인 추출 + * - "Solid Connection" 최상단 폴더를 제거하고 대괄호 패턴이 있는 첫 번째 폴더를 도메인으로 인식 + * - "숫자) 한글명 [영문키]" 형식: 1) 어드민 [Admin] → Admin + * - "한글명 [영문키]" 형식: 사용자 [users] → users + */ +function extractDomain(filePath: string, brunoDir: string): string { + const relativePath = relative(brunoDir, filePath); + const parts = relativePath.split('/'); + + // "Solid Connection" 폴더 제거 + const filteredParts = parts.filter(part => part !== 'Solid Connection'); + + // 대괄호 패턴이 있는 첫 번째 폴더 찾기 + const bracketPattern = /\[([^\]]+)\]/; + for (const part of filteredParts) { + const bracketMatch = part.match(bracketPattern); + if (bracketMatch) { + return bracketMatch[1].trim(); // 대괄호 안의 영문키 + } + } + + // 패턴이 없으면 파일이 있는 폴더명 사용 (마지막에서 두 번째) + return filteredParts[filteredParts.length - 2] || filteredParts[0] || 'default'; +} + +/** + * API 팩토리 및 타입 정의 생성 + */ +export async function generateHooks(options: GenerateHooksOptions): Promise { + const { brunoDir, outputDir, axiosInstancePath = '@/utils/axiosInstance', mswOutputDir, force = false } = options; + + const hashCache = new BrunoHashCache(outputDir); + hashCache.load(); + + console.log('🔍 Searching for .bru files...'); + const brunoFiles = findBrunoFiles(brunoDir); + console.log(`✅ Found ${brunoFiles.length} .bru files`); + + if (brunoFiles.length === 0) { + console.log('⚠️ No .bru files found'); + return; + } + + // 변경된 파일 필터링 + let changedFiles: string[] = []; + const skippedFiles: string[] = []; + + if (force) { + console.log('🔨 Force mode: regenerating all hooks'); + changedFiles = brunoFiles; + } else { + for (const filePath of brunoFiles) { + if (hashCache.hasChanged(filePath)) { + changedFiles.push(filePath); + } else { + skippedFiles.push(filePath); + } + } + } + + console.log(`📊 Changed: ${changedFiles.length}, Skipped: ${skippedFiles.length}`); + + if (changedFiles.length === 0) { + console.log('✅ All API clients are up to date!'); + return; + } + + const parsedChangedFiles = changedFiles.map(filePath => { + try { + const parsed = parseBrunoFile(filePath); + const domain = extractDomain(filePath, brunoDir); + return { filePath, parsed, domain }; + } catch (error) { + console.error(`❌ Error parsing ${filePath}:`, error); + return null; + } + }).filter(Boolean) as Array<{ filePath: string; parsed: any; domain: string }>; + + const allParsedFiles = brunoFiles.map(filePath => { + try { + const parsed = parseBrunoFile(filePath); + const domain = extractDomain(filePath, brunoDir); + return { filePath, parsed, domain }; + } catch (error) { + return null; + } + }).filter(Boolean) as Array<{ filePath: string; parsed: any; domain: string }>; + + console.log(`📝 Parsed ${parsedChangedFiles.length} changed files successfully`); + + mkdirSync(outputDir, { recursive: true }); + + const affectedDomains = new Set(parsedChangedFiles.map(f => f.domain)); + + const domainApiFunctions = new Map>(); + const domainDirs = new Set(); + + for (const { filePath, parsed, domain } of allParsedFiles) { + const apiFunc = extractApiFunction(parsed, filePath); + if (!apiFunc) { + continue; + } + + const domainDir = join(outputDir, domain); + if (!domainDirs.has(domainDir)) { + mkdirSync(domainDir, { recursive: true }); + domainDirs.add(domainDir); + } + + if (!domainApiFunctions.has(domain)) { + domainApiFunctions.set(domain, []); + } + domainApiFunctions.get(domain)!.push({ apiFunc, parsed }); + } + + console.log('\n🏭 Generating API factories...'); + for (const domain of affectedDomains) { + const domainDir = join(outputDir, domain); + mkdirSync(domainDir, { recursive: true }); + const apiFunctions = domainApiFunctions.get(domain) || []; + const factoryContent = generateApiFactory(apiFunctions, domain, axiosInstancePath); + const factoryPath = join(domainDir, 'api.ts'); + writeFileSync(factoryPath, factoryContent, 'utf-8'); + console.log(`✅ Generated: ${factoryPath}`); + } + + console.log('\n📋 Generating API definitions...'); + for (const domain of affectedDomains) { + const domainDir = join(outputDir, domain); + const apiFunctions = domainApiFunctions.get(domain) || []; + const definitionsContent = generateApiDefinitionsFile(apiFunctions, domain); + const definitionsPath = join(domainDir, 'apiDefinitions.ts'); + writeFileSync(definitionsPath, definitionsContent, 'utf-8'); + console.log(`✅ Generated: ${definitionsPath}`); + } + + for (const { filePath } of parsedChangedFiles) { + const currentHash = hashCache.calculateHash(filePath); + const apiFunc = extractApiFunction(parseBrunoFile(filePath), filePath); + + if (!apiFunc) { + hashCache.setHash(filePath, currentHash, []); + continue; + } + + const domain = extractDomain(filePath, brunoDir); + const domainDir = join(outputDir, domain); + const outputFiles = [ + join(domainDir, 'api.ts'), + join(domainDir, 'apiDefinitions.ts'), + ]; + + hashCache.setHash(filePath, currentHash, outputFiles); + } + + console.log('\n📄 Generating index files...'); + for (const domain of affectedDomains) { + const domainDir = join(outputDir, domain); + const files = readdirSync(domainDir).filter(f => f.endsWith('.ts') && f !== 'index.ts'); + + const indexContent = files + .map(file => { + const name = file.replace('.ts', ''); + if (name === 'api') { + const factoryName = `${toCamelCase(domain)}Api`; + return `export { ${factoryName} } from './api';`; + } + if (name === 'apiDefinitions') { + const camelDomain = toCamelCase(domain); + const definitionsValueName = `${camelDomain}ApiDefinitions`; + const definitionsTypeName = `${camelDomain.charAt(0).toUpperCase()}${camelDomain.slice(1)}ApiDefinitions`; + return `export { ${definitionsValueName}, ${definitionsTypeName} } from './apiDefinitions';`; + } + return `export * from './${name}';`; + }) + .join('\n') + '\n'; + + const indexPath = join(domainDir, 'index.ts'); + writeFileSync(indexPath, indexContent, 'utf-8'); + console.log(`✅ Generated: ${indexPath}`); + } + + hashCache.cleanup(); + hashCache.save(); + console.log(`\n💾 Hash cache saved: ${hashCache.getCachePath()}`); + + console.log('\n✨ All API clients generated successfully!'); + console.log(`\n📂 Output directory: ${outputDir}`); + console.log('\n📚 Usage example:'); + console.log(`import { applicationsApi } from './${relative(process.cwd(), join(outputDir, 'applications'))}';\n`); + console.log(`const data = await applicationsApi.getCompetitors({ params: { page: 1 } });`); + + if (mswOutputDir) { + console.log('\n🎭 Generating MSW handlers...'); + await generateMSWHandlers(parsedChangedFiles, mswOutputDir); + } +} + +/** + * MSW 핸들러 생성 + */ +async function generateMSWHandlers( + parsedFiles: Array<{ filePath: string; parsed: any; domain: string }>, + mswOutputDir: string +): Promise { + // MSW 출력 디렉토리 생성 + mkdirSync(mswOutputDir, { recursive: true }); + + // 도메인별로 핸들러 그룹화 + const domainHandlers = new Map>(); + + for (const { filePath, parsed, domain } of parsedFiles) { + const handler = generateMSWHandler(parsed, filePath, domain); + + // MSW 핸들러는 항상 생성 (docs 없어도 기본 응답 사용) + if (!handler) { + continue; + } + + if (!domainHandlers.has(domain)) { + domainHandlers.set(domain, []); + } + + domainHandlers.get(domain)!.push({ + fileName: handler.fileName, + content: handler.content, + }); + } + + // 도메인별 디렉토리 및 파일 생성 + const domains: string[] = []; + + for (const [domain, handlers] of domainHandlers.entries()) { + domains.push(domain); + + // 도메인 디렉토리 생성 + const domainDir = join(mswOutputDir, domain); + mkdirSync(domainDir, { recursive: true }); + + // 각 핸들러 파일 작성 + const handlerInfos: Array<{ fileName: string; handlerName: string }> = []; + + for (const handler of handlers) { + const handlerPath = join(domainDir, handler.fileName); + writeFileSync(handlerPath, handler.content, 'utf-8'); + console.log(`✅ MSW Generated: ${handlerPath}`); + + handlerInfos.push({ + fileName: handler.fileName, + handlerName: handler.fileName.replace('.ts', ''), + }); + } + + // 도메인별 index 파일 생성 + const domainIndexContent = generateDomainHandlersIndex(domain, handlerInfos); + const domainIndexPath = join(domainDir, 'index.ts'); + writeFileSync(domainIndexPath, domainIndexContent, 'utf-8'); + console.log(`✅ MSW Index Generated: ${domainIndexPath}`); + } + + // 전체 handlers index 파일 생성 + if (domains.length > 0) { + const mswIndexContent = generateMSWIndex(domains); + const mswIndexPath = join(mswOutputDir, 'handlers.ts'); + writeFileSync(mswIndexPath, mswIndexContent, 'utf-8'); + console.log(`✅ MSW Main Index Generated: ${mswIndexPath}`); + + console.log(`\n🎭 MSW handlers generated successfully!`); + console.log(`📂 MSW Output directory: ${mswOutputDir}`); + console.log(`\n📚 Usage example:`); + console.log(`import { handlers } from './${relative(process.cwd(), mswIndexPath).replace('.ts', '')}';\n`); + console.log(`const worker = setupWorker(...handlers);`); + } else { + console.log(`ℹ️ No MSW handlers generated`); + } +} diff --git a/packages/bruno-api-typescript/src/generator/mswGenerator.ts b/packages/bruno-api-typescript/src/generator/mswGenerator.ts new file mode 100644 index 00000000..426ec495 --- /dev/null +++ b/packages/bruno-api-typescript/src/generator/mswGenerator.ts @@ -0,0 +1,181 @@ +/** + * MSW (Mock Service Worker) 핸들러 생성 + * Bruno 파일에서 MSW 핸들러를 자동 생성 + */ + +import { ParsedBrunoFile, extractJsonFromDocs } from '../parser/bruParser'; + +export interface MSWHandler { + domain: string; + fileName: string; + content: string; +} + +/** + * MSW 핸들러 생성 + * 모든 API에 대해 MSW 핸들러 생성 (프론트엔드에서 플래그로 제어) + * docs가 없어도 기본 응답으로 생성 (테스트 용) + */ +export function generateMSWHandler( + parsed: ParsedBrunoFile, + filePath: string, + domain: string +): MSWHandler | null { + const { method, url } = parsed.http; + + // docs 블록에서 JSON 추출 + let responseJson: any = null; + + if (parsed.docs) { + responseJson = extractJsonFromDocs(parsed.docs); + } + + // docs가 없거나 유효하지 않으면 기본 응답 사용 + if (!responseJson) { + // 기본 응답 생성 (테스트 용) + if (method === 'GET') { + responseJson = { message: 'Mock response', data: null }; + } else if (['POST', 'PUT', 'PATCH'].includes(method)) { + responseJson = { message: 'Success', id: 1 }; + } else if (method === 'DELETE') { + responseJson = { message: 'Deleted successfully' }; + } else { + responseJson = { message: 'Mock response' }; + } + } + + const handlerName = generateHandlerName(method, url); + + // MSW 핸들러 코드 생성 + const content = generateHandlerCode(method, url, responseJson); + + return { + domain, + fileName: `${handlerName}.ts`, + content, + }; +} + +/** + * 핸들러 파일명 생성 + */ +function generateHandlerName(method: string, url: string): string { + // URL에서 경로 추출 및 클린업 + const cleanUrl = url + .split('?')[0] // 쿼리 파라미터 제거 + .replace(/^\//, '') // 시작 슬래시 제거 + .replace(/\//g, '-') // 슬래시를 하이픈으로 + .replace(/:/g, '') // 콜론 제거 (path param) + .replace(/\{|\}/g, ''); // 중괄호 제거 + + return `${method.toLowerCase()}-${cleanUrl}`; +} + +/** + * MSW 핸들러 코드 생성 + */ +function generateHandlerCode(method: string, url: string, responseData: any): string { + const httpMethod = method.toLowerCase(); + const normalizedUrl = normalizeUrl(url); + const responseJsonStr = JSON.stringify(responseData, null, 2) + .split('\n') + .map((line, index) => (index === 0 ? line : ` ${line}`)) + .join('\n'); + + return `import { http, HttpResponse } from 'msw'; + +/** + * ${method.toUpperCase()} ${url} + * Auto-generated MSW handler + */ +export const handler = http.${httpMethod}('${normalizedUrl}', () => { + return HttpResponse.json( + ${responseJsonStr} + ); +}); +`; +} + +/** + * URL 정규화 + * :param -> {param} 형식으로 변환 (MSW에서 사용) + */ +function normalizeUrl(url: string): string { + // :param을 :param 형식으로 유지 (MSW는 :param 형식 지원) + return url; +} + +/** + * 도메인별 핸들러 통합 파일 생성 + */ +export function generateDomainHandlersIndex( + domain: string, + handlers: { fileName: string; handlerName: string }[] +): string { + const imports = handlers + .map((h, index) => { + const varName = `handler${index + 1}`; + const importPath = `./${h.fileName.replace('.ts', '')}`; + return `import { handler as ${varName} } from '${importPath}';`; + }) + .join('\n'); + + const exportArray = handlers + .map((_, index) => ` handler${index + 1}`) + .join(',\n'); + + return `${imports} + +/** + * ${domain} domain MSW handlers + * Auto-generated from Bruno files + */ +export const ${domain}Handlers = [ +${exportArray} +]; +`; +} + +/** + * 전체 핸들러 통합 파일 생성 + */ +export function generateMSWIndex(domains: string[]): string { + const imports = domains + .map(domain => `import { ${domain}Handlers } from './${domain}';`) + .join('\n'); + + const exportArray = domains + .map(domain => ` ...${domain}Handlers`) + .join(',\n'); + + return `${imports} + +/** + * All MSW handlers + * Auto-generated from Bruno files + * + * 프론트엔드에서 플래그로 활성/비활성 제어: + * + * 예시 1: 환경 변수로 제어 + * const ENABLE_MSW = process.env.NEXT_PUBLIC_ENABLE_MSW === 'true'; + * export const handlers = ENABLE_MSW ? [ + * ${exportArray} + * ] : []; + * + * 예시 2: 특정 도메인만 활성화 + * export const handlers = [ + * ...authHandlers, // Auth 도메인만 활성화 + * // ...usersHandlers, // Users 도메인 비활성화 + * ]; + * + * 예시 3: 조건부 필터링 + * const enabledDomains = ['Auth', 'Users']; // 활성화할 도메인 목록 + * export const handlers = [ + * ${domains.map(d => `...(enabledDomains.includes('${d}') ? ${d}Handlers : [])`).join(',\n ')} + * ]; + */ +export const handlers = [ +${exportArray} +]; +`; +} diff --git a/packages/bruno-api-typescript/src/generator/typeGenerator.ts b/packages/bruno-api-typescript/src/generator/typeGenerator.ts new file mode 100644 index 00000000..70badd2a --- /dev/null +++ b/packages/bruno-api-typescript/src/generator/typeGenerator.ts @@ -0,0 +1,337 @@ +/** + * TypeScript 타입 생성기 + * Bruno docs 블록의 JSON에서 TypeScript 타입 생성 + */ + +export interface TypeDefinition { + name: string; + content: string; +} + +/** + * JSON 값으로부터 TypeScript 타입 추론 + */ +export function inferTypeScriptType(value: any, typeName: string = 'Unknown', indent: number = 0): string { + if (value === null || value === undefined) { + return 'null'; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return 'any[]'; + } + const itemType = inferTypeScriptType(value[0], `${typeName}Item`, indent); + // 배열 아이템이 객체면 별도 인터페이스로 추출 + if (typeof value[0] === 'object' && !Array.isArray(value[0])) { + return `${typeName}Item[]`; + } + return `${itemType}[]`; + } + + const valueType = typeof value; + + switch (valueType) { + case 'string': + return 'string'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'object': + return generateInterfaceContent(value, indent); + default: + return 'any'; + } +} + +/** + * 객체로부터 인터페이스 내용 생성 + */ +function generateInterfaceContent(obj: Record, indent: number = 0): string { + const indentStr = ' '.repeat(indent); + const properties: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + const type = inferTypeScriptType(value, toPascalCase(key), indent + 1); + properties.push(`${indentStr} ${key}: ${type};`); + } + + return `{\n${properties.join('\n')}\n${indentStr}}`; +} + +/** + * JSON 객체로부터 TypeScript 인터페이스 생성 + */ +export function generateTypeScriptInterface( + json: any, + interfaceName: string +): TypeDefinition[] { + const definitions: TypeDefinition[] = []; + + // 중첩된 타입 추출 (메인 타입 제외) + extractNestedTypes(json, '', definitions, interfaceName, true); + + // 메인 인터페이스 생성 + const properties: string[] = []; + for (const [key, value] of Object.entries(json)) { + let type = getPropertyType(value, toPascalCase(key), interfaceName); + + // 배열인 경우 유니온 타입 확인 + if (Array.isArray(value) && value.length > 0) { + const types = new Set(); + for (const item of value) { + if (item === null || item === undefined) { + types.add('null'); + } else { + const itemType = getPropertyType(item, toPascalCase(key), `${interfaceName}${toPascalCase(key)}Item`); + types.add(itemType); + } + } + const typeArray = Array.from(types); + if (typeArray.length > 1) { + type = `(${typeArray.join(' | ')})[]`; + } + } else if (value === null || value === undefined) { + // 단일 null 값은 그대로 유지 (유니온 타입 생성 불가) + type = 'null'; + } + + properties.push(` ${key}: ${type};`); + } + + // 빈 인터페이스인 경우 Record 타입으로 생성 + if (properties.length === 0) { + const emptyType = `export type ${interfaceName} = Record;`; + definitions.push({ name: interfaceName, content: emptyType }); + } else { + const mainInterface = `export interface ${interfaceName} {\n${properties.join('\n')}\n}`; + definitions.push({ name: interfaceName, content: mainInterface }); + } + + return definitions; +} + +/** + * 중첩된 타입 추출 + */ +function extractNestedTypes( + value: any, + typeName: string, + definitions: TypeDefinition[], + parentTypeName?: string, + isRoot: boolean = false +): void { + if (Array.isArray(value) && value.length > 0) { + const itemType = value[0]; + if (typeof itemType === 'object' && !Array.isArray(itemType) && itemType !== null) { + // 부모 타입 이름을 포함하여 고유한 타입 이름 생성 + const itemTypeName = parentTypeName + ? `${parentTypeName}${typeName}Item` + : `${typeName}Item`; + const properties: string[] = []; + + // 배열의 모든 아이템을 확인하여 각 필드의 타입 수집 + const fieldTypes = new Map>(); + + // 모든 아이템을 순회하며 각 필드의 타입 수집 + for (const item of value) { + if (typeof item === 'object' && !Array.isArray(item) && item !== null) { + for (const [key, val] of Object.entries(item)) { + if (!fieldTypes.has(key)) { + fieldTypes.set(key, new Set()); + } + const typeSet = fieldTypes.get(key)!; + + if (val === null || val === undefined) { + typeSet.add('null'); + } else { + // 중첩된 객체인 경우 부모 타입 이름 포함 (중복 방지) + const propTypeName = typeof val === 'object' && !Array.isArray(val) && val !== null + ? `${itemTypeName}${toPascalCase(key)}` + : toPascalCase(key); + const propType = getPropertyType(val, toPascalCase(key), itemTypeName); + typeSet.add(propType); + } + } + } + } + + // 유니온 타입 생성 및 중첩 타입 추출 + for (const [key, typeSet] of fieldTypes) { + const types = Array.from(typeSet); + const propType = types.length === 1 + ? types[0] + : types.join(' | '); + properties.push(` ${key}: ${propType};`); + + // 재귀적으로 중첩된 타입 추출 + const val = itemType[key]; + if (val !== null && val !== undefined) { + const nestedTypeName = `${itemTypeName}${toPascalCase(key)}`; + extractNestedTypes(val, toPascalCase(key), definitions, itemTypeName); + } + } + + const interfaceContent = `export interface ${itemTypeName} {\n${properties.join('\n')}\n}`; + definitions.unshift({ name: itemTypeName, content: interfaceContent }); + } + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // 루트 레벨이 아니고 부모 타입 이름이 있는 경우에만 타입 정의 생성 + if (!isRoot && parentTypeName && typeName) { + const nestedTypeName = `${parentTypeName}${typeName}`; + const properties: string[] = []; + const fieldTypes = new Map>(); + + // 모든 필드의 타입 수집 + for (const [key, val] of Object.entries(value)) { + if (!fieldTypes.has(key)) { + fieldTypes.set(key, new Set()); + } + const typeSet = fieldTypes.get(key)!; + + if (val === null || val === undefined) { + typeSet.add('null'); + } else { + const propType = getPropertyType(val, toPascalCase(key), nestedTypeName); + typeSet.add(propType); + } + } + + // 유니온 타입 생성 + for (const [key, typeSet] of fieldTypes) { + const types = Array.from(typeSet); + const propType = types.length === 1 + ? types[0] + : types.join(' | '); + properties.push(` ${key}: ${propType};`); + + // 재귀적으로 중첩된 타입 추출 + const val = value[key]; + if (val !== null && val !== undefined) { + extractNestedTypes(val, toPascalCase(key), definitions, nestedTypeName, false); + } + } + + // 타입 정의 추가 + if (properties.length > 0) { + const interfaceContent = `export interface ${nestedTypeName} {\n${properties.join('\n')}\n}`; + definitions.unshift({ name: nestedTypeName, content: interfaceContent }); + } + } else { + // 루트 레벨이거나 타입 이름이 없는 경우, 자식만 재귀적으로 추출 + for (const [key, val] of Object.entries(value)) { + const childParentTypeName = isRoot ? parentTypeName : (parentTypeName ? `${parentTypeName}${typeName}` : typeName); + extractNestedTypes(val, toPascalCase(key), definitions, childParentTypeName, false); + } + } + } +} + +/** + * 프로퍼티 타입 결정 + */ +function getPropertyType(value: any, typeName: string, parentTypeName?: string): string { + if (value === null || value === undefined) { + return 'null'; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return 'any[]'; + } + const itemType = value[0]; + if (typeof itemType === 'object' && !Array.isArray(itemType) && itemType !== null) { + // 부모 타입 이름을 포함하여 고유한 타입 이름 생성 + const itemTypeName = parentTypeName + ? `${parentTypeName}${typeName}Item` + : `${typeName}Item`; + return `${itemTypeName}[]`; + } + // 배열의 모든 아이템을 확인하여 유니온 타입 생성 + const types = new Set(); + for (const item of value) { + if (item === null || item === undefined) { + types.add('null'); + } else { + types.add(getPropertyType(item, typeName, parentTypeName)); + } + } + const typeArray = Array.from(types); + if (typeArray.length === 1) { + return `${typeArray[0]}[]`; + } + return `(${typeArray.join(' | ')})[]`; + } + + const valueType = typeof value; + + switch (valueType) { + case 'string': + return 'string'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'object': + // 부모 타입 이름을 포함하여 고유한 타입 이름 생성 (중복 방지) + if (parentTypeName) { + // 이미 부모 타입 이름이 포함되어 있는지 확인 + if (typeName.startsWith(parentTypeName)) { + return typeName; + } + return `${parentTypeName}${typeName}`; + } + return typeName; + default: + return 'any'; + } +} + +/** + * 문자열을 PascalCase로 변환 + */ +function toPascalCase(str: string): string { + return str + .replace(/[-_](.)/g, (_, c) => c.toUpperCase()) + .replace(/^(.)/, (_, c) => c.toUpperCase()); +} + +/** + * 문자열을 camelCase로 변환 + */ +export function toCamelCase(str: string): string { + return str + .replace(/[-_](.)/g, (_, c) => c.toUpperCase()) + .replace(/^(.)/, (_, c) => c.toLowerCase()); +} + +/** + * URL 경로를 함수명으로 변환 + * 예: /applications/competitors -> getApplicationsCompetitors + */ +export function urlToFunctionName(method: string, url: string): string { + // 경로 파라미터 제거 및 처리 + const pathParts = url + .split('/') + .filter(part => part.length > 0) + .map(part => { + // :id, {id} 같은 파라미터 처리 + if (part.startsWith(':') || part.startsWith('{')) { + return 'ById'; + } + return toPascalCase(part); + }); + + const baseName = pathParts.join(''); + const methodPrefix = method.toLowerCase(); + + return `${methodPrefix}${baseName}`; +} + +/** + * 함수명을 타입명으로 변환 + * 예: getApplicationsCompetitors -> GetApplicationsCompetitorsResponse + */ +export function functionNameToTypeName(functionName: string, suffix: string = 'Response'): string { + return `${toPascalCase(functionName)}${suffix}`; +} diff --git a/packages/bruno-api-typescript/src/index.ts b/packages/bruno-api-typescript/src/index.ts new file mode 100644 index 00000000..8a6130e7 --- /dev/null +++ b/packages/bruno-api-typescript/src/index.ts @@ -0,0 +1,25 @@ +/** + * bruno-openapi-sync + * Main entry point for programmatic usage + */ + +export { parseBrunoFile, extractJsonFromDocs } from './parser/bruParser'; +export type { ParsedBrunoFile, BrunoRequest } from './parser/bruParser'; + +export { inferSchema } from './converter/schemaBuilder'; +export type { OpenAPISchema } from './converter/schemaBuilder'; + +export { convertBrunoToOpenAPI } from './converter/openapiConverter'; +export type { OpenAPISpec, ConversionOptions } from './converter/openapiConverter'; + +export { detectChanges, groupChangesByDomain, isBreakingChange } from './diff/changeDetector'; +export type { + ChangeReport, + EndpointChange, + FieldChange, + ChangeType, + ChangeSeverity, +} from './diff/changeDetector'; + +export { generateChangelog, formatConsoleOutput } from './diff/changelogGenerator'; +export type { ChangelogOptions, ChangelogFormat } from './diff/changelogGenerator'; diff --git a/packages/bruno-api-typescript/src/parser/bruParser.ts b/packages/bruno-api-typescript/src/parser/bruParser.ts new file mode 100644 index 00000000..316d2c52 --- /dev/null +++ b/packages/bruno-api-typescript/src/parser/bruParser.ts @@ -0,0 +1,297 @@ +/** + * Bruno .bru 파일 파서 + * .bru 파일을 읽어서 구조화된 데이터로 변환 + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +export interface BrunoRequest { + method: string; + url: string; + name: string; + docs?: string; + headers?: Record; + body?: string; + auth?: { + type: string; + [key: string]: any; + }; +} + +export interface ParsedBrunoFile { + meta: { + name: string; + type: string; + seq?: number; + done?: boolean; + }; + http: { + method: string; + url: string; + }; + headers?: Record; + auth?: { + type: string; + [key: string]: any; + }; + body?: { + type: string; + content: string; + }; + docs?: string; + script?: { + pre?: string; + post?: string; + }; + tests?: string; +} + +/** + * .bru 파일 파싱 + */ +export function parseBrunoFile(filePath: string): ParsedBrunoFile { + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + const result: ParsedBrunoFile = { + meta: { + name: '', + type: 'http', + }, + http: { + method: 'GET', + url: '', + }, + }; + + let currentBlock: string | null = null; + let blockContent: string[] = []; + let inCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // 블록 시작 감지 + if (trimmed === 'meta {') { + currentBlock = 'meta'; + blockContent = []; + continue; + } else if (trimmed.match(/^(get|post|put|patch|delete|head|options)\s*\{/i)) { + // HTTP 메서드 블록 형식: put { url: ... } + const match = trimmed.match(/^(get|post|put|patch|delete|head|options)\s*\{/i); + if (match) { + result.http.method = match[1].toUpperCase(); + currentBlock = 'http'; + blockContent = []; + continue; + } + } else if (trimmed.match(/^(get|post|put|patch|delete|head|options)\s+/i)) { + // HTTP 메서드 라인 형식: get /api/endpoint + const match = trimmed.match(/^(get|post|put|patch|delete|head|options)\s+(.+)$/i); + if (match) { + result.http.method = match[1].toUpperCase(); + result.http.url = match[2].trim(); + } + continue; + } else if (trimmed === 'headers {') { + currentBlock = 'headers'; + blockContent = []; + continue; + } else if (trimmed === 'body:json {') { + currentBlock = 'body'; + blockContent = []; + continue; + } else if (trimmed === 'docs {') { + currentBlock = 'docs'; + blockContent = []; + inCodeBlock = false; + continue; + } else if (trimmed === 'script:pre-request {') { + currentBlock = 'script:pre'; + blockContent = []; + continue; + } else if (trimmed === 'script:post-response {') { + currentBlock = 'script:post'; + blockContent = []; + continue; + } else if (trimmed === 'tests {') { + currentBlock = 'tests'; + blockContent = []; + continue; + } + + // 블록 종료 감지 + if (trimmed === '}' && currentBlock && !inCodeBlock) { + // 블록 파싱 + parseBlock(result, currentBlock, blockContent); + currentBlock = null; + blockContent = []; + continue; + } + + // 블록 내용 수집 + if (currentBlock) { + // docs 블록에서 코드 블록 처리 + if (currentBlock === 'docs') { + if (trimmed === '```json' || trimmed === '```') { + inCodeBlock = !inCodeBlock; + // 코드 블록 라인도 포함 (정규식 매칭을 위해) + blockContent.push(line); + continue; + } + } + blockContent.push(line); + } + } + + return result; +} + +/** + * 블록 파싱 + */ +function parseBlock(result: ParsedBrunoFile, blockName: string, content: string[]): void { + switch (blockName) { + case 'meta': + parseMeta(result, content); + break; + case 'http': + parseHttp(result, content); + break; + case 'headers': + parseHeaders(result, content); + break; + case 'body': + parseBody(result, content); + break; + case 'docs': + parseDocs(result, content); + break; + case 'script:pre': + if (!result.script) result.script = {}; + result.script.pre = content.join('\n').trim(); + break; + case 'script:post': + if (!result.script) result.script = {}; + result.script.post = content.join('\n').trim(); + break; + case 'tests': + result.tests = content.join('\n').trim(); + break; + } +} + +/** + * HTTP 블록 파싱 (put { url: ... } 형식) + */ +function parseHttp(result: ParsedBrunoFile, lines: string[]): void { + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('url:')) { + result.http.url = trimmed.substring(4).trim(); + } + } +} + +/** + * meta 블록 파싱 + */ +function parseMeta(result: ParsedBrunoFile, lines: string[]): void { + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('name:')) { + result.meta.name = trimmed.substring(5).trim(); + } else if (trimmed.startsWith('type:')) { + result.meta.type = trimmed.substring(5).trim(); + } else if (trimmed.startsWith('seq:')) { + result.meta.seq = parseInt(trimmed.substring(4).trim(), 10); + } else if (trimmed.startsWith('done:')) { + const value = trimmed.substring(5).trim().toLowerCase(); + result.meta.done = value === 'true'; + } + } +} + +/** + * headers 블록 파싱 + */ +function parseHeaders(result: ParsedBrunoFile, lines: string[]): void { + result.headers = {}; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + const key = trimmed.substring(0, colonIndex).trim(); + const value = trimmed.substring(colonIndex + 1).trim(); + result.headers[key] = value; + } + } +} + +/** + * body 블록 파싱 + */ +function parseBody(result: ParsedBrunoFile, lines: string[]): void { + const content = lines.join('\n').trim(); + result.body = { + type: 'json', + content, + }; +} + +/** + * docs 블록 파싱 (JSON 추출) + */ +function parseDocs(result: ParsedBrunoFile, lines: string[]): void { + const content = lines.join('\n').trim(); + result.docs = content; +} + +/** + * docs 블록에서 JSON 추출 + * 상태 코드별 응답 지원: ## 200 OK 형식 + */ +export function extractJsonFromDocs(docs: string): any { + try { + // ## 200 OK 형식의 상태 코드별 응답 지원 + // 패턴: ## 200 OK (또는 ## 200) 다음에 빈 줄과 코드 블록 + const statusCodePattern = /##\s*(\d+)\s+[^\n]*(?:\n|$)(?:[^\n]*\n)*?\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g; + let match; + let json200 = null; + + // 모든 상태 코드 응답 찾기 + while ((match = statusCodePattern.exec(docs)) !== null) { + const statusCode = parseInt(match[1]); + const jsonContent = match[2].trim(); + + // 200 OK만 사용 + if (statusCode === 200) { + try { + json200 = JSON.parse(jsonContent); + break; // 200 OK를 찾으면 중단 + } catch (e) { + // JSON 파싱 실패시 무시 + } + } + } + + // 200 OK를 찾지 못한 경우 기존 로직 사용 + if (json200) { + return json200; + } + + // 기존 로직: 단일 JSON 코드 블록 + const jsonMatch = docs.match(/```json\s*([\s\S]*?)\s*```/); + if (jsonMatch && jsonMatch[1]) { + return JSON.parse(jsonMatch[1].trim()); + } + + // 일반 JSON 파싱 시도 + return JSON.parse(docs.trim()); + } catch (error) { + return null; + } +} diff --git a/packages/bruno-api-typescript/tests/cli.test.js b/packages/bruno-api-typescript/tests/cli.test.js new file mode 100644 index 00000000..bc778738 --- /dev/null +++ b/packages/bruno-api-typescript/tests/cli.test.js @@ -0,0 +1,561 @@ +/** + * CLI 기능 테스트 + * Node.js 기본 test runner 사용 + */ + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert'); +const { existsSync, rmSync, mkdirSync, readFileSync } = require('fs'); +const { execSync } = require('child_process'); +const { join } = require('path'); + +const FIXTURES_DIR = join(__dirname, 'fixtures'); +const TEST_OUTPUT_DIR = join(__dirname, 'output'); + +// 테스트 전 정리 +before(() => { + if (existsSync(TEST_OUTPUT_DIR)) { + rmSync(TEST_OUTPUT_DIR, { recursive: true, force: true }); + } + mkdirSync(TEST_OUTPUT_DIR, { recursive: true }); +}); + +// 테스트 후 정리 +after(() => { + if (existsSync(TEST_OUTPUT_DIR)) { + rmSync(TEST_OUTPUT_DIR, { recursive: true, force: true }); + } +}); + +describe('OpenAPI 생성 테스트', () => { + test('기본 OpenAPI 스펙 생성', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputFile = join(TEST_OUTPUT_DIR, 'openapi.json'); + + // CLI 실행 + execSync(`node dist/cli/index.js generate -i ${inputDir} -o ${outputFile}`, { + cwd: join(__dirname, '..'), + }); + + // 파일 생성 확인 + assert.ok(existsSync(outputFile), 'OpenAPI 파일이 생성되어야 함'); + + // JSON 파싱 가능 확인 + const spec = JSON.parse(readFileSync(outputFile, 'utf-8')); + + // 기본 구조 검증 + assert.ok(spec.openapi, 'openapi 버전이 있어야 함'); + assert.ok(spec.info, 'info 객체가 있어야 함'); + assert.ok(spec.paths, 'paths 객체가 있어야 함'); + + // 엔드포인트 확인 + assert.ok(spec.paths['/users/profile'], '/users/profile 엔드포인트가 있어야 함'); + assert.ok(spec.paths['/applications/competitors'], '/applications/competitors 엔드포인트가 있어야 함'); + + // GET 메서드 확인 + assert.ok(spec.paths['/users/profile'].get, 'GET /users/profile가 있어야 함'); + assert.ok(spec.paths['/applications/competitors'].get, 'GET /applications/competitors가 있어야 함'); + + console.log('✅ OpenAPI 생성 테스트 통과'); + }); + + test('도메인별 태그 그룹화', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputFile = join(TEST_OUTPUT_DIR, 'openapi-tags.json'); + + execSync(`node dist/cli/index.js generate -i ${inputDir} -o ${outputFile}`, { + cwd: join(__dirname, '..'), + }); + + const spec = JSON.parse(readFileSync(outputFile, 'utf-8')); + + // 태그 확인 + assert.ok(spec.paths['/users/profile'].get.tags, '태그가 있어야 함'); + assert.ok(spec.paths['/users/profile'].get.tags.includes('users'), 'users 태그가 있어야 함'); + + console.log('✅ 도메인별 태그 그룹화 테스트 통과'); + }); + + test('응답 스키마 생성', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputFile = join(TEST_OUTPUT_DIR, 'openapi-schema.json'); + + execSync(`node dist/cli/index.js generate -i ${inputDir} -o ${outputFile}`, { + cwd: join(__dirname, '..'), + }); + + const spec = JSON.parse(readFileSync(outputFile, 'utf-8')); + + // 응답 스키마 확인 + const userProfileResponse = spec.paths['/users/profile'].get.responses['200']; + assert.ok(userProfileResponse, '200 응답이 있어야 함'); + assert.ok(userProfileResponse.content, 'content가 있어야 함'); + assert.ok(userProfileResponse.content['application/json'], 'application/json이 있어야 함'); + assert.ok(userProfileResponse.content['application/json'].schema, 'schema가 있어야 함'); + + const schema = userProfileResponse.content['application/json'].schema; + assert.ok(schema.properties, 'properties가 있어야 함'); + assert.ok(schema.properties.id, 'id 필드가 있어야 함'); + assert.ok(schema.properties.username, 'username 필드가 있어야 함'); + + console.log('✅ 응답 스키마 생성 테스트 통과'); + }); +}); + +describe('API 클라이언트 생성 테스트', () => { + test('기본 API 파일 생성', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputDir = join(TEST_OUTPUT_DIR, 'apis'); + + execSync(`node dist/cli/index.js generate-hooks -i ${inputDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const usersDir = join(outputDir, 'users'); + const applicationsDir = join(outputDir, 'applications'); + assert.ok(existsSync(usersDir), 'users 디렉토리가 생성되어야 함'); + assert.ok(existsSync(applicationsDir), 'applications 디렉토리가 생성되어야 함'); + + const usersApiFile = join(usersDir, 'api.ts'); + const applicationsApiFile = join(applicationsDir, 'api.ts'); + assert.ok(existsSync(usersApiFile), 'users/api.ts 팩토리 파일이 생성되어야 함'); + assert.ok(existsSync(applicationsApiFile), 'applications/api.ts 팩토리 파일이 생성되어야 함'); + + const usersDefinitionsFile = join(usersDir, 'apiDefinitions.ts'); + const applicationsDefinitionsFile = join(applicationsDir, 'apiDefinitions.ts'); + assert.ok(existsSync(usersDefinitionsFile), 'users/apiDefinitions.ts 파일이 생성되어야 함'); + assert.ok(existsSync(applicationsDefinitionsFile), 'applications/apiDefinitions.ts 파일이 생성되어야 함'); + + console.log('✅ 기본 API 파일 생성 테스트 통과'); + }); + + test('API 정의 파일 내용 검증', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputDir = join(TEST_OUTPUT_DIR, 'apis-content'); + + execSync(`node dist/cli/index.js generate-hooks -i ${inputDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const usersDefinitionsFile = join(outputDir, 'users', 'apiDefinitions.ts'); + const content = readFileSync(usersDefinitionsFile, 'utf-8'); + + assert.ok(content.includes('import type'), 'type-only import가 있어야 함'); + assert.ok(content.includes('from \'./api\''), 'api.ts로부터 타입 import가 있어야 함'); + assert.ok(content.includes('export const usersApiDefinitions'), 'usersApiDefinitions 객체가 있어야 함'); + assert.ok(content.includes('method:'), 'method 필드가 있어야 함'); + assert.ok(content.includes('path:'), 'path 필드가 있어야 함'); + assert.ok(content.includes('response:'), 'response 필드가 있어야 함'); + + console.log('✅ API 정의 파일 내용 검증 테스트 통과'); + }); + + test('API 팩토리 파일 내용 검증', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputDir = join(TEST_OUTPUT_DIR, 'apis-factory'); + + execSync(`node dist/cli/index.js generate-hooks -i ${inputDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const usersApiFile = join(outputDir, 'users', 'api.ts'); + const content = readFileSync(usersApiFile, 'utf-8'); + + // 필수 import 확인 + assert.ok(content.includes('import { axiosInstance }'), 'axiosInstance import가 있어야 함'); + + // 팩토리 객체 확인 + assert.ok(content.includes('export const usersApi'), 'usersApi 팩토리 객체가 있어야 함'); + assert.ok(content.includes('getGetProfile:'), 'getGetProfile 함수가 있어야 함'); + + // 함수 시그니처 확인 + assert.ok(content.includes('async ('), 'async 함수가 있어야 함'); + assert.ok(content.includes('Promise<'), 'Promise 타입이 있어야 함'); + + console.log('✅ API 팩토리 파일 내용 검증 테스트 통과'); + }); + + test('index 파일 생성', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputDir = join(TEST_OUTPUT_DIR, 'apis-index'); + + execSync(`node dist/cli/index.js generate-hooks -i ${inputDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const usersIndex = join(outputDir, 'users', 'index.ts'); + const applicationsIndex = join(outputDir, 'applications', 'index.ts'); + + assert.ok(existsSync(usersIndex), 'users/index.ts가 생성되어야 함'); + assert.ok(existsSync(applicationsIndex), 'applications/index.ts가 생성되어야 함'); + + const usersIndexContent = readFileSync(usersIndex, 'utf-8'); + assert.ok(usersIndexContent.includes('export'), 'export가 있어야 함'); + assert.ok(usersIndexContent.includes('usersApi'), 'usersApi export가 있어야 함'); + assert.ok(usersIndexContent.includes('apiDefinitions'), 'apiDefinitions export가 있어야 함'); + + console.log('✅ index 파일 생성 테스트 통과'); + }); +}); + +describe('변경사항 감지 테스트', () => { + test('변경사항 감지 기능', () => { + const brunoV1 = join(FIXTURES_DIR, 'bruno'); + const brunoV2 = join(FIXTURES_DIR, 'bruno-v2'); + const outputV1 = join(TEST_OUTPUT_DIR, 'openapi-v1.json'); + const outputV2 = join(TEST_OUTPUT_DIR, 'openapi-v2.json'); + + // V1 생성 + execSync(`node dist/cli/index.js generate -i ${brunoV1} -o ${outputV1}`, { + cwd: join(__dirname, '..'), + }); + + // V2 생성 (변경사항 포함) + execSync(`node dist/cli/index.js generate -i ${brunoV2} -o ${outputV2}`, { + cwd: join(__dirname, '..'), + }); + + // 파일 비교 + const specV1 = JSON.parse(readFileSync(outputV1, 'utf-8')); + const specV2 = JSON.parse(readFileSync(outputV2, 'utf-8')); + + // V2에 추가된 엔드포인트 확인 + const v1Paths = Object.keys(specV1.paths); + const v2Paths = Object.keys(specV2.paths); + + assert.ok(v2Paths.length >= v1Paths.length, 'V2가 V1보다 많거나 같은 엔드포인트를 가져야 함'); + + console.log('✅ 변경사항 감지 기능 테스트 통과'); + }); +}); + +describe('새로운 폴더명 패턴 테스트', () => { + test('숫자) 한글명 [영문키] 패턴 추출', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputDir = join(TEST_OUTPUT_DIR, 'apis-pattern'); + + execSync(`node dist/cli/index.js generate-hooks -i ${inputDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const adminDir = join(outputDir, 'Admin'); + assert.ok(existsSync(adminDir), '7) 어드민 [Admin] 폴더에서 Admin이 생성되어야 함'); + + const apiFile = join(adminDir, 'api.ts'); + const definitionsFile = join(adminDir, 'apiDefinitions.ts'); + assert.ok(existsSync(apiFile), 'Admin/api.ts 파일이 생성되어야 함'); + assert.ok(existsSync(definitionsFile), 'Admin/apiDefinitions.ts 파일이 생성되어야 함'); + + console.log('✅ 숫자) 한글명 [영문키] 패턴 테스트 통과'); + }); + + test('한글명 [영문키] 파일명 패턴 추출', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputDir = join(TEST_OUTPUT_DIR, 'apis-filename-pattern'); + + execSync(`node dist/cli/index.js generate-hooks -i ${inputDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const adminApiFile = join(outputDir, 'Admin', 'api.ts'); + const adminApiContent = readFileSync(adminApiFile, 'utf-8'); + + assert.ok(adminApiContent.includes('getGetList'), 'getGetList 함수가 생성되어야 함'); + assert.ok(adminApiContent.includes('export const adminApi'), 'adminApi 팩토리가 생성되어야 함'); + + const definitionsFile = join(outputDir, 'Admin', 'apiDefinitions.ts'); + const definitionsContent = readFileSync(definitionsFile, 'utf-8'); + assert.ok(definitionsContent.includes('getGetList'), 'getGetList 정의가 생성되어야 함'); + + console.log('✅ 한글명 [영문키] 파일명 패턴 테스트 통과'); + }); +}); + +describe('상태 코드별 응답 파싱 테스트', () => { + test('200 OK만 추출 (404 무시)', () => { + const inputDir = join(FIXTURES_DIR, 'bruno'); + const outputFile = join(TEST_OUTPUT_DIR, 'openapi-status-codes.json'); + + execSync(`node dist/cli/index.js generate -i ${inputDir} -o ${outputFile}`, { + cwd: join(__dirname, '..'), + }); + + const spec = JSON.parse(readFileSync(outputFile, 'utf-8')); + + // /mentors 엔드포인트 확인 + const mentorsPath = spec.paths['/mentors']; + assert.ok(mentorsPath, '/mentors 엔드포인트가 있어야 함'); + + // 200 응답만 있는지 확인 (404는 무시되어야 함) + const getMethod = mentorsPath.get; + assert.ok(getMethod, 'GET 메서드가 있어야 함'); + assert.ok(getMethod.responses['200'], '200 응답이 있어야 함'); + assert.ok(!getMethod.responses['404'], '404 응답은 포함되지 않아야 함'); + + // 200 응답의 스키마 확인 + const response200 = getMethod.responses['200']; + assert.ok(response200.content, 'content가 있어야 함'); + assert.ok(response200.content['application/json'], 'application/json이 있어야 함'); + assert.ok(response200.content['application/json'].schema, 'schema가 있어야 함'); + + const schema = response200.content['application/json'].schema; + assert.ok(schema.properties, 'properties가 있어야 함'); + assert.ok(schema.properties.nextPageNumber, 'nextPageNumber 필드가 있어야 함'); + assert.ok(schema.properties.content, 'content 필드가 있어야 함'); + + console.log('✅ 상태 코드별 응답 파싱 테스트 통과'); + }); +}); + +describe('컬렉션 폴더 지원 테스트', () => { + test('Solid Connection 폴더 제거 및 도메인 추출', () => { + const collectionFixtureDir = join(TEST_OUTPUT_DIR, 'collection-fixture'); + const collectionDir = join(collectionFixtureDir, 'Solid Connection', '1) 인증 [Auth]'); + mkdirSync(collectionDir, { recursive: true }); + + const testFile = join(collectionDir, 'sign-out.bru'); + const bruContent = `meta { + name: Sign Out + type: http +} + +post /auth/sign-out +`; + require('fs').writeFileSync(testFile, bruContent); + + const outputDir = join(TEST_OUTPUT_DIR, 'collection-output'); + execSync(`node dist/cli/index.js generate-hooks -i ${collectionFixtureDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const authDir = join(outputDir, 'Auth'); + assert.ok(existsSync(authDir), 'Auth 디렉토리가 생성되어야 함 (Solid Connection 폴더 제거)'); + + const authApiFile = join(authDir, 'api.ts'); + const authApiContent = readFileSync(authApiFile, 'utf-8'); + assert.ok(authApiContent.includes('postSignOut'), 'postSignOut 함수가 생성되어야 함 (메서드 prefix 포함)'); + + const authDefinitionsFile = join(authDir, 'apiDefinitions.ts'); + assert.ok(existsSync(authDefinitionsFile), 'Auth/apiDefinitions.ts 파일이 생성되어야 함'); + + console.log('✅ Solid Connection 폴더 제거 및 도메인 추출 테스트 통과'); + }); +}); + +describe('파일명 규칙 테스트', () => { + test('메서드 prefix 없는 파일명 정상 동작 및 함수명에 메서드 prefix 포함', () => { + const noPrefixFixtureDir = join(TEST_OUTPUT_DIR, 'no-prefix-fixture'); + const usersDir = join(noPrefixFixtureDir, 'users'); + mkdirSync(usersDir, { recursive: true }); + + const accountFile = join(usersDir, 'account.bru'); + const accountContent = `meta { + name: Delete Account + type: http +} + +delete /users/account +`; + require('fs').writeFileSync(accountFile, accountContent); + + const signUpFile = join(usersDir, 'sign-up.bru'); + const signUpContent = `meta { + name: Sign Up + type: http +} + +post /users/sign-up + +body:json { + { + "email": "test@example.com", + "password": "password123" + } +} + +docs { + \`\`\`json + { + "id": 1, + "email": "test@example.com", + "createdAt": "2025-01-01T00:00:00Z" + } + \`\`\` +} +`; + require('fs').writeFileSync(signUpFile, signUpContent); + + const outputDir = join(TEST_OUTPUT_DIR, 'no-prefix-output'); + execSync(`node dist/cli/index.js generate-hooks -i ${noPrefixFixtureDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + const apiFile = join(outputDir, 'users', 'api.ts'); + const apiContent = readFileSync(apiFile, 'utf-8'); + assert.ok(apiContent.includes('deleteAccount'), 'deleteAccount 함수가 생성되어야 함 (메서드 prefix 포함)'); + assert.ok(apiContent.includes('postSignUp'), 'postSignUp 함수가 생성되어야 함 (메서드 prefix 포함)'); + + const definitionsFile = join(outputDir, 'users', 'apiDefinitions.ts'); + const definitionsContent = readFileSync(definitionsFile, 'utf-8'); + assert.ok(definitionsContent.includes('deleteAccount'), 'deleteAccount 정의가 생성되어야 함'); + assert.ok(definitionsContent.includes('postSignUp'), 'postSignUp 정의가 생성되어야 함'); + + console.log('✅ 메서드 prefix 없는 파일명 및 함수명 메서드 prefix 포함 테스트 통과'); + }); +}); + +describe('빈 타입 생성 테스트', () => { + test('빈 객체 {}인 경우 Record 타입 생성', () => { + const emptyObjectFixtureDir = join(FIXTURES_DIR, 'bruno-empty-object'); + mkdirSync(emptyObjectFixtureDir, { recursive: true }); + + // 폴더 구조 생성 + const testFolder = join(emptyObjectFixtureDir, 'test'); + mkdirSync(testFolder, { recursive: true }); + + const emptyObjectFile = join(testFolder, 'empty-response.bru'); + const emptyObjectContent = `meta { + name: Empty Response Test + type: http +} + +get /test/empty + +docs { + \`\`\`json + {} + \`\`\` +} +`; + require('fs').writeFileSync(emptyObjectFile, emptyObjectContent); + + const outputDir = join(TEST_OUTPUT_DIR, 'empty-object-output'); + execSync(`node dist/cli/index.js generate-hooks -i ${emptyObjectFixtureDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + // api.ts 파일 확인 (도메인은 폴더명 'test'가 됨) + const apiFile = join(outputDir, 'test', 'api.ts'); + assert.ok(existsSync(apiFile), 'api.ts 파일이 생성되어야 함'); + + const apiContent = readFileSync(apiFile, 'utf-8'); + assert.ok(apiContent.includes('Record'), '빈 객체는 Record 타입이어야 함'); + assert.ok(!apiContent.includes('export interface'), '빈 인터페이스는 생성되지 않아야 함'); + + console.log('✅ 빈 객체 Record 타입 생성 테스트 통과'); + }); + + test('parsed.docs가 있지만 JSON 추출 실패 시 void 타입 생성', () => { + const invalidJsonFixtureDir = join(FIXTURES_DIR, 'bruno-invalid-json'); + mkdirSync(invalidJsonFixtureDir, { recursive: true }); + + // 폴더 구조 생성 + const testFolder = join(invalidJsonFixtureDir, 'test'); + mkdirSync(testFolder, { recursive: true }); + + const invalidJsonFile = join(testFolder, 'invalid-json.bru'); + const invalidJsonContent = `meta { + name: Invalid JSON Test + type: http +} + +get /test/invalid + +docs { + 이것은 유효하지 않은 JSON입니다 + { invalid json } +} +`; + require('fs').writeFileSync(invalidJsonFile, invalidJsonContent); + + const outputDir = join(TEST_OUTPUT_DIR, 'invalid-json-output'); + execSync(`node dist/cli/index.js generate-hooks -i ${invalidJsonFixtureDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + // api.ts 파일 확인 (도메인은 폴더명 'test'가 됨) + const apiFile = join(outputDir, 'test', 'api.ts'); + assert.ok(existsSync(apiFile), 'api.ts 파일이 생성되어야 함'); + + const apiContent = readFileSync(apiFile, 'utf-8'); + assert.ok(apiContent.includes('void'), 'JSON 추출 실패 시 void 타입이 생성되어야 함'); + + console.log('✅ JSON 추출 실패 시 void 타입 생성 테스트 통과'); + }); + + test('빈 배열 []인 경우 any[] 타입 생성', () => { + const emptyArrayFixtureDir = join(FIXTURES_DIR, 'bruno-empty-array'); + mkdirSync(emptyArrayFixtureDir, { recursive: true }); + + // 폴더 구조 생성 + const testFolder = join(emptyArrayFixtureDir, 'test'); + mkdirSync(testFolder, { recursive: true }); + + const emptyArrayFile = join(testFolder, 'empty-array.bru'); + const emptyArrayContent = `meta { + name: Empty Array Test + type: http +} + +get /test/empty-array + +docs { + \`\`\`json + { + "items": [] + } + \`\`\` +} +`; + require('fs').writeFileSync(emptyArrayFile, emptyArrayContent); + + const outputDir = join(TEST_OUTPUT_DIR, 'empty-array-output'); + execSync(`node dist/cli/index.js generate-hooks -i ${emptyArrayFixtureDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + // api.ts 파일 확인 (도메인은 폴더명 'test'가 됨) + const apiFile = join(outputDir, 'test', 'api.ts'); + assert.ok(existsSync(apiFile), 'api.ts 파일이 생성되어야 함'); + + const apiContent = readFileSync(apiFile, 'utf-8'); + assert.ok(apiContent.includes('any[]'), '빈 배열은 any[] 타입이어야 함'); + + console.log('✅ 빈 배열 any[] 타입 생성 테스트 통과'); + }); + + test('parsed.docs가 없는 경우 void 타입 생성', () => { + const noDocsFixtureDir = join(FIXTURES_DIR, 'bruno-no-docs'); + mkdirSync(noDocsFixtureDir, { recursive: true }); + + // 폴더 구조 생성 + const testFolder = join(noDocsFixtureDir, 'test'); + mkdirSync(testFolder, { recursive: true }); + + const noDocsFile = join(testFolder, 'no-docs.bru'); + const noDocsContent = `meta { + name: No Docs Test + type: http +} + +get /test/no-docs +`; + require('fs').writeFileSync(noDocsFile, noDocsContent); + + const outputDir = join(TEST_OUTPUT_DIR, 'no-docs-output'); + execSync(`node dist/cli/index.js generate-hooks -i ${noDocsFixtureDir} -o ${outputDir}`, { + cwd: join(__dirname, '..'), + }); + + // api.ts 파일 확인 (도메인은 폴더명 'test'가 됨) + const apiFile = join(outputDir, 'test', 'api.ts'); + assert.ok(existsSync(apiFile), 'api.ts 파일이 생성되어야 함'); + + const apiContent = readFileSync(apiFile, 'utf-8'); + assert.ok(apiContent.includes('void'), 'docs가 없으면 void 타입이 생성되어야 함'); + + console.log('✅ docs 없을 때 void 타입 생성 테스트 통과'); + }); +}); + +console.log('\n🎉 모든 테스트 완료!'); diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-empty-array/empty-array.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-array/empty-array.bru new file mode 100644 index 00000000..b39176c1 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-array/empty-array.bru @@ -0,0 +1,14 @@ +meta { + name: Empty Array Test + type: http +} + +get /test/empty-array + +docs { + ```json + { + "items": [] + } + ``` +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-empty-array/test/empty-array.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-array/test/empty-array.bru new file mode 100644 index 00000000..b39176c1 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-array/test/empty-array.bru @@ -0,0 +1,14 @@ +meta { + name: Empty Array Test + type: http +} + +get /test/empty-array + +docs { + ```json + { + "items": [] + } + ``` +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-empty-object/empty-response.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-object/empty-response.bru new file mode 100644 index 00000000..a0a2d4d7 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-object/empty-response.bru @@ -0,0 +1,12 @@ +meta { + name: Empty Response Test + type: http +} + +get /test/empty + +docs { + ```json + {} + ``` +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-empty-object/test/empty-response.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-object/test/empty-response.bru new file mode 100644 index 00000000..a0a2d4d7 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-empty-object/test/empty-response.bru @@ -0,0 +1,12 @@ +meta { + name: Empty Response Test + type: http +} + +get /test/empty + +docs { + ```json + {} + ``` +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-invalid-json/invalid-json.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-invalid-json/invalid-json.bru new file mode 100644 index 00000000..17252386 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-invalid-json/invalid-json.bru @@ -0,0 +1,11 @@ +meta { + name: Invalid JSON Test + type: http +} + +get /test/invalid + +docs { + 이것은 유효하지 않은 JSON입니다 + { invalid json } +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-invalid-json/test/invalid-json.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-invalid-json/test/invalid-json.bru new file mode 100644 index 00000000..17252386 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-invalid-json/test/invalid-json.bru @@ -0,0 +1,11 @@ +meta { + name: Invalid JSON Test + type: http +} + +get /test/invalid + +docs { + 이것은 유효하지 않은 JSON입니다 + { invalid json } +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-no-docs/no-docs.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-no-docs/no-docs.bru new file mode 100644 index 00000000..13daa8be --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-no-docs/no-docs.bru @@ -0,0 +1,6 @@ +meta { + name: No Docs Test + type: http +} + +get /test/no-docs diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-no-docs/test/no-docs.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-no-docs/test/no-docs.bru new file mode 100644 index 00000000..13daa8be --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-no-docs/test/no-docs.bru @@ -0,0 +1,6 @@ +meta { + name: No Docs Test + type: http +} + +get /test/no-docs diff --git "a/packages/bruno-api-typescript/tests/fixtures/bruno-v2/7) \354\226\264\353\223\234\353\257\274 [Admin]/get-list.bru" "b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/7) \354\226\264\353\223\234\353\257\274 [Admin]/get-list.bru" new file mode 100644 index 00000000..729db6de --- /dev/null +++ "b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/7) \354\226\264\353\223\234\353\257\274 [Admin]/get-list.bru" @@ -0,0 +1,56 @@ +meta { + name: 멘토 목록 조회 + type: http + seq: 1 +} + +get /mentors?region=미주권&size=3&page=1 + +headers { + Authorization: Bearer {{token}} +} + +docs { + RequestParam중, region에 올 수 있는 것들 + - 미주권 + - 아시아권 + - 중국권 + - 유럽권 + + --- + + ## 200 OK + ``` + { + "nextPageNumber": 1, + "content": [ + { + "id": 1, + "profileImageUrl": "https://example.com/image.jpg", + "nickname": "닉네임", + "country": "프랑스", + "universityName": "파리 대학교", + "term": "2025-1", + "menteeCount": 7, + "hasBadge": true, + "introduction": "안녕하세요", + "channels": [ + { + "type": "BLOG", + "url": "https://blog.example.com" + } + ], + "isApplied": true + } + ] + } + ``` + + ## 404 Not Found + + ``` + { + "message": "이름에 해당하는 지역을 찾을 수 없습니다." + } + ``` +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/create-application.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/create-application.bru new file mode 100644 index 00000000..838d14e7 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/create-application.bru @@ -0,0 +1,35 @@ +meta { + name: Create Application + type: http + seq: 2 +} + +post /applications + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +body:json { + { + "universityId": 1, + "choice": "first", + "documents": [ + "transcript.pdf", + "recommendation.pdf" + ] + } +} + +docs { + ```json + { + "id": 123, + "status": "pending", + "submittedAt": "2025-11-12T05:00:00Z", + "universityId": 1, + "choice": "first" + } + ``` +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/get-competitors.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/get-competitors.bru new file mode 100644 index 00000000..04d65062 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/get-competitors.bru @@ -0,0 +1,55 @@ +meta { + name: Get Competitors + type: http + seq: 1 +} + +get /applications/competitors + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +docs { + ```json + { + "firstChoice": [ + { + "koreanName": "데겐도르프대학", + "englishName": "Deggendorf Institute of Technology", + "studentCapacity": 150, + "region": "Bavaria", + "country": "Germany", + "gpa": "4.5", + "applicants": [ + { + "id": 1, + "name": "John Doe", + "gpa": 4.3, + "email": "john@example.com" + } + ] + } + ], + "secondChoice": [], + "thirdChoice": [] + } + ``` +} + +script:post-response { + if (res.status === 200) { + bru.setEnvVar("competitors", JSON.stringify(res.body)); + } +} + +tests { + test("should return 200", function() { + expect(res.status).to.equal(200); + }); + + test("should have firstChoice array", function() { + expect(res.body.firstChoice).to.be.an('array'); + }); +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/submit-application.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/submit-application.bru new file mode 100644 index 00000000..830ada14 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/applications/submit-application.bru @@ -0,0 +1,27 @@ +meta { + name: Submit Application + type: http + seq: 3 +} + +post /applications/submit?region=미주권&size=3&page=1 + +headers { + Authorization: Bearer {{token}} + Content-Type: application/json +} + +body:json { + { + "applicationId": 123 + } +} + +docs { + ```json + { + "success": true, + "submittedAt": "2025-11-12T05:00:00Z" + } + ``` +} diff --git a/packages/bruno-api-typescript/tests/fixtures/bruno-v2/users/get-profile.bru b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/users/get-profile.bru new file mode 100644 index 00000000..6bcff314 --- /dev/null +++ b/packages/bruno-api-typescript/tests/fixtures/bruno-v2/users/get-profile.bru @@ -0,0 +1,24 @@ +meta { + name: Get User Profile + type: http + seq: 1 +} + +get /users/profile + +headers { + Authorization: Bearer {{token}} +} + +docs { + ```json + { + "id": 1, + "username": "johndoe", + "email": "john@example.com", + "firstName": "John", + "lastName": "Doe", + "createdAt": "2025-01-01T00:00:00Z" + } + ``` +} diff --git a/packages/bruno-api-typescript/tsconfig.json b/packages/bruno-api-typescript/tsconfig.json new file mode 100644 index 00000000..1034bb59 --- /dev/null +++ b/packages/bruno-api-typescript/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 703ccd3f..3f59ccaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tailwindcss/vite': specifier: ^4.0.6 - version: 4.1.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)) + version: 4.1.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/react-devtools': specifier: ^0.7.0 version: 0.7.11(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) @@ -52,10 +52,10 @@ importers: version: 1.158.0(@tanstack/query-core@5.90.19)(@tanstack/react-query@5.90.19(react@19.2.4))(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.158.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': specifier: ^1.132.0 - version: 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(webpack@5.104.1) + version: 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) '@tanstack/router-plugin': specifier: ^1.132.0 - version: 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(webpack@5.104.1) + version: 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) axios: specifier: ^1.6.7 version: 1.13.2 @@ -73,7 +73,7 @@ importers: version: 0.561.0(react@19.2.4) nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.1-20260202-124820-1954b824(chokidar@3.6.0)(lru-cache@11.2.5)(rollup@4.55.1)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)) + version: nitro-nightly@3.0.1-20260202-124820-1954b824(chokidar@3.6.0)(lru-cache@11.2.5)(rollup@4.55.1)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) react: specifier: ^19.2.0 version: 19.2.4 @@ -91,14 +91,14 @@ importers: version: 4.1.18 vite-tsconfig-paths: specifier: ^6.0.2 - version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)) + version: 6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) devDependencies: '@biomejs/biome': specifier: 2.2.4 version: 2.2.4 '@tanstack/devtools-vite': specifier: ^0.3.11 - version: 0.3.12(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)) + version: 0.3.12(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/dom': specifier: ^10.4.0 version: 10.4.1 @@ -116,7 +116,7 @@ importers: version: 19.2.3(@types/react@19.2.10) '@vitejs/plugin-react': specifier: ^5.0.4 - version: 5.1.3(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)) + version: 5.1.3(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^27.0.0 version: 27.4.0 @@ -125,10 +125,10 @@ importers: version: 5.9.3 vite: specifier: ^7.1.7 - version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + version: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + version: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -136,7 +136,7 @@ importers: apps/web: dependencies: '@hookform/resolvers': - specifier: ^5.1.1 + specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.1(react@18.3.1)) '@next/third-parties': specifier: ^14.2.4 @@ -218,9 +218,9 @@ importers: version: 3.4.0 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)) + version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) zod: - specifier: ^4.0.5 + specifier: ^4.0.0 version: 4.3.5 zustand: specifier: ^5.0.7 @@ -252,7 +252,32 @@ importers: version: 8.5.6 tailwindcss: specifier: ^3.4.10 - version: 3.4.19(tsx@4.21.0) + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + + packages/api-schema: + dependencies: + axios: + specifier: ^1.6.7 + version: 1.13.2 + + packages/bruno-api-typescript: + dependencies: + commander: + specifier: ^11.1.0 + version: 11.1.0 + glob: + specifier: ^10.3.10 + version: 10.5.0 + yaml: + specifier: ^2.3.4 + version: 2.8.2 + devDependencies: + '@types/node': + specifier: ^20.10.6 + version: 20.19.30 typescript: specifier: ^5.3.3 version: 5.9.3 @@ -3531,6 +3556,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4046,6 +4075,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true global-directory@4.0.1: @@ -5813,6 +5843,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -8606,12 +8641,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@tanstack/devtools-client@0.0.3': dependencies: @@ -8640,7 +8675,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.3.12(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.3.12(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -8652,7 +8687,7 @@ snapshots: chalk: 5.6.2 launch-editor: 2.12.0 picomatch: 4.0.3 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - supports-color @@ -8764,19 +8799,19 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(webpack@5.104.1)': + '@tanstack/react-start@1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)': dependencies: '@tanstack/react-router': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-client': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-server': 1.158.0(crossws@0.4.4(srvx@0.10.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-utils': 1.158.0 '@tanstack/start-client-core': 1.158.0 - '@tanstack/start-plugin-core': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(webpack@5.104.1) + '@tanstack/start-plugin-core': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) '@tanstack/start-server-core': 1.158.0(crossws@0.4.4(srvx@0.10.1)) pathe: 2.0.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -8829,7 +8864,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(webpack@5.104.1)': + '@tanstack/router-plugin@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)': dependencies: '@babel/core': 7.28.6 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) @@ -8846,7 +8881,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) webpack: 5.104.1 transitivePeerDependencies: - supports-color @@ -8881,7 +8916,7 @@ snapshots: '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(webpack@5.104.1)': + '@tanstack/start-plugin-core@1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(crossws@0.4.4(srvx@0.10.1))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1)': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.6 @@ -8889,7 +8924,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.158.0 '@tanstack/router-generator': 1.158.0 - '@tanstack/router-plugin': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))(webpack@5.104.1) + '@tanstack/router-plugin': 1.158.0(@tanstack/react-router@1.158.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1) '@tanstack/router-utils': 1.158.0 '@tanstack/start-client-core': 1.158.0 '@tanstack/start-server-core': 1.158.0(crossws@0.4.4(srvx@0.10.1)) @@ -8899,8 +8934,8 @@ snapshots: srvx: 0.10.1 tinyglobby: 0.2.15 ufo: 1.6.3 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) - vitefu: 1.1.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -8997,11 +9032,11 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 22.19.7 + '@types/node': 20.19.30 '@types/conventional-commits-parser@5.0.2': dependencies: - '@types/node': 22.19.7 + '@types/node': 20.19.30 '@types/deep-eql@4.0.2': {} @@ -9024,7 +9059,7 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 22.19.7 + '@types/node': 20.19.30 '@types/long@4.0.2': optional: true @@ -9033,7 +9068,7 @@ snapshots: '@types/mysql@2.15.27': dependencies: - '@types/node': 22.19.7 + '@types/node': 20.19.30 '@types/node@20.19.30': dependencies: @@ -9049,7 +9084,7 @@ snapshots: '@types/pg@8.15.6': dependencies: - '@types/node': 22.19.7 + '@types/node': 20.19.30 pg-protocol: 1.11.0 pg-types: 2.2.0 @@ -9075,14 +9110,14 @@ snapshots: '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 - '@types/node': 22.19.7 + '@types/node': 20.19.30 '@types/tough-cookie': 4.0.5 form-data: 2.5.5 optional: true '@types/tedious@4.0.14': dependencies: - '@types/node': 22.19.7 + '@types/node': 20.19.30 '@types/tough-cookie@4.0.5': optional: true @@ -9092,7 +9127,7 @@ snapshots: next: 14.2.35(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@vitejs/plugin-react@5.1.3(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))': + '@vitejs/plugin-react@5.1.3(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -9100,7 +9135,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.2 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -9112,13 +9147,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -9512,6 +9547,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@11.1.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -10242,7 +10279,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.19.7 + '@types/node': 20.19.30 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -10586,7 +10623,7 @@ snapshots: nf3@0.3.7: {} - nitro-nightly@3.0.1-20260202-124820-1954b824(chokidar@3.6.0)(lru-cache@11.2.5)(rollup@4.55.1)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)): + nitro-nightly@3.0.1-20260202-124820-1954b824(chokidar@3.6.0)(lru-cache@11.2.5)(rollup@4.55.1)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.10.1) @@ -10604,7 +10641,7 @@ snapshots: unstorage: 2.0.0-alpha.5(chokidar@3.6.0)(db0@0.3.4)(lru-cache@11.2.5)(ofetch@2.0.0-alpha.3) optionalDependencies: rollup: 4.55.1 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -10813,13 +10850,14 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 tsx: 4.21.0 + yaml: 2.8.2 postcss-media-query-parser@0.2.3: {} @@ -10884,7 +10922,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.19.7 + '@types/node': 20.19.30 long: 5.3.2 optional: true @@ -11267,11 +11305,11 @@ snapshots: tailwind-merge@3.4.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)): + tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19(tsx@4.21.0) + tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.2) - tailwindcss@3.4.19(tsx@4.21.0): + tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -11290,7 +11328,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -11522,13 +11560,13 @@ snapshots: uuid@9.0.1: {} - vite-node@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0): + vite-node@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -11543,17 +11581,17 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)): + vite-tsconfig-paths@6.0.5(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0): + vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -11568,16 +11606,17 @@ snapshots: lightningcss: 1.30.2 terser: 5.46.0 tsx: 4.21.0 + yaml: 2.8.2 - vitefu@1.1.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)): + vitefu@1.1.1(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0): + vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -11595,8 +11634,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) - vite-node: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.7 @@ -11760,6 +11799,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.8.2: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/turbo.json b/turbo.json index af2eed45..693afe3d 100644 --- a/turbo.json +++ b/turbo.json @@ -7,6 +7,10 @@ "outputs": [".next/**", "!.next/cache/**", "dist/**", ".output/**"], "env": ["NODE_ENV", "NEXT_PUBLIC_*"] }, + "sync:bruno": { + "outputs": ["src/apis/**"], + "cache": false + }, "lint": { "dependsOn": ["^lint"], "outputs": []