Создание модели Aeson из двух вызовов API WREQ

Я пытаюсь решить проблему, заключающуюся в том, что я создаю некоторые данные из HTTP-вызова, а затем на основе этих данных делаю еще один HTTP-вызов и обогащаю исходные данные информацией из второго вызова.

У меня есть код, который принимает вызов API Spotify недавно воспроизведенный (JSON) через wreq в качестве ByteString и возвращает мой полностью сформированный тип данных "RecentPlayed".

Однако, чтобы получить жанр трека в API Spotify, необходим второй HTTP-вызов к их конечной точке исполнителя, я не совсем уверен, как я могу изменить свой тип данных трека, добавив в него поле "Жанр". Я буду заполнять позже, я также не уверен, как на самом деле заполнить его позже, ясно, что мне нужно перебрать мою исходную структуру данных, вытащить идентификатор исполнителя, вызвать новый сервер - но я не уверен, как добавить это дополнительные данные к исходному типу данных.

{-# LANGUAGE OverloadedStrings #-}

module Types.RecentlyPlayed where

import qualified Data.ByteString.Lazy as L
import qualified Data.Vector as V
import Data.Aeson
import Data.Either

data Artist = Artist {
  id :: String
  , href :: String
  , artistName :: String
} deriving (Show)

data Track = Track {
  playedAt :: String
  , externalUrls :: String
  , name :: String
  , artists :: [Artist]
  , explicit :: Bool
} deriving (Show)

data Tracks = Tracks {
  tracks :: [Track]
} deriving (Show)

data RecentlyPlayed = RecentlyPlayed {
  recentlyPlayed :: Tracks
  , next :: String
} deriving (Show)

instance FromJSON RecentlyPlayed where
  parseJSON = withObject "items" $ \recentlyPlayed -> RecentlyPlayed 
    <$> recentlyPlayed .: "items"
    <*> recentlyPlayed .: "next"

instance FromJSON Tracks where
  parseJSON = withArray "items" $ \items -> Tracks 
    <$> mapM parseJSON (V.toList items)

instance FromJSON Track where
  parseJSON = withObject "tracks" $ \tracks -> Track 
    <$> tracks .: "played_at" 
    <*> (tracks .: "track" >>= (.: "album") >>= (.: "external_urls") >>= (.: "spotify"))
    <*> (tracks .: "track" >>= (.: "name"))
    <*> (tracks .: "track" >>= (.: "artists"))
    <*> (tracks .: "track" >>= (.: "explicit"))

instance FromJSON Artist where
  parseJSON = withObject "artists" $ \artists -> Artist
    <$> artists .: "id"
    <*> artists .: "href"
    <*> artists .: "name"

marshallRecentlyPlayedData :: L.ByteString -> Either String RecentlyPlayed
marshallRecentlyPlayedData recentlyPlayedTracks = eitherDecode recentlyPlayedTracks

( https://github.com/imjacobclark/Recify/blob/master/src/Types/RecentlyPlayed.hs)

Это прекрасно работает для одного вызова API, его использование можно увидеть здесь:

recentlyPlayedTrackData <- liftIO $ (getCurrentUsersRecentlyPlayedTracks (textToByteString . getAccessToken . AccessToken $ accessTokenFileData))

let maybeMarshalledRecentlyPlayed = (marshallRecentlyPlayedData recentlyPlayedTrackData)

( https://github.com/imjacobclark/Recify/blob/master/src/Recify.hs#L53-L55)

{-# LANGUAGE OverloadedStrings #-}

module Clients.Spotify.RecentlyPlayed where

import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Char8 as B
import qualified Network.Wreq as W
import System.Environment
import Control.Monad.IO.Class
import Control.Lens

recentlyPlayerUri = "https://api.spotify.com/v1/me/player/recently-played"

getCurrentUsersRecentlyPlayedTracks :: B.ByteString -> IO L.ByteString
getCurrentUsersRecentlyPlayedTracks accessToken = do
  let options = W.defaults & W.header "Authorization" .~ [(B.pack "Bearer ") <> accessToken] 
  text <- liftIO $ (W.getWith options recentlyPlayerUri)
  return $ text ^. W.responseBody

( https://github.com/imjacobclark/Recify/blob/master/src/Clients/Spotify/RecentlyPlayed.hs)

Я ожидаю, что смогу вызвать первый API, создать свой тип данных, вызвать второй API, а затем обогатить первый тип данных данными, возвращаемыми после второго HTTP-вызова.

1 ответ

Как вы, несомненно, знаете, в отличие от объектов Javascript, ADT Haskell не расширяемы, поэтому вы не можете просто "добавить поле". В определенных обстоятельствах может иметь смысл включить поле сMaybe тип изначально установлен на Nothingкоторый затем заполняется. В редких случаях может иметь смысл выполнить очень небезопасную операцию включения поля в его окончательный тип, но его значения инициализируются снизу (т. Е.undefined) и заселил его позже.

В качестве альтернативы вы можете переключиться на какой-нибудь явно расширяемый тип записи, например HList.

Однако наиболее простой подход и тот, который использует систему типов Haskell, как задумано, - это ввести новый тип для представления дорожки, дополненной информацией о жанре. Если у вас есть дополнительные типы данных, которые включаютTrackполя, которые вы хотите использовать повторно, их можно сделать полиморфными по типу дорожки. Итак, учитывая ваши типы данных выше, вы должны ввести новый тип:

data Track' = Track'
  { playedAt :: String
  , externalUrls :: String
  , name :: String
  , artists :: [Artist]
  , genres :: [Genre]     -- added field
  , explicit :: Bool
  }

(что требует DuplicateRecordFields расширение сосуществовать с Track) и сделаем зависимые типы полиморфными в типе дорожки:

data Tracks trk = Tracks
  { tracks :: [trk]
  }

data RecentlyPlayed trk = RecentlyPlayed
  { recentlyPlayed :: Tracks trk
  , next :: String
  }

Преобразование списка воспроизведения может быть выполнено с помощью:

addGenre :: (Artist -> [Genre]) -> RecentlyPlayed Track -> RecentlyPlayed Track'
addGenre g (RecentlyPlayed (Tracks trks) nxt)
  = RecentlyPlayed (Tracks (map cvtTrack trks)) nxt
  where
    cvtTrack (Track p e n a ex) = Track' p e n a (concatMap g a) ex

или, альтернативно, используя RecordWildCards расширение, которое будет намного более читабельным, особенно для очень больших записей:

addGenre' :: (Artist -> [Genre]) -> RecentlyPlayed Track -> RecentlyPlayed Track'
addGenre' g RecentlyPlayed{recentlyPlayed = Tracks trks, ..}
  = RecentlyPlayed{recentlyPlayed = Tracks (map cvtTrack trks), ..}
  where
    cvtTrack (Track{..}) = Track' { genres = concatMap g artists, .. }

или используя подход линзы, или даже используя deriving (Functor) экземпляры со всей тяжелой работой, сделанной fmap:

addGenre'' :: (Artist -> [Genre]) -> RecentlyPlayed Track -> RecentlyPlayed Track'
addGenre'' g = fmap cvtTrack
  where
    cvtTrack (Track{..}) = Track' { genres = concatMap g artists, .. }

хотя подход функтора не очень хорошо масштабируется, если имеется несколько дополнений (например, если вы обнаружите, что хотите ввести RecentlyPlayed artist trackтип). АData.Generics подход может хорошо работать в этом случае.

Однако с более общей точки зрения дизайна вы, возможно, захотите спросить себя, почему вы пытаетесь увеличить RecentlyPlayedпредставление таким образом. Это хорошее представление необходимых частей базового API Javascript, но это плохое представление для работы в остальной части логики вашей программы.

Предположительно, остальная часть вашей программы имеет дело в первую очередь со списком треков и не должна заботиться о следующих next URL-адреса, так почему бы не создать полный список треков с расширенным жанром напрямую?

То есть с учетом начального RecentlyPlayed list и некоторые функции ввода-вывода для получения следующего списка и поиска информации о жанрах:

firstRecentlyPlayed :: RecentlyPlayed
getNextRecentlyPlayed :: String -> IO RecentlyPlayed
getGenresByArtist :: Artist -> IO [Genre]

вы, вероятно, захотите что-то вроде:

getTracks :: IO [Track']
getTracks = go firstRecentlyPlayed
  where go :: RecentlyPlayed -> IO [Track']
        go (RecentlyPlayed (Tracks trks) next) = do
          trks' <- mapM getGenre trks
          rest <- go =<< getNextRecentlyPlayed next
          return $ trks' ++ rest
        getGenre Track{..} = do
          artistGenres <- mapM getGenresByArtist artists
          return (Track' {genres = concat artistGenres, ..})

для первой попытки. Конечно, вы захотите изменить это, чтобы не искать жанры одного и того же исполнителя снова и снова, но это идея.

Другие вопросы по тегам