Commit Graph
6 Commits
Author SHA1 Message Date
awkawbandClaude Opus 5 e4f2373b53 Add multipart document uploads for the 16 resources that have the route
First item on the GOALS.md roadmap. `upload_{x}(id, files, is_public)`
attaches files through `/{resource}/{id}/upload` and returns the
refreshed record. Verified against upstream that all 16 controllers
answer with `itemResponse($x->fresh())`.

Three details that look wrong but are not, each checked in the upstream
source rather than taken from the published spec:

- The request is a POST carrying `_method=PUT`. The route is registered
  PUT, but PHP does not parse a multipart body on a real PUT, so the
  files never arrive. Laravel's kernel calls
  `enableHttpMethodParameterOverride()`, turning the spoofed method back
  into a PUT before routing. The spec documents POST with `_method=POST`,
  which would 405 against these routes.
- `is_public` is sent as 1/0. Laravel's `boolean` rule accepts only
  true, false, 0, 1, '0' and '1' under strict comparison, so "true"
  fails validation.
- Uploads are never retried. An upload adds a document, so it is not
  idempotent whatever its verb, and a replay after a lost response would
  attach the file twice. This falls out of reqwest's multipart body being
  un-cloneable, which nothing in the types enforces, so a test pins it
  for 500, 503 and 429.

An upload with no files, a nameless file or an unparseable MIME type is
refused locally (`Error::InvalidUpload`), since the server answers an
empty upload with a success that changed nothing.

Corrects two earlier claims:

- GOALS.md and COVERAGE.md said uploads would make `create_document`
  possible. They do not: `DocumentController::store` is an empty stub,
  and an upload is the only way a document is created. There is no
  `create_document`.
- The documented method count (265) had been stale since the typed
  `statics`/`search`/`health_check` commit, which added three `_as`
  methods without updating it. Recounted from the source: 284 = 210 +
  18 + 12 + 16 uploads + 28 hand-written.

Also fixes a README example, `let statics: Value = client.statics()`,
that stopped compiling when `statics` lost its type parameter.

`edocument/upload` is left out: it imports an expense from an
e-document rather than attaching a file, so it moves to the
import/export roadmap item.

Enabling `multipart` adds mime, mime_guess and unicase; MSRV stays 1.88,
re-verified by running the suite on it. 101 tests, up from 91.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 14:26:41 -04:00
awkawbandClaude Opus 5 355cf08324 Point at the GitHub mirror and document that it is read-only
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
`Cargo.toml` named github.com/bryant/invoiceninjars, a guess from the
git author name that was never a real repository. It now points at the
mirror that actually exists, so the crates.io "Repository" link will
resolve.

Gitea is the source of truth and a push mirror copies every commit to
GitHub. Anything written to the GitHub copy is overwritten by the next
sync, so a merged pull request there would silently vanish. README and
GOALS.md say so, and direct issues and pull requests to Gitea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 11:14:33 -04:00
awkawbandClaude Opus 5 ad9553516e Pin the Rust toolchain and declare an MSRV of 1.88
The dev shell tracked `stable.latest` and CI used `@stable`, so nothing
pinned a version and nothing declared a floor. A consumer on an older
toolchain hit a confusing error deep in a dependency rather than cargo
saying the version was too old, and a new stable release could turn a
lint into an unrelated red build.

- `rust-version = "1.88"` in Cargo.toml. The floor comes from the
  dependency graph, not this crate's code: reqwest pulls in url -> idna
  -> idna_adapter -> icu_*, and the icu crates require 1.88. Verified by
  running the full suite against 1.88, not inferred.
- flake.nix pins the dev toolchain to 1.99.1 and adds an `msrv` devShell,
  so the floor can be rechecked with
  `nix develop .#msrv --command cargo test`.
- CI pins the main job to the same 1.99.1 and adds a job building on
  1.88. Lint and format stay on the pinned toolchain only, since their
  output varies by release.

Note the tradeoff: with CI pinned, a future stable that breaks the build
will not be noticed until the pin moves. Worth adding a non-blocking
`stable` job if that matters.

CLAUDE.md records the three places that must agree when either version
moves, and how to recheck the floor after a dependency bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:46:32 -04:00
awkawbandClaude Opus 5 23c660d2f9 Add retry with backoff, and narrow the untyped responses
Retry:
- `RetryPolicy` (3 retries, full-jitter exponential backoff) applied in a
  new `execute` dispatch chokepoint, configurable via
  `with_retry_policy`. `Retry-After` is honoured, and one longer than
  `max_delay` is surfaced rather than slept through.
- What may be replayed depends on the method, not just the status. A 429
  means the server refused the request without processing it, so any
  method is safe. A 5xx or timeout gives no such guarantee, so only
  idempotent methods are retried: this is a billing API, and replaying a
  POST the server had already applied would duplicate an invoice,
  payment, or email.

Response types:
- `statics`, `search`, and `health_check` return `Statics`,
  `SearchResults`, and `HealthCheck` instead of being generic.
  `HealthCheck` covers both deployments — hosted returns a bare message
  rather than a report — and `SearchResults` covers both the
  Elasticsearch and non-Elasticsearch bucket sets.
- Each keeps the `extra` convention, and `statics_as` / `search_as` /
  `health_check_as` remain for pinning shapes left untyped. Nested config
  blobs (gateways, templates, extensions) stay `Value`, same reasoning as
  `Company::settings`.
- `send_email` previously decoded the response as a bare body, but the
  controller returns `itemResponse(...)` — a `data`-wrapped entity. It now
  unwraps, so `T` is the entity. Still generic, since the endpoint serves
  invoices, quotes, credits, and purchase orders.
- `chart` stays generic on purpose: the eleven charts share no response
  shape.
- Adds `id_as_string`, since `/statics` returns `payment_types.id` as a
  string and the other tables return numbers.

Adds CHANGELOG.md (Keep a Changelog), and a CLAUDE.md note to keep it
current. 17 new tests, 43 in client_requests.rs total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:18:12 -04:00
awkawbandClaude Opus 5 9976249210 Wrap all 34 resource groups, then harden the request plumbing
Expands coverage to every `Route::resource` group in the v5 API plus
`users` (265 methods), and fixes several defects in the shared request
path that the new breadth made more exposed.

Coverage:
- 25 new models, each keeping the `extra` flatten map and the
  `null_to_default` convention so unmodeled fields round-trip.
- `crud_methods!` split out of `resource_methods!` for the three groups
  with no `/bulk` route, where a generated `bulk_*` would only 405.
- COVERAGE.md added as the detailed inventory, verified against
  `routes/api.php` on v5-stable.

Fixes, each confirmed against upstream source or an observed request:
- `list_all_*` silently truncated. `BaseController` caps page size at
  `min(abs(per_page), 5000)`, so the "short page means last page" rule
  stopped early and returned a partial list as if complete. Termination
  now prefers `meta.pagination.total_pages` (decoded but previously
  discarded), and `per_page` is clamped so the fallback stays sound.
- `per_page(0)` paged forever: the check `got < 0` has no solution. It
  now falls back to the default size, with a MAX_PAGES backstop that
  raises `Error::PaginationLimit` rather than quietly truncating.
- Ids were interpolated into paths raw, so a `/` or `?` in an id
  retargeted the request. All sites now use `seg(id)?`.
  Escaping alone is insufficient: the WHATWG rules `url` implements
  decode `%2E` to `.` before resolving dot segments, so an escaped `..`
  still collapses the path. Empty and dot-only ids are rejected.
- No request timeout. reqwest applies none, so a stalled connection hung
  the caller indefinitely. Adds a 60s default and `with_timeout`.
- Base URL is validated at construction; a missing scheme used to 404
  on every call instead of failing once.

Adds tests/client_requests.rs: 26 tests over the request plumbing
against a wiremock loopback server, including regressions for the
truncation and non-termination bugs. No network or credentials needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:08:38 -04:00
awkawbandClaude Opus 5 ff9a859d0b Initial commit: async Rust client for the Invoice Ninja v5 API
CI / test (push) Canceled after 0s
Covers Clients, Invoices, Quotes, Credits, Payments, Products, Expenses,
Projects, Tasks, and Vendors, each with list, list_all, get, create,
update, and delete, generated by the resource_methods! macro.

Every model carries a #[serde(flatten)] extra map so fields the API
returns but the crate does not model survive a get/update round-trip
instead of being silently dropped.

Includes a Nix dev shell (Rust via rust-overlay), a GitHub Actions CI
workflow running build/test/fmt/clippy, and round-trip tests for the
extra-preservation invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 14:30:01 -04:00