zendesk-api (empty) → 0.1.0.0
raw patch · 7 files changed
+623/−0 lines, 7 filesdep +aesondep +basedep +bytestringsetup-changed
Dependencies added: aeson, base, bytestring, case-insensitive, conduit, connection, data-default, failure, http-client, http-client-tls, http-conduit, http-types, monad-logger, mtl, pem, template-haskell, text, time, tls, transformers, unordered-containers, x509, x509-store, x509-validation
Files
- LICENSE +22/−0
- Setup.hs +2/−0
- src/Json.hs +50/−0
- src/Zendesk.hs +161/−0
- src/Zendesk/Common.hs +243/−0
- src/Zendesk/User.hs +94/−0
- zendesk-api.cabal +51/−0
+ LICENSE view
@@ -0,0 +1,22 @@+The MIT License (MIT)++Copyright (c) <year> <copyright holders>++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in+all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+THE SOFTWARE.+
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ src/Json.hs view
@@ -0,0 +1,50 @@+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE FlexibleInstances #-}++module Json+ ( jsonOptionsForPrefix+ , deriveJSON+ , deriveJSON_+ , deriveJSONOptions+ , deriveEnumJSON+ ) where++import Data.Aeson (FromJSON(..), ToJSON(..), Value(..))+import qualified Data.Aeson.TH as J (Options (..), defaultOptions, deriveJSON)+import Data.Char (toLower, isUpper)+import Data.Text as T (unpack)+import Language.Haskell.TH+import Language.Haskell.TH.Syntax (showName)++jsonOptionsForPrefix :: String -> J.Options+jsonOptionsForPrefix prefix =+ J.defaultOptions+ { J.fieldLabelModifier = underscoreIt . (drop $ length prefix) }++underscoreIt :: String -> String+underscoreIt s = go [] s+ where+ go :: String -> String -> String+ go [] (c:cs) = go [toLower c] cs+ go r (c:cs) | isUpper c = go (r ++ "_" ++ [(toLower c)]) cs+ go r (c:cs) = go (r ++ [c]) cs+ go r [] = r++deriveJSONOptions :: J.Options -> Name -> Q [Dec]+deriveJSONOptions = J.deriveJSON++deriveJSON :: Name -> Q [Dec]+deriveJSON name = deriveJSONOptions (jsonOptionsForPrefix (nameBase name)) name++deriveJSON_ :: Name -> Q [Dec]+deriveJSON_ name = deriveJSONOptions (jsonOptionsForPrefix ('_' : nameBase name)) name++deriveEnumJSON :: Name -> Q [Dec]+deriveEnumJSON name = [d|+ instance FromJSON $(conT name) where+ parseJSON (String s) = return $ read . T.unpack $ s+ parseJSON _ = fail $ "Invalid " ++ $(stringE (showName name))++ instance ToJSON $(conT name) where+ toJSON = toJSON . show+ |]
+ src/Zendesk.hs view
@@ -0,0 +1,161 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE TypeSynonymInstances #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverlappingInstances #-}+{-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE IncoherentInstances #-}+{-# LANGUAGE ScopedTypeVariables #-}++module Zendesk+( runZendesk+, createUser+, getUsers+, getTickets+, getTicketFields+, ZendeskConfig(..)+, runZendeskT+) where++import Control.Applicative ((<$>), (<*>))+import Control.Exception (catches, SomeException(..), Handler(..))+import Control.Failure (Failure(..))+import Control.Monad (liftM, forM)+import Control.Monad.Reader (ReaderT (..), asks)+import Control.Monad.Error (ErrorT (..), MonadError(..), Error(..))+import Control.Monad.IO.Class (MonadIO(..))+import Control.Monad.Logger (MonadLogger(..), logDebug)+import Control.Monad.Trans (lift)+import Data.Aeson as J (eitherDecode, encode, FromJSON(..), withObject, withText, (.:), object, (.=), Value(..), Object)+import Data.ByteString.Lazy (ByteString)+import qualified Data.ByteString.Lazy as LBS (toStrict)+import Data.ByteString.Char8 as BS8 (pack, unpack)+import Data.CaseInsensitive (mk)+import Data.Default (Default(..))+import qualified Data.HashMap.Strict as M+import Data.Int (Int64)+import Data.Maybe (fromJust)+import Data.PEM (PEM(..), pemParseBS)+import Data.Text as T (pack, Text, unpack)+import Data.Text.Encoding as T (encodeUtf8)+import Data.Time.Clock (UTCTime(..))+import Data.Traversable (Traversable(..))+import Data.X509 (HashALG(..), decodeSignedCertificate)+import Data.X509.CertificateStore (CertificateStore, makeCertificateStore)+import Data.X509.Validation (ValidationChecks(..), validate, defaultHooks, defaultChecks)+import Network.Connection (TLSSettings(..))+import Network.HTTP.Client (getUri, applyBasicAuth)+import Network.HTTP.Client.TLS (mkManagerSettings)+import Network.HTTP.Conduit as HTTP ( httpLbs, parseUrl, withManagerSettings+ , HttpException(..)+ , Request(..), Response(..)+ , responseBody, RequestBody(..), requestBody+ , method, requestHeaders, checkStatus)+import Network.HTTP.Types.Status (Status(..))+import Network.TLS (Credential, Credentials(..), ClientParams(..), Shared(..), ClientHooks(..), Supported(..), defaultParamsClient, credentialLoadX509FromMemory)+import Network.TLS.Extra.Cipher (ciphersuite_strong)++import Json (deriveJSON, deriveJSON_, deriveEnumJSON)++import Data.Conduit (Source, yield)++import Zendesk.Common+import Zendesk.User++getTicketsUrl :: Monad m => ZendeskT m String+getTicketsUrl = do+ baseUrl <- asks zendeskUrl+ return $ baseUrl ++ "/api/v2/tickets.json"++getTicketFieldsUrl :: Monad m => ZendeskT m String+getTicketFieldsUrl = do+ baseUrl <- asks zendeskUrl+ return $ baseUrl ++ "/api/v2/ticket_fields.json"+++data TicketField = TicketField+ { ticketFieldId :: Maybe Int+ , ticketFieldUrl :: Maybe Text+ , ticketFieldType :: Text+ , ticketFieldTitle :: Text+ , ticketFieldDescription :: Maybe Text+ , ticketFieldPosition :: Maybe Int+ , ticketFieldActive :: Maybe Bool+ , ticketFieldRequired :: Maybe Bool+ , ticketFieldCollapsedForAgents :: Maybe Bool+ , ticketFieldRegexpForValidation :: Maybe Text+ , ticketFieldTitleInPortal :: Maybe Text+ , ticketFieldVisibleInPortal :: Maybe Bool+ , ticketFieldEditableInPortal :: Maybe Bool+ , ticketFieldRequiredInPortal :: Maybe Bool+ , ticketFieldTag :: Maybe Text+ , ticketFieldCreatedAt :: Maybe UTCTime+ , ticketFieldUpdatedAt :: Maybe UTCTime+ --, ticketSystemFieldOptions :: Maybe [Text] TODO Array of something+ --, ticketCustomFieldOptions :: Maybe [Text] TODO Array of something+ , ticketFieldRemovable :: Maybe Bool+ } deriving (Show)++data TicketFieldValue = TicketFieldValue+ { ticketFieldValueId :: Int+ , ticketFieldValueValue :: Maybe Text+ } deriving (Show)++data Ticket = Ticket+ { ticketId :: Maybe Int+ , ticketUrl :: Maybe Text+ , ticketExternalId :: Maybe Text+ , ticketType :: Maybe Text+ , ticketSubject :: Maybe Text+ , ticketDescription :: Maybe Text+ , ticketPriority :: Maybe Text+ , ticketStatus :: Maybe Text+ , ticketRecipient :: Maybe Text+ , ticketRequesterId :: Int+ , ticketSubmitterId :: Maybe Int+ , ticketAssigneeId :: Maybe Int+ , ticketOrganizationId :: Maybe Int+ , ticketGroupId :: Maybe Int+ , ticketCollaboratorIds :: Maybe [Int]+ , ticketForumTopicId :: Maybe Int+ , ticketProblemId :: Maybe Int+ , ticketHasIncidents :: Maybe Bool+ , ticketDueAt :: Maybe Text+ , ticketTags :: Maybe [Text]+ , ticketVia :: Maybe Via+ , ticketCustomFields :: Maybe [TicketFieldValue]+ , ticketSatisfactionRaiting :: Maybe Object+ , ticketSharingAgreementIds :: Maybe [Int]+ , ticketFollowupIds :: Maybe [Int]+ , ticketTicketFormId :: Maybe Int+ , ticketBrandId :: Maybe Int+ , ticketCreatedAt :: Maybe UTCTime+ , ticketUpdatedAt :: Maybe UTCTime+ } deriving (Show)++data Via = Via+ { viaChannel :: Text+ , viaSource :: Maybe Object+ } deriving (Show)++instance CollectionKey Ticket where+ collectionKey _ = "tickets"++instance CollectionKey TicketField where+ collectionKey _ = "ticket_fields"++deriveJSON ''Ticket+deriveJSON ''TicketField+deriveJSON ''TicketFieldValue+deriveJSON ''Via++getTickets :: (MonadIO m, MonadLogger m) => Source (ZendeskT m) Ticket+getTickets =+ getCollection =<< (Just `liftM` (T.pack `liftM` (lift getTicketsUrl)))++getTicketFields :: (MonadIO m, MonadLogger m) => Source (ZendeskT m) TicketField+getTicketFields =+ getCollection =<< (Just `liftM` (T.pack `liftM` (lift getTicketFieldsUrl)))
+ src/Zendesk/Common.hs view
@@ -0,0 +1,243 @@+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverlappingInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE TypeSynonymInstances #-}+{-# LANGUAGE UndecidableInstances #-}++module Zendesk.Common+where++import Control.Applicative ((<$>), (<*>))++import Control.Exception (catches, SomeException(..), Handler(..))++import Control.Failure (Failure(..))++import Control.Monad (liftM, forM)++import Control.Monad.Error (ErrorT(..), MonadError(..), Error(..))+import Control.Monad.IO.Class (MonadIO(..))+import Control.Monad.Logger (MonadLogger(..), logDebug)+import Control.Monad.Reader (ReaderT (..), asks)+import Control.Monad.Trans (lift)++import Data.Aeson as J (eitherDecode, FromJSON(..), (.:), Value(..), Object)++import Data.ByteString.Char8 as BS8 (pack, unpack)+import Data.ByteString.Lazy (ByteString)++import Data.Conduit (Source, yield)++import Data.Default (Default(..))++import Data.Text as T (pack, Text, unpack)++import Data.X509 (HashALG(..))+import Data.X509.CertificateStore (CertificateStore)+import Data.X509.Validation (ValidationChecks(..), validate, defaultHooks, defaultChecks)++import Json (deriveJSON)++import Network.Connection (TLSSettings(..))++import Network.HTTP.Client (getUri, applyBasicAuth)+import Network.HTTP.Client.TLS (mkManagerSettings)+import Network.HTTP.Conduit as HTTP ( httpLbs, parseUrl, withManagerSettings+ , HttpException(..)+ , Request(..), Response(..)+ , responseBody, requestBody+ , requestHeaders, checkStatus)+import Network.HTTP.Types.Status (Status(..))++import Network.TLS ( Credential, ClientHooks(..), ClientParams(..)+ , Credentials(..), Supported(..), defaultParamsClient+ , Shared(..))+import Network.TLS.Extra.Cipher (ciphersuite_strong)++data ZendeskError = TimeoutError+ | NoResourceError+ | BadRequestError String+ | NoFreeServersError String+ | InternalError String+ | UnknownError String++instance Error ZendeskError where+ strMsg = UnknownError++instance Show ZendeskError where+ show NoResourceError = "Resource not found in datacenter"+ show TimeoutError = "Datacenter unavailable"+ show (BadRequestError s) = "Bad request: " ++ s+ show (NoFreeServersError s) = "Not enough free servers (" ++ s ++ ")"+ show (InternalError s) = "Internal error: " ++ s+ show (UnknownError s) = s++runZendeskT :: ZendeskConfig -> ZendeskT m a -> m (Either ZendeskError a)+runZendeskT c f = runErrorT $ runReaderT f c++type ZendeskT m = ReaderT ZendeskConfig (ErrorT ZendeskError m)++data ZendeskConfig = ZendeskConfig+ { zendeskUrl :: String -- zendesk domain url+ , zendeskUsername :: String+ , zendeskPassword :: String+ , zendeskTLS :: Maybe ZendeskTLSConfig+ }++data ZendeskTLSConfig = ZendeskTLSConfig+ { tlsCredential :: Credential+ , tlsCertificateStore :: CertificateStore+ }++class CollectionKey e where+ collectionKey :: e -> Text++data Collection e = Collection+ { collectionElements :: [e]+ , collectionCount :: Int+ , collectionNextPage :: Maybe Text+ , collectionPrevPage :: Maybe Text+ } deriving Show++instance (CollectionKey e, FromJSON e) => FromJSON (Collection e) where+ parseJSON (Object v) =+ Collection+ <$> v .: (collectionKey (undefined :: e))+ <*> v .: "count"+ <*> v .: "next_page"+ <*> v .: "previous_page"++showRequest :: Request -> String+showRequest x = unlines+ [ "Request {"+ , " method = " ++ show (method x)+ , " url = \"" ++ show (getUri x) ++ "\""+ , " headers = " ++ show (requestHeaders x)+ -- , " body = " ++ show (requestBody x)+ , "}"+ ]++showResponse :: Response ByteString -> String+showResponse x = unlines+ [ "Response {"+ , " status = " ++ show (statusCode $ responseStatus x)+ , " headers = " ++ show (responseHeaders x)+ , " body = " ++ show (responseBody x)+ , "}"+ ]++errorResponseText :: HTTP.Response ByteString -> String+errorResponseText response =+ either (\_ -> show $ responseBody response) errorReportError $ eitherDecode . responseBody $ response++handleExceptionsAndResult :: MonadIO m => IO a -> ZendeskT m a+handleExceptionsAndResult monad = do+ res <- liftIO $ (fmap Right monad) `catches` [ Handler handleHttpException+ , Handler handleOtherExceptions+ ]+ case res of+ Left v -> throwError v+ Right x -> return x++decodeResponseBody :: (Monad m, ParseResponseBody a)+ => Response ByteString -> m (Either ZendeskError a)+decodeResponseBody response+ | statusCode (responseStatus response) `elem` [200..299] =+ case parseBody . responseBody $ response of+ Left e -> return $ Left $ InternalError $ "Json parse error: " ++ e+ Right x -> return $ Right x+ | statusCode (responseStatus response) == 404 = return $ Left NoResourceError+ | statusCode (responseStatus response) == 400 = return $ Left $ BadRequestError $ errorResponseText response+ | statusCode (responseStatus response) == 402 = return $ Left $ NoFreeServersError $ errorResponseText response+ | statusCode (responseStatus response) == 502 = return $ Left $ TimeoutError+ | statusCode (responseStatus response) `elem` [500..599] = return $ Left $ InternalError $ errorResponseText response+ | otherwise = return $ Left $ UnknownError $ errorResponseText response++handleHttpException :: HttpException -> IO (Either ZendeskError a)+handleHttpException ResponseTimeout = return $ Left TimeoutError+handleHttpException e = return $ Left $ UnknownError $ show e++handleOtherExceptions :: SomeException -> IO (Either ZendeskError a)+handleOtherExceptions e = return $ Left $ UnknownError $ show e++instance Monad m => Failure HttpException (ZendeskT m) where+ failure e = throwError $ InternalError $ show e++class Monad m => MonadZendesk m where+ runZendesk :: ZendeskT m a -> m (Either ZendeskError a)++runRequest :: (MonadIO m, MonadLogger m, ParseResponseBody a)+ => Request -> ZendeskT m a+runRequest request = do+ username <- BS8.pack `liftM` (asks zendeskUsername)+ password <- BS8.pack `liftM` (asks zendeskPassword)+ let request' = applyBasicAuth username password $ request { checkStatus = \_ _ _ -> Nothing }++ $logDebug $ T.pack $ "Sending request to zendesk: " ++ (showRequest request')++ mTLSConfig <- asks zendeskTLS+ let tlsSettings = case mTLSConfig of+ Nothing -> def+ Just tlsConfig ->+ let params = defaultParamsClient (BS8.unpack $ host request') ""+ validationChecks = defaultChecks+ { checkCAConstraints = False+ , checkLeafV3 = False+ , checkFQHN = False+ }+ in TLSSettings $ params+ { clientShared = (clientShared params)+ { sharedCredentials = Credentials [tlsCredential tlsConfig]+ , sharedCAStore = tlsCertificateStore tlsConfig+ }+ , clientHooks = def { onCertificateRequest = \_ -> return . Just $ tlsCredential tlsConfig+ , onServerCertificate = validate HashSHA256 defaultHooks validationChecks+ }+ , clientSupported = (clientSupported params)+ { supportedCiphers = ciphersuite_strong }+ }+++ let httpSettings = mkManagerSettings tlsSettings Nothing++ response <- handleExceptionsAndResult $ do+ withManagerSettings httpSettings $ httpLbs request'++ $logDebug $ T.pack $ "Got response from zendesk: " ++ (showResponse response)++ result <- decodeResponseBody response+ case result of+ Left v -> throwError v+ Right x -> return x++runRequestTo :: (MonadIO m, MonadLogger m, ParseResponseBody a)+ => String -> ZendeskT m a+runRequestTo url = parseUrl url >>= runRequest++getCollection :: (CollectionKey e, MonadIO m, MonadLogger m, FromJSON e)+ => Maybe Text -> Source (ZendeskT m) e+getCollection Nothing = return ()+getCollection (Just url) = do+ collection <- lift $ runRequestTo $ T.unpack url+ forM (collectionElements collection) yield+ getCollection $ collectionNextPage collection++data None = None+ deriving (Show, Eq)++class ParseResponseBody a where+ parseBody :: ByteString -> Either String a++instance ParseResponseBody None where+ parseBody _ = Right $ None++instance FromJSON a => ParseResponseBody a where+ parseBody = eitherDecode++data ErrorReport = ErrorReport { errorReportError :: String }+ deriving Show++deriveJSON ''ErrorReport
+ src/Zendesk/User.hs view
@@ -0,0 +1,94 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}++module Zendesk.User+( createUser+, getUsers+, User(..)+) where++import Control.Monad (liftM)+import Control.Monad.IO.Class (MonadIO(..))+import Control.Monad.Logger (MonadLogger(..))+import Control.Monad.Reader (asks)+import Control.Monad.Trans (lift)+import Data.Aeson as J (encode, object, (.=), Value(..), Object)+import qualified Data.ByteString.Lazy as LBS (toStrict)+import Data.CaseInsensitive (mk)+import Data.Conduit (Source)+import Data.Text as T (pack, Text)+import Data.Time.Clock (UTCTime(..))++import Json (deriveJSON, deriveJSON_, deriveEnumJSON)+import Network.HTTP.Conduit as HTTP ( parseUrl+ , RequestBody(..), requestBody+ , method, requestHeaders)++import Zendesk.Common++data User = User+ { userId :: Maybe Int+ , userUrl :: Maybe Text+ , userName :: Text+ , userCreatedAt :: Maybe UTCTime+ , userUpdatedAt :: Maybe UTCTime+ , userTimeZone :: Maybe Text+ , userEmail :: Text+ , userPhone :: Maybe Text+ , userLocale :: Maybe Text+ , userLocaleId :: Maybe Int+ , userOrganizationId :: Maybe Int+ , userRole :: Text+ , userVerified :: Maybe Bool+ , userPhoto :: Maybe Attachment+ } deriving (Show)++data Attachment = Attachment+ { attachmentId :: Int+ , attachmentFileName :: Text+ , attachmentContentUrl :: Text+ , attachmentContentType :: Text+ , attachmentSize :: Int+ , attachmentThumbnails :: Maybe [Attachment]+ } deriving (Show)++data UserReply = UserReply+ { userReplyUser :: User+ }++newtype Name = Name String++newtype Email = Email String++createUser :: (MonadIO m, MonadLogger m) => Name -> Email -> ZendeskT m User+createUser (Name name) (Email email) = do+ initRequest <- parseUrl =<< getUsersUrl+ let request = initRequest+ { method = "POST"+ , requestHeaders = [ (mk "Content-Type", "application/json") ]+ , requestBody = RequestBodyBS $ LBS.toStrict $ encode+ $ object ["user" .= CreateUserRequest name email]+ }+ userReplyUser `liftM` (runRequest request)++getUsers :: (MonadIO m, MonadLogger m) => Source (ZendeskT m) User+getUsers =+ getCollection =<< (Just `liftM` (T.pack `liftM` (lift getUsersUrl)))++getUsersUrl :: Monad m => ZendeskT m String+getUsersUrl = do+ baseUrl <- asks zendeskUrl+ return $ baseUrl ++ "/api/v2/users.json"++data CreateUserRequest = CreateUserRequest+ { createUserRequestName :: String+ , createUserRequestEmail :: String+ } deriving (Show)++instance CollectionKey User where+ collectionKey _ = "users"++deriveJSON ''CreateUserRequest+deriveJSON ''User+deriveJSON ''UserReply+deriveJSON ''Attachment
+ zendesk-api.cabal view
@@ -0,0 +1,51 @@+name: zendesk-api+version: 0.1.0.0+synopsis: Zendesk API for Haskell programming language.+description: This is a library for accessing zendesk api using haskell bindings.+homepage: https://github.com/VictorDenisov/zendesk-api+license: MIT+license-file: LICENSE+author: Victor Denisov+maintainer: denisovenator@gmail.com+-- copyright:+category: API+build-type: Simple+-- extra-source-files:+cabal-version: >=1.10++library+ exposed-modules: Zendesk+ other-modules: Json+ , Zendesk.Common+ , Zendesk.User+ -- other-extensions:+ build-depends: aeson == 0.7.*+ , base >=4.5 && <4.7+ , bytestring == 0.10.0.2+ , case-insensitive == 1.1.0.3+ , conduit == 1.0.*+ , connection == 0.2.*+ , data-default == 0.5.*+ , failure == 0.2.*+ , http-client == 0.2.*+ , http-client-tls == 0.2.*+ , http-conduit == 2.0.0.3+ , http-types == 0.8.3+ , monad-logger == 0.3.*+ , mtl == 2.1.2+ , pem == 0.2.*+ , text >= 0.11.3+ , template-haskell == 2.8.0.0+ , time == 1.4.0.1+ , tls == 1.2.*+ , transformers == 0.3.0.0+ , unordered-containers == 0.2.*+ , x509 == 1.4.*+ , x509-store == 1.4.*+ , x509-validation == 1.5.*+ hs-source-dirs: src+ default-language: Haskell2010++source-repository head+ type: git+ location: https://github.com/VictorDenisov/zendesk-api