Skip to content

Have Overdrive models describe requests instead of making them (PP-4938) - #3684

Open
jonathangreen wants to merge 5 commits into
chore/overdrive-async-movefrom
chore/overdrive-request-spec
Open

Have Overdrive models describe requests instead of making them (PP-4938)#3684
jonathangreen wants to merge 5 commits into
chore/overdrive-async-movefrom
chore/overdrive-request-spec

Conversation

@jonathangreen

Copy link
Copy Markdown
Member

Description

Action and Checkout took a PatronRequestCallable and performed the HTTP request themselves. They now return a frozen RequestSpec describing the request, and the patron request layer executes it.

@dataclass(frozen=True)
class RequestSpec:
    method: str
    url: str
    data: str | None = None
    headers: Mapping[str, str] = field(default_factory=dict)

Action.request(make_request, ...) becomes Action.build_request(...), Checkout.action(name, make_request, ...) becomes Checkout.action(name, ...), and PatronRequestCallable and _overdrive_field_request are removed.

Motivation and Context

Part of separating the Overdrive integration's HTTP concerns from its business logic (PP-4938).

Response models that also initiate requests meant request construction was split across model.py and api.py, and it was the last thing keeping HTTP inside the models. Separating "what request should I make" from "make this request" is the same split as requests.PreparedRequest and Session.send.

The models keep all of their hypermedia intelligence. Action lookup, validation against the action's declared fields, default values, camelCase mapping, payload construction and URL templating are unchanged, and MissingRequiredFieldError, InvalidFieldOptionError and ExtraFieldsError are still raised before any request is made. This also makes Action consistent with LinkTemplate.template, which already returned a URL rather than fetching it.

Method casing on the wire is unaffected. RequestSpec carries the uppercase verb from the action, where patron_request used to lowercase it, but requests uppercases the method before sending either way. Some test assertions on the mock's recorded method change accordingly.

How Has This Been Tested?

The Action and Checkout tests in test_model.py now assert on the returned RequestSpec instead of on a MagicMock's call args, so they are pure value assertions with no mocks. The patron request tests assert that the spec's verb, body and headers are used verbatim, and that the Authorization header is added on top of the spec's own headers.

The business-level tests in test_api.py are unchanged apart from the method casing noted above, which is the evidence that the requests going over the wire are the same.

tox -e py312-docker -- tests/manager/

5988 passed. mypy clean.

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jonathangreen's task in 3m 38s —— View job


Summary

Solid, behavior-preserving refactor — the split between "describe the request" and "execute the request" is the right one, and moving Action/Checkout off PatronRequestCallable removes the last transport dependency from the models. I checked every converted call site against the method inference that patron_request used to do, and each one now passes the verb the old inference would have produced (RequestSpec.get for the URL-only calls, "DELETE" for release_hold, the action's own verb via build_field_request, and POST for the two non-empty field bodies in checkout/place_hold), so the only wire-visible change is the verb casing the description already calls out. Dropping the ("get", "post", "put", "delete") whitelist is also safe: HTTP._request_with_timeout hands the verb to session.request(...), which accepts any string. The frozendict() class-level default is fine for a dataclass since 3.12 gates on hashability rather than isinstance(..., dict). One thing worth fixing outside the code: the PR description still says Checkout.action(name, ...) (it is Checkout.build_action_request) and shows headers: Mapping[str, str] = field(default_factory=dict), which the later commits replaced with the frozendict default — worth refreshing before this becomes the merge commit message.

Details

Nit: tests/manager/integration/license/overdrive/test_requests.py:653

The .upper() here neutralizes the assertion for the one behavior this PR changes in the transport layer: patron_request no longer lowercases the verb. Every parametrized value is already uppercase, so this passes whether the recorded method is "DELETE" or "delete". Comparing directly (and optionally feeding a lowercase verb in) would pin the normalization end to end.

assert client.requests_methods[0] == method

assert client.requests_methods[0].upper() == method

Review checklist
  • Read the PR diff against chore/overdrive-async-move
  • Review model.py (RequestSpec, build_field_request, build_request)
  • Review requests.py (spec execution, 401 retry, header merge)
  • Review api.py call sites against the old method inference
  • Review test changes
  • Post review

Note: I reviewed statically — the sandbox here blocks running pytest/mypy, so I relied on CI and the results reported in the PR body.

· [`chore/overdrive-request-spec`](https://github.com/ThePalaceProject/circulation/tree/chore/overdrive-request-spec)

@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR separates OverDrive request description from HTTP execution while preserving action validation and retry behavior.

  • Adds immutable RequestSpec values for request method, URL, body, and headers.
  • Moves field and action request construction into model-layer builder methods.
  • Updates the patron request layer to execute request specifications and merge authorization headers.
  • Updates API call sites and tests for the new request-building interface.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/palace/manager/integration/license/overdrive/model.py Introduces immutable request specifications and converts action methods into request builders; the previous mutable-header concern is resolved by copying headers into a frozendict.
src/palace/manager/integration/license/overdrive/requests.py Executes request specifications, builds fresh authorization-header mappings per attempt, and retains token-refresh retry behavior.
src/palace/manager/integration/license/overdrive/api.py Migrates OverDrive circulation operations to construct and execute RequestSpec instances.
tests/manager/integration/license/overdrive/test_model.py Adds value-based coverage for request construction, method normalization, frozen headers, and action validation.
tests/manager/integration/license/overdrive/test_requests.py Verifies request-spec methods, bodies, headers, authorization merging, and existing retry behavior.

Sequence Diagram

sequenceDiagram
    participant API as OverdriveAPI
    participant Model as OverDrive Model
    participant Requests as Patron Request Layer
    participant OD as OverDrive
    API->>Model: Build RequestSpec
    Model-->>API: Immutable request description
    API->>Requests: patron_request(token, spec)
    Requests->>Requests: Add Authorization header
    Requests->>OD: Execute HTTP request
    alt 401 response
        Requests->>Requests: Refresh patron token
        Requests->>OD: Retry immutable request spec
    end
    OD-->>Requests: Response
    Requests-->>API: Response or validated model
Loading

Reviews (6): Last reviewed commit: "Document build_field_request" | Re-trigger Greptile

Comment thread src/palace/manager/integration/license/overdrive/model.py Outdated
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.57%. Comparing base (04eafce) to head (0d91db6).

Files with missing lines Patch % Lines
...alace/manager/integration/license/overdrive/api.py 85.71% 1 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##           chore/overdrive-async-move    #3684      +/-   ##
==============================================================
- Coverage                       93.57%   93.57%   -0.01%     
==============================================================
  Files                             514      514              
  Lines                           46980    46979       -1     
  Branches                         6410     6407       -3     
==============================================================
- Hits                            43963    43962       -1     
  Misses                           1950     1950              
  Partials                         1067     1067              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jonathangreen
jonathangreen force-pushed the chore/overdrive-request-spec branch from 1cf1be6 to 9ae8dc1 Compare August 31, 2026 17:10
Action and Checkout took a callable and performed the HTTP request
themselves, which made response models into request initiators and split
request construction across model.py and api.py. They now return a
frozen RequestSpec describing the request, and the patron request layer
executes it.

The models keep all their hypermedia intelligence: action lookup, field
validation against the action's declared fields, payload construction
and URL templating are unchanged, and their errors are still raised
before any request is made. This mirrors LinkTemplate.template, which
already returned a URL rather than fetching it.

Method casing on the wire is unaffected. RequestSpec carries the
uppercase verb from the action, where patron_request used to lowercase
it; requests itself uppercases the method before sending either way.
Freeze the headers a RequestSpec carries. The dataclass was frozen but the
headers were a plain dict, so a caller could still change them after the
fact, and every spec that had any was unhashable. Coercing in __post_init__
rather than only defaulting to frozendict covers build_field_request, which
is the one path that actually sets headers.

Return Self from RequestSpec.get, which is how from_response_data already
spells the same thing, and drop the module-wide postponed annotations that
the one forward reference needed. That import applies to every model in the
file, and pydantic resolves those types.

Rename Checkout.action to build_action_request. It returns a request to
make, not the action it was built from, and Action.request was renamed to
build_request in this same commit for that reason.
@jonathangreen
jonathangreen force-pushed the chore/overdrive-request-spec branch from 9ae8dc1 to 86dccd7 Compare September 1, 2026 19:31
_lock_in_format catches InvalidFieldOptionError from build_action_request,
so the field validation errors are part of that method's contract rather
than an internal detail of Action.build_request. Name all three on both,
including ExtraFieldsError, which build_request raises but did not mention.

Spell Mapping one way. RequestSpec introduced the collections.abc import
and build_field_request was the last user of the typing alias.
Specs are values now: tests compare them and the frozen dataclass hashes
them. requests uppercases the verb before sending, so RequestSpec("get")
and RequestSpec("GET") describe the same wire request, and they should not
compare unequal. Uppercase it alongside the headers, which lets
Action.build_request stop doing it for itself.
@jonathangreen
jonathangreen requested a review from a team September 1, 2026 19:54
It is public and its two surprises are worth stating: the method defaults
to POST, and an empty fields mapping still asks for a JSON content type
while sending no body, which is what an action taking no arguments needs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant