# 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 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.
``` haskell
{-# 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:
``` bash
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:
``` bash
. .env
stack runhaskell --package http-client-tls example1.hs
```
## Interactive testing in GHCi
``` example
. .env
stack ghci --package http-client-tls
```
``` haskell
{-# 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
``` haskell
{-# 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
```
``` bash
. .env
stack runhaskell --package http-client-tls example2.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 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](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 from your models file.
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
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:
``` plain
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
```
``` example
$ ./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`:
``` example
extra-deps:
- …
- ./api-client/
```
In your `package.yaml`:
``` example
dependencies:
- …
- erpnext-api-client-models
```
In your Haskell code:
``` haskell
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:
``` example
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:
``` example
Project {
{ projectName = "PROJ-001" "name": "PROJ-001",
, projectProject_name = "My Project" => "project_name": "My Project",
, projectStatus = Nothing
, projectProject_type = Just Null "project_type": null
} }
```
``` example
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:
``` haskell
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:
``` bash
stack runhaskell --package crypton-connection --package http-client-tls example1.hs
```