From e93952905c0f4123ca9e3b805513f9481a2d6332 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sat, 6 Jun 2026 20:21:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20API=20=EC=86=8C=EC=8A=A4=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/apiSource.ts | 9 +++ src/api/homeApi.ts | 57 ++++++++++++- src/api/playlistApi.ts | 10 ++- src/api/recapApi.ts | 14 +++- src/api/soundlogClient.ts | 112 ++++++++++++++++++++++++++ src/api/tourApi.ts | 106 +++--------------------- src/components/dev/DevTestManager.tsx | 63 ++++++++++++--- src/store/devToolsStore.ts | 12 +++ 8 files changed, 276 insertions(+), 107 deletions(-) create mode 100644 src/api/apiSource.ts create mode 100644 src/api/soundlogClient.ts diff --git a/src/api/apiSource.ts b/src/api/apiSource.ts new file mode 100644 index 0000000..42e1a22 --- /dev/null +++ b/src/api/apiSource.ts @@ -0,0 +1,9 @@ +import { ApiSource, useDevToolsStore } from '@/store/devToolsStore'; + +export function getApiSource(): ApiSource { + return useDevToolsStore.getState().apiSource; +} + +export function isServerApiSource() { + return getApiSource() === 'server'; +} diff --git a/src/api/homeApi.ts b/src/api/homeApi.ts index 0a2b0c2..0d3e9ba 100644 --- a/src/api/homeApi.ts +++ b/src/api/homeApi.ts @@ -1,3 +1,58 @@ +import { isServerApiSource } from '@/api/apiSource'; +import { requestEnvelope } from '@/api/soundlogClient'; import { mockServer } from '@/mock-server'; +import type { + FeaturedPlaylist, + GeoPoint, + MoodRecommendation, + MusicLogItem, + PlaceContext, +} from '@/types/domain'; -export const homeApi = mockServer.home; +type FeaturedPlaylistParams = { + location?: GeoPoint; + locationRecommendationEnabled: boolean; + place?: PlaceContext; + recommendationMode?: string; +}; + +type MoodRecommendationParams = { + currentPlace?: PlaceContext; + moodFilter: string; + recommendationMode?: string; + preferredGenres?: string[]; + preferredMoods?: string[]; + topFilter: string; + travelStyles?: string[]; +}; + +export const homeApi = { + getFeaturedPlaylists: (params?: FeaturedPlaylistParams) => + isServerApiSource() + ? requestEnvelope('/v1/home/featured-playlists', { + query: { + locationRecommendationEnabled: + params?.locationRecommendationEnabled ?? true, + lat: params?.location?.lat, + lng: params?.location?.lng, + placeId: params?.place?.id, + }, + }) + : mockServer.home.getFeaturedPlaylists(params), + getMoodRecommendations: (params?: MoodRecommendationParams) => + isServerApiSource() + ? requestEnvelope('/v1/home/mood-recommendations', { + query: { + topFilter: params?.topFilter ?? '전체', + moodFilter: params?.moodFilter ?? '전체', + preferredGenres: params?.preferredGenres, + preferredMoods: params?.preferredMoods, + travelStyles: params?.travelStyles, + }, + }) + : mockServer.home.getMoodRecommendations(params), + getRecentMusicLogs: () => + isServerApiSource() + ? requestEnvelope('/v1/home/recent-music-logs') + : mockServer.home.getRecentMusicLogs(), +}; diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index 4980753..c693245 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -1,3 +1,11 @@ +import { isServerApiSource } from '@/api/apiSource'; +import { requestEnvelope } from '@/api/soundlogClient'; import { mockServer } from '@/mock-server'; +import type { PlaylistCuration } from '@/types/domain'; -export const playlistApi = mockServer.playlist; +export const playlistApi = { + getPlaylist: (id?: string) => + isServerApiSource() + ? requestEnvelope(`/v1/playlists/${id ?? 'seoul-night'}`) + : mockServer.playlist.getPlaylist(id), +}; diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index 350a54b..329441e 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -1,3 +1,15 @@ +import { isServerApiSource } from '@/api/apiSource'; +import { requestEnvelope } from '@/api/soundlogClient'; import { mockServer } from '@/mock-server'; +import type { RecapItem, RecapShare } from '@/types/domain'; -export const recapApi = mockServer.recap; +export const recapApi = { + getRecapList: () => + isServerApiSource() + ? requestEnvelope('/v1/recaps') + : mockServer.recap.getRecapList(), + getRecapShare: (id?: string) => + isServerApiSource() && id + ? requestEnvelope(`/v1/recaps/${id}/share`) + : mockServer.recap.getRecapShare(id), +}; diff --git a/src/api/soundlogClient.ts b/src/api/soundlogClient.ts new file mode 100644 index 0000000..db6a23c --- /dev/null +++ b/src/api/soundlogClient.ts @@ -0,0 +1,112 @@ +type Envelope = { + data: T; +}; + +type RequestOptions = { + auth?: boolean; + body?: unknown; + headers?: Record; + method?: string; + query?: Record; +}; + +const DEFAULT_API_BASE_URL = 'http://localhost:4000'; +const DEV_DEVICE_ID = 'local-soundlog-user'; + +let accessToken: string | undefined; +let loginPromise: Promise | undefined; + +function getApiBaseUrl() { + return ( + process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL ?? DEFAULT_API_BASE_URL + ).replace(/\/$/, ''); +} + +function appendQuery(url: URL, query?: Record) { + if (!query) { + return; + } + + Object.entries(query).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + + if (Array.isArray(value)) { + url.searchParams.set(key, value.join(',')); + return; + } + + url.searchParams.set(key, String(value)); + }); +} + +async function parseJsonResponse(response: Response): Promise { + const body = await response.json().catch(() => undefined); + + if (!response.ok) { + const message = + body?.error?.message ?? `Soundlog API request failed: ${response.status}`; + throw new Error(message); + } + + return body as T; +} + +async function ensureAccessToken() { + if (accessToken) { + return accessToken; + } + + if (!loginPromise) { + loginPromise = requestEnvelope<{ + accessToken: string; + expiresIn: number; + refreshToken: string; + }>('/v1/auth/social-login', { + auth: false, + method: 'POST', + body: { + provider: 'google', + providerToken: 'local-dev-token', + deviceId: DEV_DEVICE_ID, + }, + }).then((tokens) => { + accessToken = tokens.accessToken; + return tokens.accessToken; + }); + } + + return loginPromise; +} + +export async function requestEnvelope( + path: string, + options: RequestOptions = {}, +): Promise { + const url = new URL(`${getApiBaseUrl()}${path}`); + appendQuery(url, options.query); + + const headers: Record = { + Accept: 'application/json', + ...options.headers, + }; + + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + if (options.auth ?? true) { + headers.Authorization = `Bearer ${await ensureAccessToken()}`; + } + + const response = await fetch(url.toString(), { + method: options.method ?? 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const envelope = await parseJsonResponse>(response); + + return envelope.data; +} + diff --git a/src/api/tourApi.ts b/src/api/tourApi.ts index 67961b7..b337f20 100644 --- a/src/api/tourApi.ts +++ b/src/api/tourApi.ts @@ -1,4 +1,5 @@ -import { mapTourLocationItems } from '@/mappers/tourMappers'; +import { isServerApiSource } from '@/api/apiSource'; +import { requestEnvelope } from '@/api/soundlogClient'; import { mockServer } from '@/mock-server'; import { GeoPoint, PlaceContext } from '@/types/domain'; @@ -7,102 +8,19 @@ type NearbyPlacesParams = { radiusMeters?: number; }; -type TourApiResponse = { - response?: { - body?: { - items?: { - item?: unknown; - }; - }; - header?: { - resultCode?: string; - resultMsg?: string; - }; - }; -}; - -const DEFAULT_TOUR_API_BASE_URL = 'https://apis.data.go.kr/B551011/KorService2'; const DEFAULT_RADIUS_METERS = 2000; -const TOUR_API_TIMEOUT_MS = 4500; - -function getTourApiBaseUrl() { - return process.env.EXPO_PUBLIC_TOUR_API_BASE_URL ?? DEFAULT_TOUR_API_BASE_URL; -} - -function getTourApiServiceKey() { - return process.env.EXPO_PUBLIC_TOUR_API_SERVICE_KEY; -} - -function getEncodedServiceKey(serviceKey: string) { - return serviceKey.includes('%') ? serviceKey : encodeURIComponent(serviceKey); -} - -function buildLocationBasedListUrl({ - location, - radiusMeters = DEFAULT_RADIUS_METERS, -}: NearbyPlacesParams) { - const serviceKey = getTourApiServiceKey(); - - if (!serviceKey) { - return undefined; - } - - const endpoint = `${getTourApiBaseUrl().replace(/\/$/, '')}/locationBasedList2`; - const params = new URLSearchParams({ - MobileApp: 'Soundlog', - MobileOS: 'ETC', - _type: 'json', - arrange: 'E', - mapX: String(location.lng), - mapY: String(location.lat), - numOfRows: '10', - pageNo: '1', - radius: String(radiusMeters), - }); - - return `${endpoint}?serviceKey=${getEncodedServiceKey(serviceKey)}&${params.toString()}`; -} - -async function fetchWithTimeout(url: string) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), TOUR_API_TIMEOUT_MS); - - try { - const response = await fetch(url, { signal: controller.signal }); - - if (!response.ok) { - throw new Error(`tour_api_http_${response.status}`); - } - - return (await response.json()) as TourApiResponse; - } finally { - clearTimeout(timeoutId); - } -} export const tourApi = { async getNearbyPlaces(params: NearbyPlacesParams): Promise { - const url = buildLocationBasedListUrl(params); - - if (!url) { - return mockServer.tour.getNearbyPlaces(params); - } - - try { - const data = await fetchWithTimeout(url); - const resultCode = data.response?.header?.resultCode; - - if (resultCode && resultCode !== '0000') { - return mockServer.tour.getNearbyPlaces(params); - } - - const places = mapTourLocationItems(data.response?.body?.items?.item); - - return places.length > 0 - ? places - : mockServer.tour.getNearbyPlaces(params); - } catch { - return mockServer.tour.getNearbyPlaces(params); - } + return isServerApiSource() + ? requestEnvelope('/v1/tour/nearby-places', { + auth: false, + query: { + lat: params.location.lat, + lng: params.location.lng, + radiusMeters: params.radiusMeters ?? DEFAULT_RADIUS_METERS, + }, + }) + : mockServer.tour.getNearbyPlaces(params); }, }; diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index cebda75..00b6678 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -212,21 +212,43 @@ function DevTestManagerContent() { useHomeFilterStore(); const { currentLocation, currentPlace, locationStatus, selectedMode, session } = useTravelSessionStore(); - const travelSessionActions = useTravelSessionStore((state) => ({ - clearLocation: state.clearLocation, - endSession: state.endSession, - resetSession: state.resetSession, - setLocation: state.setLocation, - setLocationStatus: state.setLocationStatus, - setMode: state.setMode, - setPlace: state.setPlace, - startSession: state.startSession, - })); + const clearLocation = useTravelSessionStore((state) => state.clearLocation); + const endSession = useTravelSessionStore((state) => state.endSession); + const resetSession = useTravelSessionStore((state) => state.resetSession); + const setLocation = useTravelSessionStore((state) => state.setLocation); + const setLocationStatus = useTravelSessionStore((state) => state.setLocationStatus); + const setMode = useTravelSessionStore((state) => state.setMode); + const setPlace = useTravelSessionStore((state) => state.setPlace); + const startSession = useTravelSessionStore((state) => state.startSession); + const travelSessionActions = useMemo( + () => ({ + clearLocation, + endSession, + resetSession, + setLocation, + setLocationStatus, + setMode, + setPlace, + startSession, + }), + [ + clearLocation, + endSession, + resetSession, + setLocation, + setLocationStatus, + setMode, + setPlace, + startSession, + ], + ); const { + apiSource, failedEndpointIds, failAllEndpoints, mockDelayMs, resetMockRuntime, + setApiSource, setFailAllEndpoints, setMockDelayMs, toggleFailedEndpoint, @@ -361,6 +383,10 @@ function DevTestManagerContent() { toggleFailedEndpoint(endpointId); invalidateMockQueries(); }; + const selectApiSource = (nextApiSource: typeof apiSource) => { + setApiSource(nextApiSource); + invalidateMockQueries(); + }; return ( <> @@ -422,11 +448,28 @@ function DevTestManagerContent() { + + + selectApiSource('mock')} + /> + selectApiSource('server')} + /> + + navigate('/')} /> navigate('/onboarding')} /> diff --git a/src/store/devToolsStore.ts b/src/store/devToolsStore.ts index 97359b3..808e1f0 100644 --- a/src/store/devToolsStore.ts +++ b/src/store/devToolsStore.ts @@ -2,6 +2,8 @@ import { create } from 'zustand'; import { MockEndpointId } from '@/mock-server/types'; +export type ApiSource = 'mock' | 'server'; + export const mockEndpointIds: MockEndpointId[] = [ 'home.featuredPlaylists', 'home.moodRecommendations', @@ -13,16 +15,25 @@ export const mockEndpointIds: MockEndpointId[] = [ ]; type DevToolsState = { + apiSource: ApiSource; failedEndpointIds: MockEndpointId[]; failAllEndpoints: boolean; mockDelayMs?: number; resetMockRuntime: () => void; + setApiSource: (apiSource: ApiSource) => void; setFailAllEndpoints: (failAllEndpoints: boolean) => void; setMockDelayMs: (mockDelayMs?: number) => void; toggleFailedEndpoint: (endpointId: MockEndpointId) => void; }; +function getInitialApiSource(): ApiSource { + return process.env.EXPO_PUBLIC_SOUNDLOG_API_SOURCE === 'server' + ? 'server' + : 'mock'; +} + export const useDevToolsStore = create((set) => ({ + apiSource: getInitialApiSource(), failedEndpointIds: [], failAllEndpoints: false, mockDelayMs: undefined, @@ -32,6 +43,7 @@ export const useDevToolsStore = create((set) => ({ failAllEndpoints: false, mockDelayMs: undefined, }), + setApiSource: (apiSource) => set({ apiSource }), setFailAllEndpoints: (failAllEndpoints) => set({ failAllEndpoints }), setMockDelayMs: (mockDelayMs) => set({ mockDelayMs }), toggleFailedEndpoint: (endpointId) =>