erpnext-api-client
This is a Haskell API client for
ERPNext. It aims to be a
light-weight library based on
http-client and
user-provided record types.
ERPNext has the concept of
DocTypes which
model entities like Customer, Sales Order, etc.
The ERPNext REST
API 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 of a DocType (getDocList)
POST to create a new document (postDoc)
GET a document by name (getDoc)
PUT to update a document by name (putDoc)
DELETE a document by name (deleteDoc)
Note: Remote method calls and file uploads are not yet supported.
In ERPNext, DocTypes 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
This sample code makes a GET request for a named document of the given
DocType.
{-# LANGUAGE OverloadedStrings #-}
import ERPNext.Client
import System.Environment
import Data.Text (pack)
import Network.HTTP.Client.TLS
import Data.Aeson
import GHC.Generics
data Customer = Customer
{ name :: String
} deriving Generic
instance FromJSON Customer
instance IsDocType Customer where
docTypeName = "Customer"
docName = error "not implemented"
main :: IO ()
main = do
url <- pack <$> getEnv "ERPNEXT_BASE_URL" -- e.g. "https://my-erpnext.frappe.cloud/api"
apiKey <- pack <$> getEnv "ERPNEXT_API_KEY"
apiSecret <- mkSecret . pack <$> getEnv "ERPNEXT_API_SECRET"
let config = mkConfig url apiKey apiSecret
manager <- newTlsManager
response <- ERPNext.Client.getDoc manager config "Company A" :: IO (ApiResponse Customer)
case response of
Ok _ _ c -> putStrLn $ name c
Err r _ -> putStrLn $ show r
For running the example, define these variables in a .env file:
export ERPNEXT_BASE_URL=https://my-test.frappe.cloud/api
export ERPNEXT_API_KEY=xxxxxx
export ERPNEXT_API_SECRET=yyyyyy
Then, inside this repository directory you can run:
. .env
stack runhaskell --package http-client-tls example1.hs
Interactive testing in GHCi
. .env
stack ghci --package http-client-tls
{-# LANGUAGE OverloadedStrings #-}
import ERPNext.Client qualified as Client
import ERPNext.Client.Simple qualified as Simple
import ERPNext.Client.QueryStringParam
import ERPNext.Client.Filter
import Network.HTTP.Client.TLS
import System.Environment
import Data.Aeson
import GHC.Generics
url <- pack <$> getEnv "ERPNEXT_BASE_URL"
apiKey <- pack <$> getEnv "ERPNEXT_API_KEY"
apiSecret <- mkSecret . pack <$> getEnv "ERPNEXT_API_SECRET"
let config = mkConfig url apiKey apiSecret
manager <- newTlsManager
-- Explicitly name the doctype to fetch, "DocType" in this example:
Ok _ jsonResponse _ <- Simple.getDoc manager config "DocType" "Task"
putStrLn $ showJsonPretty jsonResponse
-- Define a DocType. Use :{ and :} in GHCi to paste multi-line Haskell code.
data Customer = Customer
{ name :: Text
, customer_name :: Text
} deriving (Show, Generic)
instance FromJSON Customer
instance IsDocType Customer where
docTypeName = "Customer"
docName = name
-- Get a customer, infer doctype via type system:
Ok _ jsonResponse parsedCustomer <- Client.getDoc @Customer manager config "Company A"
print parsedCustomer
print $ docName parsedCustomer
-- Get a filtered list of customers, infer doctype via type system:
Ok _ jsonResponse parsedCustomerList <- Client.getDocList @Customer manager config [Fields ["name", "customer_name"], AndFilter [Like "name" "%Test%"]]
print parsedCustomerList
Advanced example use
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE DuplicateRecordFields #-}
import ERPNext.Client
import ERPNext.Client.Filter as Filter
import ERPNext.Client.QueryStringParam
import System.Environment
import Data.Text (Text, pack, unpack, intercalate)
import Network.HTTP.Client.TLS
import Data.Aeson
import GHC.Generics
data Customer = Customer
{ name :: Text
, customer_name :: Text
} deriving Generic
instance FromJSON Customer
instance IsDocType Customer where
docTypeName = "Customer"
docName = error "not implemented"
data SalesOrder = SalesOrder
{ name :: Text
, customer :: Text
} deriving Generic
instance FromJSON SalesOrder
instance IsDocType SalesOrder where
docTypeName = "Sales Order"
docName = error "not implemented"
main :: IO ()
main = do
url <- pack <$> getEnv "ERPNEXT_BASE_URL" -- e.g. "https://my-erpnext.frappe.cloud/api"
apiKey <- pack <$> getEnv "ERPNEXT_API_KEY"
apiSecret <- mkSecret . pack <$> getEnv "ERPNEXT_API_SECRET"
let cfg = mkConfig url apiKey apiSecret
mgr <- newTlsManager
-- Get all customers with given customer name:
customersRes <- getDocList @Customer mgr cfg [Fields ["name", "customer_name"], AndFilter [Filter.Eq "customer_name" "Test Customer"]]
case customersRes of
Ok _ _ customers -> putStrLn $ show (length customers) ++ " customer(s)"
err -> putStrLn $ "error: \n" ++ showJsonResponsePretty err
-- Get customer linked to sales order:
let andThenWithCustomer = andThenWith (\o -> o.customer)
(salesOrderRes, mCustomerRes) <- getDoc @SalesOrder mgr cfg "SAL-ORD-2026-00002"
`andThenWithCustomer` getDoc @Customer mgr cfg
case (salesOrderRes, mCustomerRes) of
(Ok _ _ salesOrder, Just (Ok _ _ customer)) -> putStrLn $ unpack salesOrder.name ++ ": " ++ unpack customer.customer_name
(x, my) -> putStrLn $ "error: \n" ++ showJsonResponsePretty x ++ "\n" ++ maybe "" showJsonResponsePretty my
res <- getAllFieldnames @Customer mgr cfg
case res of
Ok _ _ fieldnames -> putStrLn $ unpack $ intercalate ", " (take 5 fieldnames) <> ", ..."
err -> putStrLn $ "error: \n" ++ showJsonResponsePretty res
. .env
stack runhaskell --package http-client-tls example2.hs
Scope and Limits
Data records for DocTypes
Haskell record types for the DocTypes can be coded by hand which
requires some boilerplate 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.
- A script generates an OpenAPI
Specification file in
yaml
format from your models file.
- The
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
a dependency. The resulting record types can be used together with this
API client but the IsDocType instance must still be defined by hand.
Note: The API client part generated from the OpenAPI spec can not be
used.
Example models file:
SalesOrder
name Text
total Double
transaction_date Text
items [SalesOrderItem]
Required name
SalesOrderItem
name Text
Required name
Item
item_code Text
item_name Text
item_group Text
default_warehouse Text
country_of_origin Text
disabled Int
is_purchase_item Int
is_sales_item Int
is_stock_item Int
stock_uom Text
Required item_code item_name is_purchase_item is_sales_item is_stock_item
$ ./scripts/gen-openapi-yaml.sh 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
│ │ │ ├── SalesOrder.hs
│ │ │ ├── SalesOrderItem.hs
│ │ │ ├── Item.hs
│ │ └── Types.hs
│ └── ERPNextAPI.hs
└── stack.yaml
To include the generated model types in your stack project:
Add to your stack.yaml:
extra-deps:
- …
- ./api-client/
In your package.yaml:
dependencies:
- …
- erpnext-api-client-models
In your Haskell code:
import ERPNext.Client -- the erpnext-api-client
import ERPNextAPI.Types -- the generated types
…
-- And here some orphan instances:
instance IsDocType SalesOrder where
docTypeName = "Sales Order"
instance IsDocType Customer where
docTypeName = "Customer"
Notes on Maybe and Nullable
In the DocType definition model,
-
Maybe becomes Nullable meaning that the field value can be null
(or Null in Haskell).
-
Fields not listed after Required become Maybe meaning that the
fields are optional and Nothing will be left out in the JSON
representation.
Type level:
Project
name Text
project_name Text
status Text
project_type Text Maybe
Required name project_name
data Project = Project
{ projectName :: Text
, projectProject_name :: Text
, projectStatus :: Maybe Text
, projectProject_type :: Maybe (Nullable Text)
} deriving (Show, Eq)
data ProjectPartial = ProjectPartial
{ projectPartialName :: Maybe Text
, projectPartialProject_name :: Maybe Text
, projectPartialStatus :: Maybe Text
, projectPartialProject_type :: Maybe (Nullable Text)
} deriving (Show, Eq)
For each DocType, two types are generated, e.g. Project and
ProjectPartial. The *Partial version has no required fields i.e. all
fields are Maybe and are left out when assigned to Nothing.
Value level:
Project {
{ projectName = "PROJ-001" "name": "PROJ-001",
, projectProject_name = "My Project" => "project_name": "My Project",
, projectStatus = Nothing
, projectProject_type = Just Null "project_type": null
} }
mkProjectPartial {
{ projectProject_name = Just "My Project 2" => "project_name": "My Project"
} }
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:
import Network.HTTP.Client.TLS (mkManagerSettings)
import Network.Connection (TLSSettings (..))
…
let tlsSettings =
mkManagerSettings
( TLSSettingsSimple
{ settingDisableCertificateValidation = True
}
)
Nothing
manager <- Network.HTTP.Client.newManager tlsSettings
…
Edit the example code from the first section accordingly and run it
with:
stack runhaskell --package crypton-connection --package http-client-tls example1.hs