erpnext-api-client (empty) → 0.0.0.1
raw patch · 9 files changed
+576/−0 lines, 9 filesdep +aesondep +basedep +bytestringsetup-changed
Dependencies added: aeson, base, bytestring, http-client, http-types, network-uri, text, time
Files
- CHANGELOG.md +5/−0
- LICENSE +21/−0
- README.md +166/−0
- Setup.hs +2/−0
- erpnext-api-client.cabal +40/−0
- src/ERPNext/Client.hs +169/−0
- src/ERPNext/Client/Filters.hs +90/−0
- src/ERPNext/Client/Helper.hs +24/−0
- src/ERPNext/Client/QueryStringParams.hs +59/−0
+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Changelog for `erpnext-api-client`++The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),+and this project adheres to the+[Haskell Package Versioning Policy](https://pvp.haskell.org/).
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2025 Intensovet GmbH++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.
+ README.md view
@@ -0,0 +1,166 @@+# erpnext-api-client++This is a Haskell API client for+[ERPNext](https://github.com/frappe/erpnext). It aims to be a+light-weight library based on+[http-client](https://hackage.haskell.org/package/http-client) and+user-provided record types.++ERPNext has the concept of+[DocTypes](https://docs.frappe.io/erpnext/user/manual/en/doctype) which+model entities like Customer, Sales Order, etc.++The [ERPNext REST+API](https://docs.frappe.io/framework/user/en/api/rest) basically has+seven types of requests covering CRUD operations, remote method calls,+and file uploads.++CRUD operations on a given DocType are:++- `GET` list of all documents (`getDocTypeList`)+- `POST` to create a new document (`postDocType`)+- `GET` a document by name (`getDocType`)+- `PUT` to update a document by name (`putDocType`)+- `DELETE` a document by name (`deleteDocType`)++Note: Remote method calls and file uploads are not yet supported.++DocTypes in ERPNext can be extended and newly created by users. This is+why this library does not come with any predefined types for DocTypes.+Instead, users can provide their own types with only the fields they+need.++This library also provides tooling to generate Haskell record types for+DocTypes from a simple DSL (see section below).++## Usage++``` haskell+{-# LANGUAGE OverloadedStrings #-}++import ERPNext.Client+import Network.HTTP.Client+import Data.Aeson+import GHC.Generics++data Customer = Customer+ { name :: String+ } deriving Generic++instance FromJSON Customer+instance ToJSON Customer++instance IsDocType Customer where+ docTypeName = "Customer"++main :: IO ()+main = do+ let config = mkConfig "https://erpnext.example.com" "api-key" (mkSecret "api-secret")+ manager <- newManager defaultManagerSettings+ response <- getDocType manager config "Company A" :: IO (ApiResponse Customer)+ case response of+ Ok _ c _ -> putStrLn $ name c+ Err r _ -> print r+```++``` bash+stack runhaskell example1.hs+```++## Scope and Limits++- Only [token-based+ authentication](https://docs.frappe.io/framework/user/en/api/rest#1-token-based-authentication)+ is supported.+- [Remote Method+ Calls](https://docs.frappe.io/framework/user/en/api/rest#remote-method-calls)+ are not yet supported.+- [File+ Uploads](https://docs.frappe.io/framework/user/en/api/rest#file-uploads)+ are not yet supported.++## Data records for DocTypes++Haskell record types for the DocTypes can be coded by hand which+requires some boiler plate like Aeson instances, handling `null` or+missing values, and writing helper functions like `mkCustomer`.++This library provides tooling to generate Haskell record types from a+simple DSL very similar to [persistent's model definition+syntax](https://hackage.haskell.org/package/persistent/docs/Database-Persist-Quasi.html).++1. A script generates an [OpenAPI+ Specification](https://swagger.io/specification/) file in `yaml`+ format.+2. The+ [Haskell-OpenAPI-Client-Code-Generator](https://github.com/Haskell-OpenAPI-Code-Generator/Haskell-OpenAPI-Client-Code-Generator/)+ generates an Haskell API client, from which only the type+ definitions can be used.++The resulting files are a separate Haskell package which can be added as+dependency. The resulting record types can be used together with this+API client but the `IsDocType` instance must still be defined by hand.++****The API client part generated from the OpenAPI spec can not be+used.****++``` example+$ ./scripts/gen-openapi-yaml.sh erpnext/models > openapi.yaml+$ openapi3-code-generator-exe \+ --specification openapi.yaml \+ --package-name erpnext-api-client-models \+ --module-name ERPNextAPI \+ --force --output-dir api-client/+$ tree api-client/+api-client/+├── erpnext-api-client-models.cabal+├── src+│ ├── ERPNextAPI+│ │ ├── Common.hs+│ │ ├── Configuration.hs+│ │ ├── Operations+│ │ │ └── DummyOperation.hs+│ │ ├── SecuritySchemes.hs+│ │ ├── TypeAlias.hs+│ │ ├── Types <---- here are the generated types+│ │ │ ├── Customer.hs+│ │ │ ├── Order.hs+│ │ │ ├── Supplier.hs+│ │ └── Types.hs+│ └── ERPNextAPI.hs+├── stack.yaml+└── stack.yaml.lock+```++## Note on TLS problems++If you're running ERPNext in your test environment, chances are that+your server does not have a valid TLS certificate signed by a trusted+CA.++In this case you can configure the HTTP connection manager's TLS+settings like this:++``` haskell+import Network.HTTP.Client+import Network.HTTP.Client.TLS (mkManagerSettings)+import Network.Connection (TLSSettings (..))++…++let tlsSettings =+ mkManagerSettings+ ( TLSSettingsSimple+ { settingDisableCertificateValidation = True+ , settingDisableSession = False+ , settingUseServerName = False+ }+ )+ Nothing+manager <- Network.HTTP.Client.newManager tlsSettings+…+```++``` bash+stack runhaskell --package crypton-connection --package http-client-tls example1.hs+```
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ erpnext-api-client.cabal view
@@ -0,0 +1,40 @@+cabal-version: 3.6++name: erpnext-api-client+version: 0.0.0.1+description: xxx+homepage: xxx+license: MIT+license-file: LICENSE+author: Jakob Schöttl+maintainer: jakob.schoettl@intensovet.de+copyright: 2025 Intensovet GmbH+category: API+build-type: Simple+extra-source-files: README.md+ CHANGELOG.md++library+ hs-source-dirs: src+ exposed-modules: ERPNext.Client+ , ERPNext.Client.Helper+ , ERPNext.Client.Filters+ , ERPNext.Client.QueryStringParams+ build-depends: base >= 4.7 && < 5+ , http-client+ , http-types+ , text+ , aeson+ , time+ , network-uri+ , bytestring+ default-language: GHC2021+ ghc-options: -Wall+ -Wcompat+ -Widentities+ -Wincomplete-record-updates+ -Wincomplete-uni-patterns+ -Wmissing-export-lists+ -Wmissing-home-modules+ -Wpartial-fields+ -Wredundant-constraints
+ src/ERPNext/Client.hs view
@@ -0,0 +1,169 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE OverloadedStrings #-}++module ERPNext.Client+ ( getDocTypeList+ , getDocType+ , postDocType+ , putDocType+ , deleteDocType+ , mkSecret+ , mkConfig+ , IsDocType (..)+ , Config ()+ , Secret ()+ , QueryStringParam (..)+ , ApiResponse (..)+ , getResponse+ ) where++import Network.HTTP.Client (Response (..), Request (..), Manager, httpLbs, parseRequest, RequestBody (..))+import Network.HTTP.Types (hAuthorization, hContentType, Header)+import Data.Text hiding (map)+import Data.Text.Encoding (encodeUtf8)+import Data.Aeson+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import ERPNext.Client.QueryStringParams+import ERPNext.Client.Helper (urlEncode)++-- | Type class for types which represent an ERPNext DocType.+-- Each DocType has a unique name.+class IsDocType a where+ docTypeName :: Text+ -- TODO: implement auto-derive (using typename and generic)?++getDocTypeList :: forall a. (IsDocType a, FromJSON a)+ => Manager -> Config -> [QueryStringParam]-> IO (ApiResponse [a])+getDocTypeList manager config qsParams = do+ let path = getResourcePath @a <> "?" <> renderQueryStringParams qsParams+ request <- createRequest config path "GET"+ response <- httpLbs request manager+ return $ parseGetResponse response++getDocType :: forall a. (IsDocType a, FromJSON a)+ => Manager -> Config -> Text -> IO (ApiResponse a)+getDocType manager config name = do+ let path = getResourcePath @a <> "/" <> name+ request <- createRequest config path "GET"+ response <- httpLbs request manager+ return $ parseGetResponse response++{- | Delete a named object.++The phantom type parameter @a@ is used to figure out the DocType.+A customer can be deleted like this:++@+res <- deleteDocType @Customer manager config "<customer name>"+@+-}+deleteDocType :: forall a. (IsDocType a)+ => Manager -> Config -> Text -> IO (ApiResponse ())+deleteDocType manager config name = do+ let path = getResourcePath @a <> "/" <> name+ request <- createRequest config path "DELETE"+ response <- httpLbs request manager+ return $ parseDeleteResponse response++postDocType :: forall a. (IsDocType a, FromJSON a, ToJSON a)+ => Manager -> Config -> a -> IO (ApiResponse a)+postDocType manager config doc = do+ let path = getResourcePath @a+ request <- createRequestWithBody config path "POST" doc+ response <- httpLbs request manager+ return $ parseGetResponse response++putDocType :: forall a. (IsDocType a, FromJSON a, ToJSON a)+ => Manager -> Config -> Text -> a -> IO (ApiResponse a)+putDocType manager config name doc = do+ let path = getResourcePath @a <> "/" <> name+ request <- createRequestWithBody config path "PUT" doc+ response <- httpLbs request manager+ return $ parseGetResponse response+++mkConfig :: Text -> Text -> Secret -> Config+mkConfig baseUrl apiKey apiSecret = Config+ { baseUrl = baseUrl+ , apiKey = apiKey+ , apiSecret = apiSecret+ }++-- | Create the API secret used together with the API key for authorization.+mkSecret :: Text -> Secret+mkSecret = Secret+++-- | Create the API Request.+createRequest :: Config -> Text -> BS.ByteString -> IO Request+createRequest config path method = do+ request <- parseRequest $ unpack (baseUrl config <> path)+ return request+ { method = method+ , requestHeaders = [mkAuthHeader config]+ }++createRequestWithBody :: ToJSON a => Config -> Text -> BS.ByteString -> a -> IO Request+createRequestWithBody config path method doc = do+ request <- parseRequest $ unpack (baseUrl config <> path)+ return request+ { method = method+ , requestHeaders = mkAuthHeader config : [(hContentType, encodeUtf8 "application/json")]+ , requestBody = RequestBodyLBS (encode doc)+ }++-- | API client configuration.+data Config = Config+ { baseUrl :: Text+ , apiKey :: Text+ , apiSecret :: Secret+ }++-- | Opaque type to store the API secret.+data Secret = Secret+ { getSecret :: Text+ }++data DataWrapper a = DataWrapper { getData :: a }+ deriving Show++instance FromJSON a => FromJSON (DataWrapper a) where+ parseJSON = withObject "DataWrapper" $ \obj -> do+ dataValue <- obj .: "data"+ return (DataWrapper dataValue)++data ApiResponse a+ = Ok (Response LBS.ByteString) Value a+ | Err (Response LBS.ByteString) (Maybe (Value, Text))+ deriving Show++getResponse :: ApiResponse a -> Response LBS.ByteString+getResponse (Ok r _ _) = r+getResponse (Err r _) = r++mkAuthHeader :: Config -> Header+mkAuthHeader config = let authToken = apiKey config <> ":" <> getSecret (apiSecret config)+ in (hAuthorization, encodeUtf8 $ "token " <> authToken)++parseGetResponse :: forall a. FromJSON a => Response LBS.ByteString -> ApiResponse a+parseGetResponse response =+ case decode @Value (responseBody response) of+ Just value -> case fromJSON value :: Result (DataWrapper a) of+ Success result -> Ok response value (getData result)+ Error err -> Err response (Just (value, pack err))+ Nothing -> Err response Nothing++parseDeleteResponse :: Response LBS.ByteString -> ApiResponse ()+parseDeleteResponse response =+ case decode @Value (responseBody response) of+ Just value -> case fromJSON value :: Result (DataWrapper Text) of+ Success (DataWrapper message)+ | message == "ok" -> Ok response value ()+ | otherwise -> Err response (Just (value, message))+ Error err -> Err response (Just (value, pack err))+ Nothing -> Err response Nothing++getResourcePath :: forall a. IsDocType a => Text+getResourcePath = "/resource/" <> urlEncode (docTypeName @a)
+ src/ERPNext/Client/Filters.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE OverloadedStrings #-}++module ERPNext.Client.Filters+ ( Filter (..)+ , FilterOperator (..)+ , FilterValue (..)+ , renderFilters+ ) where++import Data.Text (Text, intercalate)+import Data.Time.Calendar (Day)+import ERPNext.Client.Helper (urlEncode, quote, tshow)++-- TODO: refactor this? rename to filter and parameterize each term with fieldname and value?+data FilterOperator+ = Eq+ | NotEq+ | Greater+ | GreaterOrEq+ | Less+ | LessOrEq+ | Like+ | NotLike+ | In+ | NotIn+ | Between+ | Is+ deriving (Show, Eq)++data FilterValue+ = FilterText Text+ | FilterNumber Double+ | FilterBool Bool+ | FilterList [FilterValue]+ | FilterDay Day+ | FilterNull -- Used only with Is+ | FilterNotNull -- Used only with Is+ deriving (Show, Eq)++data Filter = Filter+ { filterField :: Text+ , filterOperator :: FilterOperator+ , filterValue :: FilterValue+ }+ deriving (Show, Eq)++renderFilterOperator :: FilterOperator -> Text+renderFilterOperator op =+ case op of+ Eq -> "="+ NotEq -> "!="+ Greater -> ">"+ GreaterOrEq -> ">="+ Less -> "<"+ LessOrEq -> "<="+ Like -> "like"+ NotLike -> "not like"+ In -> "in"+ NotIn -> "not in"+ Between -> "between"+ Is -> "is"++renderFilterValue :: FilterValue -> Text+renderFilterValue fv =+ case fv of+ FilterText t -> quote t+ FilterNumber n -> tshow n+ FilterBool b -> if b then "1" else "0"+ FilterList vs -> "[" <> intercalate ", " (map renderFilterValue vs) <> "]"+ FilterDay d -> quote (tshow d)+ FilterNull -> quote "not set"+ FilterNotNull -> quote "set"++renderFilter :: Filter -> Text+renderFilter f =+ "["+ <> quote (filterField f)+ <> ","+ <> quote (renderFilterOperator (filterOperator f))+ <> ","+ <> renderFilterValue (filterValue f)+ <> "]"++renderFilters :: Text -> [Filter] -> Text+renderFilters prefix filters =+ let+ encoded = map renderFilter filters+ str = "[" <> intercalate ", " encoded <> "]"+ in+ prefix <> "=" <> urlEncode str
+ src/ERPNext/Client/Helper.hs view
@@ -0,0 +1,24 @@+{-# LANGUAGE OverloadedStrings #-}++module ERPNext.Client.Helper+ ( urlEncode+ , quote+ , tshow+ ) where++import Data.Text+import Data.Text qualified as T+import Network.URI (escapeURIString, isUnreserved)++urlEncode :: Text -> Text+urlEncode = pack . escapeURIString isUnreserved . unpack++sanitizeQuotes :: Text -> Text+sanitizeQuotes = T.filter (/= '"')++quote :: Text -> Text+quote t = "\"" <> sanitizeQuotes t <> "\""++tshow :: Show a => a -> Text+tshow = pack . show+
+ src/ERPNext/Client/QueryStringParams.hs view
@@ -0,0 +1,59 @@+{-# LANGUAGE OverloadedStrings #-}++module ERPNext.Client.QueryStringParams+ ( QueryStringParam (..)+ , renderQueryStringParams+ ) where++import ERPNext.Client.Filters+import ERPNext.Client.Helper (urlEncode, quote, tshow)+import Data.Text hiding (map)++-- https://docs.frappe.io/framework/user/en/api/rest++-- TODO: Maybe change type or make opaque type to prevent invalid combinations?+-- TODO: add variants for limit, offset, etc.?+data QueryStringParam+ = Debug Bool -- ^ if 'True', makes API returning query analysis info instead of data+ | AsDict Bool -- ^ if 'False', makes API returning the data records as mixed-type arrays which cannot be parsed by this library (default: 'True')+ | LimitStart Int -- ^ offset+ | LimitPageLength Int -- ^ page size+ | Asc Text+ | Desc Text+ | Fields [Text]+ | AndFilter [Filter]+ | OrFilter [Filter]++renderQueryStringParam :: QueryStringParam -> Text+renderQueryStringParam qsParam =+ case qsParam of+ Debug b -> "debug=" <> tshow b+ AsDict b -> "as_dict=" <> tshow b+ LimitStart offset -> "limit_start=" <> tshow offset+ LimitPageLength n -> "limit=" <> tshow n -- it was limit_page_length up to v13++ Asc field ->+ renderOrderBy field "asc"++ Desc field ->+ renderOrderBy field "desc"++ Fields fields ->+ renderFields fields++ AndFilter filters ->+ renderFilters "filters" filters++ OrFilter filters ->+ renderFilters "or_filters" filters++renderFields :: [Text] -> Text+renderFields fields =+ "fields=" <> urlEncode ("[" <> intercalate "," (map quote fields) <> "]")++renderOrderBy :: Text -> Text -> Text+renderOrderBy field order =+ "order_by=" <> urlEncode (field <> " " <> order)++renderQueryStringParams :: [QueryStringParam] -> Text+renderQueryStringParams params = intercalate "&" (map renderQueryStringParam params)