packages feed

erpnext-api-client-0.3.0.0: README.md

# 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
```