From 07349e22e6b5c31161c07158350209dc5c8a8c19 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 29 May 2025 12:50:51 -0400 Subject: [PATCH] prototype v1 api --- hledger-web/Hledger/Web/Application.hs | 1 + hledger-web/Hledger/Web/Handler/ApiR.hs | 306 ++++++++++++++++++++++++ hledger-web/api.http | 28 +++ hledger-web/config/openapi-v1.yaml | 264 ++++++++++++++++++++ hledger-web/config/routes | 15 ++ hledger-web/hledger-web.cabal | 1 + 6 files changed, 615 insertions(+) create mode 100644 hledger-web/Hledger/Web/Handler/ApiR.hs create mode 100644 hledger-web/api.http create mode 100644 hledger-web/config/openapi-v1.yaml diff --git a/hledger-web/Hledger/Web/Application.hs b/hledger-web/Hledger/Web/Application.hs index 037de01f1bb..46845a6fcea 100644 --- a/hledger-web/Hledger/Web/Application.hs +++ b/hledger-web/Hledger/Web/Application.hs @@ -23,6 +23,7 @@ import Yesod.Default.Config import Hledger.Data (Journal, nulljournal) import Hledger.Web.Handler.AddR +import Hledger.Web.Handler.ApiR import Hledger.Web.Handler.MiscR import Hledger.Web.Handler.EditR import Hledger.Web.Handler.UploadR diff --git a/hledger-web/Hledger/Web/Handler/ApiR.hs b/hledger-web/Hledger/Web/Handler/ApiR.hs new file mode 100644 index 00000000000..ef2987d9cd1 --- /dev/null +++ b/hledger-web/Hledger/Web/Handler/ApiR.hs @@ -0,0 +1,306 @@ +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Hledger.Web.Handler.ApiR where + +import Data.Aeson +import Network.HTTP.Types.Status +import qualified Data.Yaml as Yaml +import qualified Data.ByteString as BS +import Data.FileEmbed (embedFile) + +import Hledger +import Hledger.Cli.CliOptions +import Hledger.Web.Import +import Hledger.Web.WebOptions + +openApiYaml :: BS.ByteString +openApiYaml = $(embedFile "config/openapi-v1.yaml") + +getOpenApiV1R :: Handler Value +getOpenApiV1R = + case Yaml.decodeEither' openApiYaml of + Left _ -> notFound + Right openapi -> do + addHeader "Content-Type" "application/json" + return openapi + +-- | GET /api/v1/accounts +getApiAccountsR :: Handler Value +getApiAccountsR = do + VD{j} <- getViewData + require ViewPermission + let accts = journalAccountNames j + returnJson accts + +-- | GET /api/v1/accounts/{account_name} +getApiAccountR :: Text -> Handler Value +getApiAccountR acctName = do + VD{j} <- getViewData + require ViewPermission + let accts = journalAccountNames j + if acctName `elem` accts + then returnJson $ object ["account" .= acctName, "exists" .= True] + else sendResponseStatus status404 ("account not found" :: Text) + +getSimpleAccountBalance :: Journal -> AccountName -> Value +getSimpleAccountBalance j acctName = + let acctq = Acct $ accountNameToAccountRegex acctName + rspec = defreportspec{_rsQuery = acctq} + (balanceItems, _total) = balanceReport rspec j + in case balanceItems of + [] -> object + [ "name" .= acctName + , "balance" .= ("0" :: Text) + , "commodity" .= ("" :: Text) + , "depth" .= (0 :: Int) + ] + ((fullName, _, depth_, mixedAmt):_) -> + let (commodity, amount) = case amounts mixedAmt of + [] -> ("", "0") + (amt:_) -> (acommodity amt, showAmount amt) + in object + [ "name" .= fullName + , "balance" .= amount + , "commodity" .= commodity + , "depth" .= depth_ + ] + +-- | GET /api/v1/accounts/{account_name}/balance +getApiAccountBalanceR :: Text -> Handler Value +getApiAccountBalanceR acctName = do + VD{j} <- getViewData + require ViewPermission + returnJson $ getSimpleAccountBalance j acctName + + +-- | GET /api/v1/balance +getApiBalanceReportR :: Handler Value +getApiBalanceReportR = do + VD{j, q, opts} <- getViewData + require ViewPermission + let rspec = (reportspec_ $ cliopts_ opts){_rsQuery = q} + (balanceItems, total) = balanceReport rspec j + styledItems = styleAmounts (journalCommodityStylesWith HardRounding j) balanceItems + styledTotal = styleAmounts (journalCommodityStylesWith HardRounding j) total + + accountsJson = map itemToJson styledItems + totalAmount = case amounts styledTotal of + [] -> "0" + (amt:_) -> showAmount amt + + returnJson $ object + [ "accounts" .= accountsJson + , "total" .= totalAmount + ] + where + itemToJson (fullName, _, depth_, mixedAmt) = + let (commodity, amount) = case amounts mixedAmt of + [] -> ("", "0") + (amt:_) -> (acommodity amt, showAmount amt) + in object + [ "name" .= fullName + , "balance" .= amount + , "commodity" .= commodity + , "depth" .= depth_ + ] + +-- | GET /api/v1/transactions +getApiTransactionsR :: Handler Value +getApiTransactionsR = do + VD{j, q, opts} <- getViewData + require ViewPermission + let rspec = (reportspec_ $ cliopts_ opts){_rsQuery = q} + items = entriesReport rspec j + styledItems = styleAmounts (journalCommodityStylesWith HardRounding j) items + returnJson $ map transactionToJson styledItems + where + transactionToJson txn = + let postingsJson = map postingToJson (tpostings txn) + status = case tstatus txn of + -- don't use Show because it renders as !/* + Unmarked -> "unmarked" + Pending -> "pending" + Cleared -> "cleared" + in object + [ "date" .= tdate txn + , "description" .= tdescription txn + , "postings" .= postingsJson + , "status" .= (status :: String) + ] + + postingToJson posting_ = + let amount = case pamount posting_ of + mixedAmt -> case amounts mixedAmt of + [] -> "0" + (amt:_) -> showAmount amt + in object + [ "account" .= paccount posting_ + , "amount" .= amount + ] + +-- | GET /api/v1/register +getApiRegisterReportR :: Handler Value +getApiRegisterReportR = do + VD{j, q} <- getViewData + require ViewPermission + let transactions = jtxns j + filteredTxns = filter (matchesTransaction q) transactions + styledTxns = styleAmounts (journalCommodityStylesWith HardRounding j) filteredTxns + + (txnItems, finalBalance) = buildRegisterItems styledTxns + + returnJson $ object + [ "final_balance" .= mixedAmountToJson finalBalance + , "transactions" .= txnItems + ] + where + buildRegisterItems txns = + let (items, balance) = foldl' collectTransaction ([], nullmixedamt) txns + in (reverse items, balance) + + collectTransaction (acc, runningBal) txn = + let newBal = runningBal + sumPostings (tpostings txn) + item = object + [ "date" .= tdate txn + , "description" .= tdescription txn + , "postings" .= map postingToJson (tpostings txn) + , "status" .= statusToText (tstatus txn) + , "running_balance" .= mixedAmountToJson newBal + ] + in (item:acc, newBal) + + postingToJson p = object + [ "account" .= paccount p + , "amount" .= mixedAmountToJson (pamount p) + ] + + mixedAmountToJson ma = toJSON $ map amountToJson $ amounts ma + + amountToJson amt = object $ + [ "commodity" .= acommodity amt + , "quantity" .= + showAmountWith defaultFmt{displayCost=False, displayCommodity=False} amt + ] ++ costField + where + costField = case acost amt of + Nothing -> [] + Just cost -> ["cost_basis" .= show cost] + + statusToText :: Hledger.Status -> String + statusToText Unmarked = "unmarked" + statusToText Pending = "pending" + statusToText Cleared = "cleared" + +-- let's not worry about adding/updating transactions yet. i'm not even sure how +-- that would work tbh, i'd need to look at how its implemented in the webapp +-- side +-- +-- -- | POST /api/v1/transactions +-- postApiTransactionsR :: Handler Value +-- postApiTransactionsR = do +-- VD{j, opts} <- getViewData +-- require AddPermission +-- (r :: Result Transaction) <- parseCheckJsonBody +-- case r of +-- Error err -> sendResponseStatus status400 ("could not parse transaction: " <> T.pack err) +-- Success t -> do +-- result <- liftIO $ journalAddTransaction j (cliopts_ opts) t +-- case result of +-- Left err -> sendResponseStatus status400 (T.pack $ show err) +-- Right _ -> sendResponseStatus status201 ("transaction added" :: Text) +-- +-- -- | GET /api/v1/transactions/{txn_id} +-- getApiTransactionR :: Text -> Handler Value +-- getApiTransactionR txnId = do +-- VD{j} <- getViewData +-- require ViewPermission +-- -- naive implementation - find by description match +-- let txns = jtxns j +-- matchingTxns = filter (\t -> T.isInfixOf txnId (tdescription t)) txns +-- case matchingTxns of +-- [t] -> returnJson t +-- [] -> sendResponseStatus status404 ("transaction not found" :: Text) +-- _ -> sendResponseStatus status400 ("ambiguous transaction id" :: Text) +-- +-- -- | PUT /api/v1/transactions/{txn_id} +-- putApiTransactionR :: Text -> Handler Value +-- putApiTransactionR _ = do +-- require EditPermission +-- -- simplified - would need proper txn replacement logic +-- sendResponseStatus status501 ("transaction updates not implemented" :: Text) +-- +-- -- | DELETE /api/v1/transactions/{txn_id} +-- deleteApiTransactionR :: Text -> Handler Value +-- deleteApiTransactionR _ = do +-- require EditPermission +-- sendResponseStatus status501 ("transaction deletion not implemented" :: Text) +-- +-- -- | GET /api/v1/register +-- getApiRegisterReportR :: Handler Value +-- getApiRegisterReportR = do +-- VD{j, q, opts} <- getViewData +-- require ViewPermission +-- let rspec = (reportspec_ $ cliopts_ opts){_rsQuery = q} +-- transactions = jtxns j -- get all transactions +-- filteredTxns = filter (matchesTransaction q) transactions +-- styledTxns = styleAmounts (journalCommodityStylesWith HardRounding j) filteredTxns +-- +-- (txnItems, finalBalance) = buildRegisterItems styledTxns +-- +-- returnJson $ object +-- [ "final_balance" .= showMixedAmount finalBalance +-- , "transactions" .= txnItems +-- ] +-- where +-- buildRegisterItems txns = +-- let (items, balance) = foldl' addTransaction ([], nullmixedamt) txns +-- in (reverse items, balance) +-- +-- addTransaction (acc, runningBal) txn = +-- let newBal = runningBal + sumPostings (tpostings txn) +-- newBalStr = showMixedAmount newBal +-- item = object +-- [ "date" .= tdate txn +-- , "description" .= tdescription txn +-- , "postings" .= map postingToJson (tpostings txn) +-- , "status" .= statusToText (tstatus txn) +-- , "running_balance" .= newBalStr +-- ] +-- in (item:acc, newBal) +-- +-- postingToJson p = object +-- [ "account" .= paccount p +-- , "amount" .= showMixedAmount (pamount p) +-- ] +-- +-- statusToText :: Hledger.Status -> String +-- statusToText Unmarked = "unmarked" +-- statusToText Pending = "pending" +-- statusToText Cleared = "cleared" +-- +-- do i want these? i never use them at the cli, and they are implemented in +-- Hledger.Cli anyway, not in the hledger-lib package +-- +-- -- | GET /api/v1/income-statement +-- getApiIncomeStatementR :: Handler Value +-- getApiIncomeStatementR = do +-- VD{j, q, opts} <- getViewData +-- require ViewPermission +-- let rspec = (reportspec_ $ cliopts_ opts){_rsQuery = q} +-- is = incomeStatement rspec j +-- returnJson $ +-- styleAmounts (journalCommodityStylesWith HardRounding j) is +-- +-- -- | GET /api/v1/balance-sheet +-- getApiBalanceSheetR :: Handler Value +-- getApiBalanceSheetR = do +-- VD{j, q, opts} <- getViewData +-- require ViewPermission +-- let rspec = (reportspec_ $ cliopts_ opts){_rsQuery = q} +-- bs = balanceSheet rspec j +-- returnJson $ +-- styleAmounts (journalCommodityStylesWith HardRounding j) bs diff --git a/hledger-web/api.http b/hledger-web/api.http new file mode 100644 index 00000000000..30430e5fbb5 --- /dev/null +++ b/hledger-web/api.http @@ -0,0 +1,28 @@ +# -*- restclient -*- +# + +:host = http://localhost:5000 +:api = :host/api/v1 + +# spec +GET :api/openapi.json + +# Get account names +GET :api/accounts + +# how much i spent on coffee +GET :api/accounts/ex:us:want:coffee/balance + +# Get one account balance. This fails bc `coffee` matches literally on the whole +# account name rather than a portion like the cli does. +GET :api/accounts/coffee/balance + +# get some txs +GET :api/transactions + + +# get a full balance report +GET :api/balance + +# get a register +GET :api/register diff --git a/hledger-web/config/openapi-v1.yaml b/hledger-web/config/openapi-v1.yaml new file mode 100644 index 00000000000..d1d00896a8c --- /dev/null +++ b/hledger-web/config/openapi-v1.yaml @@ -0,0 +1,264 @@ +# this was generated by claud sonnet 4 by pasting the ApiR.hs file into the chat +# and asking it to create this spec +openapi: 3.0.3 + +info: + title: hledger-web json api + description: > + rest api for hledger plaintext accounting data. provides access to accounts, + transactions, and financial reports with structured json responses. + version: 1.0.0 + +servers: + - url: http://localhost:5000/api/v1 + description: local hledger-web server + +paths: + /accounts: + get: + summary: list all accounts + description: returns array of account names from the journal + operationId: getAccounts + responses: + '200': + description: list of account names + content: + application/json: + schema: + type: array + items: + type: string + example: ["assets:checking", "expenses:food", "income:salary"] + + /accounts/{account_name}: + get: + summary: check if account exists + description: verifies whether the specified account exists in the journal + operationId: getAccount + parameters: + - name: account_name + in: path + required: true + schema: + type: string + example: "assets:checking" + responses: + '200': + description: account exists + content: + application/json: + schema: + type: object + properties: + account: + type: string + exists: + type: boolean + example: + account: "assets:checking" + exists: true + '404': + description: account not found + + /accounts/{account_name}/balance: + get: + summary: get account balance + description: returns the current balance for the specified account + operationId: getAccountBalance + parameters: + - name: account_name + in: path + required: true + schema: + type: string + responses: + '200': + description: account balance information + content: + application/json: + schema: + $ref: '#/components/schemas/AccountBalance' + + # hide these for now because they blow up the llm context window, i need to + # add pagination and filter params first + # + # /balance: + # get: + # summary: balance report + # description: generates a balance report showing account balances + # operationId: getBalanceReport + # responses: + # '200': + # description: balance report with accounts and total + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/BalanceReport' + + # /transactions: + # get: + # summary: list transactions + # description: returns all transactions with postings + # operationId: getTransactions + # responses: + # '200': + # description: array of transactions + # content: + # application/json: + # schema: + # type: array + # items: + # $ref: '#/components/schemas/Transaction' + + # /register: + # get: + # summary: register report + # description: chronological transaction register with running balances + # operationId: getRegisterReport + # responses: + # '200': + # description: register report with final balance and transaction list + # content: + # application/json: + # schema: + # $ref: '#/components/schemas/RegisterReport' + +components: + schemas: + AccountBalance: + type: object + properties: + name: + type: string + description: account name + balance: + type: string + description: account balance amount + commodity: + type: string + description: currency or commodity symbol + depth: + type: integer + description: account hierarchy depth + example: + name: "assets:checking" + balance: "1234.56" + commodity: "USD" + depth: 2 + + BalanceReport: + type: object + properties: + accounts: + type: array + items: + $ref: '#/components/schemas/AccountBalance' + total: + type: string + description: total of all account balances + example: + accounts: + - name: "assets:checking" + balance: "1000.00" + commodity: "USD" + depth: 2 + total: "1000.00" + + Transaction: + type: object + properties: + date: + type: string + format: date + description: transaction date + description: + type: string + description: transaction description + postings: + type: array + items: + $ref: '#/components/schemas/SimplePosting' + status: + type: string + enum: ["unmarked", "pending", "cleared"] + example: + date: "2024-01-15" + description: "groceries" + postings: + - account: "expenses:food" + amount: "45.67" + - account: "assets:checking" + amount: "-45.67" + status: "cleared" + + SimplePosting: + type: object + properties: + account: + type: string + description: account name + amount: + type: string + description: posting amount as string + example: + account: "expenses:food" + amount: "45.67" + + RegisterReport: + type: object + properties: + final_balance: + type: array + items: + $ref: '#/components/schemas/Amount' + transactions: + type: array + items: + $ref: '#/components/schemas/RegisterTransaction' + + RegisterTransaction: + type: object + properties: + date: + type: string + format: date + description: + type: string + postings: + type: array + items: + $ref: '#/components/schemas/StructuredPosting' + status: + type: string + enum: ["unmarked", "pending", "cleared"] + running_balance: + type: array + items: + $ref: '#/components/schemas/Amount' + + StructuredPosting: + type: object + properties: + account: + type: string + amount: + type: array + items: + $ref: '#/components/schemas/Amount' + + Amount: + type: object + properties: + commodity: + type: string + description: currency or commodity symbol + quantity: + type: string + description: numeric amount without commodity + cost_basis: + type: string + description: cost basis if this is a valued commodity + nullable: true + example: + commodity: "USD" + quantity: "1234.56" diff --git a/hledger-web/config/routes b/hledger-web/config/routes index c0eeb1d46db..f43d0d9510c 100644 --- a/hledger-web/config/routes +++ b/hledger-web/config/routes @@ -21,3 +21,18 @@ /commodities CommoditiesR GET /accounts AccountsR GET /accounttransactions/#AccountName AccounttransactionsR GET + +-- v1 API endpoints + +/api/v1/openapi.json OpenApiV1R GET +/api/v1/accounts ApiAccountsR GET +/api/v1/accounts/#Text ApiAccountR GET +/api/v1/accounts/#Text/balance ApiAccountBalanceR GET +/api/v1/transactions ApiTransactionsR GET -- POST +/api/v1/register ApiRegisterReportR GET +/api/v1/balance ApiBalanceReportR GET + +-- others to implement someday? +-- /api/v1/transactions/#Text ApiTransactionR GET PUT DELETE +-- /api/v1/income-statement ApiIncomeStatementR GET +-- /api/v1/balance-sheet ApiBalanceSheetR GET diff --git a/hledger-web/hledger-web.cabal b/hledger-web/hledger-web.cabal index 39e71f50a24..50c0b6797ac 100644 --- a/hledger-web/hledger-web.cabal +++ b/hledger-web/hledger-web.cabal @@ -143,6 +143,7 @@ library other-modules: Hledger.Web.App Hledger.Web.Handler.AddR + Hledger.Web.Handler.ApiR Hledger.Web.Handler.EditR Hledger.Web.Handler.JournalR Hledger.Web.Handler.MiscR