Skip to content

Latest commit

 

History

History
819 lines (567 loc) · 22.3 KB

File metadata and controls

819 lines (567 loc) · 22.3 KB

API Contract (Server ↔ iOS)

갱신: 2026-05-13 | V2 음식 검색/레시피 상세/조리 모드 계약 반영


1. 전역 규칙

1.1 Base URL / Headers

Base URL : AppConfig.baseURL (기본 http://localhost:8000)
Timeout  : 30초 (기본), 파싱 API 300초
헤더 비고
Content-Type application/json 모든 요청
X-Device-Id DeviceManager.deviceId 기기 식별 (리뷰/기록 귀속)

1.2 응답 래퍼

모든 성공 응답은 SuccessResponse<T> 래퍼:

{ "success": true, "data": <T> }

에러 응답:

{ "success": false, "error": { "code": "RECIPE_NOT_FOUND", "message": "레시피를 찾을 수 없습니다." } }

1.3 페이지네이션

PaginatedData<T>:

{ "items": [...], "total": 120, "page": 1, "size": 20, "total_pages": 6 }

쿼리: ?page=1&size=20

1.4 직렬화

방향 전략 예시
서버→iOS (디코딩) convertFromSnakeCase recipe_idrecipeId
iOS→서버 (인코딩) convertToSnakeCase recipeIdrecipe_id

1.5 ID 규칙

  • 서버는 id 필드 사용 (Mongo _id는 서버에서 id로 정규화)
  • iOS에서 CodingKeys 제거 — convertFromSnakeCase 전략과 충돌 방지

2. 엔드포인트 전체 목록

2.0 V2 음식 검색 (FoodV2Service)

신규 검색 UX는 legacy /api/recipes 목록 검색이 아니라 V2 음식 중심 흐름을 사용한다. V3 v3_* 컬렉션과 endpoint는 lab 전용이며 앱에서 직접 호출하지 않는다.

GET /api/v2/dishes/search — 음식 검색

쿼리: ?q=떡볶이&page=1&size=20

응답: PaginatedData<V2Dish>

호출: FoodSearchViewModel.search()


GET /api/v2/dishes/{dishId} — 음식 상세/variant

응답: V2Dish (variants 포함 가능)

호출: V2DishDetailViewModel.load()


GET /api/v2/dishes/{dishId}/creators — 유튜버별 비교

응답: PaginatedData<V2DishCreatorProfile>

creator.display_name, creator.thumbnail_url, top-level thumbnail_url, style_summary, common_style_tags, signature_ingredients, comment_recipe_rating을 유튜버 비교 카드에 사용한다. 앱은 creator.display_name이 비어 있고 creator_idyoutube:UC... 형태이면 원시 id를 노출하지 않고 일반 채널 fallback과 썸네일/이니셜 아바타를 표시한다.

호출: V2DishDetailViewModel.load()


GET /api/v2/dishes/{dishId}/recipes — 음식별 레시피 목록

쿼리:

파라미터 타입 기본값 비고
variant_id String? 음식 variant 필터
creator_id String? 유튜버 필터
style_tags String? 쉼표 구분 스타일 필터
sort String quality_score iOS 기본 정렬
order String desc
page, size Int 1, 20

응답: PaginatedData<V2Recipe>

호출: V2DishDetailViewModel.load()


GET /api/v2/recipes/{recipeId} — V2 레시피 상세

응답: V2Recipe

호출: V2RecipeDetailSheet.loadDetail()

상세 요청이 404 또는 네트워크 오류를 반환해도 목록에서 받은 V2Recipe 데이터가 있으면 사용자에게 서버 오류 배너를 노출하지 않고 그대로 조리 시작까지 진행한다.


GET /api/v2/recipes/{recipeId}/steps — V2 조리 모드 단계

응답:

{
  "recipe_id": "recipe-id",
  "title": "떡볶이",
  "steps": [
    { "step_number": 1, "instruction": "...", "timer_seconds": 180, "timer_label": "양념 끓이기", "timestamp": "02:10", "tip": "...", "action_type": "cook" }
  ],
  "ingredients": [
    { "name": "", "amount": 300, "unit": "g", "note": null, "is_main": true }
  ]
}

timer_seconds, timer_label, tip, action_type은 조리 UI에서 그대로 사용한다. action_type UI 매핑: prep 준비, cook 가열, wait 대기, plate 담기.

호출: CookingViewModel.startSession(recipe: V2Recipe)

/steps 요청이 시간 초과/서버 오류를 반환했지만 현재 V2Recipe.steps가 있으면 조리모드는 embedded steps로 즉시 시작한다. 이 경우 이전 timeout/offline 배너를 유지하지 않는다.


2.1 레시피 (RecipeService)

Legacy 레시피 저장/파싱/리뷰 호환 경로다. 새 음식 검색과 V2 데이터 조리모드는 위 FoodV2Service를 사용한다.

POST /api/recipes/parse — 레시피 파싱 (유튜브/인스타)

Timeout: 300초 (AppConfig.parseTimeoutSeconds)

요청:

// 유튜브
{ "youtube_url": "https://youtube.com/watch?v=...", "instagram_url": null, "source_type": "youtube" }
// 인스타그램
{ "youtube_url": null, "instagram_url": "https://instagram.com/p/...", "source_type": "instagram" }

응답: Recipe (전체 필드 — §3.1 참조)

호출: HomeViewModel.parseRecipe()


GET /api/recipes — 레시피 목록

쿼리:

파라미터 타입 기본값 비고
page Int 1
size Int 20
category String? 카테고리 필터
tags String? 태그 필터
search String? 검색어
sort String created_at 정렬 기준
order String desc asc/desc

응답: PaginatedData<RecipeListItem>

호출: RecipeListViewModel.fetchPage()


GET /api/recipes/{id} — 레시피 상세

응답: Recipe

호출: RecipeDetailViewModel


GET /api/recipes/{id}/steps — 요리 단계 (요리 모드용)

응답:

{
  "recipe_id": "abc123",
  "title": "김치찌개",
  "steps": [
    { "step_number": 1, "instruction": "...", "timer_seconds": 300, "timer_label": "끓이기", "timestamp": "0:30", "tip": "...", "action_type": "cook" }
  ],
  "ingredients": [
    { "id": "...", "name": "김치", "amount": 200, "unit": "g", "note": null, "is_main": true }
  ]
}

호출: CookingViewModel.startSession()


DELETE /api/recipes/{id} — 레시피 삭제

응답: { "deleted": true }

호출: RecipeDetailViewModel


POST /api/my-recipes — 나만의 레시피 등록

요청:

{
  "title": "나만의 파스타",
  "description": "간단 레시피",
  "servings": "2인분",
  "total_time_minutes": 30,
  "difficulty": "easy",
  "category": "양식",
  "tags": ["파스타", "간단"],
  "source_type": "custom",
  "ingredients": [
    { "name": "스파게티면", "amount": 200, "unit": "g", "note": null, "is_main": true }
  ],
  "steps": [
    { "step_number": 1, "instruction": "물을 끓인다", "timer_seconds": 600, "tip": "소금 한 꼬집" }
  ]
}

응답: Recipe

호출: UserRecipeCreateViewModel


GET /api/my-recipes — 내 레시피 목록

쿼리: ?page=1&size=20

응답: PaginatedData<RecipeListItem>

호출: 현재 미사용 (향후 내 레시피 화면)


2.2 바코드 (RecipeService)

바코드는 legacy 기능으로 유지한다. 현재 V2 앱 탭/검색/조리 흐름에서는 새 진입점으로 쓰지 않는다.

POST /api/barcode/search — 바코드 상품 검색

요청:

{ "barcode": "8801234567890" }

입력 타입: 바코드 문자열(권장 8~14자리 숫자), 또는 QR 문자열.

클라이언트 정규화 규칙:

  • 바코드/QR 원문에서 공백 제거
  • 바코드 문자열에서 숫자 시퀀스를 추출해 8~14자리이면 우선 사용
  • 추출 불가 시 원문 문자열 그대로 전송

응답:

{
  "product": {
    "id": "...",
    "barcode": "8801234567890",
    "product_name": "CJ 햇반",
    "name": "햇반",
    "brand": "CJ",
    "category": "가공식품",
    "image_url": "https://..."
  },
  "extracted_ingredients": ["", ""],
  "recipes": [ /* RecommendationItem[] */ ],
  "message": "2개의 레시피를 찾았습니다."
}

extracted_ingredients는 바코드/QR로부터 추출한 재료명 후보이며, 검색 실패 시 null 가능.

필드 타입 Nullable 설명
product BarcodeProductInfo 미등록 바코드 시 null
extracted_ingredients [String] 상품에서 추출한 재료명
recipes [RecommendationItem] 재료 기반 추천 레시피
message String 사용자 안내 메시지

호출: BarcodeScanViewModel.searchBarcode()


2.3 요리 세션 (CookingService)

POST /api/cooking/start — 요리 시작

요청:

{ "recipe_id": "abc123" }

응답:

{ "session_id": "sess_xyz", "recipe_id": "abc123", "started_at": "2026-03-19T10:00:00Z" }

호출: CookingViewModel.startSession()


PATCH /api/cooking/{sessionId}/step — 현재 단계 갱신

요청:

{ "step_number": 3 }

응답: { "step_number": 3 }

호출: CookingViewModel.onStepChanged() (단계 이동 시 자동)


POST /api/cooking/{sessionId}/complete — 요리 완료

요청:

{ "deducted_ingredients": ["김치", "돼지고기", "두부"] }

응답:

{
  "completed_at": "2026-03-19T11:30:00Z",
  "deducted": [
    { "name": "김치", "before": 500, "after": 300, "deleted": false },
    { "name": "두부", "before": 1, "after": 0, "deleted": true }
  ],
  "rating_reminder_at": "2026-03-19T14:30:00Z",
  "calories_per_serving": 450,
  "servings_count": 2,
  "servings_label": "2인분",
  "total_calories": 900
}
필드 타입 Nullable 설명
completed_at String ISO 8601 완료 시각
deducted [DeductedItem] 차감 결과 (빈 배열 가능)
rating_reminder_at String 별점 리마인더 시각
calories_per_serving Int 1인분 칼로리
servings_count Int 인분 수
servings_label String "2인분" 등 표시 문구
total_calories Int 총 칼로리 (HealthKit 연동용)

HealthKit 연동: total_calories > 0이면 HealthKitNutritionLogger.recordMealCompletion() 호출. 권한 거부 시 VoiceGuide.healthKitDenied 텍스트 표시.

호출: CookingViewModel.completeSession()


POST /api/cooking/{sessionId}/rate — 별점 등록

요청:

{ "rating": 4, "memo": "맛있었어요" }
필드 타입 Nullable 범위
rating Int 1~5
memo String

응답:

{ "rating": 4, "rated_at": "2026-03-19T12:00:00Z" }

호출: CookingViewModel+Voice.submitRating(), HistoryViewModel


GET /api/cooking/history — 조리 기록

쿼리: ?page=1&size=20

응답: PaginatedData<CookingHistoryItem>

{
  "id": "hist_123",
  "recipe_id": "abc123",
  "recipe_title": "김치찌개",
  "recipe_thumbnail": "https://...",
  "started_at": "2026-03-19T10:00:00Z",
  "completed_at": "2026-03-19T11:30:00Z",
  "is_completed": true,
  "rating": 4
}

호출: HistoryViewModel


2.4 채팅 (ChatService)

POST /api/chat — AI 채팅 질의

요청:

{
  "session_id": "sess_xyz",
  "recipe_id": "abc123",
  "message": "이 단계에서 불 세기는?",
  "current_step": 3,
  "session_context": {
    "active_timers": [
      { "label": "끓이기", "remaining_seconds": 120 }
    ],
    "completed_steps": [1, 2]
  }
}

응답:

{ "answer": "중불로 조리하세요.", "suggestions": ["다음 단계", "타이머 확인"] }
필드 타입 Nullable
answer String
suggestions [String]

오프라인 폴백: 네트워크 미연결 시 LocalCookingAssistant.fallbackReply() → 키워드 매칭 응답. 오프라인 음성 모드 시 LocalCookingAssistant.reply() → Foundation Models / llama.cpp / 키워드 3단 폴백.

호출: CookingViewModel+Chat.sendChat()


POST /api/chat/proactive — 능동 안내 (타이머 완료 등)

요청:

{
  "session_id": "sess_xyz",
  "recipe_id": "abc123",
  "event_type": "timer_completed",
  "current_step": 3,
  "event_data": { "label": "끓이기" }
}

응답:

{ "message": "끓이기가 완료되었습니다. 불을 줄이고 5분간 뜸을 들이세요.", "suggestions": ["다음 단계"] }

호출: CookingViewModel.handleTimerComplete()


GET /api/chat/history/{sessionId} — 채팅 기록

응답: 서버 [{ role, message }] → iOS [ChatMessage] 변환

호출: 현재 미사용 (향후 채팅 기록 복원)


2.5 리뷰 (ReviewService)

GET /api/recipes/{recipeId}/reviews — 리뷰 목록

쿼리: ?page=1&size=20

응답: PaginatedData<Review>

{
  "id": "rev_123",
  "recipe_id": "abc123",
  "device_id": "device_xyz",
  "rating": 4,
  "comment": "맛있어요!",
  "created_at": "2026-03-19T12:00:00Z",
  "updated_at": null
}

호출: ReviewViewModel.loadInitial(), ReviewViewModel.loadMore()


POST /api/recipes/{recipeId}/reviews — 리뷰 작성

요청:

{ "rating": 5, "comment": "정말 맛있어요" }

응답: Review

호출: ReviewViewModel.submitReview()


GET /api/recipes/{recipeId}/reviews/summary — 리뷰 통계

응답:

{ "average_rating": 4.2, "rating_count": 15 }
필드 타입 Nullable
average_rating Double
rating_count Int

호출: ReviewViewModel.loadInitial(), CookingViewModel+Voice.fetchAndReadReviews() (음성 리뷰 조회)


GET /api/recipes/{recipeId}/reviews/mine — 내 리뷰 조회

응답: Review? (없으면 null)

호출: ReviewViewModel.loadInitial()


DELETE /api/recipes/reviews/{reviewId} — 리뷰 삭제

응답: { "deleted": true }

호출: 현재 미사용 (요리 중 음성 삭제는 차단 → 터치 안내)


2.6 냉장고 재료 (IngredientService)

GET /api/ingredients — 재료 목록

쿼리: ?category=육류 (선택)

응답: [UserIngredient]

호출: FridgeViewModel.load()


POST /api/ingredients — 재료 등록

요청:

{
  "name": "돼지고기",
  "category": "육류",
  "quantity": 500,
  "unit": "g",
  "storage_type": "fridge",
  "purchase_date": "2026-03-15",
  "expiry_date": "2026-03-22",
  "is_opened": false,
  "memo": null
}

응답: UserIngredient

호출: FridgeViewModel.addIngredient()


PUT /api/ingredients/{id} — 재료 수정

요청: 부분 업데이트 (모든 필드 optional)

{ "quantity": 300, "is_opened": true }

응답: UserIngredient

호출: FridgeViewModel.editIngredient()


DELETE /api/ingredients/{id} — 재료 삭제

응답: { "deleted": true }

호출: FridgeViewModel.deleteIngredient()


GET /api/ingredients/expiring — 임박 재료

쿼리: ?days=3

응답: [UserIngredient]

호출: FridgeViewModel.load()


2.7 추천 (RecommendationService)

GET /api/recommendations/popular — 인기 레시피

쿼리: ?period=weekly&limit=10

응답: [RecommendationItem]

호출: RecommendationViewModel.load(), HomeViewModel.loadHome()


GET /api/recommendations/random — 랜덤 추천

쿼리: ?count=20&category=한식&tags=매운

응답: [RecommendationItem]

호출: RecommendationViewModel.load(), HomeViewModel.loadHome()


GET /api/recommendations/by-ingredients — 냉장고 기반 추천

쿼리: ?limit=20

응답: [RecommendationItem] (matchScore, matchedIngredients, missingIngredients 포함)

호출: RecommendationViewModel.load()


GET /api/recommendations/expiring — 임박 재료 기반 추천

쿼리: ?days=3&limit=10

응답: [RecommendationItem]

호출: RecommendationViewModel.load(), HomeViewModel.loadHome()


2.8 카테고리/태그 (CategoryService)

GET /api/categories — 카테고리 목록

응답: ["한식", "중식", "일식", "양식", ...]

호출: RecipeListViewModel.loadInitial()


GET /api/tags — 태그 목록

응답: ["매운", "간단", "다이어트", ...]

호출: RecipeListViewModel.loadInitial()


3. 모델 필드 계약

3.1 Recipe (전체)

필드 (iOS) 서버 키 타입 Nullable 비고
id id String
youtubeVideoId youtube_video_id String
youtubeUrl youtube_url String
instagramUrl instagram_url String
channelName channel_name String
channelId channel_id String
thumbnailUrl thumbnail_url String
title title String
description description String
servings servings String "2인분"
totalTimeMinutes total_time_minutes Int
difficulty difficulty String easy/medium/hard
category category String
tags tags [String]
ingredients ingredients [IngredientItem]
steps steps [RecipeStep]
commentAnalysis comment_analysis CommentAnalysis
viewCount view_count Int
cookCount cook_count Int
status status String
parsingTimeSeconds parsing_time_seconds Double
sourceType source_type String youtube/instagram/custom/user_upload
createdAt created_at String ISO 8601
caloriesPerServing calories_per_serving Int AI 추정 칼로리
estimationMethod estimation_method String ai/rule_based/failed
deliveryPriceKrw delivery_price_krw Int 배달 예상가 (신규 필드)
estimatedCostKrw estimated_cost_krw Int 집밥 원가 (신규 필드)
costSavingKrw cost_saving_krw Int 절감액 (신규 필드)
averageRating average_rating Double
ratingCount rating_count Int
deviceId device_id String 작성자 기기 ID

비용 필드 정합: 현재는 _krw 시리즈(delivery_price_krw, estimated_cost_krw, cost_saving_krw)만 클라이언트에서 수신/표시 대상입니다. 구버전(homeCookingCost, estimatedDeliveryCost, savingsAmount)은 서버 응답 계약에서 제외됐습니다.

3.2 RecipeListItem / RecommendationItem

Recipe의 서브셋 + 추천 전용 필드:

추천 전용 필드 타입 비고
matchScore Double? 냉장고 매칭 점수 (0~1)
matchedIngredients [String]? 매칭된 재료명
missingIngredients [String]? 부족 재료명

3.3 CodingKeys 예외

IngredientItemCodingKeys 사용 — id 필드 옵셔널 폴백 (서버 미제공 시 UUID 자동 생성).


4. 에러 코드

HTTP 서버 코드 의미 iOS 처리
400 INVALID_REQUEST 잘못된 요청 APIError.serveruserMessage 표시
404 RECIPE_NOT_FOUND 레시피 없음 에러 메시지 표시
404 SESSION_NOT_FOUND 세션 없음 에러 메시지 표시
409 ALREADY_RATED 이미 별점 등록 VoiceGuide.ratingFailed
500 INTERNAL_ERROR 서버 내부 오류 키워드 폴백 or 에러 표시

5. ViewModel → Service 호출 맵

ViewModel Service 호출
HomeViewModel RecommendationService.popular/random/expiring, RecipeService.parse/parseInstagram
FoodSearchViewModel FoodV2Service.status/searchDishes
V2DishDetailViewModel FoodV2Service.dish/recipes/creators
V2RecipeDetailSheet FoodV2Service.recipe
RecipeListViewModel CategoryService.categories/tags, RecipeService.list
RecipeDetailViewModel RecipeService.get/delete
BarcodeScanViewModel RecipeService.searchBarcode
UserRecipeCreateViewModel RecipeService.createCustom
CookingViewModel legacy: CookingService.start/updateStep/complete, RecipeService.getSteps; V2: FoodV2Service.recipeSteps; 공통: ChatService.proactive
CookingViewModel+Chat ChatService.chat
CookingViewModel+Voice CookingService.rate, ReviewService.stats
ReviewViewModel ReviewService.list/create/stats/myReview
HistoryViewModel CookingService.rate/history
FridgeViewModel IngredientService.list/create/update/delete/expiring
RecommendationViewModel RecommendationService.popular/random/byIngredients/expiring

6. 누락/미사용 엔드포인트 체크리스트

엔드포인트 구현 호출 상태
GET /api/my-recipes 내 레시피 화면 미구현
GET /api/chat/history/{sessionId} 채팅 기록 복원 미사용
DELETE /api/recipes/reviews/{reviewId} 요리 중 음성 삭제 차단, 터치 삭제 미연결
DELETE /api/recipes/{id} RecipeDetailView에서 사용
RecipeService.listMyRecipes() ViewModel 미연결

7. 직렬화 정책 / CodingKeys 가이드

  • 새 모델 추가 시 CodingKeys 없이 작성
  • 서버 키가 snake_case → iOS 프로퍼티는 camelCase로만 선언
  • 예외 매핑이 필요하면 최소 범위 CodingKeys 도입 + 이 문서 갱신
  • Mock 모드는 JSON 디코딩 경로를 타지 않으므로 실서버 테스트 필수

8. 변경 이력

2026-03-20

  • 전체 엔드포인트 계약 확정 (7개 Service, 28개 엔드포인트)
  • 요청/응답 페이로드 + 예시 JSON 추가
  • 바코드 API 입력 타입 명확화 (숫자 문자열/QR 문자열 지원)
  • 요리 완료 API 칼로리/HealthKit 필드 정합
  • 비용 필드 _krw 단일화 정책 반영
  • 누락 엔드포인트 체크리스트 추가
  • ViewModel→Service 호출 맵 추가

2026-03-05

  • 대상 파일 4개에서 지정 타입 7개의 CodingKeys 제거 정책 확정
  • API_CONTRACT.md 문서 신규 작성
  • 전역 원칙: APIClient의 snake_case 자동 변환 전략을 단일 소스로 유지