Document the new endpoints and the fixture workflow

Updates the three coverage summaries to 325 methods so they agree, moves the
per-resource extras out of the gap list in `COVERAGE.md` now that they are
wrapped, and records why the fixtures exist and how to refresh them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:56:46 -04:00
co-authored by Claude Opus 5
parent a7d9a2d9d6
commit 0485272080
5 changed files with 233 additions and 54 deletions
+45 -1
View File
@@ -16,6 +16,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the file twice. This is the only way the API creates a document, so there the file twice. This is the only way the API creates a document, so there
is no `create_document`. Enables reqwest's `multipart` feature, which adds is no `create_document`. Enables reqwest's `multipart` feature, which adds
`mime`, `mime_guess`, and `unicase` and leaves the MSRV at 1.88. `mime`, `mime_guess`, and `unicase` and leaves the MSRV at 1.88.
- Per-resource extras, nineteen methods: `retry_webhook`, `sort_tasks`,
`set_default_design`, `set_default_client_gateway_token`,
`subscription_steps`, `check_subscription_steps`, the company-gateway
`test`/`clone`/`import_customers` trio, four bank-integration and
bank-transaction routes, and six client routes (`update_client_tax_data`,
`client_show_settings`, `client_peppol_discovery`, `check_client_vat`,
`client_vat_status`, `client_documents`). Each is a `POST` unless noted in
`COVERAGE.md`, so none is retried on a 5xx. Adds `MessageResponse` for the
`{"message": ...}` replies and `SubscriptionStep`.
- Password-protected actions, eight methods: `purge_client`, `merge_clients`,
`merge_vendors`, `purge_company`, `purge_company_save_settings`,
`invite_user`, `purge_user`, and `new_user_template`. They need
`with_api_password`, most cannot be undone, and none is retried on a 5xx.
A merge keeps the first id and folds the second into it. Checked against
the upstream controllers and mock servers; not yet against a real instance.
- Activity log, email history and previews, fourteen methods:
`list_activities`, `list_all_activities`, `activity_feed`,
`entity_activity`, `add_activity_note`, `download_activity_entity`,
`client_email_history`, `entity_email_history`, and the previews `preview`,
`preview_html`, `live_preview`, `live_design`, `preview_purchase_order` and
`live_preview_purchase_order`. Adds `Activity`, `ActivityFeedEntry` and
`ActivityRef`. Email history is returned as raw JSON. See `COVERAGE.md` for
which were confirmed against a real server and the easy mistakes (a
singular entity name in one route, a plural in the next).
- `FileUpload` and `Error::InvalidUpload`. An upload with no files, a nameless - `FileUpload` and `Error::InvalidUpload`. An upload with no files, a nameless
file, or an unparseable MIME type is refused locally, because the server file, or an unparseable MIME type is refused locally, because the server
answers an empty upload with a success that changed nothing. answers an empty upload with a success that changed nothing.
@@ -27,7 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
release. The advisory job fails visibly rather than silently; it is kept release. The advisory job fails visibly rather than silently; it is kept
non-blocking through branch protection rather than `continue-on-error`. non-blocking through branch protection rather than `continue-on-error`.
- Wrappers for every one of the API's 34 `Route::resource` groups, plus - Wrappers for every one of the API's 34 `Route::resource` groups, plus
`users` — 284 methods (including document uploads). See `COVERAGE.md` for the per-endpoint inventory. `users` — 325 methods (including document uploads, per-resource extras, password-protected actions, and activity, email-history and preview routes). See `COVERAGE.md` for the per-endpoint inventory.
- `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why, - `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why,
checked against `routes/api.php` on the `v5-stable` branch. checked against `routes/api.php` on the `v5-stable` branch.
- Retry with full-jitter exponential backoff, configurable through - Retry with full-jitter exponential backoff, configurable through
@@ -83,6 +107,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `list_all_*` with `per_page(0)` never terminated: the check `got < 0` has - `list_all_*` with `per_page(0)` never terminated: the check `got < 0` has
no solution. It now falls back to the default page size, and a `MAX_PAGES` no solution. It now falls back to the default page size, and a `MAX_PAGES`
backstop raises `Error::PaginationLimit` rather than quietly truncating. backstop raises `Error::PaginationLimit` rather than quietly truncating.
- Creating a recurring invoice or recurring expense failed after the record
had been created: a real server returns `frequency_id` as the string `"5"`
and the field was a strict integer. It now accepts a number or a numeric
string.
- Creating a purchase order, location, or webhook from a default model was
rejected by a real server. An unset `PurchaseOrder.status_id`,
`Location.country_id`, or `Webhook.format` was sent as `""`, which the
server turns into `NULL` and then refuses (a 500 for the purchase order and
webhook, a 422 for the location). They are now left out of the request when
empty.
- `with_api_password` refused a password containing a control character (a
newline, say) with a misleading `InvalidToken` error. Such a password is
now sent base64-encoded as `X-API-PASSWORD-BASE64`, the form the server's
middleware accepts for exactly this.
- A payment whose `invoices` entry had a `null` or missing `invoice_id` or
`amount` failed to decode the whole payment. Both now default.
- `list_system_logs` failed to decode against a real server. `SystemLog`'s
`created_at` and `updated_at` were typed as strings, following the OpenAPI
spec, but the server sends integer timestamps. They are now `i64`, like
every other model.
- Requests could hang indefinitely. `reqwest` applies no timeout of its own - Requests could hang indefinitely. `reqwest` applies no timeout of its own
and none was configured. and none was configured.
- Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#` - Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#`
+64 -13
View File
@@ -84,10 +84,51 @@ IN_API_TOKEN=... cargo run --example list_clients
``` ```
Tests live in `tests/` (integration-style, no network access required); Tests live in `tests/` (integration-style, no network access required);
`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the `src/` has no `#[test]` modules. Four files:
serde layer, and `client_requests.rs` covers the request plumbing
(pagination, path escaping, headers, timeouts, error mapping) against a - `model_roundtrip.rs` — the serde layer, from hand-written payloads.
`wiremock` server on loopback — no credentials or internet needed. - `client_requests.rs` — the request plumbing (pagination, path escaping,
headers, timeouts, retry, error mapping, one test per endpoint extra)
against a `wiremock` server on loopback.
- `decode_tolerance.rs` — for every model, serialises `Default`, then nulls
and removes each field in turn and requires the result to still decode.
A new model belongs in its list; a new non-`Option` field without
`null_to_default` fails it.
- `real_shapes.rs` — decodes `tests/fixtures/*.json`, which are the *shapes*
(keys and JSON types, no values) of responses captured from a real server,
and checks a decode + re-serialise loses no key. It also refuses fixtures
that hold any real value, and fixtures with no test.
No credentials or internet needed for any of them.
**Why the fixtures matter:** twice, mocks written from the upstream source
passed while a real server disagreed (system-log timestamps; the
`/subscriptions/steps` shape), and `frequency_id` and several empty-string
fields were wrong the same way. When the smoke test finds a divergence,
refresh the fixtures so it stays caught:
```sh
# 1. Fill a DISPOSABLE instance with one row of each kind (writes!).
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
cargo run --example seed_sample_data
# 2. Capture shapes (GET only). Review `git diff tests/fixtures` before committing.
IN_BASE_URL=... IN_API_TOKEN=... cargo run --example capture_shapes
```
A resource with no rows is skipped, so the fixture set only covers what the
instance held. Bank integrations, company gateways, client gateway tokens,
subscriptions, task schedulers, and recurring quotes are not captured: they
need external accounts, or (recurring quotes on 5.13.50) the server's own
insert fails.
**Fields omitted when empty.** The server runs `ConvertEmptyStringsToNull`,
so an unset `String` serialised as `""` becomes `NULL`, which a non-null
column or an `integer` rule then rejects. `Location.country_id`,
`PurchaseOrder.status_id` and `Webhook.format` therefore carry
`skip_serializing_if = "String::is_empty"`. If a create from a `Default`
model 500s or 422s on an empty field, do the same — and add the path to
`OMITTED_WHEN_EMPTY` in `tests/real_shapes.rs` and `examples/smoke_test.rs`,
which is deliberately a list of decisions rather than a blanket exemption.
### Testing against a real server ### Testing against a real server
@@ -112,10 +153,20 @@ tier leaves a gap in the client number sequence and a soft-deleted row (a
real purge needs the account password), and it may fire webhooks. Use a real purge needs the account password), and it may fire webhooks. Use a
disposable instance — a local VM or container — for it. disposable instance — a local VM or container — for it.
Beyond "does it decode", the read tier fetches the same rows raw and fails
if a typed row lost a key (`keys kept: <resource>`, names only, never
values), and the write tier creates a purchase order, location, webhook and
recurring invoice from bare `Default` models, since those are what broke.
With `IN_API_PASSWORD` set as well, a password tier merges two throwaway
clients and purges the survivor (a real, permanent delete); it has not yet
been run against an instance. `.github/workflows/smoke.yml` runs it on demand (`workflow_dispatch` only,
needing `IN_BASE_URL` and `IN_API_TOKEN` secrets); the write tier runs only if
the `write_host` input names the instance's host.
It prints PASS/FAIL per check and never the contents of a record. The upload It prints PASS/FAIL per check and never the contents of a record. The upload
wire format (`POST` plus `_method=PUT`) was read from the framework source wire format (`POST` plus `_method=PUT`) was read from the framework source,
rather than observed, so the first run of the write tier against a real then confirmed by running the write tier against a disposable Invoice Ninja
instance is the real verification of it. 5.13.50 instance (2026-10-24): single and multi-file uploads both attached.
## Changelog ## Changelog
@@ -264,18 +315,18 @@ changes, doc tweaks) does not need an entry.
## Coverage ## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users` **All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 284 methods: 30 resources with CRUD + bulk, 3 with CRUD (which isn't one). 325 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, 16 with document uploads, and 28 hand-written. only, 4 with entity actions, 16 with document uploads, and 69 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, `report`, `chart`, `search`, `statics`, the four action-capable resources, `report`, `chart`, `search`, `statics`,
`send_email`, `refund_payment`, `ping`, and `health_check`. `send_email`, `refund_payment`, `ping`, and `health_check`.
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every **`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. The main remaining gaps are a handful of gap, and the reason for each gap. The main remaining gaps are the
per-resource extras integration and auth routes, import/export, the public invitation-key
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the downloads (which take no auth token, so they don't fit the client's
integration/auth routes. request path), and a few company and user extras.
Keep `COVERAGE.md` current when adding or removing an endpoint: it names Keep `COVERAGE.md` current when adding or removing an endpoint: it names
exact counts and route groups, so it goes stale quietly. The three coverage exact counts and route groups, so it goes stale quietly. The three coverage
+102 -29
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns. > exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is **All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 284 methods in total. not a resource group). 325 methods in total.
## Resources with full CRUD + bulk ## Resources with full CRUD + bulk
@@ -58,6 +58,10 @@ not a resource group). 284 methods in total.
| Task Schedulers | `/task_schedulers` | `TaskScheduler` | | Task Schedulers | `/task_schedulers` | `TaskScheduler` |
| Tokens | `/tokens` | `CompanyToken` | | Tokens | `/tokens` | `CompanyToken` |
`create_recurring_quote` currently 500s against Invoice Ninja 5.13.50: the
server's own insert names a `design_id` column its `recurring_quotes` table
does not have. The request is well-formed; nothing here can work around it.
`bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and `bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and
`delete` work on all of them. An `ids` list matching nothing returns an empty `delete` work on all of them. An `ids` list matching nothing returns an empty
`Vec`, not an error. `Vec`, not an error.
@@ -212,6 +216,103 @@ the whole tail — `chart("project_burnup/<id>", &body)`.
Large report exports are queued and emailed rather than returned inline; in Large report exports are queued and emailed rather than returned inline; in
that case the body is a short JSON message instead of CSV. that case the body is a short JSON message instead of CSV.
## Activities, email history and previews
Each has a response shape of its own. Marked *observed* where confirmed
against a 5.13.50 server.
| Endpoint | Method | Returns |
| --- | --- | --- |
| `GET /activities` | `list_activities`, `list_all_activities` | `Vec<Activity>` (*observed*) |
| `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
| `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
| `POST /activities/notes` | `add_activity_note(entity, id, notes)` | `Activity` (*observed*) |
| `GET /activities/download_entity/{id}` | `download_activity_entity` | PDF bytes |
| `POST /emails/clientHistory/{client}` | `client_email_history` | `Vec<serde_json::Value>` |
| `POST /emails/entityHistory` | `entity_email_history` | `Vec<serde_json::Value>` |
| `POST /preview` | `preview`, `preview_html` | PDF bytes / HTML |
| `POST /live_preview` | `live_preview` | PDF bytes (*observed*) |
| `POST /live_design` | `live_design` | PDF bytes |
| `POST /preview/purchase_order` | `preview_purchase_order` | PDF bytes |
| `POST /live_preview/purchase_order` | `live_preview_purchase_order` | PDF bytes |
Things that are easy to get wrong:
- **The entity name is singular in one route and plural in the next.**
`entity_activity` takes `invoice`; `add_activity_note` takes `invoices`.
- **`rows`, not `per_page`, caps the activity log** (default 75). Pass it
through `ListParams::param`. `entity_activity` leaves `rows` out when not
given rather than sending `null`, because the server tests for the key and
a null would count as given and remove the cap.
- **The two activity shapes differ.** `Activity` carries hashed ids and a
string `activity_type_id`; the feed's `ActivityFeedEntry` carries a numeric
one and labelled references (`invoice`, `client`, … each `{label,
hashed_id}`).
- **Email history is raw JSON.** The objects mirror what the mail provider
reported, and the instance available had never sent mail, so there was no
shape to take.
- **Not yet run against a real server:** `preview`, `preview_html`, and both
purchase-order previews (they need a design object or a purchase order and a
PDF backend), and `live_design`, which answered a bare minimal request with
a 500 from the server on 5.13.50.
## Password-protected actions
Behind the `password_protected` middleware, so each needs
`with_api_password` (the server also lets a recent password-authenticated
session through without the header). Most are **irreversible**, and all are
`POST`s except one, so none is retried on a 5xx.
| Endpoint | Method | Returns |
| --- | --- | --- |
| `POST /clients/{id}/purge` | `purge_client` | `MessageResponse` |
| `POST /clients/{id}/{other}/merge` | `merge_clients(id, other)` | the surviving `Client` |
| `POST /vendors/{id}/{other}/merge` | `merge_vendors(id, other)` | the surviving `Vendor` |
| `POST /companies/purge/{id}` | `purge_company` | `MessageResponse` |
| `POST /companies/purge_save_settings/{id}` | `purge_company_save_settings` | `MessageResponse` |
| `POST /users/{id}/invite` | `invite_user` | `MessageResponse` |
| `POST /users/{id}/purge` | `purge_user` | `()` (the server answers 204) |
| `GET /users/create` | `new_user_template` | a blank `User`, nothing stored |
A merge keeps the first id and folds the second into it. These are verified
against the upstream controllers and mock servers only: running them needs the
account password, so the smoke test's optional password tier (merge two
throwaway clients, purge the survivor) has not yet been run against a real
instance. A password containing a control character cannot be a header value
and goes out as `X-API-PASSWORD-BASE64` instead.
## Per-resource extras
Routes that sit beside a resource's CRUD set, each with its own response
shape (read from the upstream controllers; the ones marked *observed* were
also confirmed against a 5.13.50 server). All are `POST` unless noted, so none
is retried on a 5xx or a timeout.
| Endpoint | Method | Returns |
| --- | --- | --- |
| `POST /webhooks/{id}/retry` | `retry_webhook` | `Webhook` |
| `POST /tasks/sort` | `sort_tasks` | `MessageResponse` |
| `POST /designs/set/default` | `set_default_design` | `MessageResponse` |
| `POST /client_gateway_tokens/{id}/setAsDefault` | `set_default_client_gateway_token` | `ClientGatewayToken` |
| `GET /subscriptions/steps` | `subscription_steps` | `Vec<SubscriptionStep>` (*observed*: an object keyed by class name, not the array the source suggests) |
| `POST /subscriptions/steps/check` | `check_subscription_steps` | `()` |
| `POST /company_gateways/{id}/test` | `test_company_gateway` | `MessageResponse` |
| `POST /company_gateways/{id}/clone` | `clone_company_gateway` | `CompanyGateway` |
| `POST /company_gateways/{id}/import_customers` | `import_company_gateway_customers` | `MessageResponse` |
| `POST /bank_integrations/refresh_accounts` | `refresh_bank_accounts` | `()` (the body is a serialised query builder) |
| `POST /bank_integrations/remove_account/{id}` | `remove_bank_account` | `BankIntegration` |
| `POST /bank_integrations/get_transactions/{id}` | `fetch_bank_transactions` | `MessageResponse` (the server ignores the id) |
| `POST /bank_transactions/match` | `match_bank_transactions` | `Vec<BankTransaction>` |
| `POST /clients/{id}/updateTaxData` | `update_client_tax_data` | `Client` |
| `POST /clients/{id}/show_settings` | `client_show_settings` | `serde_json::Value` (*observed*) |
| `POST /clients/{id}/peppol_discovery` | `client_peppol_discovery` | `serde_json::Value` |
| `POST /clients/{id}/check_vat` | `check_client_vat` | `MessageResponse` |
| `GET /clients/{id}/vat_status` | `client_vat_status` | `MessageResponse` (*observed*) |
| `POST /clients/{id}/documents` | `client_documents` | `Vec<Document>` (*observed*; one page) |
`updateTaxData` and `check_vat` call out to external tax services, so the
smoke test does not exercise them.
## Not covered ## Not covered
### Nothing is blocked on a dependency ### Nothing is blocked on a dependency
@@ -231,42 +332,14 @@ from `src/models/mod.rs`, and add a `resource_methods!` invocation in
`src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route — `src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route —
check `routes/api.php`). check `routes/api.php`).
### Password-protected endpoints still unwrapped
`with_api_password` makes these reachable, but the methods themselves aren't
written yet:
- `POST /clients/{id}/purge`
- `POST /clients/{id}/{other}/merge`
- `POST /vendors/{id}/{other}/merge`
- `POST /companies/purge/{company}`,
`POST /companies/purge_save_settings/{company}`
- `POST /users/{id}/invite`, `POST /users/{id}/purge`, `GET /users/create`
### Individual endpoints not wrapped ### Individual endpoints not wrapped
- **Client extras** — `updateTaxData`, `show_settings`, `peppol_discovery`,
`check_vat`, `vat_status`, `documents`
- **Bank integration extras** — `refresh_accounts`, `remove_account/{id}`,
`get_transactions/{id}`, and `POST /bank_transactions/match`
- **Company gateway extras** — `test`, `clone`, `import_customers`
- **Company extras** — `GET /companies/{id}/logo`, - **Company extras** — `GET /companies/{id}/logo`,
`POST /companies/{id}/default`, `updateOriginTaxData`, `POST /companies/{id}/default`, `updateOriginTaxData`,
`POST /companies/current` `POST /companies/current`
- **Design extras** — `POST /designs/set/default`
- **Client gateway token extras** — `setAsDefault`
- **Task extras** — `POST /tasks/sort`
- **Webhook extras** — `POST /webhooks/{id}/retry`
- **Subscription extras** — `GET /subscriptions/steps`,
`POST /subscriptions/steps/check`
- **User extras** (not password-protected, unlike the rest of `/users`) — - **User extras** (not password-protected, unlike the rest of `/users`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`, `POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm` `POST /user/{id}/reconfirm`
- **Preview** — `POST /preview`, `POST /preview/purchase_order`, live preview
- **Activities** — `GET /activities`, `POST /activities/entity`,
`POST /activities/notes`, `download_entity/{activity}`
- **Email history** — `POST /emails/clientHistory/{client}`,
`POST /emails/entityHistory`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`, - **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
passkey routes passkey routes
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`, - **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
+21 -10
View File
@@ -149,18 +149,29 @@ way a document is created. There is no `create_document` and there will not
be one. `edocument/upload` was left out because it imports an expense from be one. `edocument/upload` was left out because it imports an expense from
an e-document rather than attaching a file, so it belongs with item 6. an e-document rather than attaching a file, so it belongs with item 6.
**Done:** the per-resource extras — webhook retry, task sort, default
design, default gateway token, subscription steps, and the client,
bank-integration, and company-gateway routes (19 methods). The numbering
below keeps its original item numbers. Running the smoke test against a real
server overturned one reading of the source here: `/subscriptions/steps`
answers with an object keyed by class name, not the array the controller
suggests. That is the case for keeping the real-server tier.
**Done:** the password-protected actions — client purge and merge, vendor
merge, company purge, user invite and purge, and the blank-user template (8
methods). They are verified against the upstream controllers and mocks only;
the smoke test has a password tier for the first real-server run
(`IN_API_PASSWORD`), which has not happened yet. The claim here that
`with_api_password` already made them reachable was not quite true: a
password with a control character was refused locally.
**Done:** activities, email history and previews (14 methods, two new
models). The email history stays raw JSON, since no instance here had sent
mail to take a shape from; promoting it to a model needs a sample. Several
previews are unverified against a real server (see `COVERAGE.md`).
Still to do: Still to do:
2. **Per-resource extras** — mechanical, and they reuse existing patterns:
`webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`,
`client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the
client extras (`updateTaxData`, `show_settings`, `check_vat`, …), the
bank-integration and company-gateway extras.
3. **Password-protected actions** — `with_api_password` already makes these
reachable; only the methods are missing. Client purge and merge, vendor
merge, company purge, user invite and purge.
4. **Preview, activities, and email history** — distinct response shapes, so
each needs its own hand-written method and probably its own model.
5. **Public invitation-key downloads** — eight routes that take no auth 5. **Public invitation-key downloads** — eight routes that take no auth
token, so they do not fit the authenticated request path and need a token, so they do not fit the authenticated request path and need a
different entry point. different entry point.
+1 -1
View File
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage ## Coverage
Every one of the API's 34 resource groups, plus users — 284 methods. Each Every one of the API's 34 resource groups, plus users — 325 methods. Each
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
(where the API offers one) `bulk`: (where the API offers one) `bulk`: