diff --git a/cabal.project.freeze b/cabal.project.freeze index ce48525bba4..fa24055e757 100644 --- a/cabal.project.freeze +++ b/cabal.project.freeze @@ -1,6 +1,7 @@ active-repositories: hackage.haskell.org:merge constraints: any.Cabal ==3.2.1.0, Cabal -bundled-binary-generic, + any.Diff ==0.4.1, any.HTTP ==4000.3.16, HTTP -conduit10 -mtl1 +network-uri -warn-as-error -warp-tests, any.HUnit ==1.6.2.0, @@ -43,7 +44,7 @@ constraints: any.Cabal ==3.2.1.0, any.authenticate-oauth ==1.7, any.auto-update ==0.1.6, any.autodocodec ==0.0.1.0, - any.autodocodec-openapi3 ==0.1.0.0, + any.autodocodec-openapi3 ==0.2.0.0, any.barbies ==2.0.3.1, any.base ==4.14.3.0, any.base-compat ==0.11.2, @@ -181,11 +182,13 @@ constraints: any.Cabal ==3.2.1.0, any.hpc ==0.6.1.0, any.hsc2hs ==0.68.8, hsc2hs -in-ghc-tree, + any.hscolour ==1.24.4, any.hspec ==2.9.4, any.hspec-core ==2.9.4, any.hspec-discover ==2.9.4, any.hspec-expectations ==0.8.2, any.hspec-expectations-lifted ==0.10.0, + any.hspec-expectations-pretty-diff ==0.7.2.6, any.hspec-hedgehog ==0.0.1.2, any.hspec-wai ==0.11.0, any.hspec-wai-json ==0.11.0, @@ -264,6 +267,7 @@ constraints: any.Cabal ==3.2.1.0, any.network-info ==0.2.1, any.network-ip ==0.3.0.3, any.network-uri ==2.6.4.1, + any.nicify-lib ==1.0.1, any.odbc ==0.2.6, any.old-locale ==1.0.0.7, any.old-time ==1.1.0.3, @@ -410,6 +414,7 @@ constraints: any.Cabal ==3.2.1.0, any.type-hint ==0.1, any.typed-process ==0.2.8.0, any.unbounded-delays ==0.1.1.1, + any.unicode-show ==0.1.1.0, any.unix ==2.7.2.2, any.unix-compat ==0.5.4, unix-compat -old-time, @@ -464,4 +469,4 @@ constraints: any.Cabal ==3.2.1.0, yaml +no-examples +no-exe, any.zlib ==0.6.2.3, zlib -bundled-c-zlib -non-blocking-ffi -pkg-config -index-state: hackage.haskell.org 2022-02-16T22:54:12Z +index-state: hackage.haskell.org 2022-04-06T04:57:40Z diff --git a/server/graphql-engine.cabal b/server/graphql-engine.cabal index cbf876d87ee..69d1433fd03 100644 --- a/server/graphql-engine.cabal +++ b/server/graphql-engine.cabal @@ -1103,3 +1103,48 @@ test-suite tests-hspec Test.WhereSpec Test.RunSQLSpec Test.InsertCheckPermissionSpec + +test-suite tests-gdw-api + import: common-all, common-exe + type: exitcode-stdio-1.0 + build-depends: + , aeson + , autodocodec + , autodocodec-openapi3 + , base + , bytestring + , deepseq + , file-embed + , gdw-api + , hashable + , hspec + , hspec-core + , hspec-expectations-pretty-diff + , http-client + , lens + , lens-aeson + , mtl + , network-uri + , openapi3 + , optparse-applicative + , scientific + , servant + , servant-client + , servant-client-core + , servant-openapi3 + , text + , unordered-containers + , vector + hs-source-dirs: tests-gdw-api + -- Turning off optimizations is intentional; tests aren't + -- performance sensitive and waiting for compilation is a problem. + ghc-options: -Wall -O0 -threaded + main-is: Main.hs + other-modules: + Command + , Paths_graphql_engine + , Test.Data + , Test.QuerySpec + , Test.QuerySpec.BasicSpec + , Test.QuerySpec.RelationshipsSpec + , Test.SchemaSpec diff --git a/server/src-gdw-api/Hasura/Backends/DataWrapper/API.hs b/server/src-gdw-api/Hasura/Backends/DataWrapper/API.hs index be465c86383..0e79b37cd6f 100644 --- a/server/src-gdw-api/Hasura/Backends/DataWrapper/API.hs +++ b/server/src-gdw-api/Hasura/Backends/DataWrapper/API.hs @@ -5,6 +5,8 @@ module Hasura.Backends.DataWrapper.API SchemaApi, QueryApi, openApiSchema, + Routes (..), + apiClient, ) where @@ -13,6 +15,7 @@ import Data.OpenApi (OpenApi) import Hasura.Backends.DataWrapper.API.V0.API as V0 import Servant.API import Servant.API.Generic +import Servant.Client (Client, ClientM, client) import Servant.OpenApi -------------------------------------------------------------------------------- @@ -42,3 +45,7 @@ type Api = SchemaApi :<|> QueryApi -- | Provide an OpenApi 3.0 schema for the API openApiSchema :: OpenApi openApiSchema = toOpenApi (Proxy :: Proxy Api) + +apiClient :: Client ClientM (NamedRoutes Routes) +apiClient = + client (Proxy @(NamedRoutes Routes)) diff --git a/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Query.hs b/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Query.hs index 433d5e0a465..08cef7d428e 100644 --- a/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Query.hs +++ b/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Query.hs @@ -62,7 +62,7 @@ instance HasCodec Query where -------------------------------------------------------------------------------- data RelField = RelField - { fieldMapping :: M.HashMap PrimaryKey ForeignKey, + { columnMapping :: M.HashMap PrimaryKey ForeignKey, query :: Query } deriving stock (Eq, Ord, Show, Generic, Data) @@ -70,7 +70,7 @@ data RelField = RelField instance HasObjectCodec RelField where objectCodec = RelField - <$> requiredField "field_mapping" "Mapping from local fields to remote fields" .= fieldMapping + <$> requiredField "column_mapping" "Mapping from local fields to remote fields" .= columnMapping <*> requiredField "query" "Relationship query" .= query -------------------------------------------------------------------------------- diff --git a/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Table.hs b/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Table.hs index 04136a56cfb..f1f918ebba4 100644 --- a/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Table.hs +++ b/server/src-gdw-api/Hasura/Backends/DataWrapper/API/V0/Table.hs @@ -48,7 +48,7 @@ instance HasCodec TableInfo where codec = object "TableInfo" $ TableInfo - <$> requiredField "table_name" "The name of the table" .= dtiName + <$> requiredField "name" "The name of the table" .= dtiName <*> requiredField "columns" "The columns of the table" .= dtiColumns <*> optionalFieldOrNull "primary_key" "The primary key of the table" .= dtiPrimaryKey <*> optionalFieldOrNull "description" "Description of the table" .= dtiDescription diff --git a/server/src-test/Hasura/Backends/DataWrapper/API/V0/QuerySpec.hs b/server/src-test/Hasura/Backends/DataWrapper/API/V0/QuerySpec.hs index bf2654c72e0..b12a24b21d9 100644 --- a/server/src-test/Hasura/Backends/DataWrapper/API/V0/QuerySpec.hs +++ b/server/src-test/Hasura/Backends/DataWrapper/API/V0/QuerySpec.hs @@ -38,7 +38,7 @@ spec = do (RelationshipField $ RelField fieldMapping query) [aesonQQ| { "type": "relationship", - "field_mapping": {"id": "my_foreign_id"}, + "column_mapping": {"id": "my_foreign_id"}, "query": {"fields": {}, "from": "my_table_name"} } |] diff --git a/server/src-test/Hasura/Backends/DataWrapper/API/V0/TableSpec.hs b/server/src-test/Hasura/Backends/DataWrapper/API/V0/TableSpec.hs index 172cd3bbbdc..06b20ab54bd 100644 --- a/server/src-test/Hasura/Backends/DataWrapper/API/V0/TableSpec.hs +++ b/server/src-test/Hasura/Backends/DataWrapper/API/V0/TableSpec.hs @@ -24,7 +24,7 @@ spec = do testToFromJSONToSchema (TableInfo (TableName "my_table_name") [] Nothing Nothing) [aesonQQ| - { "table_name": "my_table_name", + { "name": "my_table_name", "columns": [] } |] @@ -37,7 +37,7 @@ spec = do (Just "my description") ) [aesonQQ| - { "table_name": "my_table_name", + { "name": "my_table_name", "columns": [{"name": "id", "type": "string", "nullable": false}], "primary_key": "id", "description": "my description" diff --git a/server/tests-gdw-api/Command.hs b/server/tests-gdw-api/Command.hs new file mode 100644 index 00000000000..466bdb0b727 --- /dev/null +++ b/server/tests-gdw-api/Command.hs @@ -0,0 +1,197 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Command + ( Command (..), + TestOptions (..), + AgentCapabilities (..), + parseCommandLine, + ) +where + +import Control.Arrow (left) +import Control.Lens (contains, modifying, use, (^.), _2) +import Control.Lens.TH (makeLenses) +import Control.Monad (when) +import Control.Monad.State (State, runState) +import Data.HashSet (HashSet) +import Data.HashSet qualified as HashSet +import Data.Text (Text) +import Data.Text qualified as Text +import Data.Version (showVersion) +import Hasura.Backends.DataWrapper.API qualified as API +import Options.Applicative +import Paths_graphql_engine qualified as PackageInfo +import Servant.Client (BaseUrl, parseBaseUrl) +import Prelude + +data Command + = Test TestOptions + | ExportOpenAPISpec + +data TestOptions = TestOptions + { _toAgentBaseUrl :: BaseUrl, + _toAgentCapabilities :: AgentCapabilities, + _toParallelDegree :: Maybe Int, + _toMatch :: Maybe String, + _toSkip :: Maybe String + } + +data AgentCapabilities + = AutoDetect + | Explicit API.Capabilities + +data CapabilitiesState = CapabilitiesState + { _csRemainingCapabilities :: HashSet Text, + _csCapabilitiesEnquired :: HashSet Text + } + +$(makeLenses ''CapabilitiesState) + +parseCommandLine :: IO Command +parseCommandLine = + execParser $ + info + (helper <*> version <*> commandParser) + ( fullDesc + <> header "Hasura GraphQL Data Wrapper Agent Test Utility" + ) + +version :: Parser (a -> a) +version = + infoOption + displayText + ( long "version" + <> short 'v' + <> help "Prints the version of the application and quits" + <> hidden + ) + where + displayText = "Version " <> showVersion PackageInfo.version + +commandParser :: Parser Command +commandParser = + subparser + (testCommand <> exportOpenApiSpecCommand) + where + testCommand = + command + "test" + ( info + (helper <*> testCommandParser) + (progDesc "Executes a suite of tests against an agent to ensure its correct function") + ) + exportOpenApiSpecCommand = + command + "export-openapi-spec" + ( info + (helper <*> pure ExportOpenAPISpec) + (progDesc "Exports the OpenAPI specification of the GDW API agents must implement") + ) + +testOptionsParser :: Parser TestOptions +testOptionsParser = + TestOptions + <$> option + baseUrl + ( long "agent-base-url" + <> short 'u' + <> metavar "URL" + <> help "The base URL of the GDW agent to be tested" + ) + <*> agentCapabilitiesParser + <*> optional + ( option + positiveNonZeroInt + ( long "jobs" + <> short 'j' + <> metavar "INT" + <> help "Run at most N parallelizable tests simultaneously (default: number of available processors)" + ) + ) + <*> optional + ( option + auto + ( long "match" + <> short 'm' + <> metavar "PATTERN" + <> help "Only run tests that match given PATTERN" + ) + ) + <*> optional + ( option + auto + ( long "skip" + <> short 's' + <> metavar "PATTERN" + <> help "Skip tests that match given PATTERN" + ) + ) + +testCommandParser :: Parser Command +testCommandParser = Test <$> testOptionsParser + +baseUrl :: ReadM BaseUrl +baseUrl = eitherReader $ left show . parseBaseUrl + +positiveNonZeroInt :: ReadM Int +positiveNonZeroInt = + auto >>= \int -> + if int <= 0 then readerError "Must be a positive, non-zero integer" else pure int + +agentCapabilitiesParser :: Parser AgentCapabilities +agentCapabilitiesParser = + option + agentCapabilities + ( long "capabilities" + <> short 'c' + <> metavar "CAPABILITIES" + <> value AutoDetect + <> help (Text.unpack helpText) + ) + where + helpText = + "The capabilities that the agent has, to determine what tests to run. By default, they will be autodetected. The valid capabilities are: " <> allCapabilitiesText + allCapabilitiesText = + "[autodetect | none | " <> Text.intercalate "," (HashSet.toList allPossibleCapabilities) <> "]" + +agentCapabilities :: ReadM AgentCapabilities +agentCapabilities = + str >>= \text -> do + let capabilities = HashSet.fromList $ Text.strip <$> Text.split (== ',') text + if HashSet.member "autodetect" capabilities + then + if HashSet.size capabilities == 1 + then pure AutoDetect + else readerError "You can either autodetect capabilities or specify them manually, not both" + else + if HashSet.member "none" capabilities + then + if HashSet.size capabilities == 1 + then pure . Explicit . fst $ readCapabilities mempty + else readerError "You cannot specify other capabilities when specifying none" + else Explicit <$> readExplicitCapabilities capabilities + where + readExplicitCapabilities :: HashSet Text -> ReadM API.Capabilities + readExplicitCapabilities providedCapabilities = + let (capabilities, CapabilitiesState {..}) = readCapabilities providedCapabilities + in if _csRemainingCapabilities /= mempty + then readerError . Text.unpack $ "Unknown capabilities: " <> Text.intercalate "," (HashSet.toList _csRemainingCapabilities) + else pure capabilities + +readCapabilities :: HashSet Text -> (API.Capabilities, CapabilitiesState) +readCapabilities providedCapabilities = + flip runState (CapabilitiesState providedCapabilities mempty) $ + API.Capabilities + <$> readCapability "relationships" + +readCapability :: Text -> State CapabilitiesState Bool +readCapability capability = do + modifying csCapabilitiesEnquired $ HashSet.insert capability + hasCapability <- use $ csRemainingCapabilities . contains capability + when hasCapability $ + modifying csRemainingCapabilities $ HashSet.delete capability + pure hasCapability + +allPossibleCapabilities :: HashSet Text +allPossibleCapabilities = + readCapabilities mempty ^. _2 . csCapabilitiesEnquired diff --git a/server/tests-gdw-api/Main.hs b/server/tests-gdw-api/Main.hs new file mode 100644 index 00000000000..7d482a8ccc1 --- /dev/null +++ b/server/tests-gdw-api/Main.hs @@ -0,0 +1,60 @@ +module Main (main) where + +import Command (AgentCapabilities (..), Command (..), TestOptions (..), parseCommandLine) +import Control.Exception (throwIO) +import Control.Monad ((>=>)) +import Data.Aeson.Text (encodeToLazyText) +import Data.Proxy (Proxy (..)) +import Data.Text.Lazy.IO qualified as Text +import Hasura.Backends.DataWrapper.API (Routes (..), apiClient, openApiSchema) +import Hasura.Backends.DataWrapper.API qualified as API +import Network.HTTP.Client (defaultManagerSettings, newManager) +import Servant.API (NamedRoutes) +import Servant.Client (Client, ClientError, hoistClient, mkClientEnv, runClientM, (//)) +import Test.Hspec (Spec) +import Test.Hspec.Core.Runner (runSpec) +import Test.Hspec.Core.Util (filterPredicate) +import Test.Hspec.Runner (Config (..), defaultConfig, evaluateSummary) +import Test.QuerySpec qualified +import Test.SchemaSpec qualified +import Prelude + +tests :: Client IO (NamedRoutes Routes) -> API.Capabilities -> Spec +tests api capabilities = do + Test.SchemaSpec.spec api capabilities + Test.QuerySpec.spec api capabilities + +main :: IO () +main = do + command <- parseCommandLine + case command of + Test testOptions -> do + api <- mkIOApiClient testOptions + agentCapabilities <- getAgentCapabilities api (_toAgentCapabilities testOptions) + runSpec (tests api agentCapabilities) (applyTestConfig defaultConfig testOptions) >>= evaluateSummary + ExportOpenAPISpec -> + Text.putStrLn $ encodeToLazyText openApiSchema + + pure () + +mkIOApiClient :: TestOptions -> IO (Client IO (NamedRoutes Routes)) +mkIOApiClient TestOptions {..} = do + manager <- newManager defaultManagerSettings + let clientEnv = mkClientEnv manager _toAgentBaseUrl + pure $ hoistClient (Proxy @(NamedRoutes Routes)) (flip runClientM clientEnv >=> throwClientError) apiClient + +throwClientError :: Either ClientError a -> IO a +throwClientError = either throwIO pure + +getAgentCapabilities :: Client IO (NamedRoutes Routes) -> AgentCapabilities -> IO API.Capabilities +getAgentCapabilities api = \case + AutoDetect -> fmap API.srCapabilities $ api // _schema + Explicit capabilities -> pure capabilities + +applyTestConfig :: Config -> TestOptions -> Config +applyTestConfig config TestOptions {..} = + config + { configConcurrentJobs = _toParallelDegree, + configFilterPredicate = filterPredicate <$> _toMatch, + configSkipPredicate = filterPredicate <$> _toSkip + } diff --git a/server/tests-gdw-api/README.md b/server/tests-gdw-api/README.md new file mode 100644 index 00000000000..ab804c8aedd --- /dev/null +++ b/server/tests-gdw-api/README.md @@ -0,0 +1,31 @@ +# GraphQL Data Wrappers Agent Tests +This test suite provides a set of tests that is able to test any GDW agent that contains the Chinook data set to ensure the agent is behaving as expected. The test executable is designed to be distributable to customers building GDW agents, but is also useful to ensure Hasura's own agents are working correctly. + +Not all tests will be appropriate for all agents. Agents self-describe their capabilities and only the tests appropriate for those capabilities will be run. + +The executable also has the ability to export the OpenAPI spec of the GDW agent API so that customers can use that to ensure their agent complies with the API format. + +## How to Use +First, start your GDW agent and ensure it is populated with the Chinook data set. For example, you could start the Reference Agent by following the instructions in [its README](../../gdw-agents/reference/README.md). + +To run the tests against the agent (for example), you must specify the agent's URL on the command line: + +``` +cabal run test:tests-gdw-api -- test -u "http://localhost:8100" +``` + +By default, the test suite will discover what capabilities the agent exposes by querying it. Otherwise, the user can use command line flags to specify which capabilities their agent has to ensure that it exposes the expected capabilities and that the test suite only runs the tests that correspond to those capabilities. + +To set the agent's available the capabilities use `-c` and comma separate them: + +``` +> cabal run test:tests-gdw-api -- test -u "http://localhost:8100" -c relationships +``` + +If `-c` is omitted, the default value is `autodetect`. If you have no capabilities, you can specify `none`. + +To export the OpenAPI spec, you can run this command, and the spec will be written to stdout. + +``` +> cabal run test:tests-gdw-api -- export-openapi-spec +``` diff --git a/server/tests-gdw-api/Test/Data.hs b/server/tests-gdw-api/Test/Data.hs new file mode 100644 index 00000000000..3b6b7f50644 --- /dev/null +++ b/server/tests-gdw-api/Test/Data.hs @@ -0,0 +1,90 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE TemplateHaskell #-} + +module Test.Data + ( Artist (..), + Album (..), + schemaTables, + artists, + artistsAsJson, + artistsAsJsonById, + albums, + albumsAsJson, + sortBy, + ) +where + +import Control.Lens (ix, (^?)) +import Data.Aeson (FromJSON (..), Object, eitherDecodeStrict, withObject, (.:)) +import Data.Aeson.Lens (_Number) +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile, makeRelativeToProject) +import Data.HashMap.Strict (HashMap) +import Data.HashMap.Strict qualified as HashMap +import Data.Hashable (Hashable) +import Data.List (sortOn) +import Data.Maybe (mapMaybe) +import Data.Scientific (Scientific) +import Data.Text (Text) +import GHC.Generics (Generic) +import Hasura.Backends.DataWrapper.API (TableInfo (..)) +import Prelude + +data Artist = Artist + { _artistId :: Int, + _artistName :: Text + } + deriving stock (Eq, Show, Ord, Generic) + deriving anyclass (Hashable) + +instance FromJSON Artist where + parseJSON = withObject "Artist" $ \obj -> + Artist + <$> obj .: "id" + <*> obj .: "name" + +data Album = Album + { _albumId :: Int, + _albumTitle :: Text, + _albumArtistId :: Int + } + deriving stock (Eq, Show, Ord, Generic) + deriving anyclass (Hashable) + +instance FromJSON Album where + parseJSON = withObject "Album" $ \obj -> + Album + <$> obj .: "id" + <*> obj .: "title" + <*> obj .: "artist_id" + +schemaBS :: ByteString +schemaBS = $(makeRelativeToProject "tests-gdw-api/Test/Data/schema-tables.json" >>= embedFile) + +artistsBS :: ByteString +artistsBS = $(makeRelativeToProject "tests-gdw-api/Test/Data/artists.json" >>= embedFile) + +albumsBS :: ByteString +albumsBS = $(makeRelativeToProject "tests-gdw-api/Test/Data/albums.json" >>= embedFile) + +schemaTables :: [TableInfo] +schemaTables = sortOn dtiName . either error id . eitherDecodeStrict $ schemaBS + +artists :: [Artist] +artists = sortOn _artistId . either error id . eitherDecodeStrict $ artistsBS + +artistsAsJson :: [Object] +artistsAsJson = sortBy "id" . either error id . eitherDecodeStrict $ artistsBS + +artistsAsJsonById :: HashMap Scientific Object +artistsAsJsonById = + HashMap.fromList $ mapMaybe (\artist -> (,artist) <$> artist ^? ix "id" . _Number) artistsAsJson + +albums :: [Album] +albums = sortOn _albumId . either error id . eitherDecodeStrict $ albumsBS + +albumsAsJson :: [Object] +albumsAsJson = sortBy "id" . either error id . eitherDecodeStrict $ albumsBS + +sortBy :: Text -> [Object] -> [Object] +sortBy propName = sortOn (^? ix propName) diff --git a/server/tests-gdw-api/Test/Data/albums.json b/server/tests-gdw-api/Test/Data/albums.json new file mode 100644 index 00000000000..9a911bed1ee --- /dev/null +++ b/server/tests-gdw-api/Test/Data/albums.json @@ -0,0 +1,1702 @@ +[ + { + "id": 2, + "title": "Balls to the Wall", + "artist_id": 2 + }, + { + "id": 3, + "title": "Restless and Wild", + "artist_id": 2 + }, + { + "id": 4, + "title": "Let There Be Rock", + "artist_id": 1 + }, + { + "id": 5, + "title": "Big Ones", + "artist_id": 3 + }, + { + "id": 6, + "title": "Jagged Little Pill", + "artist_id": 4 + }, + { + "id": 8, + "title": "Warner 25 Anos", + "artist_id": 6 + }, + { + "id": 9, + "title": "Plays Metallica By Four Cellos", + "artist_id": 7 + }, + { + "id": 12, + "title": "BackBeat Soundtrack", + "artist_id": 9 + }, + { + "id": 14, + "title": "Alcohol Fueled Brewtality Live! [Disc 1]", + "artist_id": 11 + }, + { + "id": 15, + "title": "Alcohol Fueled Brewtality Live! [Disc 2]", + "artist_id": 11 + }, + { + "id": 16, + "title": "Black Sabbath", + "artist_id": 12 + }, + { + "id": 17, + "title": "Black Sabbath Vol. 4 (Remaster)", + "artist_id": 12 + }, + { + "id": 18, + "title": "Body Count", + "artist_id": 13 + }, + { + "id": 19, + "title": "Chemical Wedding", + "artist_id": 14 + }, + { + "id": 20, + "title": "The Best Of Buddy Guy - The Millenium Collection", + "artist_id": 15 + }, + { + "id": 21, + "title": "Prenda Minha", + "artist_id": 16 + }, + { + "id": 22, + "title": "Sozinho Remix Ao Vivo", + "artist_id": 16 + }, + { + "id": 23, + "title": "Minha Historia", + "artist_id": 17 + }, + { + "id": 24, + "title": "Afrociberdelia", + "artist_id": 18 + }, + { + "id": 25, + "title": "Da Lama Ao Caos", + "artist_id": 18 + }, + { + "id": 26, + "title": "Acústico MTV [Live]", + "artist_id": 19 + }, + { + "id": 27, + "title": "Cidade Negra - Hits", + "artist_id": 19 + }, + { + "id": 28, + "title": "Na Pista", + "artist_id": 20 + }, + { + "id": 29, + "title": "Axé Bahia 2001", + "artist_id": 21 + }, + { + "id": 30, + "title": "BBC Sessions [Disc 1] [Live]", + "artist_id": 22 + }, + { + "id": 31, + "title": "Bongo Fury", + "artist_id": 23 + }, + { + "id": 32, + "title": "Carnaval 2001", + "artist_id": 21 + }, + { + "id": 33, + "title": "Chill: Brazil (Disc 1)", + "artist_id": 24 + }, + { + "id": 34, + "title": "Chill: Brazil (Disc 2)", + "artist_id": 6 + }, + { + "id": 35, + "title": "Garage Inc. (Disc 1)", + "artist_id": 50 + }, + { + "id": 36, + "title": "Greatest Hits II", + "artist_id": 51 + }, + { + "id": 37, + "title": "Greatest Kiss", + "artist_id": 52 + }, + { + "id": 38, + "title": "Heart of the Night", + "artist_id": 53 + }, + { + "id": 39, + "title": "International Superhits", + "artist_id": 54 + }, + { + "id": 40, + "title": "Into The Light", + "artist_id": 55 + }, + { + "id": 41, + "title": "Meus Momentos", + "artist_id": 56 + }, + { + "id": 42, + "title": "Minha História", + "artist_id": 57 + }, + { + "id": 43, + "title": "MK III The Final Concerts [Disc 1]", + "artist_id": 58 + }, + { + "id": 44, + "title": "Physical Graffiti [Disc 1]", + "artist_id": 22 + }, + { + "id": 45, + "title": "Sambas De Enredo 2001", + "artist_id": 21 + }, + { + "id": 46, + "title": "Supernatural", + "artist_id": 59 + }, + { + "id": 47, + "title": "The Best of Ed Motta", + "artist_id": 37 + }, + { + "id": 51, + "title": "Up An' Atom", + "artist_id": 69 + }, + { + "id": 52, + "title": "Vinícius De Moraes - Sem Limite", + "artist_id": 70 + }, + { + "id": 53, + "title": "Vozes do MPB", + "artist_id": 21 + }, + { + "id": 54, + "title": "Chronicle, Vol. 1", + "artist_id": 76 + }, + { + "id": 55, + "title": "Chronicle, Vol. 2", + "artist_id": 76 + }, + { + "id": 56, + "title": "Cássia Eller - Coleção Sem Limite [Disc 2]", + "artist_id": 77 + }, + { + "id": 57, + "title": "Cássia Eller - Sem Limite [Disc 1]", + "artist_id": 77 + }, + { + "id": 58, + "title": "Come Taste The Band", + "artist_id": 58 + }, + { + "id": 59, + "title": "Deep Purple In Rock", + "artist_id": 58 + }, + { + "id": 60, + "title": "Fireball", + "artist_id": 58 + }, + { + "id": 61, + "title": "Knocking at Your Back Door: The Best Of Deep Purple in the 80's", + "artist_id": 58 + }, + { + "id": 62, + "title": "Machine Head", + "artist_id": 58 + }, + { + "id": 63, + "title": "Purpendicular", + "artist_id": 58 + }, + { + "id": 64, + "title": "Slaves And Masters", + "artist_id": 58 + }, + { + "id": 65, + "title": "Stormbringer", + "artist_id": 58 + }, + { + "id": 66, + "title": "The Battle Rages On", + "artist_id": 58 + }, + { + "id": 67, + "title": "Vault: Def Leppard's Greatest Hits", + "artist_id": 78 + }, + { + "id": 68, + "title": "Outbreak", + "artist_id": 79 + }, + { + "id": 69, + "title": "Djavan Ao Vivo - Vol. 02", + "artist_id": 80 + }, + { + "id": 70, + "title": "Djavan Ao Vivo - Vol. 1", + "artist_id": 80 + }, + { + "id": 71, + "title": "Elis Regina-Minha História", + "artist_id": 41 + }, + { + "id": 72, + "title": "The Cream Of Clapton", + "artist_id": 81 + }, + { + "id": 73, + "title": "Unplugged", + "artist_id": 81 + }, + { + "id": 74, + "title": "albums Of The Year", + "artist_id": 82 + }, + { + "id": 75, + "title": "Angel Dust", + "artist_id": 82 + }, + { + "id": 76, + "title": "King For A Day Fool For A Lifetime", + "artist_id": 82 + }, + { + "id": 77, + "title": "The Real Thing", + "artist_id": 82 + }, + { + "id": 78, + "title": "Deixa Entrar", + "artist_id": 83 + }, + { + "id": 79, + "title": "In Your Honor [Disc 1]", + "artist_id": 84 + }, + { + "id": 80, + "title": "In Your Honor [Disc 2]", + "artist_id": 84 + }, + { + "id": 81, + "title": "One By One", + "artist_id": 84 + }, + { + "id": 82, + "title": "The Colour And The Shape", + "artist_id": 84 + }, + { + "id": 83, + "title": "My Way: The Best Of Frank Sinatra [Disc 1]", + "artist_id": 85 + }, + { + "id": 84, + "title": "Roda De Funk", + "artist_id": 86 + }, + { + "id": 85, + "title": "As Canções de Eu Tu Eles", + "artist_id": 27 + }, + { + "id": 86, + "title": "Quanta Gente Veio Ver (Live)", + "artist_id": 27 + }, + { + "id": 87, + "title": "Quanta Gente Veio ver--Bônus De Carnaval", + "artist_id": 27 + }, + { + "id": 88, + "title": "Faceless", + "artist_id": 87 + }, + { + "id": 89, + "title": "American Idiot", + "artist_id": 54 + }, + { + "id": 90, + "title": "Appetite for Destruction", + "artist_id": 88 + }, + { + "id": 91, + "title": "Use Your Illusion I", + "artist_id": 88 + }, + { + "id": 92, + "title": "Use Your Illusion II", + "artist_id": 88 + }, + { + "id": 93, + "title": "Blue Moods", + "artist_id": 89 + }, + { + "id": 94, + "title": "A Matter of Life and Death", + "artist_id": 90 + }, + { + "id": 95, + "title": "A Real Dead One", + "artist_id": 90 + }, + { + "id": 96, + "title": "A Real Live One", + "artist_id": 90 + }, + { + "id": 97, + "title": "Brave New World", + "artist_id": 90 + }, + { + "id": 98, + "title": "Dance Of Death", + "artist_id": 90 + }, + { + "id": 99, + "title": "Fear Of The Dark", + "artist_id": 90 + }, + { + "id": 100, + "title": "Iron Maiden", + "artist_id": 90 + }, + { + "id": 101, + "title": "Killers", + "artist_id": 90 + }, + { + "id": 102, + "title": "Live After Death", + "artist_id": 90 + }, + { + "id": 103, + "title": "Live At Donington 1992 (Disc 1)", + "artist_id": 90 + }, + { + "id": 104, + "title": "Live At Donington 1992 (Disc 2)", + "artist_id": 90 + }, + { + "id": 105, + "title": "No Prayer For The Dying", + "artist_id": 90 + }, + { + "id": 106, + "title": "Piece Of Mind", + "artist_id": 90 + }, + { + "id": 107, + "title": "Powerslave", + "artist_id": 90 + }, + { + "id": 108, + "title": "Rock In Rio [CD1]", + "artist_id": 90 + }, + { + "id": 109, + "title": "Rock In Rio [CD2]", + "artist_id": 90 + }, + { + "id": 110, + "title": "Seventh Son of a Seventh Son", + "artist_id": 90 + }, + { + "id": 111, + "title": "Somewhere in Time", + "artist_id": 90 + }, + { + "id": 112, + "title": "The Number of The Beast", + "artist_id": 90 + }, + { + "id": 113, + "title": "The X Factor", + "artist_id": 90 + }, + { + "id": 114, + "title": "Virtual XI", + "artist_id": 90 + }, + { + "id": 115, + "title": "Sex Machine", + "artist_id": 91 + }, + { + "id": 116, + "title": "Emergency On Planet Earth", + "artist_id": 92 + }, + { + "id": 117, + "title": "Synkronized", + "artist_id": 92 + }, + { + "id": 118, + "title": "The Return Of The Space Cowboy", + "artist_id": 92 + }, + { + "id": 119, + "title": "Get Born", + "artist_id": 93 + }, + { + "id": 120, + "title": "Are You Experienced?", + "artist_id": 94 + }, + { + "id": 121, + "title": "Surfing with the Alien (Remastered)", + "artist_id": 95 + }, + { + "id": 122, + "title": "Jorge Ben Jor 25 Anos", + "artist_id": 46 + }, + { + "id": 123, + "title": "Jota Quest-1995", + "artist_id": 96 + }, + { + "id": 124, + "title": "Cafezinho", + "artist_id": 97 + }, + { + "id": 125, + "title": "Living After Midnight", + "artist_id": 98 + }, + { + "id": 126, + "title": "Unplugged [Live]", + "artist_id": 52 + }, + { + "id": 127, + "title": "BBC Sessions [Disc 2] [Live]", + "artist_id": 22 + }, + { + "id": 128, + "title": "Coda", + "artist_id": 22 + }, + { + "id": 129, + "title": "Houses Of The Holy", + "artist_id": 22 + }, + { + "id": 130, + "title": "In Through The Out Door", + "artist_id": 22 + }, + { + "id": 131, + "title": "IV", + "artist_id": 22 + }, + { + "id": 132, + "title": "Led Zeppelin I", + "artist_id": 22 + }, + { + "id": 133, + "title": "Led Zeppelin II", + "artist_id": 22 + }, + { + "id": 134, + "title": "Led Zeppelin III", + "artist_id": 22 + }, + { + "id": 135, + "title": "Physical Graffiti [Disc 2]", + "artist_id": 22 + }, + { + "id": 136, + "title": "Presence", + "artist_id": 22 + }, + { + "id": 137, + "title": "The Song Remains The Same (Disc 1)", + "artist_id": 22 + }, + { + "id": 138, + "title": "The Song Remains The Same (Disc 2)", + "artist_id": 22 + }, + { + "id": 139, + "title": "A TempestadeTempestade Ou O Livro Dos Dias", + "artist_id": 99 + }, + { + "id": 140, + "title": "Mais Do Mesmo", + "artist_id": 99 + }, + { + "id": 141, + "title": "Greatest Hits", + "artist_id": 100 + }, + { + "id": 142, + "title": "Lulu Santos - RCA 100 Anos De Música - Álbum 01", + "artist_id": 101 + }, + { + "id": 143, + "title": "Lulu Santos - RCA 100 Anos De Música - Álbum 02", + "artist_id": 101 + }, + { + "id": 144, + "title": "Misplaced Childhood", + "artist_id": 102 + }, + { + "id": 145, + "title": "Barulhinho Bom", + "artist_id": 103 + }, + { + "id": 146, + "title": "Seek And Shall Find: More Of The Best (1963-1981)", + "artist_id": 104 + }, + { + "id": 147, + "title": "The Best Of Men At Work", + "artist_id": 105 + }, + { + "id": 148, + "title": "Black albums", + "artist_id": 50 + }, + { + "id": 149, + "title": "Garage Inc. (Disc 2)", + "artist_id": 50 + }, + { + "id": 150, + "title": "Kill 'Em All", + "artist_id": 50 + }, + { + "id": 151, + "title": "Load", + "artist_id": 50 + }, + { + "id": 152, + "title": "Master Of Puppets", + "artist_id": 50 + }, + { + "id": 153, + "title": "ReLoad", + "artist_id": 50 + }, + { + "id": 154, + "title": "Ride The Lightning", + "artist_id": 50 + }, + { + "id": 155, + "title": "St. Anger", + "artist_id": 50 + }, + { + "id": 156, + "title": "...And Justice For All", + "artist_id": 50 + }, + { + "id": 157, + "title": "Miles Ahead", + "artist_id": 68 + }, + { + "id": 158, + "title": "Milton Nascimento Ao Vivo", + "artist_id": 42 + }, + { + "id": 159, + "title": "Minas", + "artist_id": 42 + }, + { + "id": 160, + "title": "Ace Of Spades", + "artist_id": 106 + }, + { + "id": 161, + "title": "Demorou...", + "artist_id": 108 + }, + { + "id": 162, + "title": "Motley Crue Greatest Hits", + "artist_id": 109 + }, + { + "id": 163, + "title": "From The Muddy Banks Of The Wishkah [Live]", + "artist_id": 110 + }, + { + "id": 164, + "title": "Nevermind", + "artist_id": 110 + }, + { + "id": 165, + "title": "Compositores", + "artist_id": 111 + }, + { + "id": 166, + "title": "Olodum", + "artist_id": 112 + }, + { + "id": 167, + "title": "Acústico MTV", + "artist_id": 113 + }, + { + "id": 168, + "title": "Arquivo II", + "artist_id": 113 + }, + { + "id": 169, + "title": "Arquivo Os Paralamas Do Sucesso", + "artist_id": 113 + }, + { + "id": 170, + "title": "Bark at the Moon (Remastered)", + "artist_id": 114 + }, + { + "id": 171, + "title": "Blizzard of Ozz", + "artist_id": 114 + }, + { + "id": 172, + "title": "Diary of a Madman (Remastered)", + "artist_id": 114 + }, + { + "id": 173, + "title": "No More Tears (Remastered)", + "artist_id": 114 + }, + { + "id": 174, + "title": "Tribute", + "artist_id": 114 + }, + { + "id": 175, + "title": "Walking Into Clarksdale", + "artist_id": 115 + }, + { + "id": 176, + "title": "Original Soundtracks 1", + "artist_id": 116 + }, + { + "id": 177, + "title": "The Beast Live", + "artist_id": 117 + }, + { + "id": 178, + "title": "Live On Two Legs [Live]", + "artist_id": 118 + }, + { + "id": 179, + "title": "Pearl Jam", + "artist_id": 118 + }, + { + "id": 180, + "title": "Riot Act", + "artist_id": 118 + }, + { + "id": 181, + "title": "Ten", + "artist_id": 118 + }, + { + "id": 182, + "title": "Vs.", + "artist_id": 118 + }, + { + "id": 183, + "title": "Dark Side Of The Moon", + "artist_id": 120 + }, + { + "id": 184, + "title": "Os Cães Ladram Mas A Caravana Não Pára", + "artist_id": 121 + }, + { + "id": 185, + "title": "Greatest Hits I", + "artist_id": 51 + }, + { + "id": 186, + "title": "News Of The World", + "artist_id": 51 + }, + { + "id": 187, + "title": "Out Of Time", + "artist_id": 122 + }, + { + "id": 188, + "title": "Green", + "artist_id": 124 + }, + { + "id": 189, + "title": "New Adventures In Hi-Fi", + "artist_id": 124 + }, + { + "id": 190, + "title": "The Best Of R.E.M.: The IRS Years", + "artist_id": 124 + }, + { + "id": 191, + "title": "Cesta Básica", + "artist_id": 125 + }, + { + "id": 192, + "title": "Raul Seixas", + "artist_id": 126 + }, + { + "id": 193, + "title": "Blood Sugar Sex Magik", + "artist_id": 127 + }, + { + "id": 194, + "title": "By The Way", + "artist_id": 127 + }, + { + "id": 195, + "title": "Californication", + "artist_id": 127 + }, + { + "id": 196, + "title": "Retrospective I (1974-1980)", + "artist_id": 128 + }, + { + "id": 197, + "title": "Santana - As Years Go By", + "artist_id": 59 + }, + { + "id": 198, + "title": "Santana Live", + "artist_id": 59 + }, + { + "id": 199, + "title": "Maquinarama", + "artist_id": 130 + }, + { + "id": 200, + "title": "O Samba Poconé", + "artist_id": 130 + }, + { + "id": 201, + "title": "Judas 0: B-Sides and Rarities", + "artist_id": 131 + }, + { + "id": 202, + "title": "Rotten Apples: Greatest Hits", + "artist_id": 131 + }, + { + "id": 203, + "title": "A-Sides", + "artist_id": 132 + }, + { + "id": 204, + "title": "Morning Dance", + "artist_id": 53 + }, + { + "id": 205, + "title": "In Step", + "artist_id": 133 + }, + { + "id": 206, + "title": "Core", + "artist_id": 134 + }, + { + "id": 207, + "title": "Mezmerize", + "artist_id": 135 + }, + { + "id": 208, + "title": "[1997] Black Light Syndrome", + "artist_id": 136 + }, + { + "id": 209, + "title": "Live [Disc 1]", + "artist_id": 137 + }, + { + "id": 210, + "title": "Live [Disc 2]", + "artist_id": 137 + }, + { + "id": 211, + "title": "The Singles", + "artist_id": 138 + }, + { + "id": 212, + "title": "Beyond Good And Evil", + "artist_id": 139 + }, + { + "id": 213, + "title": "Pure Cult: The Best Of The Cult (For Rockers, Ravers, Lovers & Sinners) [UK]", + "artist_id": 139 + }, + { + "id": 214, + "title": "The Doors", + "artist_id": 140 + }, + { + "id": 215, + "title": "The Police Greatest Hits", + "artist_id": 141 + }, + { + "id": 216, + "title": "Hot Rocks, 1964-1971 (Disc 1)", + "artist_id": 142 + }, + { + "id": 217, + "title": "No Security", + "artist_id": 142 + }, + { + "id": 218, + "title": "Voodoo Lounge", + "artist_id": 142 + }, + { + "id": 219, + "title": "Tangents", + "artist_id": 143 + }, + { + "id": 220, + "title": "Transmission", + "artist_id": 143 + }, + { + "id": 221, + "title": "My Generation - The Very Best Of The Who", + "artist_id": 144 + }, + { + "id": 222, + "title": "Serie Sem Limite (Disc 1)", + "artist_id": 145 + }, + { + "id": 223, + "title": "Serie Sem Limite (Disc 2)", + "artist_id": 145 + }, + { + "id": 224, + "title": "Acústico", + "artist_id": 146 + }, + { + "id": 225, + "title": "Volume Dois", + "artist_id": 146 + }, + { + "id": 226, + "title": "Battlestar Galactica: The Story So Far", + "artist_id": 147 + }, + { + "id": 227, + "title": "Battlestar Galactica, Season 3", + "artist_id": 147 + }, + { + "id": 228, + "title": "Heroes, Season 1", + "artist_id": 148 + }, + { + "id": 229, + "title": "Lost, Season 3", + "artist_id": 149 + }, + { + "id": 230, + "title": "Lost, Season 1", + "artist_id": 149 + }, + { + "id": 231, + "title": "Lost, Season 2", + "artist_id": 149 + }, + { + "id": 232, + "title": "Achtung Baby", + "artist_id": 150 + }, + { + "id": 233, + "title": "All That You Can't Leave Behind", + "artist_id": 150 + }, + { + "id": 234, + "title": "B-Sides 1980-1990", + "artist_id": 150 + }, + { + "id": 235, + "title": "How To Dismantle An Atomic Bomb", + "artist_id": 150 + }, + { + "id": 236, + "title": "Pop", + "artist_id": 150 + }, + { + "id": 237, + "title": "Rattle And Hum", + "artist_id": 150 + }, + { + "id": 238, + "title": "The Best Of 1980-1990", + "artist_id": 150 + }, + { + "id": 239, + "title": "War", + "artist_id": 150 + }, + { + "id": 240, + "title": "Zooropa", + "artist_id": 150 + }, + { + "id": 241, + "title": "UB40 The Best Of - Volume Two [UK]", + "artist_id": 151 + }, + { + "id": 242, + "title": "Diver Down", + "artist_id": 152 + }, + { + "id": 243, + "title": "The Best Of Van Halen, Vol. I", + "artist_id": 152 + }, + { + "id": 244, + "title": "Van Halen", + "artist_id": 152 + }, + { + "id": 245, + "title": "Van Halen III", + "artist_id": 152 + }, + { + "id": 246, + "title": "Contraband", + "artist_id": 153 + }, + { + "id": 247, + "title": "Vinicius De Moraes", + "artist_id": 72 + }, + { + "id": 248, + "title": "Ao Vivo [IMPORT]", + "artist_id": 155 + }, + { + "id": 249, + "title": "The Office, Season 1", + "artist_id": 156 + }, + { + "id": 250, + "title": "The Office, Season 2", + "artist_id": 156 + }, + { + "id": 251, + "title": "The Office, Season 3", + "artist_id": 156 + }, + { + "id": 252, + "title": "Un-Led-Ed", + "artist_id": 157 + }, + { + "id": 253, + "title": "Battlestar Galactica (Classic), Season 1", + "artist_id": 158 + }, + { + "id": 254, + "title": "Aquaman", + "artist_id": 159 + }, + { + "id": 255, + "title": "Instant Karma: The Amnesty International Campaign to Save Darfur", + "artist_id": 150 + }, + { + "id": 256, + "title": "Speak of the Devil", + "artist_id": 114 + }, + { + "id": 257, + "title": "20th Century Masters - The Millennium Collection: The Best of Scorpions", + "artist_id": 179 + }, + { + "id": 258, + "title": "House of Pain", + "artist_id": 180 + }, + { + "id": 259, + "title": "Radio Brasil (O Som da Jovem Vanguarda) - Seleccao de Henrique Amaro", + "artist_id": 36 + }, + { + "id": 260, + "title": "Cake: B-Sides and Rarities", + "artist_id": 196 + }, + { + "id": 261, + "title": "LOST, Season 4", + "artist_id": 149 + }, + { + "id": 262, + "title": "Quiet Songs", + "artist_id": 197 + }, + { + "id": 263, + "title": "Muso Ko", + "artist_id": 198 + }, + { + "id": 264, + "title": "Realize", + "artist_id": 199 + }, + { + "id": 265, + "title": "Every Kind of Light", + "artist_id": 200 + }, + { + "id": 266, + "title": "Duos II", + "artist_id": 201 + }, + { + "id": 267, + "title": "Worlds", + "artist_id": 202 + }, + { + "id": 268, + "title": "The Best of Beethoven", + "artist_id": 203 + }, + { + "id": 269, + "title": "Temple of the Dog", + "artist_id": 204 + }, + { + "id": 270, + "title": "Carry On", + "artist_id": 205 + }, + { + "id": 271, + "title": "Revelations", + "artist_id": 8 + }, + { + "id": 272, + "title": "Adorate Deum: Gregorian Chant from the Proper of the Mass", + "artist_id": 206 + }, + { + "id": 273, + "title": "Allegri: Miserere", + "artist_id": 207 + }, + { + "id": 274, + "title": "Pachelbel: Canon & Gigue", + "artist_id": 208 + }, + { + "id": 275, + "title": "Vivaldi: The Four Seasons", + "artist_id": 209 + }, + { + "id": 276, + "title": "Bach: Violin Concertos", + "artist_id": 210 + }, + { + "id": 277, + "title": "Bach: Goldberg Variations", + "artist_id": 211 + }, + { + "id": 278, + "title": "Bach: The Cello Suites", + "artist_id": 212 + }, + { + "id": 279, + "title": "Handel: The Messiah (Highlights)", + "artist_id": 213 + }, + { + "id": 280, + "title": "The World of Classical Favourites", + "artist_id": 214 + }, + { + "id": 281, + "title": "Sir Neville Marriner: A Celebration", + "artist_id": 215 + }, + { + "id": 282, + "title": "Mozart: Wind Concertos", + "artist_id": 216 + }, + { + "id": 283, + "title": "Haydn: Symphonies 99 - 104", + "artist_id": 217 + }, + { + "id": 284, + "title": "Beethoven: Symhonies Nos. 5 & 6", + "artist_id": 218 + }, + { + "id": 285, + "title": "A Soprano Inspired", + "artist_id": 219 + }, + { + "id": 286, + "title": "Great Opera Choruses", + "artist_id": 220 + }, + { + "id": 287, + "title": "Wagner: Favourite Overtures", + "artist_id": 221 + }, + { + "id": 288, + "title": "Fauré: Requiem, Ravel: Pavane & Others", + "artist_id": 222 + }, + { + "id": 289, + "title": "Tchaikovsky: The Nutcracker", + "artist_id": 223 + }, + { + "id": 290, + "title": "The Last Night of the Proms", + "artist_id": 224 + }, + { + "id": 291, + "title": "Puccini: Madama Butterfly - Highlights", + "artist_id": 225 + }, + { + "id": 292, + "title": "Holst: The Planets, Op. 32 & Vaughan Williams: Fantasies", + "artist_id": 226 + }, + { + "id": 293, + "title": "Pavarotti's Opera Made Easy", + "artist_id": 227 + }, + { + "id": 294, + "title": "Great Performances - Barber's Adagio and Other Romantic Favorites for Strings", + "artist_id": 228 + }, + { + "id": 295, + "title": "Carmina Burana", + "artist_id": 229 + }, + { + "id": 296, + "title": "A Copland Celebration, Vol. I", + "artist_id": 230 + }, + { + "id": 297, + "title": "Bach: Toccata & Fugue in D Minor", + "artist_id": 231 + }, + { + "id": 298, + "title": "Prokofiev: Symphony No.1", + "artist_id": 232 + }, + { + "id": 299, + "title": "Scheherazade", + "artist_id": 233 + }, + { + "id": 300, + "title": "Bach: The Brandenburg Concertos", + "artist_id": 234 + }, + { + "id": 301, + "title": "Chopin: Piano Concertos Nos. 1 & 2", + "artist_id": 235 + }, + { + "id": 302, + "title": "Mascagni: Cavalleria Rusticana", + "artist_id": 236 + }, + { + "id": 303, + "title": "Sibelius: Finlandia", + "artist_id": 237 + }, + { + "id": 304, + "title": "Beethoven Piano Sonatas: Moonlight & Pastorale", + "artist_id": 238 + }, + { + "id": 305, + "title": "Great Recordings of the Century - Mahler: Das Lied von der Erde", + "artist_id": 240 + }, + { + "id": 306, + "title": "Elgar: Cello Concerto & Vaughan Williams: Fantasias", + "artist_id": 241 + }, + { + "id": 307, + "title": "Adams, John: The Chairman Dances", + "artist_id": 242 + }, + { + "id": 308, + "title": "Tchaikovsky: 1812 Festival Overture, Op.49, Capriccio Italien & Beethoven: Wellington's Victory", + "artist_id": 243 + }, + { + "id": 309, + "title": "Palestrina: Missa Papae Marcelli & Allegri: Miserere", + "artist_id": 244 + }, + { + "id": 310, + "title": "Prokofiev: Romeo & Juliet", + "artist_id": 245 + }, + { + "id": 311, + "title": "Strauss: Waltzes", + "artist_id": 226 + }, + { + "id": 312, + "title": "Berlioz: Symphonie Fantastique", + "artist_id": 245 + }, + { + "id": 313, + "title": "Bizet: Carmen Highlights", + "artist_id": 246 + }, + { + "id": 314, + "title": "English Renaissance", + "artist_id": 247 + }, + { + "id": 315, + "title": "Handel: Music for the Royal Fireworks (Original Version 1749)", + "artist_id": 208 + }, + { + "id": 316, + "title": "Grieg: Peer Gynt Suites & Sibelius: Pelléas et Mélisande", + "artist_id": 248 + }, + { + "id": 317, + "title": "Mozart Gala: Famous Arias", + "artist_id": 249 + }, + { + "id": 318, + "title": "SCRIABIN: Vers la flamme", + "artist_id": 250 + }, + { + "id": 319, + "title": "Armada: Music from the Courts of England and Spain", + "artist_id": 251 + }, + { + "id": 320, + "title": "Mozart: Symphonies Nos. 40 & 41", + "artist_id": 248 + }, + { + "id": 321, + "title": "Back to Black", + "artist_id": 252 + }, + { + "id": 322, + "title": "Frank", + "artist_id": 252 + }, + { + "id": 323, + "title": "Carried to Dust (Bonus Track Version)", + "artist_id": 253 + }, + { + "id": 324, + "title": "Beethoven: Symphony No. 6 'Pastoral' Etc.", + "artist_id": 254 + }, + { + "id": 325, + "title": "Bartok: Violin & Viola Concertos", + "artist_id": 255 + }, + { + "id": 326, + "title": "Mendelssohn: A Midsummer Night's Dream", + "artist_id": 256 + }, + { + "id": 327, + "title": "Bach: Orchestral Suites Nos. 1 - 4", + "artist_id": 257 + }, + { + "id": 328, + "title": "Charpentier: Divertissements, Airs & Concerts", + "artist_id": 258 + }, + { + "id": 329, + "title": "South American Getaway", + "artist_id": 259 + }, + { + "id": 330, + "title": "Górecki: Symphony No. 3", + "artist_id": 260 + }, + { + "id": 331, + "title": "Purcell: The Fairy Queen", + "artist_id": 261 + }, + { + "id": 332, + "title": "The Ultimate Relexation albums", + "artist_id": 262 + }, + { + "id": 333, + "title": "Purcell: Music for the Queen Mary", + "artist_id": 263 + }, + { + "id": 334, + "title": "Weill: The Seven Deadly Sins", + "artist_id": 264 + }, + { + "id": 335, + "title": "J.S. Bach: Chaconne, Suite in E Minor, Partita in E Major & Prelude, Fugue and Allegro", + "artist_id": 265 + }, + { + "id": 336, + "title": "Prokofiev: Symphony No.5 & Stravinksy: Le Sacre Du Printemps", + "artist_id": 248 + }, + { + "id": 337, + "title": "Szymanowski: Piano Works, Vol. 1", + "artist_id": 266 + }, + { + "id": 338, + "title": "Nielsen: The Six Symphonies", + "artist_id": 267 + }, + { + "id": 339, + "title": "Great Recordings of the Century: Paganini's 24 Caprices", + "artist_id": 268 + }, + { + "id": 340, + "title": "Liszt - 12 Études D'Execution Transcendante", + "artist_id": 269 + }, + { + "id": 341, + "title": "Great Recordings of the Century - Shubert: Schwanengesang, 4 Lieder", + "artist_id": 270 + }, + { + "id": 342, + "title": "Locatelli: Concertos for Violin, Strings and Continuo, Vol. 3", + "artist_id": 271 + }, + { + "id": 343, + "title": "Respighi:Pines of Rome", + "artist_id": 226 + }, + { + "id": 344, + "title": "Schubert: The Late String Quartets & String Quintet (3 CD's)", + "artist_id": 272 + }, + { + "id": 345, + "title": "Monteverdi: L'Orfeo", + "artist_id": 273 + }, + { + "id": 346, + "title": "Mozart: Chamber Music", + "artist_id": 274 + }, + { + "id": 347, + "title": "Koyaanisqatsi (Soundtrack from the Motion Picture)", + "artist_id": 275 + }, + { + "id": 1, + "title": "For Those About To Rck We Salute You", + "artist_id": 1 + } +] diff --git a/server/tests-gdw-api/Test/Data/artists.json b/server/tests-gdw-api/Test/Data/artists.json new file mode 100644 index 00000000000..79d1dadfb68 --- /dev/null +++ b/server/tests-gdw-api/Test/Data/artists.json @@ -0,0 +1,1102 @@ +[ + { + "id": 1, + "name": "AC/DC" + }, + { + "id": 2, + "name": "Accept" + }, + { + "id": 3, + "name": "Aerosmith" + }, + { + "id": 4, + "name": "Alanis Morissette" + }, + { + "id": 5, + "name": "Alice In Chains" + }, + { + "id": 6, + "name": "Antônio Carlos Jobim" + }, + { + "id": 7, + "name": "Apocalyptica" + }, + { + "id": 8, + "name": "Audioslave" + }, + { + "id": 9, + "name": "BackBeat" + }, + { + "id": 10, + "name": "Billy Cobham" + }, + { + "id": 11, + "name": "Black Label Society" + }, + { + "id": 12, + "name": "Black Sabbath" + }, + { + "id": 13, + "name": "Body Count" + }, + { + "id": 14, + "name": "Bruce Dickinson" + }, + { + "id": 15, + "name": "Buddy Guy" + }, + { + "id": 16, + "name": "Caetano Veloso" + }, + { + "id": 17, + "name": "Chico Buarque" + }, + { + "id": 18, + "name": "Chico Science & Nação Zumbi" + }, + { + "id": 19, + "name": "Cidade Negra" + }, + { + "id": 20, + "name": "Cláudio Zoli" + }, + { + "id": 21, + "name": "Various artistss" + }, + { + "id": 22, + "name": "Led Zeppelin" + }, + { + "id": 23, + "name": "Frank Zappa & Captain Beefheart" + }, + { + "id": 24, + "name": "Marcos Valle" + }, + { + "id": 25, + "name": "Milton Nascimento & Bebeto" + }, + { + "id": 26, + "name": "Azymuth" + }, + { + "id": 27, + "name": "Gilberto Gil" + }, + { + "id": 28, + "name": "João Gilberto" + }, + { + "id": 29, + "name": "Bebel Gilberto" + }, + { + "id": 30, + "name": "Jorge Vercilo" + }, + { + "id": 31, + "name": "Baby Consuelo" + }, + { + "id": 32, + "name": "Ney Matogrosso" + }, + { + "id": 33, + "name": "Luiz Melodia" + }, + { + "id": 34, + "name": "Nando Reis" + }, + { + "id": 35, + "name": "Pedro Luís & A Parede" + }, + { + "id": 36, + "name": "O Rappa" + }, + { + "id": 37, + "name": "Ed Motta" + }, + { + "id": 38, + "name": "Banda Black Rio" + }, + { + "id": 39, + "name": "Fernanda Porto" + }, + { + "id": 40, + "name": "Os Cariocas" + }, + { + "id": 41, + "name": "Elis Regina" + }, + { + "id": 42, + "name": "Milton Nascimento" + }, + { + "id": 43, + "name": "A Cor Do Som" + }, + { + "id": 44, + "name": "Kid Abelha" + }, + { + "id": 45, + "name": "Sandra De Sá" + }, + { + "id": 46, + "name": "Jorge Ben" + }, + { + "id": 47, + "name": "Hermeto Pascoal" + }, + { + "id": 48, + "name": "Barão Vermelho" + }, + { + "id": 49, + "name": "Edson, DJ Marky & DJ Patife Featuring Fernanda Porto" + }, + { + "id": 50, + "name": "Metallica" + }, + { + "id": 51, + "name": "Queen" + }, + { + "id": 52, + "name": "Kiss" + }, + { + "id": 53, + "name": "Spyro Gyra" + }, + { + "id": 54, + "name": "Green Day" + }, + { + "id": 55, + "name": "David Coverdale" + }, + { + "id": 56, + "name": "Gonzaguinha" + }, + { + "id": 57, + "name": "Os Mutantes" + }, + { + "id": 58, + "name": "Deep Purple" + }, + { + "id": 59, + "name": "Santana" + }, + { + "id": 60, + "name": "Santana Feat. Dave Matthews" + }, + { + "id": 61, + "name": "Santana Feat. Everlast" + }, + { + "id": 62, + "name": "Santana Feat. Rob Thomas" + }, + { + "id": 63, + "name": "Santana Feat. Lauryn Hill & Cee-Lo" + }, + { + "id": 64, + "name": "Santana Feat. The Project G&B" + }, + { + "id": 65, + "name": "Santana Feat. Maná" + }, + { + "id": 66, + "name": "Santana Feat. Eagle-Eye Cherry" + }, + { + "id": 67, + "name": "Santana Feat. Eric Clapton" + }, + { + "id": 68, + "name": "Miles Davis" + }, + { + "id": 69, + "name": "Gene Krupa" + }, + { + "id": 70, + "name": "Toquinho & Vinícius" + }, + { + "id": 71, + "name": "Vinícius De Moraes & Baden Powell" + }, + { + "id": 72, + "name": "Vinícius De Moraes" + }, + { + "id": 73, + "name": "Vinícius E Qurteto Em Cy" + }, + { + "id": 74, + "name": "Vinícius E Odette Lara" + }, + { + "id": 75, + "name": "Vinicius, Toquinho & Quarteto Em Cy" + }, + { + "id": 76, + "name": "Creedence Clearwater Revival" + }, + { + "id": 77, + "name": "Cássia Eller" + }, + { + "id": 78, + "name": "Def Leppard" + }, + { + "id": 79, + "name": "Dennis Chambers" + }, + { + "id": 80, + "name": "Djavan" + }, + { + "id": 81, + "name": "Eric Clapton" + }, + { + "id": 82, + "name": "Faith No More" + }, + { + "id": 83, + "name": "Falamansa" + }, + { + "id": 84, + "name": "Foo Fighters" + }, + { + "id": 85, + "name": "Frank Sinatra" + }, + { + "id": 86, + "name": "Funk Como Le Gusta" + }, + { + "id": 87, + "name": "Godsmack" + }, + { + "id": 88, + "name": "Guns N' Roses" + }, + { + "id": 89, + "name": "Incognito" + }, + { + "id": 90, + "name": "Iron Maiden" + }, + { + "id": 91, + "name": "James Brown" + }, + { + "id": 92, + "name": "Jamiroquai" + }, + { + "id": 93, + "name": "JET" + }, + { + "id": 94, + "name": "Jimi Hendrix" + }, + { + "id": 95, + "name": "Joe Satriani" + }, + { + "id": 96, + "name": "Jota Quest" + }, + { + "id": 97, + "name": "João Suplicy" + }, + { + "id": 98, + "name": "Judas Priest" + }, + { + "id": 99, + "name": "Legião Urbana" + }, + { + "id": 100, + "name": "Lenny Kravitz" + }, + { + "id": 101, + "name": "Lulu Santos" + }, + { + "id": 102, + "name": "Marillion" + }, + { + "id": 103, + "name": "Marisa Monte" + }, + { + "id": 104, + "name": "Marvin Gaye" + }, + { + "id": 105, + "name": "Men At Work" + }, + { + "id": 106, + "name": "Motörhead" + }, + { + "id": 107, + "name": "Motörhead & Girlschool" + }, + { + "id": 108, + "name": "Mônica Marianno" + }, + { + "id": 109, + "name": "Mötley Crüe" + }, + { + "id": 110, + "name": "Nirvana" + }, + { + "id": 111, + "name": "O Terço" + }, + { + "id": 112, + "name": "Olodum" + }, + { + "id": 113, + "name": "Os Paralamas Do Sucesso" + }, + { + "id": 114, + "name": "Ozzy Osbourne" + }, + { + "id": 115, + "name": "Page & Plant" + }, + { + "id": 116, + "name": "Passengers" + }, + { + "id": 117, + "name": "Paul D'Ianno" + }, + { + "id": 118, + "name": "Pearl Jam" + }, + { + "id": 119, + "name": "Peter Tosh" + }, + { + "id": 120, + "name": "Pink Floyd" + }, + { + "id": 121, + "name": "Planet Hemp" + }, + { + "id": 122, + "name": "R.E.M. Feat. Kate Pearson" + }, + { + "id": 123, + "name": "R.E.M. Feat. KRS-One" + }, + { + "id": 124, + "name": "R.E.M." + }, + { + "id": 125, + "name": "Raimundos" + }, + { + "id": 126, + "name": "Raul Seixas" + }, + { + "id": 127, + "name": "Red Hot Chili Peppers" + }, + { + "id": 128, + "name": "Rush" + }, + { + "id": 129, + "name": "Simply Red" + }, + { + "id": 130, + "name": "Skank" + }, + { + "id": 131, + "name": "Smashing Pumpkins" + }, + { + "id": 132, + "name": "Soundgarden" + }, + { + "id": 133, + "name": "Stevie Ray Vaughan & Double Trouble" + }, + { + "id": 134, + "name": "Stone Temple Pilots" + }, + { + "id": 135, + "name": "System Of A Down" + }, + { + "id": 136, + "name": "Terry Bozzio, Tony Levin & Steve Stevens" + }, + { + "id": 137, + "name": "The Black Crowes" + }, + { + "id": 138, + "name": "The Clash" + }, + { + "id": 139, + "name": "The Cult" + }, + { + "id": 140, + "name": "The Doors" + }, + { + "id": 141, + "name": "The Police" + }, + { + "id": 142, + "name": "The Rolling Stones" + }, + { + "id": 143, + "name": "The Tea Party" + }, + { + "id": 144, + "name": "The Who" + }, + { + "id": 145, + "name": "Tim Maia" + }, + { + "id": 146, + "name": "Titãs" + }, + { + "id": 147, + "name": "Battlestar Galactica" + }, + { + "id": 148, + "name": "Heroes" + }, + { + "id": 149, + "name": "Lost" + }, + { + "id": 150, + "name": "U2" + }, + { + "id": 151, + "name": "UB40" + }, + { + "id": 152, + "name": "Van Halen" + }, + { + "id": 153, + "name": "Velvet Revolver" + }, + { + "id": 154, + "name": "Whitesnake" + }, + { + "id": 155, + "name": "Zeca Pagodinho" + }, + { + "id": 156, + "name": "The Office" + }, + { + "id": 157, + "name": "Dread Zeppelin" + }, + { + "id": 158, + "name": "Battlestar Galactica (Classic)" + }, + { + "id": 159, + "name": "Aquaman" + }, + { + "id": 160, + "name": "Christina Aguilera featuring BigElf" + }, + { + "id": 161, + "name": "Aerosmith & Sierra Leone's Refugee Allstars" + }, + { + "id": 162, + "name": "Los Lonely Boys" + }, + { + "id": 163, + "name": "Corinne Bailey Rae" + }, + { + "id": 164, + "name": "Dhani Harrison & Jakob Dylan" + }, + { + "id": 165, + "name": "Jackson Browne" + }, + { + "id": 166, + "name": "Avril Lavigne" + }, + { + "id": 167, + "name": "Big & Rich" + }, + { + "id": 168, + "name": "Youssou N'Dour" + }, + { + "id": 169, + "name": "Black Eyed Peas" + }, + { + "id": 170, + "name": "Jack Johnson" + }, + { + "id": 171, + "name": "Ben Harper" + }, + { + "id": 172, + "name": "Snow Patrol" + }, + { + "id": 173, + "name": "Matisyahu" + }, + { + "id": 174, + "name": "The Postal Service" + }, + { + "id": 175, + "name": "Jaguares" + }, + { + "id": 176, + "name": "The Flaming Lips" + }, + { + "id": 177, + "name": "Jack's Mannequin & Mick Fleetwood" + }, + { + "id": 178, + "name": "Regina Spektor" + }, + { + "id": 179, + "name": "Scorpions" + }, + { + "id": 180, + "name": "House Of Pain" + }, + { + "id": 181, + "name": "Xis" + }, + { + "id": 182, + "name": "Nega Gizza" + }, + { + "id": 183, + "name": "Gustavo & Andres Veiga & Salazar" + }, + { + "id": 184, + "name": "Rodox" + }, + { + "id": 185, + "name": "Charlie Brown Jr." + }, + { + "id": 186, + "name": "Pedro Luís E A Parede" + }, + { + "id": 187, + "name": "Los Hermanos" + }, + { + "id": 188, + "name": "Mundo Livre S/A" + }, + { + "id": 189, + "name": "Otto" + }, + { + "id": 190, + "name": "Instituto" + }, + { + "id": 191, + "name": "Nação Zumbi" + }, + { + "id": 192, + "name": "DJ Dolores & Orchestra Santa Massa" + }, + { + "id": 193, + "name": "Seu Jorge" + }, + { + "id": 194, + "name": "Sabotage E Instituto" + }, + { + "id": 195, + "name": "Stereo Maracana" + }, + { + "id": 196, + "name": "Cake" + }, + { + "id": 197, + "name": "Aisha Duo" + }, + { + "id": 198, + "name": "Habib Koité and Bamada" + }, + { + "id": 199, + "name": "Karsh Kale" + }, + { + "id": 200, + "name": "The Posies" + }, + { + "id": 201, + "name": "Luciana Souza/Romero Lubambo" + }, + { + "id": 202, + "name": "Aaron Goldberg" + }, + { + "id": 203, + "name": "Nicolaus Esterhazy Sinfonia" + }, + { + "id": 204, + "name": "Temple of the Dog" + }, + { + "id": 205, + "name": "Chris Cornell" + }, + { + "id": 206, + "name": "Alberto Turco & Nova Schola Gregoriana" + }, + { + "id": 207, + "name": "Richard Marlow & The Choir of Trinity College, Cambridge" + }, + { + "id": 208, + "name": "English Concert & Trevor Pinnock" + }, + { + "id": 209, + "name": "Anne-Sophie Mutter, Herbert Von Karajan & Wiener Philharmoniker" + }, + { + "id": 210, + "name": "Hilary Hahn, Jeffrey Kahane, Los Angeles Chamber Orchestra & Margaret Batjer" + }, + { + "id": 211, + "name": "Wilhelm Kempff" + }, + { + "id": 212, + "name": "Yo-Yo Ma" + }, + { + "id": 213, + "name": "Scholars Baroque Ensemble" + }, + { + "id": 214, + "name": "Academy of St. Martin in the Fields & Sir Neville Marriner" + }, + { + "id": 215, + "name": "Academy of St. Martin in the Fields Chamber Ensemble & Sir Neville Marriner" + }, + { + "id": 216, + "name": "Berliner Philharmoniker, Claudio Abbado & Sabine Meyer" + }, + { + "id": 217, + "name": "Royal Philharmonic Orchestra & Sir Thomas Beecham" + }, + { + "id": 218, + "name": "Orchestre Révolutionnaire et Romantique & John Eliot Gardiner" + }, + { + "id": 219, + "name": "Britten Sinfonia, Ivor Bolton & Lesley Garrett" + }, + { + "id": 220, + "name": "Chicago Symphony Chorus, Chicago Symphony Orchestra & Sir Georg Solti" + }, + { + "id": 221, + "name": "Sir Georg Solti & Wiener Philharmoniker" + }, + { + "id": 222, + "name": "Academy of St. Martin in the Fields, John Birch, Sir Neville Marriner & Sylvia McNair" + }, + { + "id": 223, + "name": "London Symphony Orchestra & Sir Charles Mackerras" + }, + { + "id": 224, + "name": "Barry Wordsworth & BBC Concert Orchestra" + }, + { + "id": 225, + "name": "Herbert Von Karajan, Mirella Freni & Wiener Philharmoniker" + }, + { + "id": 226, + "name": "Eugene Ormandy" + }, + { + "id": 227, + "name": "Luciano Pavarotti" + }, + { + "id": 228, + "name": "Leonard Bernstein & New York Philharmonic" + }, + { + "id": 229, + "name": "Boston Symphony Orchestra & Seiji Ozawa" + }, + { + "id": 230, + "name": "Aaron Copland & London Symphony Orchestra" + }, + { + "id": 231, + "name": "Ton Koopman" + }, + { + "id": 232, + "name": "Sergei Prokofiev & Yuri Temirkanov" + }, + { + "id": 233, + "name": "Chicago Symphony Orchestra & Fritz Reiner" + }, + { + "id": 234, + "name": "Orchestra of The Age of Enlightenment" + }, + { + "id": 235, + "name": "Emanuel Ax, Eugene Ormandy & Philadelphia Orchestra" + }, + { + "id": 236, + "name": "James Levine" + }, + { + "id": 237, + "name": "Berliner Philharmoniker & Hans Rosbaud" + }, + { + "id": 238, + "name": "Maurizio Pollini" + }, + { + "id": 239, + "name": "Academy of St. Martin in the Fields, Sir Neville Marriner & William Bennett" + }, + { + "id": 240, + "name": "Gustav Mahler" + }, + { + "id": 241, + "name": "Felix Schmidt, London Symphony Orchestra & Rafael Frühbeck de Burgos" + }, + { + "id": 242, + "name": "Edo de Waart & San Francisco Symphony" + }, + { + "id": 243, + "name": "Antal Doráti & London Symphony Orchestra" + }, + { + "id": 244, + "name": "Choir Of Westminster Abbey & Simon Preston" + }, + { + "id": 245, + "name": "Michael Tilson Thomas & San Francisco Symphony" + }, + { + "id": 246, + "name": "Chor der Wiener Staatsoper, Herbert Von Karajan & Wiener Philharmoniker" + }, + { + "id": 247, + "name": "The King's Singers" + }, + { + "id": 248, + "name": "Berliner Philharmoniker & Herbert Von Karajan" + }, + { + "id": 249, + "name": "Sir Georg Solti, Sumi Jo & Wiener Philharmoniker" + }, + { + "id": 250, + "name": "Christopher O'Riley" + }, + { + "id": 251, + "name": "Fretwork" + }, + { + "id": 252, + "name": "Amy Winehouse" + }, + { + "id": 253, + "name": "Calexico" + }, + { + "id": 254, + "name": "Otto Klemperer & Philharmonia Orchestra" + }, + { + "id": 255, + "name": "Yehudi Menuhin" + }, + { + "id": 256, + "name": "Philharmonia Orchestra & Sir Neville Marriner" + }, + { + "id": 257, + "name": "Academy of St. Martin in the Fields, Sir Neville Marriner & Thurston Dart" + }, + { + "id": 258, + "name": "Les Arts Florissants & William Christie" + }, + { + "id": 259, + "name": "The 12 Cellists of The Berlin Philharmonic" + }, + { + "id": 260, + "name": "Adrian Leaper & Doreen de Feis" + }, + { + "id": 261, + "name": "Roger Norrington, London Classical Players" + }, + { + "id": 262, + "name": "Charles Dutoit & L'Orchestre Symphonique de Montréal" + }, + { + "id": 263, + "name": "Equale Brass Ensemble, John Eliot Gardiner & Munich Monteverdi Orchestra and Choir" + }, + { + "id": 264, + "name": "Kent Nagano and Orchestre de l'Opéra de Lyon" + }, + { + "id": 265, + "name": "Julian Bream" + }, + { + "id": 266, + "name": "Martin Roscoe" + }, + { + "id": 267, + "name": "Göteborgs Symfoniker & Neeme Järvi" + }, + { + "id": 268, + "name": "Itzhak Perlman" + }, + { + "id": 269, + "name": "Michele Campanella" + }, + { + "id": 270, + "name": "Gerald Moore" + }, + { + "id": 271, + "name": "Mela Tenenbaum, Pro Musica Prague & Richard Kapp" + }, + { + "id": 272, + "name": "Emerson String Quartet" + }, + { + "id": 273, + "name": "C. Monteverdi, Nigel Rogers - Chiaroscuro; London Baroque; London Cornett & Sackbu" + }, + { + "id": 274, + "name": "Nash Ensemble" + }, + { + "id": 275, + "name": "Philip Glass Ensemble" + } +] diff --git a/server/tests-gdw-api/Test/Data/schema-tables.json b/server/tests-gdw-api/Test/Data/schema-tables.json new file mode 100644 index 00000000000..ebc9183a07b --- /dev/null +++ b/server/tests-gdw-api/Test/Data/schema-tables.json @@ -0,0 +1,46 @@ +[ + { + "name": "artists", + "primary_key": "id", + "description": "Collection of artists of music", + "columns": [ + { + "name": "id", + "type": "number", + "nullable": false, + "description": "Artist primary key identifier" + }, + { + "name": "name", + "type": "string", + "nullable": false, + "description": "The name of the artist" + } + ] + }, + { + "name": "albums", + "primary_key": "id", + "description": "Collection of music albums created by artists", + "columns": [ + { + "name": "id", + "type": "number", + "nullable": false, + "description": "Album primary key identifier" + }, + { + "name": "title", + "type": "string", + "nullable": false, + "description": "The title of the album" + }, + { + "name": "artist_id", + "type": "number", + "nullable": false, + "description": "The ID of the artist that created this album" + } + ] + } +] diff --git a/server/tests-gdw-api/Test/QuerySpec.hs b/server/tests-gdw-api/Test/QuerySpec.hs new file mode 100644 index 00000000000..c9270844837 --- /dev/null +++ b/server/tests-gdw-api/Test/QuerySpec.hs @@ -0,0 +1,17 @@ +module Test.QuerySpec (spec) where + +import Control.Monad (when) +import Hasura.Backends.DataWrapper.API (Capabilities (..), Routes (..)) +import Servant.API (NamedRoutes) +import Servant.Client (Client) +import Test.Hspec +import Test.QuerySpec.BasicSpec qualified +import Test.QuerySpec.RelationshipsSpec qualified +import Prelude + +spec :: Client IO (NamedRoutes Routes) -> Capabilities -> Spec +spec api Capabilities {..} = do + describe "query API" do + Test.QuerySpec.BasicSpec.spec api + when (dcRelationships) $ + Test.QuerySpec.RelationshipsSpec.spec api diff --git a/server/tests-gdw-api/Test/QuerySpec/BasicSpec.hs b/server/tests-gdw-api/Test/QuerySpec/BasicSpec.hs new file mode 100644 index 00000000000..dc0e0e79ead --- /dev/null +++ b/server/tests-gdw-api/Test/QuerySpec/BasicSpec.hs @@ -0,0 +1,218 @@ +module Test.QuerySpec.BasicSpec (spec) where + +import Autodocodec.Extended (ValueWrapper (..), ValueWrapper2 (..), ValueWrapper3 (ValueWrapper3)) +import Control.Lens (ix, (^?)) +import Data.Aeson.Lens (AsNumber (_Number), AsPrimitive (_String)) +import Data.HashMap.Strict qualified as HashMap +import Data.List (sortOn) +import Data.List.NonEmpty (NonEmpty (..)) +import Data.Ord (Down (..)) +import Data.Text (Text) +import Hasura.Backends.DataWrapper.API +import Servant.API (NamedRoutes) +import Servant.Client (Client, (//)) +import Test.Data qualified as Data +import Test.Hspec (Spec, describe, it) +import Test.Hspec.Expectations.Pretty (shouldBe) +import Prelude + +spec :: Client IO (NamedRoutes Routes) -> Spec +spec api = describe "Basic Queries" $ do + describe "Column Fields" $ do + it "can query for a list of artists" $ do + let query = artistsQuery + receivedArtists <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedArtists = Data.artistsAsJson + receivedArtists `shouldBe` expectedArtists + + it "can query for a list of albums with a subset of columns" $ do + let fields = HashMap.fromList [("artist_id", columnField "artist_id"), ("title", columnField "title")] + let query = albumsQuery {fields} + receivedAlbums <- fmap (Data.sortBy "title" . getQueryResponse) $ api // _query $ query + + let filterToRequiredProperties = + HashMap.filterWithKey (\propName _value -> propName == "artist_id" || propName == "title") + + let expectedAlbums = Data.sortBy "title" $ filterToRequiredProperties <$> Data.albumsAsJson + receivedAlbums `shouldBe` expectedAlbums + + it "can project columns into fields with different names" $ do + let fields = HashMap.fromList [("artist_id", columnField "id"), ("artist_name", columnField "name")] + let query = artistsQuery {fields} + receivedArtists <- fmap (Data.sortBy "artist_id" . getQueryResponse) $ api // _query $ query + + let renameProperties = + HashMap.mapKeys + ( \case + "id" -> "artist_id" + "name" -> "artist_name" + other -> other + ) + + let expectedArtists = Data.sortBy "artist_id" $ renameProperties <$> Data.artistsAsJson + receivedArtists `shouldBe` expectedArtists + + describe "Limit & Offset" $ do + it "can use limit and offset to paginate results" $ do + let allQuery = artistsQuery + let page1Query = artistsQuery {limit = Just 10, offset = Just 0} + let page2Query = artistsQuery {limit = Just 10, offset = Just 10} + + allArtists <- fmap getQueryResponse $ api // _query $ allQuery + page1Artists <- fmap getQueryResponse $ api // _query $ page1Query + page2Artists <- fmap getQueryResponse $ api // _query $ page2Query + + page1Artists `shouldBe` take 10 allArtists + page2Artists `shouldBe` take 10 (drop 10 allArtists) + + describe "Order By" $ do + it "can use order by to order results in ascending order" $ do + let orderBy = OrderBy (ColumnName "title") Ascending :| [] + let query = albumsQuery {orderBy = Just orderBy} + receivedAlbums <- fmap getQueryResponse $ api // _query $ query + + let expectedAlbums = sortOn (^? ix "title") Data.albumsAsJson + receivedAlbums `shouldBe` expectedAlbums + + it "can use order by to order results in descending order" $ do + let orderBy = OrderBy (ColumnName "title") Descending :| [] + let query = albumsQuery {orderBy = Just orderBy} + receivedAlbums <- fmap getQueryResponse $ api // _query $ query + + let expectedAlbums = sortOn (Down . (^? ix "title")) Data.albumsAsJson + receivedAlbums `shouldBe` expectedAlbums + + it "can use multiple order bys to order results" $ do + let orderBy = OrderBy (ColumnName "artist_id") Ascending :| [OrderBy (ColumnName "title") Descending] + let query = albumsQuery {orderBy = Just orderBy} + receivedAlbums <- fmap getQueryResponse $ api // _query $ query + + let expectedAlbums = + sortOn (\album -> (album ^? ix "artist_id", Down (album ^? ix "title"))) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + describe "Where" $ do + it "can filter using an equality expression" $ do + let where' = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 2)))) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter ((== Just 2) . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can filter using an inequality expression" $ do + let where' = NotEqual (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 2)))) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter ((/= Just 2) . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can filter using an in expression" $ do + let where' = In (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) [Number 2, Number 3]) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter (flip elem [Just 2, Just 3] . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can negate an in expression filter using a not expression" $ do + let where' = Not (ValueWrapper (In (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) [Number 2, Number 3]))) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter (flip notElem [Just 2, Just 3] . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can combine filters using an and expression" $ do + let where1 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "artist_id"))) (Literal (ValueWrapper (Number 58)))) + let where2 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "title"))) (Literal (ValueWrapper (String "Stormbringer")))) + let where' = And (ValueWrapper [where1, where2]) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter + ( \album -> + (album ^? ix "artist_id" . _Number == Just 58) && (album ^? ix "title" . _String == Just "Stormbringer") + ) + Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can combine filters using an or expression" $ do + let where1 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 2)))) + let where2 = Equal (ValueWrapper2 (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 3)))) + let where' = Or (ValueWrapper [where1, where2]) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter (flip elem [Just 2, Just 3] . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can filter by applying the greater than operator" $ do + let where' = ApplyOperator (ValueWrapper3 GreaterThan (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 300)))) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter ((> Just 300) . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can filter by applying the greater than or equal operator" $ do + let where' = ApplyOperator (ValueWrapper3 GreaterThanOrEqual (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 300)))) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter ((>= Just 300) . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can filter by applying the less than operator" $ do + let where' = ApplyOperator (ValueWrapper3 LessThan (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 100)))) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter ((< Just 100) . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + + it "can filter by applying the less than or equal operator" $ do + let where' = ApplyOperator (ValueWrapper3 LessThanOrEqual (Column (ValueWrapper (ColumnName "id"))) (Literal (ValueWrapper (Number 100)))) + let query = albumsQuery {where_ = Just where'} + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let expectedAlbums = + filter ((<= Just 100) . (^? ix "id" . _Number)) Data.albumsAsJson + + receivedAlbums `shouldBe` expectedAlbums + +artistsQuery :: Query +artistsQuery = + let fields = HashMap.fromList [("id", columnField "id"), ("name", columnField "name")] + tableName = TableName "artists" + in Query fields tableName Nothing Nothing Nothing Nothing + +albumsQuery :: Query +albumsQuery = + let fields = HashMap.fromList [("id", columnField "id"), ("artist_id", columnField "artist_id"), ("title", columnField "title")] + tableName = TableName "albums" + in Query fields tableName Nothing Nothing Nothing Nothing + +columnField :: Text -> Field +columnField = ColumnField . ValueWrapper . ColumnName diff --git a/server/tests-gdw-api/Test/QuerySpec/RelationshipsSpec.hs b/server/tests-gdw-api/Test/QuerySpec/RelationshipsSpec.hs new file mode 100644 index 00000000000..fc1ad5f69e1 --- /dev/null +++ b/server/tests-gdw-api/Test/QuerySpec/RelationshipsSpec.hs @@ -0,0 +1,93 @@ +module Test.QuerySpec.RelationshipsSpec (spec) where + +import Autodocodec.Extended (ValueWrapper (..)) +import Control.Lens (ix, (^?)) +import Data.Aeson (Object, Value (..)) +import Data.Aeson qualified as J +import Data.Aeson.Lens (_Number) +import Data.HashMap.Strict qualified as HashMap +import Data.List.NonEmpty (NonEmpty (..)) +import Data.Text (Text) +import Data.Vector qualified as Vector +import Hasura.Backends.DataWrapper.API +import Servant.API (NamedRoutes) +import Servant.Client (Client, (//)) +import Test.Data qualified as Data +import Test.Hspec (Spec, describe, it) +import Test.Hspec.Expectations.Pretty (shouldBe) +import Prelude + +spec :: Client IO (NamedRoutes Routes) -> Spec +spec api = describe "Relationship Queries" $ do + it "perform a many to one query by joining artist to albums" $ do + let query = albumsWithArtistQuery id + receivedAlbums <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let joinInArtist (album :: Object) = + let artist = (album ^? ix "artist_id" . _Number) >>= \artistId -> Data.artistsAsJsonById ^? ix artistId + artistPropVal = maybe J.Null (Array . Vector.singleton . Object) artist + in HashMap.insert "artist" artistPropVal album + let removeArtistId = HashMap.delete "artist_id" + + let expectedAlbums = (removeArtistId . joinInArtist) <$> Data.albumsAsJson + receivedAlbums `shouldBe` expectedAlbums + + it "perform a one to many query by joining albums to artists" $ do + let query = artistsWithAlbumsQuery id + receivedArtists <- fmap (Data.sortBy "id" . getQueryResponse) $ api // _query $ query + + let joinInAlbums (artist :: Object) = + let artistId = artist ^? ix "id" . _Number + albums = maybe [] (\artistId' -> filter (\album -> album ^? ix "artist_id" . _Number == Just artistId') Data.albumsAsJson) artistId + albums' = Object . HashMap.delete "artist_id" <$> albums + in HashMap.insert "albums" (Array . Vector.fromList $ albums') artist + + let expectedAlbums = joinInAlbums <$> Data.artistsAsJson + receivedArtists `shouldBe` expectedAlbums + +albumsWithArtistQuery :: (Query -> Query) -> Query +albumsWithArtistQuery modifySubquery = + let joinFieldMapping = + HashMap.fromList + [ (PrimaryKey $ ColumnName "artist_id", ForeignKey $ ColumnName "id") + ] + artistsSubquery = modifySubquery artistsQuery + fields = + HashMap.fromList + [ ("id", columnField "id"), + ("title", columnField "title"), + ("artist", RelationshipField $ RelField joinFieldMapping artistsSubquery) + ] + in albumsQuery {fields} + +artistsWithAlbumsQuery :: (Query -> Query) -> Query +artistsWithAlbumsQuery modifySubquery = + let joinFieldMapping = + HashMap.fromList + [ (PrimaryKey $ ColumnName "id", ForeignKey $ ColumnName "artist_id") + ] + albumFields = HashMap.fromList [("id", columnField "id"), ("title", columnField "title")] + albumsSort = OrderBy (ColumnName "id") Ascending :| [] + albumsSubquery = modifySubquery (albumsQuery {fields = albumFields, orderBy = Just albumsSort}) + fields = + HashMap.fromList + [ ("id", columnField "id"), + ("name", columnField "name"), + ("albums", RelationshipField $ RelField joinFieldMapping albumsSubquery) + ] + in artistsQuery {fields} + +artistsQuery :: Query +artistsQuery = + let fields = HashMap.fromList [("id", columnField "id"), ("name", columnField "name")] + tableName = TableName "artists" + in Query fields tableName Nothing Nothing Nothing Nothing + +albumsQuery :: Query +albumsQuery = + let fields = HashMap.fromList [("id", columnField "id"), ("artist_id", columnField "artist_id"), ("title", columnField "title")] + tableName = TableName "albums" + in Query fields tableName Nothing Nothing Nothing Nothing + +columnField :: Text -> Field +columnField = ColumnField . ValueWrapper . ColumnName diff --git a/server/tests-gdw-api/Test/SchemaSpec.hs b/server/tests-gdw-api/Test/SchemaSpec.hs new file mode 100644 index 00000000000..1d6b928d650 --- /dev/null +++ b/server/tests-gdw-api/Test/SchemaSpec.hs @@ -0,0 +1,20 @@ +module Test.SchemaSpec (spec) where + +import Data.List (sortOn) +import Hasura.Backends.DataWrapper.API (Capabilities, Routes (..), SchemaResponse (..), TableInfo (..)) +import Servant.API (NamedRoutes) +import Servant.Client (Client, (//)) +import Test.Data qualified as Data +import Test.Hspec (Spec, describe, it) +import Test.Hspec.Expectations.Pretty (shouldBe) +import Prelude + +spec :: Client IO (NamedRoutes Routes) -> Capabilities -> Spec +spec api expectedCapabilities = describe "schema API" $ do + it "returns the expected capabilities" $ do + capabilities <- fmap srCapabilities $ api // _schema + capabilities `shouldBe` expectedCapabilities + + it "returns Chinook schema" $ do + tables <- fmap (sortOn dtiName . srTables) $ api // _schema + tables `shouldBe` Data.schemaTables