erpnext-api-client 0.1.0.1 → 0.2.0.0
raw patch · 6 files changed
+195/−81 lines, 6 filesdep +doctestdep +erpnext-api-clientdep +hedgehogdep ~basePVP ok
version bump matches the API change (PVP)
Dependencies added: doctest, erpnext-api-client, hedgehog, hspec, hspec-hedgehog
Dependency ranges changed: base
API changes (from Hackage documentation)
- ERPNext.Client.Filters: Filter :: Text -> FilterOperator -> FilterValue -> Filter
- ERPNext.Client.Filters: FilterList :: [FilterValue] -> FilterValue
- ERPNext.Client.Filters: FilterNotNull :: FilterValue
- ERPNext.Client.Filters: FilterNull :: FilterValue
- ERPNext.Client.Filters: Is :: FilterOperator
- ERPNext.Client.Filters: [filterField] :: Filter -> Text
- ERPNext.Client.Filters: [filterOperator] :: Filter -> FilterOperator
- ERPNext.Client.Filters: [filterValue] :: Filter -> FilterValue
- ERPNext.Client.Filters: data FilterOperator
- ERPNext.Client.Filters: instance GHC.Classes.Eq ERPNext.Client.Filters.FilterOperator
- ERPNext.Client.Filters: instance GHC.Show.Show ERPNext.Client.Filters.FilterOperator
+ ERPNext.Client.Filters: IsNotNull :: Fieldname -> Filter
+ ERPNext.Client.Filters: IsNull :: Fieldname -> Filter
+ ERPNext.Client.Filters: type Fieldname = Text
+ ERPNext.Client.Helper: type Fieldname = Text
+ ERPNext.Client.Helper: urlDecode :: Text -> Text
- ERPNext.Client: Asc :: Text -> QueryStringParam
+ ERPNext.Client: Asc :: Fieldname -> QueryStringParam
- ERPNext.Client: Desc :: Text -> QueryStringParam
+ ERPNext.Client: Desc :: Fieldname -> QueryStringParam
- ERPNext.Client: Fields :: [Text] -> QueryStringParam
+ ERPNext.Client: Fields :: [Fieldname] -> QueryStringParam
- ERPNext.Client.Filters: Between :: FilterOperator
+ ERPNext.Client.Filters: Between :: Fieldname -> FilterValue -> FilterValue -> Filter
- ERPNext.Client.Filters: Eq :: FilterOperator
+ ERPNext.Client.Filters: Eq :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: Greater :: FilterOperator
+ ERPNext.Client.Filters: Greater :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: GreaterOrEq :: FilterOperator
+ ERPNext.Client.Filters: GreaterOrEq :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: In :: FilterOperator
+ ERPNext.Client.Filters: In :: Fieldname -> [FilterValue] -> Filter
- ERPNext.Client.Filters: Less :: FilterOperator
+ ERPNext.Client.Filters: Less :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: LessOrEq :: FilterOperator
+ ERPNext.Client.Filters: LessOrEq :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: Like :: FilterOperator
+ ERPNext.Client.Filters: Like :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: NotEq :: FilterOperator
+ ERPNext.Client.Filters: NotEq :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: NotIn :: FilterOperator
+ ERPNext.Client.Filters: NotIn :: Fieldname -> [FilterValue] -> Filter
- ERPNext.Client.Filters: NotLike :: FilterOperator
+ ERPNext.Client.Filters: NotLike :: Fieldname -> FilterValue -> Filter
- ERPNext.Client.Filters: renderFilters :: Text -> [Filter] -> Text
+ ERPNext.Client.Filters: renderFilters :: [Filter] -> Text
- ERPNext.Client.QueryStringParams: Asc :: Text -> QueryStringParam
+ ERPNext.Client.QueryStringParams: Asc :: Fieldname -> QueryStringParam
- ERPNext.Client.QueryStringParams: Desc :: Text -> QueryStringParam
+ ERPNext.Client.QueryStringParams: Desc :: Fieldname -> QueryStringParam
- ERPNext.Client.QueryStringParams: Fields :: [Text] -> QueryStringParam
+ ERPNext.Client.QueryStringParams: Fields :: [Fieldname] -> QueryStringParam
Files
- CHANGELOG.md +16/−0
- erpnext-api-client.cabal +35/−2
- src/ERPNext/Client/Filters.hs +59/−58
- src/ERPNext/Client/Helper.hs +12/−5
- src/ERPNext/Client/QueryStringParams.hs +24/−16
- test/Main.hs +49/−0
CHANGELOG.md view
@@ -3,3 +3,19 @@ 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/).++## [0.2.0.0] - 2025-04-12++### Changed++- Refactored the ERPNext.Client.Filters module.++### Fixed++- Fix percent-encoding.+- Fix encoding of double quotes in filters and query string.++## [0.1.0.1]+## [0.1.0.0]++Initial release.
erpnext-api-client.cabal view
@@ -1,13 +1,13 @@ cabal-version: 3.6 name: erpnext-api-client-version: 0.1.0.1+version: 0.2.0.0 synopsis: Generic API client library for ERPNext description: 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. homepage: https://github.com/schoettl/erpnext-api-client license: MIT license-file: LICENSE-author: Jakob Schöttl+author: Jakob Schöttl, Omar Elbeltagui maintainer: jakob.schoettl@intensovet.de copyright: 2025 Intensovet GmbH category: API@@ -29,6 +29,39 @@ , 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++test-suite tests+ type: exitcode-stdio-1.0+ main-is: Main.hs+ other-modules: ERPNext.Client+ , ERPNext.Client.Helper+ , ERPNext.Client.Filters+ , ERPNext.Client.QueryStringParams+ hs-source-dirs: test+ , src+ build-depends: base+ , erpnext-api-client+ , http-client+ , http-types+ , text+ , aeson+ , time+ , network-uri+ , bytestring+ , doctest >= 0.22.2 && < 1+ , hedgehog >= 1.4 && < 1.6+ , hspec >= 2.11.7 && < 2.12+ , hspec-hedgehog >= 0.1.0 default-language: GHC2021 ghc-options: -Wall -Wcompat
src/ERPNext/Client/Filters.hs view
@@ -2,90 +2,91 @@ module ERPNext.Client.Filters ( Filter (..)- , FilterOperator (..)+ , Fieldname , FilterValue (..) , renderFilters ) where -import Data.Text (Text, intercalate)+import Data.Text (Text, intercalate, pack) -- pack is used in doctests! import Data.Time.Calendar (Day)-import ERPNext.Client.Helper (urlEncode, quote, tshow)+import ERPNext.Client.Helper --- 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+data Filter+ = Eq Fieldname FilterValue+ | NotEq Fieldname FilterValue+ | Greater Fieldname FilterValue+ | GreaterOrEq Fieldname FilterValue+ | Less Fieldname FilterValue+ | LessOrEq Fieldname FilterValue+ | Like Fieldname FilterValue+ | NotLike Fieldname FilterValue+ | In Fieldname [FilterValue]+ | NotIn Fieldname [FilterValue]+ | Between Fieldname FilterValue FilterValue+ | IsNull Fieldname+ | IsNotNull Fieldname 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)+renderFilter :: Filter -> Text+renderFilter f =+ case f of+ Eq field val -> renderWithValue field "=" val+ NotEq field val -> renderWithValue field "!=" val+ Greater field val -> renderWithValue field ">" val+ GreaterOrEq field val-> renderWithValue field ">=" val+ Less field val -> renderWithValue field "<" val+ LessOrEq field val -> renderWithValue field "<=" val+ Like field val -> renderWithValue field "like" val+ NotLike field val -> renderWithValue field "not like" val+ In field vals -> renderWithArray field "in" vals+ NotIn field vals -> renderWithArray field "not in" vals+ Between field val1 val2 -> renderWithArray field "between" [val1, val2]+ IsNull field -> renderWithText field "is" "not set"+ IsNotNull field -> renderWithText field "is" "set" -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"+renderWithValue :: Fieldname -> Text -> FilterValue -> Text+renderWithValue field op val =+ renderTextArray [quote field, quote op, renderFilterValue val] +renderWithText :: Fieldname -> Text -> Text -> Text+renderWithText field op txt =+ renderTextArray [quote field, quote op, quote txt]++renderWithArray :: Fieldname -> Text -> [FilterValue] -> Text+renderWithArray field op vals =+ renderTextArray [quote field, quote op, renderTextArray (map renderFilterValue vals)]++renderTextArray :: [Text] -> Text+renderTextArray xs =+ "[" <> intercalate "," xs <> "]"+ 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)- <> "]"- -- | Render the filter terms for the URL query string.-renderFilters :: Text -> [Filter] -> Text-renderFilters prefix filters =+--+-- >>> urlDecode $ renderFilters [Eq (pack "idx") (FilterNumber 99)]+-- "[[\"idx\",\"=\",99.0]]"+--+-- >>> urlDecode $ renderFilters [IsNull (pack "status"), In (pack "name") [FilterText (pack "a"), FilterText (pack "b")]]+-- "[[\"status\",\"is\",\"not set\"],[\"name\",\"in\",[\"a\",\"b\"]]]"+--+renderFilters :: [Filter] -> Text+renderFilters filters = let encoded = map renderFilter filters- str = "[" <> intercalate ", " encoded <> "]"+ str = "[" <> intercalate "," encoded <> "]" in- prefix <> "=" <> urlEncode str+ urlEncode str
src/ERPNext/Client/Helper.hs view
@@ -2,23 +2,30 @@ module ERPNext.Client.Helper ( urlEncode+ , urlDecode , quote , tshow+ , Fieldname ) where import Data.Text-import Data.Text qualified as T-import Network.URI (escapeURIString, isUnreserved)+import Network.URI (escapeURIString, isUnreserved, unEscapeString) +-- | Type for field names of DocTypes.+type Fieldname = Text+ -- | Percent-encode string for use in a URL. urlEncode :: Text -> Text urlEncode = pack . escapeURIString isUnreserved . unpack +-- | Opposite of 'urlEncode'.+urlDecode :: Text -> Text+urlDecode = pack . unEscapeString . unpack+ sanitizeQuotes :: Text -> Text-sanitizeQuotes = T.filter (/= '"')+sanitizeQuotes = replace "\"" "\\\"" --- TODO: is it a good idea to just drop "?--- | Double-quote string after dropping existing double quotes.+-- | Double-quote string after backslash-escaping existing double quotes. quote :: Text -> Text quote t = "\"" <> sanitizeQuotes t <> "\""
src/ERPNext/Client/QueryStringParams.hs view
@@ -6,21 +6,19 @@ ) where import ERPNext.Client.Filters-import ERPNext.Client.Helper (urlEncode, quote, tshow)+import ERPNext.Client.Helper 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 -- ^ Page offset (starts at 0) | LimitPageLength Int -- ^ Page size- | Asc Text -- ^ Ascending order by given field- | Desc Text -- ^ Descending order by given field- | Fields [Text] -- ^ Select fields+ | Asc Fieldname -- ^ Ascending order by given field+ | Desc Fieldname -- ^ Descending order by given field+ | Fields [Fieldname] -- ^ Select fields | AndFilter [Filter] -- ^ Filter terms combined with logical AND | OrFilter [Filter] -- ^ Filter terms combined with logical OR @@ -30,31 +28,41 @@ Debug b -> "debug=" <> tshow b AsDict b -> "as_dict=" <> tshow b LimitStart offset -> "limit_start=" <> tshow (max 0 offset)- LimitPageLength n -> "limit=" <> tshow n -- it was limit_page_length up to v13+ LimitPageLength n -> "limit=" <> tshow (max 0 n) -- it was named limit_page_length up to v13 Asc field ->- renderOrderBy field "asc"+ "order_by=" <> renderOrderBy field "asc" Desc field ->- renderOrderBy field "desc"+ "order_by=" <> renderOrderBy field "desc" Fields fields ->- renderFields fields+ "fields=" <> renderFields fields AndFilter filters ->- renderFilters "filters" filters+ "filters=" <> renderFilters filters OrFilter filters ->- renderFilters "or_filters" filters+ "or_filters=" <> renderFilters filters -renderFields :: [Text] -> Text+renderFields :: [Fieldname] -> Text renderFields fields =- "fields=" <> urlEncode ("[" <> intercalate "," (map quote fields) <> "]")+ urlEncode ("[" <> intercalate "," (map quote fields) <> "]") -renderOrderBy :: Text -> Text -> Text+renderOrderBy :: Fieldname -> Text -> Text renderOrderBy field order =- "order_by=" <> urlEncode (field <> " " <> order)+ urlEncode (field <> " " <> order) -- | Render the query string for the URL.+--+-- >>> renderQueryStringParams []+-- ""+--+-- >>> renderQueryStringParams [LimitStart 20, LimitPageLength 10]+-- "limit_start=20&limit=10"+--+-- >>> urlDecode $ renderQueryStringParams [Fields [pack "id", pack "name"], Asc (pack "idx")]+-- "fields=[\"id\",\"name\"]&order_by=idx asc"+-- renderQueryStringParams :: [QueryStringParam] -> Text renderQueryStringParams params = intercalate "&" (map renderQueryStringParam params)
+ test/Main.hs view
@@ -0,0 +1,49 @@+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++import ERPNext.Client.Helper+import ERPNext.Client.QueryStringParams+import Test.DocTest+import Data.Text+import Data.Text qualified as T+import Hedgehog.Gen qualified as Gen+import Hedgehog.Range qualified as Range+import Test.Hspec (describe, hspec, it, shouldBe)+import Test.Hspec.Hedgehog (forAll, hedgehog, (===))++main :: IO ()+main = do+ doctest ["src/"]+ hspec $ do+ describe "ERPNext.Client.QueryStringParams" $ do+ describe "renderQueryStringParams" $ do+ it "escapes quote and comma" $ do+ renderQueryStringParams [Asc "\","] `shouldBe` "order_by=%22%2C%20asc"+ it "escapes even brackets" $ do+ renderQueryStringParams [Fields []] `shouldBe` "fields=%5B%5D"+ describe "ERPNext.Client.Helper" $ do+ describe "quote" $ do+ it "is always surrounded by double quotes" $ hedgehog $ do+ t <- forAll $ Gen.text (Range.linear 0 100) Gen.unicode+ let quoted = quote t+ ("\"" `isPrefixOf` quoted) === True+ ("\"" `isSuffixOf` quoted) === True+ it "never returns something with double quotes inside" $ do+ quote "\"end" `shouldBe` "\"\\\"end\""+ quote "Mutter 2,5\"" `shouldBe` "\"Mutter 2,5\\\"\""+ describe "Internal helper functions" $ do+ describe "removeFirstLastChar" $ do+ it "seems to work" $ do+ removeFirstLastChar "abcd" `shouldBe` "bc"+ removeFirstLastChar "abc" `shouldBe` "b"+ removeFirstLastChar "ab" `shouldBe` ""+ removeFirstLastChar "a" `shouldBe` ""+ removeFirstLastChar "" `shouldBe` ""+ it "strips two characters" $ hedgehog $ do+ t <- forAll $ Gen.text (Range.linear 2 100) Gen.unicode+ let result = removeFirstLastChar t+ T.length result === T.length t - 2++removeFirstLastChar :: Text -> Text+removeFirstLastChar = T.reverse . T.drop 1 . T.reverse . T.drop 1