Validate all types before returning to user

This commit is contained in:
Zechariah 2022-03-27 21:56:30 +08:00
parent 892fd5f82f
commit 45692dcaa8
11 changed files with 415 additions and 335 deletions

View File

@ -4,7 +4,7 @@ import axios, { AxiosInstance } from "axios"
import PlaylistParser from "./parsers/PlaylistParser"
import SearchParser from "./parsers/SearchParser"
import SongParser from "./parsers/SongParser"
import traverse from "./traverse"
import traverse from "./utils/traverse"
import VideoParser from "./parsers/VideoParser"
import {
AlbumDetailed,

View File

@ -1,162 +1,18 @@
import ObjectValidator from "validate-any/dist/validators/ObjectValidator"
import Validator from "validate-any/dist/classes/Validator"
import YTMusic, {
AlbumBasic,
AlbumDetailed,
AlbumFull,
ArtistBasic,
ArtistDetailed,
ArtistFull,
PlaylistFull,
SongDetailed,
SongFull,
ThumbnailFull,
VideoDetailed,
VideoFull
} from ".."
import YTMusic from ".."
import {
BOOLEAN,
iValidationError,
LIST,
NULL,
NUMBER,
OBJECT,
OR,
STRING,
validate
} from "validate-any"
//#region Interfaces
const THUMBNAIL_FULL: ObjectValidator<ThumbnailFull> = OBJECT({
url: STRING(),
width: NUMBER(),
height: NUMBER()
})
const ARTIST_BASIC: ObjectValidator<ArtistBasic> = OBJECT({
artistId: OR(STRING(), NULL()),
name: STRING()
})
const ALBUM_BASIC: ObjectValidator<AlbumBasic> = OBJECT({
albumId: STRING(),
name: STRING()
})
const SONG_DETAILED: ObjectValidator<SongDetailed> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const VIDEO_DETAILED: ObjectValidator<VideoDetailed> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const ARTIST_DETAILED: ObjectValidator<ArtistDetailed> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL)
})
const ALBUM_DETAILED: ObjectValidator<AlbumDetailed> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const SONG_FULL: ObjectValidator<SongFull> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
formats: LIST(OBJECT()),
adaptiveFormats: LIST(OBJECT())
})
const VIDEO_FULL: ObjectValidator<VideoFull> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
unlisted: BOOLEAN(),
familySafe: BOOLEAN(),
paid: BOOLEAN(),
tags: LIST(STRING())
})
const ARTIST_FULL: ObjectValidator<ArtistFull> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
subscribers: NUMBER(),
topSongs: LIST(
OBJECT({
type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
thumbnails: LIST(THUMBNAIL_FULL)
})
),
topAlbums: LIST(ALBUM_DETAILED)
})
const ALBUM_FULL: ObjectValidator<AlbumFull> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
songs: LIST(SONG_DETAILED)
})
const PLAYLIST_DETAILED: ObjectValidator<PlaylistFull> = OBJECT({
type: STRING("PLAYLIST"),
playlistId: STRING(),
name: STRING(),
artist: ARTIST_BASIC,
videoCount: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
const PLAYLIST_VIDEO: ObjectValidator<Omit<VideoDetailed, "views">> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
//#endregion
ALBUM_DETAILED,
ALBUM_FULL,
ARTIST_DETAILED,
ARTIST_FULL,
PLAYLIST_FULL,
PLAYLIST_VIDEO,
SONG_DETAILED,
SONG_FULL,
VIDEO_DETAILED,
VIDEO_FULL
} from "../interfaces"
import { iValidationError, LIST, STRING, validate } from "validate-any"
const issues: iValidationError[][] = []
const queries = ["Lilac", "Weekend", "Eill", "Eminem", "Lisa Hannigan"]
@ -231,7 +87,7 @@ queries.forEach(query => {
ytmusic
.search(query, "PLAYLIST")
.then(playlists => {
_expect(playlists, LIST(PLAYLIST_DETAILED))
_expect(playlists, LIST(PLAYLIST_FULL))
done()
})
.catch(done)
@ -246,7 +102,7 @@ queries.forEach(query => {
LIST(
ALBUM_DETAILED,
ARTIST_DETAILED,
PLAYLIST_DETAILED,
PLAYLIST_FULL,
SONG_DETAILED,
VIDEO_DETAILED
)
@ -327,7 +183,7 @@ queries.forEach(query => {
.search(query, "PLAYLIST")
.then(playlists => ytmusic.getPlaylist(playlists[0]!.playlistId!))
.then(playlist => {
_expect(playlist, PLAYLIST_DETAILED)
_expect(playlist, PLAYLIST_FULL)
done()
})
.catch(done)

146
src/interfaces.ts Normal file
View File

@ -0,0 +1,146 @@
import ObjectValidator from "validate-any/dist/validators/ObjectValidator"
import {
AlbumBasic,
AlbumDetailed,
AlbumFull,
ArtistBasic,
ArtistDetailed,
ArtistFull,
PlaylistFull,
SongDetailed,
SongFull,
ThumbnailFull,
VideoDetailed,
VideoFull
} from "."
import { BOOLEAN, LIST, NULL, NUMBER, OBJECT, OR, STRING } from "validate-any"
export const THUMBNAIL_FULL: ObjectValidator<ThumbnailFull> = OBJECT({
url: STRING(),
width: NUMBER(),
height: NUMBER()
})
export const ARTIST_BASIC: ObjectValidator<ArtistBasic> = OBJECT({
artistId: OR(STRING(), NULL()),
name: STRING()
})
export const ALBUM_BASIC: ObjectValidator<AlbumBasic> = OBJECT({
albumId: STRING(),
name: STRING()
})
export const SONG_DETAILED: ObjectValidator<SongDetailed> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const VIDEO_DETAILED: ObjectValidator<VideoDetailed> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ARTIST_DETAILED: ObjectValidator<ArtistDetailed> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const ALBUM_DETAILED: ObjectValidator<AlbumDetailed> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const SONG_FULL: ObjectValidator<SongFull> = OBJECT({
type: STRING("SONG"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
formats: LIST(OBJECT()),
adaptiveFormats: LIST(OBJECT())
})
export const VIDEO_FULL: ObjectValidator<VideoFull> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
views: NUMBER(),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: STRING(),
unlisted: BOOLEAN(),
familySafe: BOOLEAN(),
paid: BOOLEAN(),
tags: LIST(STRING())
})
export const ARTIST_FULL: ObjectValidator<ArtistFull> = OBJECT({
artistId: STRING(),
name: STRING(),
type: STRING("ARTIST"),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
subscribers: NUMBER(),
topSongs: LIST(
OBJECT({
type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
thumbnails: LIST(THUMBNAIL_FULL)
})
),
topAlbums: LIST(ALBUM_DETAILED)
})
export const ALBUM_FULL: ObjectValidator<AlbumFull> = OBJECT({
type: STRING("ALBUM"),
albumId: STRING(),
playlistId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
year: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL),
description: OR(STRING(), NULL()),
songs: LIST(SONG_DETAILED)
})
export const PLAYLIST_FULL: ObjectValidator<PlaylistFull> = OBJECT({
type: STRING("PLAYLIST"),
playlistId: STRING(),
name: STRING(),
artist: ARTIST_BASIC,
videoCount: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})
export const PLAYLIST_VIDEO: ObjectValidator<Omit<VideoDetailed, "views">> = OBJECT({
type: STRING("VIDEO"),
videoId: OR(STRING(), NULL()),
name: STRING(),
artists: LIST(ARTIST_BASIC),
duration: NUMBER(),
thumbnails: LIST(THUMBNAIL_FULL)
})

View File

@ -1,5 +1,7 @@
import checkType from "../utils/checkType"
import SongParser from "./SongParser"
import traverse from "../traverse"
import traverse from "../utils/traverse"
import { ALBUM_DETAILED, ALBUM_FULL } from "../interfaces"
import { AlbumDetailed, AlbumFull, ArtistBasic } from ".."
export default class AlbumParser {
@ -14,7 +16,8 @@ export default class AlbumParser {
const thumbnails = [traverse(data, "header", "thumbnails")].flat()
const description = traverse(data, "description", "text")
return {
return checkType<AlbumFull>(
{
type: "ALBUM",
...albumBasic,
playlistId: traverse(data, "buttonRenderer", "playlistId"),
@ -27,13 +30,16 @@ export default class AlbumParser {
.map((item: any) =>
SongParser.parseAlbumSong(item, artists, albumBasic, thumbnails)
)
}
},
ALBUM_FULL
)
}
public static parseSearchResult(item: any): AlbumDetailed {
const flexColumns = traverse(item, "flexColumns")
return {
return checkType<AlbumDetailed>(
{
type: "ALBUM",
albumId: [traverse(item, "browseId")].flat().at(-1),
playlistId: traverse(item, "overlay", "playlistId"),
@ -43,11 +49,14 @@ export default class AlbumParser {
name: traverse(flexColumns[0], "runs", "text"),
year: +traverse(flexColumns[1], "runs", "text").at(-1),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
ALBUM_DETAILED
)
}
public static parseArtistAlbum(item: any, artistBasic: ArtistBasic): AlbumDetailed {
return {
return checkType<AlbumDetailed>(
{
type: "ALBUM",
albumId: [traverse(item, "browseId")].flat().at(-1),
playlistId: traverse(item, "thumbnailOverlay", "playlistId"),
@ -55,11 +64,14 @@ export default class AlbumParser {
artists: [artistBasic],
year: +traverse(item, "subtitle", "text").at(-1),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
ALBUM_DETAILED
)
}
public static parseArtistTopAlbums(item: any, artistBasic: ArtistBasic): AlbumDetailed {
return {
return checkType<AlbumDetailed>(
{
type: "ALBUM",
albumId: traverse(item, "browseId").at(-1),
playlistId: traverse(item, "musicPlayButtonRenderer", "playlistId"),
@ -67,6 +79,8 @@ export default class AlbumParser {
artists: [artistBasic],
year: +traverse(item, "subtitle", "text").at(-1),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
ALBUM_DETAILED
)
}
}

View File

@ -1,7 +1,9 @@
import AlbumParser from "./AlbumParser"
import checkType from "../utils/checkType"
import Parser from "./Parser"
import SongParser from "./SongParser"
import traverse from "../traverse"
import traverse from "../utils/traverse"
import { ARTIST_DETAILED, ARTIST_FULL } from "../interfaces"
import { ArtistDetailed, ArtistFull } from ".."
export default class ArtistParser {
@ -13,7 +15,8 @@ export default class ArtistParser {
const description = traverse(data, "header", "description", "text")
return {
return checkType<ArtistFull>(
{
type: "ARTIST",
...artistBasic,
thumbnails: [traverse(data, "header", "thumbnails")].flat(),
@ -25,18 +28,25 @@ export default class ArtistParser {
topAlbums: [traverse(data, "musicCarouselShelfRenderer")]
.flat()
.at(0)
.contents.map((item: any) => AlbumParser.parseArtistTopAlbums(item, artistBasic))
}
.contents.map((item: any) =>
AlbumParser.parseArtistTopAlbums(item, artistBasic)
)
},
ARTIST_FULL
)
}
public static parseSearchResult(item: any): ArtistDetailed {
const flexColumns = traverse(item, "flexColumns")
return {
return checkType<ArtistDetailed>(
{
type: "ARTIST",
artistId: traverse(item, "browseId"),
name: traverse(flexColumns[0], "runs", "text"),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
ARTIST_DETAILED
)
}
}

View File

@ -1,9 +1,12 @@
import traverse from "../traverse"
import checkType from "../utils/checkType"
import traverse from "../utils/traverse"
import { PLAYLIST_FULL } from "../interfaces"
import { PlaylistFull } from ".."
export default class PlaylistParser {
public static parse(data: any, playlistId: string): PlaylistFull {
return {
return checkType<PlaylistFull>(
{
type: "PLAYLIST",
playlistId,
name: traverse(data, "header", "title", "text").at(0),
@ -17,14 +20,17 @@ export default class PlaylistParser {
.at(0)
.replaceAll(",", ""),
thumbnails: traverse(data, "header", "thumbnails")
}
},
PLAYLIST_FULL
)
}
public static parseSearchResult(item: any): PlaylistFull {
const flexColumns = traverse(item, "flexColumns")
const artistId = traverse(flexColumns[1], "browseId")
return {
return checkType<PlaylistFull>(
{
type: "PLAYLIST",
playlistId: traverse(item, "overlay", "playlistId"),
name: traverse(flexColumns[0], "runs", "text"),
@ -38,6 +44,8 @@ export default class PlaylistParser {
.at(0)
.replaceAll(",", ""),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
PLAYLIST_FULL
)
}
}

View File

@ -2,7 +2,7 @@ import AlbumParser from "./AlbumParser"
import ArtistParser from "./ArtistParser"
import PlaylistParser from "./PlaylistParser"
import SongParser from "./SongParser"
import traverse from "../traverse"
import traverse from "../utils/traverse"
import VideoParser from "./VideoParser"
import { SearchResult } from ".."

View File

@ -1,10 +1,14 @@
import checkType from "../utils/checkType"
import Parser from "./Parser"
import traverse from "../traverse"
import traverse from "../utils/traverse"
import { ALBUM_BASIC, ARTIST_BASIC, SONG_DETAILED, SONG_FULL, THUMBNAIL_FULL } from "../interfaces"
import { AlbumBasic, ArtistBasic, SongDetailed, SongFull, ThumbnailFull } from ".."
import { LIST, OBJECT, STRING } from "validate-any"
export default class SongParser {
public static parse(data: any): SongFull {
return {
return checkType<SongFull>(
{
type: "SONG",
videoId: traverse(data, "videoDetails", "videoId"),
name: traverse(data, "videoDetails", "title"),
@ -19,14 +23,17 @@ export default class SongParser {
description: traverse(data, "description"),
formats: traverse(data, "streamingData", "formats"),
adaptiveFormats: traverse(data, "streamingData", "adaptiveFormats")
}
},
SONG_FULL
)
}
public static parseSearchResult(item: any): SongDetailed {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
return checkType<SongDetailed>(
{
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
@ -40,14 +47,17 @@ export default class SongParser {
},
duration: Parser.parseDuration(traverse(flexColumns[1], "runs", "text").at(-1)),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
SONG_DETAILED
)
}
public static parseArtistSong(item: any): SongDetailed {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
return checkType<SongDetailed>(
{
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
@ -64,7 +74,9 @@ export default class SongParser {
},
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
SONG_DETAILED
)
}
public static parseArtistTopSong(
@ -74,7 +86,8 @@ export default class SongParser {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
return checkType<Omit<SongDetailed, "duration">>(
{
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
@ -84,7 +97,16 @@ export default class SongParser {
name: traverse(flexColumns[2], "browseId")
},
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
OBJECT({
type: STRING("SONG"),
videoId: STRING(),
name: STRING(),
artists: LIST(ARTIST_BASIC),
album: ALBUM_BASIC,
thumbnails: LIST(THUMBNAIL_FULL)
})
)
}
public static parseAlbumSong(
@ -96,7 +118,8 @@ export default class SongParser {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playlistItemData", "videoId")
return {
return checkType<SongDetailed>(
{
type: "SONG",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
@ -104,6 +127,8 @@ export default class SongParser {
album: albumBasic,
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
thumbnails
}
},
SONG_DETAILED
)
}
}

View File

@ -1,5 +1,7 @@
import checkType from "../utils/checkType"
import Parser from "./Parser"
import traverse from "../traverse"
import traverse from "../utils/traverse"
import { PLAYLIST_VIDEO } from "../interfaces"
import { VideoDetailed, VideoFull } from ".."
export default class VideoParser {
@ -47,7 +49,8 @@ export default class VideoParser {
const flexColumns = traverse(item, "flexColumns")
const videoId = traverse(item, "playNavigationEndpoint", "videoId")
return {
return checkType<Omit<VideoDetailed, "views">>(
{
type: "VIDEO",
videoId: videoId instanceof Array ? null : videoId,
name: traverse(flexColumns[0], "runs", "text"),
@ -57,6 +60,8 @@ export default class VideoParser {
.map((run: any) => ({ artistId: traverse(run, "browseId"), name: run.text })),
duration: Parser.parseDuration(traverse(item, "fixedColumns", "runs", "text")),
thumbnails: [traverse(item, "thumbnails")].flat()
}
},
PLAYLIST_VIDEO
)
}
}

16
src/utils/checkType.ts Normal file
View File

@ -0,0 +1,16 @@
import Validator from "validate-any/dist/classes/Validator"
import { validate } from "validate-any"
export default <T>(data: T, validator: Validator<T>): T => {
const result = validate(data, validator)
if (result.success) {
return result.data
} else {
console.error("Invalid data schema, please report as an issue", {
expected: validator.formatSchema(),
actual: data,
errors: result.errors
})
return data
}
}