Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/api/apiSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiSource, useDevToolsStore } from '@/store/devToolsStore';

export function getApiSource(): ApiSource {
return useDevToolsStore.getState().apiSource;
}

export function isServerApiSource() {
return getApiSource() === 'server';
}
57 changes: 56 additions & 1 deletion src/api/homeApi.ts
Original file line number Diff line number Diff line change
@@ -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<FeaturedPlaylist[]>('/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<MoodRecommendation[]>('/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<MusicLogItem[]>('/v1/home/recent-music-logs')
: mockServer.home.getRecentMusicLogs(),
};
10 changes: 9 additions & 1 deletion src/api/playlistApi.ts
Original file line number Diff line number Diff line change
@@ -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<PlaylistCuration>(`/v1/playlists/${id ?? 'seoul-night'}`)
: mockServer.playlist.getPlaylist(id),
};
14 changes: 13 additions & 1 deletion src/api/recapApi.ts
Original file line number Diff line number Diff line change
@@ -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<RecapItem[]>('/v1/recaps')
: mockServer.recap.getRecapList(),
getRecapShare: (id?: string) =>
isServerApiSource() && id
? requestEnvelope<RecapShare>(`/v1/recaps/${id}/share`)
: mockServer.recap.getRecapShare(id),
};
112 changes: 112 additions & 0 deletions src/api/soundlogClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
type Envelope<T> = {
data: T;
};

type RequestOptions = {
auth?: boolean;
body?: unknown;
headers?: Record<string, string>;
method?: string;
query?: Record<string, unknown>;
};

const DEFAULT_API_BASE_URL = 'http://localhost:4000';
const DEV_DEVICE_ID = 'local-soundlog-user';

let accessToken: string | undefined;
let loginPromise: Promise<string> | undefined;

function getApiBaseUrl() {
return (
process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL ?? DEFAULT_API_BASE_URL
).replace(/\/$/, '');
}

function appendQuery(url: URL, query?: Record<string, unknown>) {
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<T>(response: Response): Promise<T> {
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<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const url = new URL(`${getApiBaseUrl()}${path}`);
appendQuery(url, options.query);

const headers: Record<string, string> = {
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<Envelope<T>>(response);

return envelope.data;
}

106 changes: 12 additions & 94 deletions src/api/tourApi.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<PlaceContext[]> {
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<PlaceContext[]>('/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);
},
};
Loading
Loading