diff --git a/lib/customer-deposit-wallet/customer-deposit-wallet.cabal b/lib/customer-deposit-wallet/customer-deposit-wallet.cabal index ab317756520..2c320a7f22b 100644 --- a/lib/customer-deposit-wallet/customer-deposit-wallet.cabal +++ b/lib/customer-deposit-wallet/customer-deposit-wallet.cabal @@ -11,6 +11,7 @@ author: Cardano Foundation (High Assurance Lab) maintainer: hal@cardanofoundation.org copyright: 2023 Cardano Foundation category: Web +data-files: data/swagger.json extra-source-files: spec/**/*.lagda.md @@ -109,9 +110,15 @@ library customer-deposit-wallet-http hs-source-dirs: http build-depends: , aeson + , aeson-pretty , base + , bytestring , customer-deposit-wallet + , http-media + , insert-ordered-containers + , lens , memory + , openapi3 , servant , servant-server , text @@ -124,6 +131,7 @@ library customer-deposit-wallet-http Cardano.Wallet.Deposit.HTTP.Types.API Cardano.Wallet.Deposit.HTTP.Types.JSON Cardano.Wallet.Deposit.HTTP.Types.JSON.Encoding + Cardano.Wallet.Deposit.HTTP.Types.OpenAPI test-suite unit import: language, opts-exe @@ -132,6 +140,7 @@ test-suite unit main-is: test-suite-unit.hs build-depends: , aeson + , aeson-pretty , base , bytestring , cardano-crypto @@ -139,13 +148,18 @@ test-suite unit , cardano-wallet-primitive , cardano-wallet-test-utils , customer-deposit-wallet:{customer-deposit-wallet, customer-deposit-wallet-http} + , directory , hspec >=2.8.2 + , hspec-golden + , openapi3 , QuickCheck , with-utf8 build-tool-depends: hspec-discover:hspec-discover other-modules: Cardano.Wallet.Deposit.HTTP.JSON.JSONSpec + Cardano.Wallet.Deposit.HTTP.OpenAPISpec Cardano.Wallet.Deposit.PureSpec + Paths_customer_deposit_wallet Spec executable customer-deposit-wallet diff --git a/lib/customer-deposit-wallet/data/swagger.json b/lib/customer-deposit-wallet/data/swagger.json new file mode 100644 index 00000000000..170d7fb307f --- /dev/null +++ b/lib/customer-deposit-wallet/data/swagger.json @@ -0,0 +1,70 @@ +{ + "components": { + "schemas": { + "ApiT Address": { + "format": "hex", + "type": "string" + }, + "ApiT Customer": { + "maximum": 2147483647, + "minimum": 0, + "type": "integer" + }, + "ApiT CustomerList": { + "items": { + "properties": { + "address": { + "format": "hex", + "type": "string" + }, + "customer": { + "maximum": 2147483647, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + } + } + }, + "info": { + "description": "This is the API for the deposit wallet", + "license": { + "name": "Apache 2", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "title": "Cardano Deposit Wallet API", + "version": "0.0.0.1" + }, + "openapi": "3.0.0", + "paths": { + "/customers": { + "parameters": [ + { + "in": "path", + "name": "customerId", + "schema": { + "$ref": "#/components/schemas/ApiT Customer" + } + } + ], + "put": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiT Address" + } + } + }, + "description": "Ok" + } + }, + "summary": "Add customer" + } + } + } +} \ No newline at end of file diff --git a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Implementation.hs b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Implementation.hs index 482a518d801..a944af7e8bd 100644 --- a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Implementation.hs +++ b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Implementation.hs @@ -11,7 +11,7 @@ module Cardano.Wallet.Deposit.HTTP.Implementation where import Cardano.Wallet.Deposit.HTTP.Types.API - ( API + ( CustomerAPI ) import Data.Proxy ( Proxy (..) @@ -29,10 +29,10 @@ import qualified Cardano.Wallet.Deposit.IO as Wallet {----------------------------------------------------------------------------- Types ------------------------------------------------------------------------------} -api :: Proxy API +api :: Proxy CustomerAPI api = Proxy -implementation :: Wallet.WalletInstance -> Server API +implementation :: Wallet.WalletInstance -> Server CustomerAPI implementation w = HTTP.listCustomers w :<|> HTTP.createAddress w diff --git a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs index 33ee8cfda50..f302b5c9be6 100644 --- a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs +++ b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/API.hs @@ -7,7 +7,7 @@ -- Servant Type for our HTTP API. -- module Cardano.Wallet.Deposit.HTTP.Types.API - ( API + ( CustomerAPI ) where @@ -30,7 +30,7 @@ import Servant.API API ------------------------------------------------------------------------------} -type API = +type CustomerAPI = "customers" :> Verb 'GET 200 '[JSON] (ApiT CustomerList) :<|> diff --git a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs index 5306f6ab0e8..7452ebd108b 100644 --- a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs +++ b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/JSON.hs @@ -22,7 +22,11 @@ import Prelude import Cardano.Wallet.Deposit.HTTP.Types.JSON.Encoding ( ViaText (..) - , customOptions + ) +import Cardano.Wallet.Deposit.HTTP.Types.OpenAPI + ( addressSchema + , customerListSchema + , customerSchema ) import Cardano.Wallet.Deposit.Pure ( Customer @@ -33,12 +37,20 @@ import Cardano.Wallet.Deposit.Read import Data.Aeson ( FromJSON (..) , ToJSON (..) - , genericParseJSON - , genericToJSON + , object + , withObject + , (.:) + , (.=) + ) +import Data.Aeson.Types + ( Parser ) import Data.Bifunctor - ( bimap - , first + ( first + ) +import Data.OpenApi + ( NamedSchema (..) + , ToSchema (..) ) import Data.Text ( Text @@ -74,6 +86,13 @@ newtype ApiT a = ApiT {unApiT :: a} deriving via ViaText Address instance FromJSON (ApiT Address) deriving via ViaText Address instance ToJSON (ApiT Address) +instance ToSchema (ApiT Address) where + declareNamedSchema _ = do + pure + $ NamedSchema + (Just "ApiT Address") + addressSchema + -- Customer instance FromHttpApiData (ApiT Customer) where parseUrlPiece = fmap (ApiT . toEnum) . fromText' @@ -84,21 +103,40 @@ instance FromJSON (ApiT Customer) where instance ToJSON (ApiT Customer) where toJSON = toJSON . fromEnum . unApiT +instance ToSchema (ApiT Customer) where + declareNamedSchema _ = do + pure + $ NamedSchema + (Just "ApiT Customer") + customerSchema + -- | 'fromText' but with a simpler error type. fromText' :: FromText a => Text -> Either Text a fromText' = first (T.pack . getTextDecodingError) . fromText --- CustomerList -type ApiCustomerList = [(ApiT Customer, ApiT Address)] - -toApiCustomerList :: ApiT CustomerList -> ApiCustomerList -toApiCustomerList = fmap (bimap ApiT ApiT) . unApiT +instance ToJSON (ApiT (Customer, Address)) where + toJSON (ApiT (c, a)) = object + [ "customer" .= toJSON (ApiT c) + , "address" .= toJSON (ApiT a) + ] -fromApiCustomerList :: ApiCustomerList -> ApiT CustomerList -fromApiCustomerList = ApiT . fmap (bimap unApiT unApiT) +instance FromJSON (ApiT (Customer, Address)) where + parseJSON = withObject "ApiT (Customer, Address)" $ \obj -> do + customerApiT <- obj .: "customer" + addressApiT <- obj .: "address" + pure $ ApiT (unApiT customerApiT, unApiT addressApiT) instance FromJSON (ApiT CustomerList) where - parseJSON = fmap fromApiCustomerList . genericParseJSON customOptions + parseJSON l = do + custoList <- (parseJSON l :: Parser [ApiT (Customer, Address)]) + pure $ ApiT (unApiT <$> custoList) instance ToJSON (ApiT CustomerList) where - toJSON = genericToJSON customOptions . toApiCustomerList + toJSON (ApiT cl)= toJSON (toJSON . ApiT <$> cl) + +instance ToSchema (ApiT CustomerList) where + declareNamedSchema _ = do + pure + $ NamedSchema + (Just "ApiT CustomerList") + customerListSchema diff --git a/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/OpenAPI.hs b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/OpenAPI.hs new file mode 100644 index 00000000000..29d6e149638 --- /dev/null +++ b/lib/customer-deposit-wallet/http/Cardano/Wallet/Deposit/HTTP/Types/OpenAPI.hs @@ -0,0 +1,178 @@ +{-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Cardano.Wallet.Deposit.HTTP.Types.OpenAPI + ( generateOpenapi3 + , apiSchema + , depositPaths + , depositDefinitions + + , customerSchema + , addressSchema + , customerListSchema + ) where + +import Prelude + +import Control.Lens + ( At (..) + , (&) + , (.~) + , (?~) + ) +import Data.Aeson.Encode.Pretty + ( encodePretty + ) +import Data.HashMap.Strict.InsOrd + ( InsOrdHashMap + ) +import Data.OpenApi + ( Definitions + , HasComponents (..) + , HasContent (..) + , HasDescription (..) + , HasFormat (..) + , HasGet (..) + , HasIn (..) + , HasInfo (..) + , HasItems (..) + , HasLicense (license) + , HasMaximum (..) + , HasMinimum (..) + , HasName (..) + , HasParameters (..) + , HasPaths (..) + , HasProperties (..) + , HasPut (..) + , HasSchema (..) + , HasSchemas (..) + , HasSummary (..) + , HasTitle (..) + , HasType (..) + , HasUrl (..) + , HasVersion (..) + , License + , OpenApi + , OpenApiItems (..) + , OpenApiType (..) + , Operation + , ParamLocation (..) + , PathItem + , Reference (..) + , Referenced (..) + , Schema + , URL (..) + , _Inline + ) +import Network.HTTP.Media + ( MediaType + ) + +import qualified Data.ByteString.Lazy.Char8 as BL + +generateOpenapi3 :: BL.ByteString +generateOpenapi3 = encodePretty apiSchema + +apiSchema :: OpenApi +apiSchema :: OpenApi = + mempty + & info . title .~ "Cardano Deposit Wallet API" + & info . version .~ "0.0.0.1" + & info . description ?~ "This is the API for the deposit wallet" + & info . license ?~ license' + & paths .~ depositPaths + & components . schemas .~ depositDefinitions + +license' :: License +license' = + "Apache 2" + & url ?~ URL "https://www.apache.org/licenses/LICENSE-2.0.html" + +depositPaths :: InsOrdHashMap FilePath PathItem +depositPaths = + [ getCustomersListPath + , putCustomerPath + ] + +depositDefinitions :: Definitions Schema +depositDefinitions = + [ ("ApiT Customer", customerSchema) + , ("ApiT Address", addressSchema) + , ("ApiT CustomerList", customerListSchema) + ] + +-- | Paths +jsonMediaType :: MediaType +jsonMediaType = "application/json" + +getCustomersListPath :: (FilePath, PathItem) +getCustomersListPath = ("/customers", pathItem) + where + pathItem :: PathItem + pathItem = mempty & get ?~ operation + operation :: Operation + operation = + mempty + & summary ?~ summary' + & at 200 ?~ at200 + summary' = "Obtain the list of customers" + at200 = + "Ok" + & _Inline . content . at jsonMediaType + ?~ (mempty & schema ?~ Ref (Reference "ApiT CustomerList")) + +putCustomerPath :: (FilePath, PathItem) +putCustomerPath = ("/customers", pathItem) + where + pathItem :: PathItem + pathItem = + mempty + & put ?~ operation + & parameters + .~ [ Inline + $ mempty + & in_ .~ ParamPath + & name .~ "customerId" + & schema ?~ Ref (Reference "ApiT Customer") + ] + operation :: Operation + operation = + mempty + & summary ?~ summary' + & at 200 ?~ at200 + summary' = "Add customer" + at200 = + "Ok" + & _Inline . content . at jsonMediaType + ?~ (mempty & schema ?~ Ref (Reference "ApiT Address")) + +-- | Input/Output type schemas +customerSchema :: Schema +customerSchema = + mempty + & type_ ?~ OpenApiInteger + & minimum_ ?~ 0 + & maximum_ ?~ 2147483647 + +addressSchema :: Schema +addressSchema = + mempty + & type_ ?~ OpenApiString + & format ?~ "hex" + +customerListItemSchema :: Schema +customerListItemSchema = + mempty + & type_ ?~ OpenApiObject + & properties + .~ [ ("customer", Inline customerSchema) + , ("address", Inline addressSchema) + ] + +customerListSchema :: Schema +customerListSchema = + mempty + & type_ ?~ OpenApiArray + & items + ?~ OpenApiItemsObject + (Inline customerListItemSchema) diff --git a/lib/customer-deposit-wallet/spec/openapi/HTTP.yaml b/lib/customer-deposit-wallet/spec/openapi/HTTP.yaml deleted file mode 100644 index 85c93837183..00000000000 --- a/lib/customer-deposit-wallet/spec/openapi/HTTP.yaml +++ /dev/null @@ -1,59 +0,0 @@ -openapi: 3.0.0 -info: - title: Customer Deposit Wallet HTTP API - version: 0.1.0.0 - license: - name: Apache-2.0 - url: https://raw.githubusercontent.com/cardano-foundation/cardano-wallet/master/LICENSE - description: | - Customer Deposit Wallet - -servers: - - url: https://localhost:8090/ - -############################################################################### -# Parameters -############################################################################### - -components: - parameters: - in: path - name: customerId - required: true - schema: - $ref: './HTTP/Types/JSON#/components/schemas/Customer' - -############################################################################### -# Paths -############################################################################### - -paths: - /customers: - get: - operationId: listCustomers - summary: List - description: | - Return a list of all known customer IDs and their addresses. - responses: - 200: - description: Ok - content: - application/json: - schema: - $ref: './HTTP/Types/JSON#/components/schemas/CustomerList' - - /customers/{customerId}: - put: - operationId: createAddress - summary: Create - description: | - Create an association between a customer ID and an address. - parameters: - - $ref: '#/components/parameters/customerId' - responses: - 200: - description: Ok - content: - application/json: - schema: - $ref: './HTTP/Types/JSON#/components/schemas/Address' diff --git a/lib/customer-deposit-wallet/spec/openapi/HTTP/Types/JSON.yaml b/lib/customer-deposit-wallet/spec/openapi/HTTP/Types/JSON.yaml deleted file mode 100644 index 4c1c0dd59b1..00000000000 --- a/lib/customer-deposit-wallet/spec/openapi/HTTP/Types/JSON.yaml +++ /dev/null @@ -1,27 +0,0 @@ -openapi: 3.0.0 -info: - title: Cardano.Wallet.Deposit.HTTP.Types.JSON - version: '' - -components: - schemas: - Address: - type: string - format: base16 - Customer: - type: integer - minimum: 0 - CustomerList: - type: array - items: - additionalProperties: false - properties: - 'customer': - allOf: - - $ref: '#/components/schemas/Customer' - 'address': - allOf: - - $ref: '#/components/schemas/Address' - required: - - 'customer' - - 'address' diff --git a/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/HTTP/JSON/JSONSpec.hs b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/HTTP/JSON/JSONSpec.hs index 8eeaba6bcaa..2f222fb8832 100644 --- a/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/HTTP/JSON/JSONSpec.hs +++ b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/HTTP/JSON/JSONSpec.hs @@ -16,6 +16,12 @@ import Cardano.Wallet.Deposit.HTTP.Types.JSON , Customer , CustomerList ) +import Cardano.Wallet.Deposit.HTTP.Types.OpenAPI + ( addressSchema + , customerListSchema + , customerSchema + , depositDefinitions + ) import Cardano.Wallet.Deposit.Pure ( Word31 , fromRawCustomer @@ -26,19 +32,34 @@ import Cardano.Wallet.Deposit.Read import Data.Aeson ( FromJSON (..) , ToJSON (..) + , Value , decode , encode ) +import Data.Aeson.Encode.Pretty + ( encodePretty + ) +import Data.OpenApi + ( Definitions + , Schema + , validateJSON + ) import Test.Hspec - ( Spec + ( Expectation + , Spec , describe , it + , shouldBe ) import Test.QuickCheck ( Arbitrary (..) + , Gen , Property + , Testable , arbitrarySizedBoundedIntegral , chooseInt + , counterexample + , forAll , property , shrinkIntegral , vectorOf @@ -46,9 +67,11 @@ import Test.QuickCheck ) import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy.Char8 as BL +import qualified Data.List as L spec :: Spec -spec = +spec = do describe "JSON serialization & deserialization" $ do it "ApiT Address" $ property $ prop_jsonRoundtrip @(ApiT Address) @@ -56,28 +79,78 @@ spec = prop_jsonRoundtrip @(ApiT Customer) it "ApiT CustomerList" $ property $ prop_jsonRoundtrip @(ApiT CustomerList) + describe "schema checks" $ do + it "ApiT Address" + $ jsonMatchesSchema genApiTAddress depositDefinitions addressSchema + it "ApiT Customer" + $ jsonMatchesSchema genApiTCustomer depositDefinitions customerSchema + it "ApiT CustomerList" + $ jsonMatchesSchema genApiTCustomerList depositDefinitions customerListSchema + +jsonMatchesSchema + :: (ToJSON a, Show a) + => Gen a + -> Definitions Schema + -> Schema + -> Property +jsonMatchesSchema gen defs schema = + forAll gen + $ counterExampleJSON "validate" + $ validateInstance defs schema + where + validate :: Definitions Schema -> Schema -> Value -> Expectation + validate defs' sch' x = validateJSON defs' sch' x `shouldBe` [] + + validateInstance :: ToJSON a => Definitions Schema -> Schema -> a -> Expectation + validateInstance defs' sch' = validate defs' sch' . toJSON + + counterExampleJSON + :: (Testable prop, ToJSON a) + => String + -> (a -> prop) + -> a + -> Property + counterExampleJSON t f x = + counterexample + ("Failed to " <> t <> ":\n" <> BL.unpack (encodePretty $ toJSON x)) + $ f x prop_jsonRoundtrip :: (Eq a, Show a, FromJSON a, ToJSON a) => a -> Property prop_jsonRoundtrip val = decode (encode val) === Just val +genAddress :: Gen Address +genAddress = do + --enterprise address type with key hash credential is 01100000, network (mainnet) is 1 + --meaning first byte is 01100001 ie. 96+1=97 + let firstByte = 97 + keyhashCred <- BS.pack <$> vectorOf 28 arbitrary + pure $ fromRawAddress $ BS.append (BS.singleton firstByte) keyhashCred + +genApiTAddress :: Gen (ApiT Address) +genApiTAddress = ApiT <$> genAddress + +genApiTCustomer :: Gen (ApiT Customer) +genApiTCustomer = + ApiT . fromRawCustomer <$> arbitrary + +genApiTCustomerList :: Gen (ApiT CustomerList) +genApiTCustomerList = do + listLen <- chooseInt (0, 100) + let genPair = (,) <$> (unApiT <$> arbitrary) <*> (unApiT <$> arbitrary) + vectors <- vectorOf listLen genPair + let uniqueCustomer = L.nubBy (\a b -> fst a == fst b) + let uniqueAddr = L.nubBy (\a b -> snd a == snd b) + pure $ ApiT $ uniqueAddr $ uniqueCustomer vectors + instance Arbitrary (ApiT Address) where - arbitrary = do - --enterprise address type with key hash credential is 01100000, network (mainnet) is 1 - --meaning first byte is 01100001 ie. 96+1=97 - let firstByte = 97 - keyhashCred <- BS.pack <$> vectorOf 28 arbitrary - pure $ ApiT $ fromRawAddress $ BS.append (BS.singleton firstByte) keyhashCred + arbitrary = genApiTAddress instance Arbitrary (ApiT Customer) where - arbitrary = - ApiT . fromRawCustomer <$> arbitrary + arbitrary = genApiTCustomer instance Arbitrary (ApiT CustomerList) where - arbitrary = do - listLen <- chooseInt (0, 100) - let genPair = (,) <$> (unApiT <$> arbitrary) <*> (unApiT <$> arbitrary) - ApiT <$> vectorOf listLen genPair + arbitrary = genApiTCustomerList instance Arbitrary Word31 where arbitrary = arbitrarySizedBoundedIntegral diff --git a/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/HTTP/OpenAPISpec.hs b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/HTTP/OpenAPISpec.hs new file mode 100644 index 00000000000..3dcaeb4dbdc --- /dev/null +++ b/lib/customer-deposit-wallet/test/unit/Cardano/Wallet/Deposit/HTTP/OpenAPISpec.hs @@ -0,0 +1,55 @@ +module Cardano.Wallet.Deposit.HTTP.OpenAPISpec + ( spec + ) where + +import Prelude + +import Cardano.Wallet.Deposit.HTTP.Types.OpenAPI + ( generateOpenapi3 + ) +import Paths_customer_deposit_wallet + ( getDataDir + , getDataFileName + ) +import System.Directory + ( doesDirectoryExist + , doesFileExist + ) +import Test.Hspec + ( Spec + , describe + , it + , shouldReturn + ) +import Test.Hspec.Golden + ( Golden (..) + ) + +import qualified Data.ByteString.Lazy.Char8 as BL + +spec :: Spec +spec = do + describe "data dir" $ do + it "should exist" $ do + f <- getDataDir + doesDirectoryExist f `shouldReturn` True + describe "swagger.yaml" $ do + it "should be generated" $ do + f <- getDataFileName "data/swagger.json" + doesFileExist f `shouldReturn` True + it "contains the actual schema" $ do + f <- getDataFileName "data/swagger.json" + let output' = generateOpenapi3 + pure $ swaggerGolden f $ BL.unpack output' + +swaggerGolden :: FilePath -> String -> Golden String +swaggerGolden goldenPath output_ = + Golden + { output = output_ + , encodePretty = show + , writeToFile = writeFile + , readFromFile = readFile + , goldenFile = goldenPath + , actualFile = Nothing + , failFirstTime = False + }