packages feed

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 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)