← ExamplesSpecification

Template source

Complete consumer-owned files. Start with Setup.md; download the project and source files together. The catalog viewer bar is not included.

ApiDocumentation.fs

Download file
namespace Docs.Examples

open System.Text.Json
open FSharp.ViewEngine
open Acme.Components
open type Html
open Model
open Ledger.Domain

/// Consumer-authored API reference: resource navigation, readable contracts and cURL alongside JSON.
module ApiDocumentation =
    let navigation =
        [ "/examples/api-documentation", "Overview"
          "/examples/api-documentation/list-accounts", "List accounts"
          "/examples/api-documentation/create-account", "Create account"
          "/examples/api-documentation/update-account", "Update account"
          "/examples/api-documentation/delete-account", "Delete account"
          "/examples/api-documentation/list-transactions", "List transactions"
          "/examples/api-documentation/get-transaction", "Get transaction" ]
    let title = function
        | "list-accounts" -> "List accounts" | "create-account" -> "Create account" | "update-account" -> "Update account" | "delete-account" -> "Delete account" | "list-transactions" -> "List transactions" | "get-transaction" -> "Get transaction" | _ -> "Ledger API reference"
    let private pretty (source:string) =
        use document = JsonDocument.Parse source
        JsonSerializer.Serialize(document.RootElement,JsonSerializerOptions(WriteIndented=true))
    let private parameters (rows:(string*string*string) list) =
        dl {
            _class "divide-y divide-[var(--fve-border)]"
            for name,kind,description in rows do
                div {
                    _class "py-5 first:pt-0"
                    dt { _class "flex flex-wrap items-baseline gap-2"; code { _class "font-mono text-sm font-semibold"; name }; span { _class "text-xs text-[var(--fve-muted-text)]"; kind } }
                    dd { _class "mt-2 text-base leading-7 text-[var(--fve-muted-text)]"; description }
                }
        }
    let private methodPath (method':string) (path:string) =
        div {
            _class "flex min-w-0 flex-wrap items-center gap-3"
            Badge.create method' |> Badge.withColor (if method'="DELETE" then BadgeColor.Error elif method'="GET" then BadgeColor.Success else BadgeColor.Info) |> Badge.withVariant BadgeVariant.Soft |> Badge.render
            code { _class "break-all font-mono text-sm"; path }
        }
    let private split (narrative:HtmlElement) (examples:HtmlElement) =
        div {
            _class "grid min-w-0 xl:grid-cols-2"
            article { _class "min-w-0 px-4 py-8 sm:px-6 lg:px-8 xl:py-10"; div { _class "mx-auto grid max-w-2xl gap-8"; narrative } }
            aside {
                _ariaLabel "Request and response examples"
                _style "--fve-docs-code-surface:var(--fve-background)"
                _class ((ComponentsTheme.sky |> ComponentsTheme.withDensity Density.Compact |> ComponentsTheme.withControlSize ControlSize.Small |> ComponentsTheme.className)+" dark min-w-0 border-t border-[var(--fve-border)] bg-[var(--fve-background)] px-4 py-8 text-[var(--fve-text)] sm:px-6 lg:px-8 xl:border-t-0 xl:border-l xl:py-10")
                div { _class "mx-auto grid max-w-2xl gap-8"; examples }
            }
        }
    let private operation page method' path (description:string) request requestFields (returns:string) (responses:(string*string*string) list) =
        let narrative = div {
            _class "grid gap-8"
            header { _class "grid gap-4"; p { _class "text-base leading-7 text-[var(--fve-muted-text)]"; description }; methodPath method' path }
            Layout.section "Parameters" (if List.isEmpty requestFields then p { _class "text-base text-[var(--fve-muted-text)]"; "No parameters." } else parameters requestFields)
            Layout.section "Returns" (p { _class "text-base leading-7 text-[var(--fve-muted-text)]"; returns })
            Layout.section "Response codes" (dl {
                _class "grid gap-4"
                for status,description,_ in responses do
                    div {
                        _class "grid grid-cols-[3rem_1fr] gap-3"
                        dt { _class "font-mono text-sm font-semibold"; status }
                        dd { _class "text-base leading-6 text-[var(--fve-muted-text)]"; description }
                    }
            })
            p { _class "border-t border-[var(--fve-border)] pt-5 text-sm leading-6 text-[var(--fve-muted-text)]"; "Illustrative API contract. Set "; code { "$API_ORIGIN" }; " to your own backend; this example does not host these endpoints." }
        }
        let examples = div {
            _class "grid gap-8"
            section {
                _ariaLabel "cURL request"; _class "grid gap-4"
                div { _class "flex items-center justify-between gap-3"; h2 { _class "text-sm font-semibold"; "Request" }; span { _class "rounded-md bg-[var(--fve-surface-subtle)] px-2 py-1 font-mono text-xs"; "cURL" } }
                methodPath method' path
                CodeBlock.create "shell" request |> CodeBlock.render
            }
            section {
                _ariaLabel "JSON response"; _class "grid gap-4"
                h2 { _class "text-sm font-semibold"; "Response" }
                Tabs.create "template-api-responses" "Response status" (responses |> List.map (fun (status,description,body) ->
                    TabItem.create status status (div { _class "grid gap-4"; p { _class "text-sm leading-6 text-[var(--fve-muted-text)]"; description }; if body<>"" then CodeBlock.create "json" (pretty body) |> CodeBlock.render else p { _class "rounded-xl border border-[var(--fve-border)] p-4 font-mono text-sm"; "No response body." } }))) |> Tabs.render
            }
        }
        split narrative examples
    let render page =
        let account = accounts.Head
        let transaction = transactions.Head
        let nameFields = ["name","string · required","Unique without regard to case. Must contain 1–80 characters."; "accountType","enum · required","One of Asset, Liability, Equity, Revenue or Expense. Reporting currency is USD."]
        let validation = "{\"error\":\"validation_failed\",\"fields\":{\"name\":\"Enter a unique account name of 1–80 characters.\"}}"
        let missing = "{\"error\":\"not_found\",\"message\":\"The resource does not exist.\"}"
        let collection payload records = "["+(records |> List.map payload |> String.concat ",")+"]"
        let content =
            match page with
            | "list-accounts" ->
                operation page "GET" "/api/accounts" "Returns the account collection. Filter by account type to inspect one part of the ledger's financial classification." "curl \"$API_ORIGIN/api/accounts?accountType=Asset\"" ["accountType","enum · query · optional","Restricts results to Asset, Liability, Equity, Revenue or Expense. Omit to return all accounts."] "An array of account objects. Each includes its identity, type, reporting currency and balance derived from opening balance plus transactions." ["200","Account collection",collection accountPayload (accounts |> List.filter (fun row -> row.accountType=AccountType.Asset))]
            | "create-account" ->
                let created = { id=106; name="Reserve fund"; accountType=AccountType.Asset; currency="USD"; openingBalance=0M }
                operation page "POST" "/api/accounts" "Creates a financial account with a unique name and an available account type. The new account starts with a zero balance in USD." "curl -X POST \"$API_ORIGIN/api/accounts\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\":\"Reserve fund\",\"accountType\":\"Asset\"}'" nameFields "The created account object. The Location response header identifies /api/accounts/106. Invalid fields and duplicate names do not create a record." ["201","Created account · Location: /api/accounts/106",accountPayload created; "400","Invalid account fields",validation; "409","Duplicate account name","{\"error\":\"account_name_exists\"}"]
            | "update-account" ->
                operation page "PATCH" "/api/accounts/101" "Updates the account's name and type. Both fields are required by this illustrative contract; the account ID and existing balance remain unchanged." "curl -X PATCH \"$API_ORIGIN/api/accounts/101\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\":\"Main operating account\",\"accountType\":\"Asset\"}'" (("id","integer · path · required","The identifier of an existing account.")::nameFields) "The updated account object, or a field-validation, missing-resource or duplicate-name error." ["200","Updated account",accountPayload { account with name="Main operating account" }; "400","Invalid fields",validation; "404","Unknown account",missing; "409","Duplicate name","{\"error\":\"account_name_exists\"}"]
            | "delete-account" ->
                operation page "DELETE" "/api/accounts/105" "Deletes an unused account. An account must have a zero balance and no transactions before it can be deleted." "curl -X DELETE \"$API_ORIGIN/api/accounts/105\"" ["id","integer · path · required","The identifier of an existing account. Unassigned expense (105) is the eligible seeded example."] "No response body on success. An unknown ID returns 404; an account with financial dependencies returns 409." ["204","Account deleted. No response body.",""; "404","Unknown account",missing; "409","Account has transactions or a non-zero balance","{\"error\":\"account_has_dependencies\"}"]
            | "list-transactions" ->
                operation page "GET" "/api/transactions" "Returns dated financial activity. Optionally narrow the collection to one account to reconcile its balance and inspect verification state." "curl \"$API_ORIGIN/api/transactions?accountId=101\"" ["accountId","integer · query · optional","Restricts results to transactions for an existing account. Omit to return all transactions."] "An array of transaction objects with a date, signed USD amount, account ID and verification status." ["200","Transactions for the requested account",collection transactionPayload (transactions |> List.filter (fun row -> row.accountId=101))]
            | "get-transaction" ->
                operation page "GET" "/api/transactions/201" "Retrieves one transaction by its stable identifier, including its associated account and verification state." "curl \"$API_ORIGIN/api/transactions/201\"" ["id","integer · path · required","The identifier of an existing transaction."] "The matching transaction object, or a not-found error. Dates use YYYY-MM-DD and amounts are signed decimal USD values." ["200","Transaction details",transactionPayload transaction; "404","Unknown transaction",missing]
            | _ ->
                split (div {
                    _class "grid gap-8"
                    header { _class "grid gap-4"; p { _class "text-base leading-7 text-[var(--fve-muted-text)]"; "Accounts and transactions over JSON. Explore resource definitions, request parameters and response states alongside copyable cURL examples." } }
                    Layout.section "Getting started" (div { _class "grid gap-4 text-base leading-7 text-[var(--fve-muted-text)]"; p { "These pages describe an API you can implement using the same financial model as the Application and Specification." }; p { "Set "; code { "$API_ORIGIN" }; " to your backend's origin before using the requests. This catalog does not expose a financial API, issue credentials or run requests." } })
                    Layout.section "Resources" (div {
                        _class "grid gap-5"
                        for resource,description,url in ["Accounts","Financial classification, reporting currency and derived balances.","/examples/api-documentation/list-accounts";"Transactions","Dated signed activity, linked accounts and verification state.","/examples/api-documentation/list-transactions"] do
                            div {
                                Layout.link url resource
                                p { _class "px-3 text-base leading-7 text-[var(--fve-muted-text)]"; description }
                            }
                    })
                    Layout.section "Conventions" (parameters ["Content-Type","application/json","Requests and responses use JSON. Resource identifiers are integers.";"Dates and amounts","date-only · decimal","Transaction dates use YYYY-MM-DD. All signed amounts and account balances are USD.";"Errors","JSON object","Field validation uses 400; unknown resources use 404; duplicate names and deletion dependencies use 409."])
                    p { _class "text-sm leading-6 text-[var(--fve-muted-text)]"; "Application demo forms validate without persistence. API request examples describe an implementation contract, not operations performed by the preview." }
                }) (div {
                    _class "grid gap-8"
                    Layout.section "Your first request · cURL" (div { _class "grid gap-4"; methodPath "GET" "/api/accounts"; CodeBlock.create "shell" "curl \"$API_ORIGIN/api/accounts\"" |> CodeBlock.render })
                    Layout.section "Example response" (CodeBlock.create "json" (pretty (collection accountPayload accounts)) |> CodeBlock.render)
                    Layout.section "Account object" (CodeBlock.create "json" (accountPayload account) |> CodeBlock.render)
                })
        let current = if page="" then "/examples/api-documentation" else "/examples/api-documentation/"+page
        let groups = ["",[navigation.Head];"Accounts",navigation |> List.skip 1 |> List.take 4;"Transactions",navigation |> List.skip 5]
        Layout.shell "Ledger API" current groups (title page) "Accounts and transactions · JSON contract" (Layout.link "/examples/specification" "Read specification") content

Install the listed components, compile your Tailwind stylesheet, and provide the assets configured in Layout.fs.

Update account