import Artist, { ArtistHandler } from './models/Artist'; import Album from './models/Album'; import Chapter from './models/Chapter'; import Lesson from './models/Lesson'; import Genre, { GenreHandler } from './models/Genre'; import LessonHistory from './models/LessonHistory'; import { LikedSong, LikedSongHandler } from './models/LikedSong'; import Song, { SongHandler, SongInclude } from './models/Song'; import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory'; import User, { UserHandler } from './models/User'; import store from './state/Store'; import { Platform } from 'react-native'; import { en } from './i18n/Translations'; import UserSettings, { UserSettingsHandler } from './models/UserSettings'; import { PartialDeep, RequireExactlyOne } from 'type-fest'; import SearchHistory, { SearchHistoryHandler } from './models/SearchHistory'; import { Query } from './Queries'; import CompetenciesTable from './components/CompetenciesTable'; import ResponseHandler from './models/ResponseHandler'; import { PlageHandler } from './models/Plage'; import { ListHandler } from './models/List'; import { AccessTokenResponseHandler } from './models/AccessTokenResponse'; import * as yup from 'yup'; import { base64ToBlob } from './utils/base64ToBlob'; import { ImagePickerAsset } from 'expo-image-picker'; import { SongCursorInfos, SongCursorInfosHandler } from './models/SongCursorInfos'; type AuthenticationInput = { username: string; password: string }; type RegistrationInput = AuthenticationInput & { email: string }; export type AccessToken = string; type FetchParams = { route: string; body?: object; formData?: FormData; method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT'; }; type HandleParams = RequireExactlyOne<{ raw: true; emptyResponse: true; handler: ResponseHandler; }>; // This Exception is intended to cover all business logic errors (invalid credentials, couldn't find a song, etc.) // technical errors (network, server, etc.) should be handled as standard Error exceptions // it helps to filter errors in the catch block, APIErrors messages should // be safe to use in combination with the i18n library export class APIError extends Error { constructor( message: string, public status: number, // Set the message to the correct error this is a placeholder // when the error is only used internally (middleman) public userMessage: keyof typeof en = 'unknownError' ) { super(message); } } export class ValidationError extends Error { constructor(message: string) { super(message); } } function getBaseAPIUrl() { if (Platform.OS === 'web') { if (__DEV__ && process.env.EXPO_PUBLIC_API_URL) { return process.env.EXPO_PUBLIC_API_URL; } return '/api'; } if (process.env.EXPO_PUBLIC_API_URL) { return process.env.EXPO_PUBLIC_API_URL; } // fallback since some mobile build seems to not have the env variable return 'https://nightly.chroma.octohub.app/api'; } export default class API { public static readonly baseUrl = getBaseAPIUrl(); public static async fetch( params: FetchParams, handle: Pick, 'raw'> ): Promise; public static async fetch( params: FetchParams, handle: Pick, 'emptyResponse'> ): Promise; public static async fetch( params: FetchParams, handle: Pick>, 'handler'> ): Promise; public static async fetch(params: FetchParams): Promise; public static async fetch(params: FetchParams, handle?: HandleParams) { const jwtToken = store.getState().user.accessToken; const headers = { ...(params.formData == undefined && { 'Content-Type': 'application/json' }), ...(jwtToken && { Authorization: `Bearer ${jwtToken}` }), }; const response = await fetch(`${API.baseUrl}${params.route}`, { headers: headers, body: params.formData ?? JSON.stringify(params.body), method: params.method ?? 'GET', }).catch(() => { throw new Error('Error while fetching API: ' + API.baseUrl); }); if (!handle || handle.emptyResponse) { if (!response.ok) { let responseMessage = response.statusText; try { const responseData = await response.json(); console.log(responseData); if (responseData.message) responseMessage = responseData.message; } catch (e) { console.log(e); throw new APIError(response.statusText, response.status, 'unknownError'); } throw new APIError(responseMessage, response.status, 'unknownError'); } return; } if (handle.raw) { return response.arrayBuffer(); } const handler = handle.handler; const body = await response.text(); if (!response.ok) { throw new APIError(response.statusText ?? body, response.status, 'unknownError'); } try { const jsonResponse = JSON.parse(body); const validated = await handler.validator.validate(jsonResponse).catch((e) => { if (e instanceof yup.ValidationError) { console.error(e, 'Got: ' + body); throw new ValidationError(e.message); } throw e; }); if (!handler.transformer) return handler.validator.cast(validated); return handler.transformer(handler.validator.cast(validated)); } catch (e) { if (e instanceof SyntaxError) throw new Error("Error while parsing Server's response"); console.error(e); throw e; } } public static async authenticate( authenticationInput: AuthenticationInput ): Promise { return API.fetch( { route: '/auth/login', body: authenticationInput, method: 'POST', }, { handler: AccessTokenResponseHandler } ) .then((responseBody) => responseBody.access_token) .catch((e) => { /// If validation fails, it means that auth failed. /// We want that 401 error to be thrown, instead of the plain validation vone if (e.status == 401) throw new APIError('invalidCredentials', 401, 'invalidCredentials'); throw e; }); } /** * Create a new user profile, with an email and a password * @param registrationInput the credentials to create a new profile * @returns A Promise. On success, will be resolved into an instance of the API wrapper */ public static async createAccount(registrationInput: RegistrationInput): Promise { await API.fetch({ route: '/auth/register', body: registrationInput, method: 'POST', }); // In the Future we should move autheticate out of this function // and maybe create a new function to create and login in one go return API.authenticate({ username: registrationInput.username, password: registrationInput.password, }); } public static async createAndGetGuestAccount(username: string): Promise { return API.fetch( { route: '/auth/guest', method: 'POST', body: { username }, }, { handler: AccessTokenResponseHandler } ) .then(({ access_token }) => access_token) .catch((e) => { if (e.status == 401) throw new APIError('invalidCredentials', 401, 'invalidCredentials'); if (!(e instanceof APIError)) throw e; throw e; }); } public static async transformGuestToUser(registrationInput: RegistrationInput): Promise { await API.fetch({ route: '/auth/me', body: registrationInput, method: 'PUT', }); } /*** * Retrieve information of the currently authentified user */ public static getUserInfo(): Query { return { key: 'user', exec: async () => API.fetch( { route: '/auth/me', }, { handler: UserHandler } ), }; } public static getUserSettings(): Query { return { key: 'settings', exec: () => API.fetch( { route: '/auth/me/settings', }, { handler: UserSettingsHandler, } ), }; } public static async updateUserSettings(settings: PartialDeep): Promise { const dto = { pushNotification: settings.notifications?.pushNotif, emailNotification: settings.notifications?.emailNotif, trainingNotification: settings.notifications?.trainNotif, newSongNotification: settings.notifications?.newSongNotif, recommendations: settings.recommendations, weeklyReport: settings.weeklyReport, leaderBoard: settings.leaderBoard, showActivity: settings.showActivity, }; return API.fetch({ method: 'PATCH', route: '/auth/me/settings', body: dto, }); } public static getUserSkills(): Query[0]> { return { key: 'skills', exec: async () => ({ pedalsCompetency: Math.random() * 100, rightHandCompetency: Math.random() * 100, leftHandCompetency: Math.random() * 100, accuracyCompetency: Math.random() * 100, arpegeCompetency: Math.random() * 100, chordsCompetency: Math.random() * 100, }), }; } public static getAllSongs(include?: SongInclude[]): Query { include ??= []; return { key: ['songs', include], exec: () => API.fetch( { route: `/song?include=${include!.join(',')}`, }, { handler: PlageHandler(SongHandler), } ).then(({ data }) => data), }; } /** * Retrieve a song * @param songId the id to find the song */ public static getSong(songId: number, include?: SongInclude[]): Query { include ??= []; return { key: ['song', songId, include], exec: async () => API.fetch( { route: `/song/${songId}?include=${include!.join(',')}`, }, { handler: SongHandler } ), }; } /** * @description retrieves songs from a specific artist * @param artistId is the id of the artist that composed the songs aimed * @returns a Promise of Songs type array */ public static getSongsByArtist(artistId: number): Query { return { key: ['artist', artistId, 'songs'], exec: () => API.fetch( { route: `/song?artistId=${artistId}`, }, { handler: PlageHandler(SongHandler) } ).then(({ data }) => data), }; } /** * Retrieves all songs corresponding to the given genre ID * @param genreId the id of the genre we're aiming * @returns a promise of an array of Songs */ public static getSongsByGenre(genreId: number, includes?: SongInclude[]): Query { includes ??= []; return { key: ['genre', genreId, 'songs', includes], exec: () => API.fetch( { route: `/song?genreId=${genreId}&includes=${includes!.join(',')}`, }, { handler: PlageHandler(SongHandler) } ).then(({ data }) => data), }; } /** * Retrive a song's midi partition * @param songId the id to find the song */ public static getSongMidi(songId: number): Query { return { key: ['midi', songId], exec: () => API.fetch( { route: `/song/${songId}/midi`, }, { raw: true, } ), }; } /** * Retrive a song's midi partition * @param songId the id to find the song */ public static getArtistIllustration(artistId: number): string { return `${API.baseUrl}/artist/${artistId}/illustration`; } /** * Retrive a song's midi partition * @param songId the id to find the song */ public static getGenreIllustration(genreId: number): string { return `${API.baseUrl}/genre/${genreId}/illustration`; } /** * Retrieves a genre * @param genreId the id of the aimed genre */ public static getGenre(genreId: number): Query { return { key: ['genre', genreId], exec: () => API.fetch( { route: `/genre/${genreId}`, }, { handler: GenreHandler } ), }; } public static getAllGenres(): Query { return { key: ['genres'], exec: () => API.fetch( { route: '/genre', }, { handler: PlageHandler(GenreHandler) } ).then(({ data }) => data), }; } /** * Retrive a song's musicXML partition * @param songId the id to find the song */ public static getSongMusicXML(songId: number): Query { return { key: ['musixml', songId], exec: () => API.fetch( { route: `/song/${songId}/musicXml`, }, { raw: true } ), }; } /** * Retrive an artist */ public static getArtist(artistId: number): Query { return { key: ['artist', artistId], exec: () => API.fetch( { route: `/artist/${artistId}`, }, { handler: ArtistHandler } ), }; } public static getAllArtists(): Query { return { key: ['artists'], exec: () => API.fetch( { route: `/artist`, }, { handler: PlageHandler(ArtistHandler) } ).then(({ data }) => data), }; } /** * Retrive a song's chapters * @param songId the id to find the song */ public static getSongChapters(songId: number): Query { return { key: ['chapters', songId], exec: async () => [1, 2, 3, 4, 5].map((value) => ({ start: 100 * (value - 1), end: 100 * value, songId: songId, name: `Chapter ${value}`, type: 'chorus', key_aspect: 'rhythm', difficulty: value, id: value * 10, })), }; } /** * Retrieve a song's play history * @param songId the id to find the song */ public static getSongHistory(songId: number) { return { key: ['song', 'history', songId], exec: () => API.fetch( { route: `/song/${songId}/history`, }, { handler: SongHistoryHandler } ), }; } /** * Search a song by its name * @param query the string used to find the songs */ public static searchSongs(query: string): Query { return { key: ['search', 'song', query], exec: () => API.fetch( { route: `/search/songs/${query}`, }, { handler: ListHandler(SongHandler) } ), }; } /** * Search artists by name * @param query the string used to find the artists */ public static searchArtists(query: string): Query { return { key: ['search', 'artist', query], exec: () => API.fetch( { route: `/search/artists/${query}`, }, { handler: ListHandler(ArtistHandler) } ), }; } /** * Search Album by name * @param query the string used to find the album */ public static searchAlbum(query: string): Query { return { key: ['search', 'album', query], exec: async () => [ { id: 1, name: 'Super Trooper', }, { id: 2, name: 'Kingdom Heart 365/2 OST', }, { id: 3, name: 'The Legend Of Zelda Ocarina Of Time OST', }, { id: 4, name: 'Random Access Memories', }, ], }; } /** * Retrieve music genres */ public static searchGenres(query: string): Query { return { key: ['search', 'genre', query], exec: () => API.fetch( { route: `/search/genres/${query}`, }, { handler: ListHandler(GenreHandler) } ), }; } /** * Retrieve a lesson * @param lessonId the id to find the lesson */ public static getLesson(lessonId: number): Query { return { key: ['lesson', lessonId], exec: async () => ({ name: 'Song', description: 'A song', requiredLevel: 1, mainSkill: 'lead-head-change', id: lessonId, }), }; } /** * Retrieve the authenticated user's search history * @param skip number of entries skipped before returning * @param take how much do we take to return * @returns Returns an array of history entries (temporary type any) */ public static getSearchHistory(skip?: number, take?: number): Query { return { key: ['search', 'history', 'skip', skip, 'take', take], exec: () => API.fetch( { route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`, method: 'GET', }, { handler: ListHandler(SearchHistoryHandler) } ), }; } /** * Posts a new entry in the user's search history * @param query is the query itself * @param type the type of object searched * @param timestamp the date it's been issued * @returns nothing */ public static async createSearchHistoryEntry(query: string, type: string): Promise { return await API.fetch({ route: `/history/search`, method: 'POST', body: { query: query, type: type, }, }); } /** * Retrieve the authenticated user's recommendations * @returns an array of songs */ public static getSongSuggestions(include?: SongInclude[]): Query { return API.getAllSongs(include); } /** * Retrieve the authenticated user's play history * * @returns an array of songs */ public static getUserPlayHistory(include?: SongInclude[]): Query { include ??= []; return { key: ['history', include], exec: () => API.fetch( { route: `/history?include=${include!.join(',')}`, }, { handler: ListHandler(SongHistoryItemHandler) } ), }; } /** * Retrieve a lesson's history * @param lessonId the id to find the lesson */ public static getLessonHistory(lessonId: number): Query { return { key: ['lesson', 'history', lessonId], exec: async () => [ { lessonId, userId: 1, }, ], }; } public static async updateUserEmail(newEmail: string): Promise { return API.fetch( { route: '/auth/me', method: 'PUT', body: { email: newEmail, }, }, { handler: UserHandler } ); } public static async updateUserPassword( oldPassword: string, newPassword: string ): Promise { return API.fetch( { route: '/auth/me', method: 'PUT', body: { oldPassword: oldPassword, password: newPassword, }, }, { handler: UserHandler } ); } public static async updateProfileAvatar(image: ImagePickerAsset): Promise { const data = await base64ToBlob(image.uri); const formData = new FormData(); formData.append('file', data); return API.fetch({ route: '/auth/me/picture', method: 'POST', formData, }); } public static async addLikedSong(songId: number): Promise { await API.fetch({ route: `/auth/me/likes/${songId}`, method: 'POST', }); } public static async removeLikedSong(songId: number): Promise { await API.fetch({ route: `/auth/me/likes/${songId}`, method: 'DELETE', }); } public static getLikedSongs(include?: SongInclude[]): Query { include ??= []; return { key: ['liked songs', include], exec: () => API.fetch( { route: `/auth/me/likes?include=${include!.join(',')}`, }, { handler: ListHandler(LikedSongHandler) } ), }; } public static async updateUserTotalScore(score: number): Promise { await API.fetch({ route: `/auth/me/score/${score}`, method: 'PATCH', }); } public static getTopTwentyPlayers(): Query { return { key: ['score'], exec: () => API.fetch( { route: '/scores/top/20', method: 'GET', }, { handler: ListHandler(UserHandler) } ), }; } public static getSongCursorInfos(songId: number): Query { return { key: ['cursorInfos', songId], exec: () => { return API.fetch( { route: `/song/${songId}/assets/cursors`, }, { handler: SongCursorInfosHandler } ); }, }; } public static getPartitionSvgUrl(songId: number): string { return `${API.baseUrl}/song/${songId}/assets/partition`; } }