From 350a4870cddb23de3e182deae6d21b072e130e1b Mon Sep 17 00:00:00 2001 From: Arthur Jamet <60505370+Arthi-chaud@users.noreply.github.com> Date: Mon, 3 Jul 2023 06:46:16 +0100 Subject: [PATCH 1/2] Front: WebSocket Connection: Fix (#244) --- front/views/PlayView.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/front/views/PlayView.tsx b/front/views/PlayView.tsx index 117462d..652b61e 100644 --- a/front/views/PlayView.tsx +++ b/front/views/PlayView.tsx @@ -32,7 +32,7 @@ import PartitionView from '../components/PartitionView'; import TextButton from '../components/TextButton'; import { MIDIAccess, MIDIMessageEvent, requestMIDIAccess } from '@motiz88/react-native-midi'; import * as Linking from 'expo-linking'; -import { URL } from 'url'; +import url from 'url'; type PlayViewProps = { songId: number; @@ -48,9 +48,9 @@ type ScoreMessage = { let scoroBaseApiUrl = Constants.manifest?.extra?.scoroUrl; if (process.env.NODE_ENV != 'development' && Platform.OS === 'web') { - Linking.getInitialURL().then((url) => { - if (url !== null) { - const location = new URL(url); + Linking.getInitialURL().then((initUrl) => { + if (initUrl !== null) { + const location = url.parse(initUrl); if (location.protocol === 'https:') { scoroBaseApiUrl = 'wss://' + location.host + '/ws'; } else { From 10d1342294f1fb25eb75dbcd5539c951d74c47cd Mon Sep 17 00:00:00 2001 From: Arthur Jamet <60505370+Arthi-chaud@users.noreply.github.com> Date: Wed, 5 Jul 2023 09:22:55 +0100 Subject: [PATCH 2/2] Front: Api models validation (#245) * Front: Model: Write Validators * Front: Plage response validator * Front: API: Typing 'fetch' return * Front: Basic Models: Response Handlers * Front: API: Validate authentication response * Front: Validate Search History * Front: Validate Responses of User updates * Front: On Validation Error, more verbose console error --- front/API.ts | 414 ++++++++++++++-------------- front/models/AccessTokenResponse.ts | 13 + front/models/Album.ts | 10 +- front/models/Artist.ts | 16 +- front/models/Genre.ts | 15 +- front/models/Lesson.ts | 33 +-- front/models/List.ts | 11 + front/models/Model.ts | 10 +- front/models/Plage.ts | 34 +++ front/models/ResponseHandler.ts | 8 + front/models/SearchHistory.ts | 27 +- front/models/Skill.ts | 32 ++- front/models/Song.ts | 33 ++- front/models/SongDetails.ts | 31 ++- front/models/SongHistory.ts | 42 ++- front/models/User.ts | 39 ++- front/models/UserData.ts | 8 - front/models/UserSettings.ts | 37 +++ front/package.json | 6 +- front/yarn.lock | 43 ++- 20 files changed, 575 insertions(+), 287 deletions(-) create mode 100644 front/models/AccessTokenResponse.ts create mode 100644 front/models/List.ts create mode 100644 front/models/Plage.ts create mode 100644 front/models/ResponseHandler.ts delete mode 100644 front/models/UserData.ts diff --git a/front/API.ts b/front/API.ts index 3f0b407..9eff42c 100644 --- a/front/API.ts +++ b/front/API.ts @@ -1,21 +1,26 @@ -import Artist from './models/Artist'; +import Artist, { ArtistHandler } from './models/Artist'; import Album from './models/Album'; import Chapter from './models/Chapter'; import Lesson from './models/Lesson'; -import Genre from './models/Genre'; +import Genre, { GenreHandler } from './models/Genre'; import LessonHistory from './models/LessonHistory'; -import Song from './models/Song'; -import SongHistory from './models/SongHistory'; -import User from './models/User'; +import Song, { SongHandler } from './models/Song'; +import { SongHistoryHandler, SongHistoryItem, SongHistoryItemHandler } from './models/SongHistory'; +import User, { UserHandler } from './models/User'; import Constants from 'expo-constants'; import store from './state/Store'; import { Platform } from 'react-native'; import { en } from './i18n/Translations'; -import UserSettings from './models/UserSettings'; -import { PartialDeep } from 'type-fest'; -import SearchHistory from './models/SearchHistory'; +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'; type AuthenticationInput = { username: string; password: string }; type RegistrationInput = AuthenticationInput & { email: string }; @@ -26,10 +31,14 @@ type FetchParams = { route: string; body?: object; method?: 'GET' | 'POST' | 'DELETE' | 'PATCH' | 'PUT'; - // If true, No JSON parsing is done, the raw response's content is returned - raw?: true; }; +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 @@ -46,37 +55,66 @@ export class APIError extends Error { } } -// we will need the same thing for the scorometer API url -const baseAPIUrl = - process.env.NODE_ENV != 'development' && Platform.OS === 'web' - ? '/api' - : Constants.manifest?.extra?.apiUrl; +export class ValidationError extends Error { + constructor(message: string) { + super(message); + } +} export default class API { - public static async fetch(params: FetchParams) { + public static readonly baseUrl = + process.env.NODE_ENV != 'development' && Platform.OS === 'web' + ? '/api' + : Constants.manifest?.extra?.apiUrl; + 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 header = { 'Content-Type': 'application/json', }; - const response = await fetch(`${baseAPIUrl}${params.route}`, { + const response = await fetch(`${API.baseUrl}${params.route}`, { headers: (jwtToken && { ...header, Authorization: `Bearer ${jwtToken}` }) || header, body: JSON.stringify(params.body), method: params.method ?? 'GET', }).catch(() => { - throw new Error('Error while fetching API: ' + baseAPIUrl); + throw new Error('Error while fetching API: ' + API.baseUrl); }); - if (params.raw) { + if (!handle || handle.emptyResponse) { + return; + } + if (handle.raw) { return response.arrayBuffer(); } + const handler = handle.handler; const body = await response.text(); try { - const jsonResponse = body.length != 0 ? JSON.parse(body) : {}; + const jsonResponse = JSON.parse(body); if (!response.ok) { throw new APIError(response.statusText ?? body, response.status); } - return jsonResponse; + 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; + }); + 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; } } @@ -84,17 +122,21 @@ export default class API { public static async authenticate( authenticationInput: AuthenticationInput ): Promise { - return API.fetch({ - route: '/auth/login', - body: authenticationInput, - method: 'POST', - }) + return API.fetch( + { + route: '/auth/login', + body: authenticationInput, + method: 'POST', + }, + { handler: AccessTokenResponseHandler } + ) .then((responseBody) => responseBody.access_token) .catch((e) => { - if (!(e instanceof APIError)) throw 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'); + if (!(e instanceof APIError)) throw e; throw e; }); } @@ -118,12 +160,20 @@ export default class API { } public static async createAndGetGuestAccount(): Promise { - const response = await API.fetch({ - route: '/auth/guest', - method: 'POST', - }); - if (!response.access_token) throw new APIError('No access token', response.status); - return response.access_token; + return API.fetch( + { + route: '/auth/guest', + method: 'POST', + }, + { 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 { @@ -140,50 +190,28 @@ export default class API { public static getUserInfo(): Query { return { key: 'user', - exec: async () => { - const user = await API.fetch({ - route: '/auth/me', - }); - - // this a dummy settings object, we will need to fetch the real one from the API - return { - id: user.id as number, - name: (user.username ?? user.name) as string, - email: user.email as string, - premium: false, - isGuest: user.isGuest as boolean, - data: { - gamesPlayed: user.partyPlayed as number, - xp: 0, - createdAt: new Date('2023-04-09T00:00:00.000Z'), - avatar: 'https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn', + exec: async () => + API.fetch( + { + route: '/auth/me', }, - } as User; - }, + { handler: UserHandler } + ), }; } public static getUserSettings(): Query { return { key: 'settings', - exec: async () => { - const settings = await API.fetch({ - route: '/auth/me/settings', - }); - - return { - notifications: { - pushNotif: settings.pushNotification, - emailNotif: settings.emailNotification, - trainNotif: settings.trainingNotification, - newSongNotif: settings.newSongNotification, + exec: () => + API.fetch( + { + route: '/auth/me/settings', }, - recommendations: settings.recommendations, - weeklyReport: settings.weeklyReport, - leaderBoard: settings.leaderBoard, - showActivity: settings.showActivity, - }; - }, + { + handler: UserSettingsHandler, + } + ), }; } @@ -222,28 +250,15 @@ export default class API { public static getAllSongs(): Query { return { key: 'songs', - exec: async () => { - const songs = await API.fetch({ - route: '/song', - }); - - // this is a dummy illustration, we will need to fetch the real one from the API - return songs.data.map( - // To be fixed with #168 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (song: any) => - ({ - id: song.id as number, - name: song.name as string, - artistId: song.artistId as number, - albumId: song.albumId as number, - genreId: song.genreId as number, - details: song.difficulties, - cover: `${baseAPIUrl}/song/${song.id}/illustration`, - metrics: {}, - } as Song) - ); - }, + exec: () => + API.fetch( + { + route: '/song', + }, + { + handler: PlageHandler(SongHandler), + } + ).then(({ data }) => data), }; } @@ -254,22 +269,13 @@ export default class API { public static getSong(songId: number): Query { return { key: ['song', songId], - exec: async () => { - const song = await API.fetch({ - route: `/song/${songId}`, - }); - - // this is a dummy illustration, we will need to fetch the real one from the API - return { - id: song.id as number, - name: song.name as string, - artistId: song.artistId as number, - albumId: song.albumId as number, - genreId: song.genreId as number, - details: song.difficulties, - cover: `${baseAPIUrl}/song/${song.id}/illustration`, - } as Song; - }, + exec: async () => + API.fetch( + { + route: `/song/${songId}`, + }, + { handler: SongHandler } + ), }; } /** @@ -280,10 +286,14 @@ export default class API { return { key: ['midi', songId], exec: () => - API.fetch({ - route: `/song/${songId}/midi`, - raw: true, - }), + API.fetch( + { + route: `/song/${songId}/midi`, + }, + { + raw: true, + } + ), }; } @@ -292,7 +302,7 @@ export default class API { * @param songId the id to find the song */ public static getArtistIllustration(artistId: number): string { - return `${baseAPIUrl}/artist/${artistId}/illustration`; + return `${API.baseUrl}/artist/${artistId}/illustration`; } /** @@ -300,7 +310,7 @@ export default class API { * @param songId the id to find the song */ public static getGenreIllustration(genreId: number): string { - return `${baseAPIUrl}/genre/${genreId}/illustration`; + return `${API.baseUrl}/genre/${genreId}/illustration`; } /** @@ -311,10 +321,12 @@ export default class API { return { key: ['musixml', songId], exec: () => - API.fetch({ - route: `/song/${songId}/musicXml`, - raw: true, - }), + API.fetch( + { + route: `/song/${songId}/musicXml`, + }, + { raw: true } + ), }; } @@ -325,9 +337,12 @@ export default class API { return { key: ['artist', artistId], exec: () => - API.fetch({ - route: `/artist/${artistId}`, - }), + API.fetch( + { + route: `/artist/${artistId}`, + }, + { handler: ArtistHandler } + ), }; } @@ -356,13 +371,16 @@ export default class API { * Retrieve a song's play history * @param songId the id to find the song */ - public static getSongHistory(songId: number): Query<{ best: number; history: SongHistory[] }> { + public static getSongHistory(songId: number) { return { key: ['song', 'history', songId], exec: () => - API.fetch({ - route: `/song/${songId}/history`, - }), + API.fetch( + { + route: `/song/${songId}/history`, + }, + { handler: SongHistoryHandler } + ), }; } @@ -374,9 +392,12 @@ export default class API { return { key: ['search', 'song', query], exec: () => - API.fetch({ - route: `/search/songs/${query}`, - }), + API.fetch( + { + route: `/search/songs/${query}`, + }, + { handler: ListHandler(SongHandler) } + ), }; } @@ -388,9 +409,12 @@ export default class API { return { key: ['search', 'artist', query], exec: () => - API.fetch({ - route: `/search/artists/${query}`, - }), + API.fetch( + { + route: `/search/artists/${query}`, + }, + { handler: ListHandler(ArtistHandler) } + ), }; } @@ -398,31 +422,27 @@ export default class API { * Search Album by name * @param query the string used to find the album */ - public static searchAlbum( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - query: string - ): Query { + 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', - }, - ] as Album[], + 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', + }, + ], }; } @@ -433,9 +453,12 @@ export default class API { return { key: ['search', 'genre', query], exec: () => - API.fetch({ - route: `/search/genres/${query}`, - }), + API.fetch( + { + route: `/search/genres/${query}`, + }, + { handler: ListHandler(GenreHandler) } + ), }; } @@ -447,7 +470,7 @@ export default class API { return { key: ['lesson', lessonId], exec: async () => ({ - title: 'Song', + name: 'Song', description: 'A song', requiredLevel: 1, mainSkill: 'lead-head-change', @@ -466,22 +489,12 @@ export default class API { return { key: ['search', 'history', 'skip', skip, 'take', take], exec: () => - API.fetch({ - route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`, - method: 'GET', - }).then((value) => - value.map( - // To be fixed with #168 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (e: any) => - ({ - id: e.id, - query: e.query, - type: e.type, - userId: e.userId, - timestamp: new Date(e.searchDate), - } as SearchHistory) - ) + API.fetch( + { + route: `/history/search?skip=${skip ?? 0}&take=${take ?? 5}`, + method: 'GET', + }, + { handler: ListHandler(SearchHistoryHandler) } ), }; } @@ -516,13 +529,16 @@ export default class API { * Retrieve the authenticated user's play history * * @returns an array of songs */ - public static getUserPlayHistory(): Query { + public static getUserPlayHistory(): Query { return { key: ['history'], exec: () => - API.fetch({ - route: '/history', - }), + API.fetch( + { + route: '/history', + }, + { handler: ListHandler(SongHistoryItemHandler) } + ), }; } @@ -543,36 +559,32 @@ export default class API { } public static async updateUserEmail(newEmail: string): Promise { - const rep = await API.fetch({ - route: '/auth/me', - method: 'PUT', - body: { - email: newEmail, + return API.fetch( + { + route: '/auth/me', + method: 'PUT', + body: { + email: newEmail, + }, }, - }); - - if (rep.error) { - throw new Error(rep.error); - } - return rep; + { handler: UserHandler } + ); } public static async updateUserPassword( oldPassword: string, newPassword: string ): Promise { - const rep = await API.fetch({ - route: '/auth/me', - method: 'PUT', - body: { - oldPassword: oldPassword, - password: newPassword, + return API.fetch( + { + route: '/auth/me', + method: 'PUT', + body: { + oldPassword: oldPassword, + password: newPassword, + }, }, - }); - - if (rep.error) { - throw new Error(rep.error); - } - return rep; + { handler: UserHandler } + ); } } diff --git a/front/models/AccessTokenResponse.ts b/front/models/AccessTokenResponse.ts new file mode 100644 index 0000000..4089a54 --- /dev/null +++ b/front/models/AccessTokenResponse.ts @@ -0,0 +1,13 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +const AccessTokenResponseValidator = yup.object({ + access_token: yup.string().required(), +}); + +type AccessTokenResponse = yup.InferType; + +export const AccessTokenResponseHandler: ResponseHandler = { + validator: AccessTokenResponseValidator, + transformer: (value) => value, +}; diff --git a/front/models/Album.ts b/front/models/Album.ts index fc8afbb..3cf9129 100644 --- a/front/models/Album.ts +++ b/front/models/Album.ts @@ -1,4 +1,12 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; + +export const AlbumValidator = yup + .object({ + name: yup.string().required(), + artistId: yup.number().required(), + }) + .concat(ModelValidator); interface Album extends Model { name: string; diff --git a/front/models/Artist.ts b/front/models/Artist.ts index 03c1d0d..1af8b55 100644 --- a/front/models/Artist.ts +++ b/front/models/Artist.ts @@ -1,8 +1,20 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const ArtistValidator = yup + .object({ + name: yup.string().required(), + }) + .concat(ModelValidator); + +export const ArtistHandler: ResponseHandler = { + validator: ArtistValidator, + transformer: (value) => value, +}; interface Artist extends Model { name: string; - picture?: string; } export default Artist; diff --git a/front/models/Genre.ts b/front/models/Genre.ts index fc6456e..61d7a4e 100644 --- a/front/models/Genre.ts +++ b/front/models/Genre.ts @@ -1,4 +1,17 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const GenreValidator = yup + .object({ + name: yup.string().required(), + }) + .concat(ModelValidator); + +export const GenreHandler: ResponseHandler = { + validator: GenreValidator, + transformer: (value) => value, +}; interface Genre extends Model { name: string; diff --git a/front/models/Lesson.ts b/front/models/Lesson.ts index ee4fbd9..3fea61b 100644 --- a/front/models/Lesson.ts +++ b/front/models/Lesson.ts @@ -1,26 +1,19 @@ -import Skill from './Skill'; -import Model from './Model'; +import { SkillValidator } from './Skill'; +import { ModelValidator } from './Model'; +import * as yup from 'yup'; + +export const LessonValidator = yup + .object({ + name: yup.string().required(), + description: yup.string().required(), + requiredLevel: yup.number().required(), + mainSkill: SkillValidator.required(), + }) + .concat(ModelValidator); /** * A Lesson is an exercice that the user can try to practice a skill */ -interface Lesson extends Model { - /** - * The title of the lesson - */ - title: string; - /** - * Short description of the lesson - */ - description: string; - /** - * The minimum level required for the user to access this lesson - */ - requiredLevel: number; - /** - * The main skill learnt in this lesson - */ - mainSkill: Skill; -} +type Lesson = yup.InferType; export default Lesson; diff --git a/front/models/List.ts b/front/models/List.ts new file mode 100644 index 0000000..9ac2142 --- /dev/null +++ b/front/models/List.ts @@ -0,0 +1,11 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +const ListValidator = (itemType: yup.Schema) => yup.array(itemType).required(); + +export const ListHandler = ( + itemHandler: ResponseHandler +): ResponseHandler => ({ + validator: ListValidator(itemHandler.validator), + transformer: (plage) => plage.map((item) => itemHandler.transformer(item)), +}); diff --git a/front/models/Model.ts b/front/models/Model.ts index 57fed04..b7afbb0 100644 --- a/front/models/Model.ts +++ b/front/models/Model.ts @@ -1,5 +1,9 @@ -interface Model { - id: number; -} +import * as yup from 'yup'; + +export const ModelValidator = yup.object({ + id: yup.number().required(), +}); + +type Model = yup.InferType; export default Model; diff --git a/front/models/Plage.ts b/front/models/Plage.ts new file mode 100644 index 0000000..a92cae0 --- /dev/null +++ b/front/models/Plage.ts @@ -0,0 +1,34 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +// Ty https://github.com/Arthi-chaud/Meelo/blob/master/front/src/models/pagination.ts +export const PlageValidator = (itemType: yup.Schema) => + yup.object({ + data: yup.array(itemType).required(), + metadata: yup.object({ + /** + * Current route + */ + this: yup.string().required(), + /** + * route to use for the next items + */ + next: yup.string().required().nullable(), + /** + * route to use for the previous items + */ + previous: yup.string().required().nullable(), + }), + }); + +type Plage = yup.InferType>>; + +export const PlageHandler = ( + itemHandler: ResponseHandler +): ResponseHandler, Plage> => ({ + validator: PlageValidator(itemHandler.validator), + transformer: (plage) => ({ + ...plage, + data: plage.data.map((item) => itemHandler.transformer(item)), + }), +}); diff --git a/front/models/ResponseHandler.ts b/front/models/ResponseHandler.ts new file mode 100644 index 0000000..f6bb5c4 --- /dev/null +++ b/front/models/ResponseHandler.ts @@ -0,0 +1,8 @@ +import * as yup from 'yup'; + +type ResponseHandler = { + validator: yup.Schema; + transformer: (value: APIType) => ModelType; +}; + +export default ResponseHandler; diff --git a/front/models/SearchHistory.ts b/front/models/SearchHistory.ts index a291dea..05c433b 100644 --- a/front/models/SearchHistory.ts +++ b/front/models/SearchHistory.ts @@ -1,4 +1,29 @@ -import Model from './Model'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const SearchType = ['song', 'artist', 'album'] as const; +export type SearchType = (typeof SearchType)[number]; + +const SearchHistoryValidator = yup + .object({ + query: yup.string().required(), + type: yup.mixed().oneOf(SearchType).required(), + userId: yup.number().required(), + searchDate: yup.date().required(), + }) + .concat(ModelValidator); + +export const SearchHistoryHandler: ResponseHandler< + yup.InferType, + SearchHistory +> = { + validator: SearchHistoryValidator, + transformer: (value) => ({ + ...value, + timestamp: value.searchDate, + }), +}; interface SearchHistory extends Model { query: string; diff --git a/front/models/Skill.ts b/front/models/Skill.ts index d41564a..8604fc4 100644 --- a/front/models/Skill.ts +++ b/front/models/Skill.ts @@ -1,15 +1,21 @@ -type Skill = - | 'rhythm' - | 'two-hands' - | 'combos' - | 'arpeggio' - | 'distance' - | 'left-hand' - | 'right-hand' - | 'lead-head-change' - | 'chord-complexity' - | 'chord-timing' - | 'pedal' - | 'precision'; +import * as yup from 'yup'; + +const Skills = [ + 'rhythm', + 'two-hands', + 'combos', + 'arpeggio', + 'distance', + 'left-hand', + 'right-hand', + 'lead-head-change', + 'chord-complexity', + 'chord-timing', + 'pedal', + 'precision', +] as const; +type Skill = (typeof Skills)[number]; + +export const SkillValidator = yup.mixed().oneOf(Skills); export default Skill; diff --git a/front/models/Song.ts b/front/models/Song.ts index 19c0d6b..b8a181a 100644 --- a/front/models/Song.ts +++ b/front/models/Song.ts @@ -1,6 +1,35 @@ -import Model from './Model'; -import SongDetails from './SongDetails'; +import Model, { ModelValidator } from './Model'; +import SongDetails, { SongDetailsHandler, SongDetailsValidator } from './SongDetails'; import Artist from './Artist'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; +import API from '../API'; + +export const SongValidator = yup + .object({ + name: yup.string().required(), + midiPath: yup.string().required(), + musicXmlPath: yup.string().required(), + artistId: yup.number().required(), + albumId: yup.number().required().nullable(), + genreId: yup.number().required().nullable(), + difficulties: SongDetailsValidator.required(), + illustrationPath: yup.string().required(), + }) + .concat(ModelValidator); + +export const SongHandler: ResponseHandler, Song> = { + validator: SongValidator, + transformer: (song) => ({ + id: song.id, + name: song.name, + artistId: song.artistId, + albumId: song.albumId, + genreId: song.genreId, + details: SongDetailsHandler.transformer(song.difficulties), + cover: `${API.baseUrl}/song/${song.id}/illustration`, + }), +}; interface Song extends Model { id: number; diff --git a/front/models/SongDetails.ts b/front/models/SongDetails.ts index 527f387..5681fed 100644 --- a/front/models/SongDetails.ts +++ b/front/models/SongDetails.ts @@ -1,7 +1,34 @@ +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const SongDetailsValidator = yup.object({ + length: yup.number().required(), + rhythm: yup.number().required(), + arpeggio: yup.number().required(), + distance: yup.number().required(), + lefthand: yup.number().required(), + twohands: yup.number().required(), + notecombo: yup.number().required(), + precision: yup.number().required(), + righthand: yup.number().required(), + pedalpoint: yup.number().required(), + chordtiming: yup.number().required(), + leadhandchange: yup.number().required(), + chordcomplexity: yup.number().required(), +}); + +export const SongDetailsHandler: ResponseHandler< + yup.InferType, + SongDetails +> = { + validator: SongDetailsValidator, + transformer: (value) => value, +}; + interface SongDetails { length: number; rhythm: number; - arppegio: number; + arpeggio: number; distance: number; lefthand: number; righthand: number; @@ -10,7 +37,7 @@ interface SongDetails { precision: number; pedalpoint: number; chordtiming: number; - leadheadchange: number; + leadhandchange: number; chordcomplexity: number; } diff --git a/front/models/SongHistory.ts b/front/models/SongHistory.ts index 9796395..c08145b 100644 --- a/front/models/SongHistory.ts +++ b/front/models/SongHistory.ts @@ -1,8 +1,44 @@ -interface SongHistory { +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const SongHistoryItemValidator = yup.object({ + songID: yup.number().required(), + userID: yup.number().required(), + score: yup.number().required(), + difficulties: yup.mixed().required(), +}); + +export const SongHistoryItemHandler: ResponseHandler< + yup.InferType, + SongHistoryItem +> = { + validator: SongHistoryItemValidator, + transformer: (value) => ({ + ...value, + difficulties: value.difficulties, + }), +}; + +export const SongHistoryValidator = yup.object({ + best: yup.number().required().nullable(), + history: yup.array(SongHistoryItemValidator).required(), +}); + +export type SongHistory = yup.InferType; + +export const SongHistoryHandler: ResponseHandler = { + validator: SongHistoryValidator, + transformer: (value) => ({ + ...value, + history: value.history.map((item) => SongHistoryItemHandler.transformer(item)), + }), +}; + +export type SongHistoryItem = { songID: number; userID: number; score: number; - difficulties: JSON; -} + difficulties: object; +}; export default SongHistory; diff --git a/front/models/User.ts b/front/models/User.ts index 8543fb9..4c88922 100644 --- a/front/models/User.ts +++ b/front/models/User.ts @@ -1,6 +1,31 @@ -import UserData from './UserData'; -import Model from './Model'; -import UserSettings from './UserSettings'; +import Model, { ModelValidator } from './Model'; +import * as yup from 'yup'; +import ResponseHandler from './ResponseHandler'; + +export const UserValidator = yup + .object({ + username: yup.string().required(), + password: yup.string().required(), + email: yup.string().required(), + isGuest: yup.boolean().required(), + partyPlayed: yup.number().required(), + }) + .concat(ModelValidator); + +export const UserHandler: ResponseHandler, User> = { + validator: UserValidator, + transformer: (value) => ({ + ...value, + name: value.username, + premium: false, + data: { + gamesPlayed: value.partyPlayed as number, + xp: 0, + createdAt: new Date('2023-04-09T00:00:00.000Z'), + avatar: 'https://imgs.search.brave.com/RnQpFhmAFvuQsN_xTw7V-CN61VeHDBg2tkEXnKRYHAE/rs:fit:768:512:1/g:ce/aHR0cHM6Ly96b29h/c3Ryby5jb20vd3At/Y29udGVudC91cGxv/YWRzLzIwMjEvMDIv/Q2FzdG9yLTc2OHg1/MTIuanBn', + }, + }), +}; interface User extends Model { name: string; @@ -8,7 +33,13 @@ interface User extends Model { isGuest: boolean; premium: boolean; data: UserData; - settings: UserSettings; +} + +interface UserData { + gamesPlayed: number; + xp: number; + avatar: string | undefined; + createdAt: Date; } export default User; diff --git a/front/models/UserData.ts b/front/models/UserData.ts deleted file mode 100644 index 91bfde8..0000000 --- a/front/models/UserData.ts +++ /dev/null @@ -1,8 +0,0 @@ -interface UserData { - gamesPlayed: number; - xp: number; - avatar: string | undefined; - createdAt: Date; -} - -export default UserData; diff --git a/front/models/UserSettings.ts b/front/models/UserSettings.ts index 2bffa04..6c7181c 100644 --- a/front/models/UserSettings.ts +++ b/front/models/UserSettings.ts @@ -1,3 +1,40 @@ +import * as yup from 'yup'; +import { ModelValidator } from './Model'; +import ResponseHandler from './ResponseHandler'; + +export const UserSettingsValidator = yup + .object({ + userId: yup.number().required(), + pushNotification: yup.boolean().required(), + emailNotification: yup.boolean().required(), + trainingNotification: yup.boolean().required(), + newSongNotification: yup.boolean().required(), + recommendations: yup.boolean().required(), + weeklyReport: yup.boolean().required(), + leaderBoard: yup.boolean().required(), + showActivity: yup.boolean().required(), + }) + .concat(ModelValidator); + +export const UserSettingsHandler: ResponseHandler< + yup.InferType, + UserSettings +> = { + validator: UserSettingsValidator, + transformer: (settings) => ({ + notifications: { + pushNotif: settings.pushNotification, + emailNotif: settings.emailNotification, + trainNotif: settings.trainingNotification, + newSongNotif: settings.newSongNotification, + }, + recommendations: settings.recommendations, + weeklyReport: settings.weeklyReport, + leaderBoard: settings.leaderBoard, + showActivity: settings.showActivity, + }), +}; + interface UserSettings { notifications: { pushNotif: boolean; diff --git a/front/package.json b/front/package.json index e377171..4987f1d 100644 --- a/front/package.json +++ b/front/package.json @@ -68,7 +68,7 @@ "soundfont-player": "^0.12.0", "standardized-audio-context": "^25.3.51", "type-fest": "^3.6.0", - "yup": "^0.32.11" + "yup": "^1.2.0" }, "devDependencies": { "@babel/core": "^7.19.3", @@ -87,11 +87,11 @@ "@types/react": "~18.0.24", "@types/react-native": "~0.70.6", "@types/react-navigation": "^3.4.0", + "@typescript-eslint/eslint-plugin": "^5.43.0", + "@typescript-eslint/parser": "^5.0.0", "babel-loader": "^8.3.0", "babel-plugin-transform-inline-environment-variables": "^0.4.4", "chromatic": "^6.14.0", - "@typescript-eslint/eslint-plugin": "^5.43.0", - "@typescript-eslint/parser": "^5.0.0", "eslint": "^8.42.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", diff --git a/front/yarn.lock b/front/yarn.lock index cbc5877..6760b25 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -1232,7 +1232,7 @@ dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.2", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.20.13" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b" integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA== @@ -4743,7 +4743,7 @@ dependencies: "@types/node" "*" -"@types/lodash@^4.14.167", "@types/lodash@^4.14.175": +"@types/lodash@^4.14.167": version "4.14.191" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa" integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ== @@ -12591,11 +12591,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - lodash.clone@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" @@ -13705,11 +13700,6 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" -nanoclone@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" - integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== - nanoid@^3.1.23, nanoid@^3.3.1: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" @@ -15423,7 +15413,7 @@ prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.7.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -property-expr@^2.0.4: +property-expr@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== @@ -17931,6 +17921,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A== +tiny-case@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03" + integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q== + tinycolor2@^1.4.2: version "1.5.2" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.5.2.tgz#7d30b4584d8b7d62b9a94dacc505614a6516a95f" @@ -18165,6 +18160,11 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-fest@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + type-fest@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.6.0.tgz#827c36c0e7fcff0cb2d55d091a5c4cf586432b8a" @@ -19366,18 +19366,15 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yup@^0.32.11: - version "0.32.11" - resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" - integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== +yup@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/yup/-/yup-1.2.0.tgz#9e51af0c63bdfc9be0fdc6c10aa0710899d8aff6" + integrity sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ== dependencies: - "@babel/runtime" "^7.15.4" - "@types/lodash" "^4.14.175" - lodash "^4.17.21" - lodash-es "^4.17.21" - nanoclone "^0.2.1" - property-expr "^2.0.4" + property-expr "^2.0.5" + tiny-case "^1.0.3" toposort "^2.0.2" + type-fest "^2.19.0" zwitch@^1.0.0: version "1.0.5"