Request Interfaces¶
RequestInterface lets a manager read and query data from a remote HTTP-style service while keeping the familiar GeneralManager API (filter(), exclude(), all(), manager attribute access, and named collection operations).
If both services use GeneralManager, prefer RemoteManagerInterface for the client side and RemoteAPI on the server side. That layer builds on top of RequestInterface and synthesizes the standard GeneralManager REST contract for you.
Unlike DatabaseInterface, a request interface does not create a Django model. Instead, you declare:
- manager fields as class attributes
- request configuration inside
Interface.Meta - explicit query and mutation operations
- how a compiled request plan is executed, usually through a shared transport
This keeps the public API familiar while making remote-service behavior explicit and strict.
When to use a request interface¶
Use RequestInterface when:
- the source of truth lives in another service
- you want manager-style reads and queries without mirroring that service into your database
- the upstream API has a stable resource model such as
projects,documents, orwork_orders
Do not use it as a generic ad hoc HTTP client. Request interfaces are resource-first and declaration-driven.
Mental model¶
A request-backed manager has five layers:
- Manager API: callers use
Project.filter(status="active"),Project.exclude(...),Project.all(), orProject.Interface.query_operation("search", ...). - Field schema:
RequestFieldclass attributes define the remote resource shape exposed by the manager. - Filter and operation config:
Interface.Metadeclares filters, query operations, mutation operations, auth provider, retry policy, and serializers. - Request plan: GeneralManager builds a
RequestQueryPlanwith method, path, query params, headers, body, path params, and optional local predicates. - Execution hook: the interface hands that plan to a shared transport, which turns it into a real HTTP call.
The key design point is that callers never pass raw HTTP details. They only use declared manager filters and operations.
Core pieces¶
RequestField¶
RequestField declares a manager attribute and where it comes from in a remote payload.
RequestField(int)
RequestField(str, source="displayName")
RequestField(str, source=("owner", "name"))
Common options:
field_type: expected Python typesource: payload key or dotted/nested pathdefault: fallback for optional fieldsis_required: whether missing payload data should raise an errornormalizer: optional payload-to-Python conversion
RequestFilter¶
RequestFilter maps a GeneralManager lookup to remote request semantics.
RequestFilter(remote_name="state", value_type=str)
RequestFilter(remote_name="modifiedAfter", value_type=datetime)
RequestFilter(remote_name="q", location="body", value_type=str)
Important options:
remote_name: upstream parameter namelocation: where the value goes:"query","headers","path", or"body"value_type: strict input validationserializer: optional value transformation before sendingsupports_exclude: whetherexclude()is safe for this filterexclude_remote_name: upstream negation parameter ifexclude()uses a different keyallow_local_fallback: opt-in client-side filtering after the remote responseoperation_names: restrict a filter to specific collection operationscompiler: custom request-plan compiler for non-standard cases
RequestQueryOperation¶
RequestQueryOperation declares a named remote operation.
RequestQueryOperation(name="list", method="GET", path="/projects")
RequestQueryOperation(name="detail", method="GET", path="/projects/{id}")
RequestQueryOperation(name="search", method="POST", path="/projects/search")
Use operations when the upstream service exposes multiple collection shapes, for example:
- a normal list endpoint
- a POST-based search endpoint
- a special status report endpoint
Shared transport¶
RequestInterface now supports a first-class shared transport path. In the common case you declare:
transport: an object implementingSharedRequestTransportor theRequestTransportprotocoltransport_config: base URL, timeout, retry policy, and optional response normalizerauth_provider: a provider-style hook onInterface.Meta
Then the default execute_request_plan() implementation delegates to that transport.
For most HTTP integrations, start with the built-in UrllibRequestTransport. Keep a custom SharedRequestTransport subclass only when the upstream service needs special request signing, non-JSON wire behavior, or custom response parsing.
The shared transport is responsible for:
- building the outbound request from the
RequestQueryPlan - merging static operation query params, headers, and body fragments
- enforcing timeout configuration
- applying auth through
Meta.auth_provider - applying framework retry policy from
Meta.retry_policy - adding idempotency keys for retried non-idempotent requests when configured
- normalizing transport responses into
RequestQueryResult - mapping upstream status failures into stable request exceptions
RequestQueryResult(
items=({"id": 1, "name": "Alpha"},),
total_count=1,
)
Minimal example¶
The example below shows a manager backed by a remote project service.
For a fuller cookbook-style version of the same pattern, see the request interface end-to-end recipe.
from __future__ import annotations
from datetime import datetime
from typing import Any, ClassVar
from general_manager.interface import (
BearerTokenAuthProvider,
FieldMappingSerializer,
RequestField,
RequestFilter,
RequestInterface,
RequestMutationOperation,
RequestRetryPolicy,
RequestTransportConfig,
RequestQueryOperation,
UrllibRequestTransport,
)
from general_manager.manager.general_manager import GeneralManager
from general_manager.manager.input import Input
class RemoteProject(GeneralManager):
class Interface(RequestInterface):
id = Input(type=int)
name = RequestField(str)
status = RequestField(str, source="state")
updated_at = RequestField(datetime, source="modifiedAt")
class Meta:
filters: ClassVar[dict[str, RequestFilter]] = {
"status": RequestFilter(
remote_name="state",
value_type=str,
supports_exclude=True,
exclude_remote_name="state_not",
),
"name__icontains": RequestFilter(
remote_name="search",
value_type=str,
),
"updated_at__gte": RequestFilter(
remote_name="modifiedAfter",
value_type=datetime,
serializer=lambda value: value.isoformat(),
),
"page": RequestFilter(remote_name="page", value_type=int),
"page_size": RequestFilter(remote_name="pageSize", value_type=int),
}
query_operations: ClassVar[dict[str, RequestQueryOperation]] = {
"detail": RequestQueryOperation(
name="detail",
method="GET",
path="/projects/{id}",
),
"list": RequestQueryOperation(
name="list",
method="GET",
path="/projects",
),
}
create_operation = RequestMutationOperation(
name="create",
method="POST",
path="/projects",
)
update_operation = RequestMutationOperation(
name="update",
method="PATCH",
path="/projects/{id}",
)
transport = UrllibRequestTransport()
transport_config = RequestTransportConfig(
base_url="https://service.example.com/api",
timeout=10,
)
auth_provider = BearerTokenAuthProvider(token=lambda: "replace-me")
retry_policy = RequestRetryPolicy(
max_attempts=3,
base_backoff_seconds=0.25,
max_backoff_seconds=2.0,
jitter_ratio=0.25,
)
create_serializer = FieldMappingSerializer(
{"name": "name", "state": "status"}
)
update_serializer = FieldMappingSerializer({"state": "status"})
Usage:
active_projects = RemoteProject.filter(status="active", page=1, page_size=50)
project = RemoteProject(id=42)
for item in active_projects:
print(item.name, item.status)
print(project.name)
Practical examples¶
Example 1: standard list filters¶
A typical GET list endpoint maps directly from manager lookups to query parameters.
RemoteProject.filter(
status="active",
name__icontains="alpha",
updated_at__gte=datetime(2026, 3, 1, 0, 0, 0),
page=2,
page_size=25,
)
This compiles into a request plan roughly like:
RequestQueryPlan(
operation_name="list",
action="filter",
method="GET",
path="/projects",
query_params={
"state": "active",
"search": "alpha",
"modifiedAfter": "2026-03-01T00:00:00",
"page": 2,
"pageSize": 25,
},
)
Example 2: safe exclude()¶
Only filters that explicitly support negation may be used with exclude().
inactive_projects = RemoteProject.exclude(status="inactive")
If a filter does not declare supports_exclude=True, exclude() raises an error instead of guessing.
Example 3: operation-specific search¶
Some APIs use a different endpoint for full-text search. In that case, declare a named operation and operation-specific filters.
class RemoteProject(GeneralManager):
class Interface(RequestInterface):
id = Input(type=int)
name = RequestField(str)
class Meta:
query_operations = {
"search": RequestQueryOperation(
name="search",
method="POST",
path="/projects/search",
filters={
"query": RequestFilter(
remote_name="q",
location="body",
value_type=str,
),
"page": RequestFilter(
remote_name="page",
location="body",
value_type=int,
),
},
),
}
Usage:
matches = RemoteProject.Interface.query_operation(
"search",
query="tower crane",
page=1,
)
This keeps the manager API high-level while allowing endpoint-specific request shapes.
Example 4: operation-restricted filters¶
You can keep a filter available only on selected operations.
filters = {
"archived": RequestFilter(
remote_name="archived",
value_type=bool,
operation_names=frozenset({"list"}),
),
}
RemoteProject.filter(archived=True) works on the list operation, but trying to use the same filter on another operation raises an error.
Example 5: local fallback filtering¶
Some upstream APIs cannot express every filter server-side. You can allow a strictly opt-in local fallback:
filters = {
"local_name__icontains": RequestFilter(
value_type=str,
allow_local_fallback=True,
),
}
Usage:
RemoteProject.filter(local_name__icontains="alpha")
GeneralManager will fetch the declared remote operation first and then apply the predicate locally.
Important caveat:
- local fallback is intentionally strict
- it is not a substitute for full remote filtering support
- partial paginated pages are rejected when local fallback would make counts or page semantics incorrect
Detail reads¶
Manager attribute access can lazily load a detail endpoint. If a manager is created with only its identification fields, GeneralManager resolves attributes by executing the "detail" request operation.
project = RemoteProject(id=42)
print(project.name)
This works when:
- the interface declares a
"detail"operation execute_request_plan()returns exactly one item for that operation
If the detail operation returns zero or multiple items, GeneralManager raises an explicit response-shape error instead of pretending the payload is valid.
Strictness and failure behavior¶
Request interfaces intentionally fail early.
You should expect errors when:
- a caller uses an undeclared filter key
- a filter value has the wrong type
exclude()is used on a filter that does not explicitly support negation- a filter is restricted to a different operation
- two filters try to write conflicting values into the same request-plan location
- a required payload field is missing
- local fallback is attempted on a partial remote page
This is by design. Remote APIs vary too much for safe implicit behavior.
Supported lookup vocabulary¶
Request filters currently support a bounded lookup vocabulary:
exactincontainsicontainsgtgteltlteisnull
You still declare every supported lookup explicitly in the filters mapping, for example name__icontains or updated_at__gte.
Production guidance¶
The current request interface gives you the declaration, planning, and shared transport layer. For production use, configure your shared transport path to handle:
- provider-based authentication and token refresh
- mandatory timeouts
- retry policy through
Meta.retry_policy - capped backoff and jitter through
RequestRetryPolicy - structured logging and request IDs
- optional metrics and trace hooks through
RequestTransportConfig - response normalization and schema checks
- rate-limit handling
- secret masking in logs and errors
If multiple managers talk to the same upstream service, keep one shared transport and auth provider rather than duplicating request code inside each interface.
Relevant public transport and error types are available from general_manager.interface, including:
UrllibRequestTransportBearerTokenAuthProviderHeaderApiKeyAuthProviderQueryApiKeyAuthProviderBasicAuthProviderFieldMappingSerializerSharedRequestTransportRequestTransportConfigRequestRetryPolicyRequestTransportRequestRequestTransportResponseRequestMutationOperationRequestRemoteErrorRequestTransportErrorRequestAuthenticationErrorRequestAuthorizationErrorRequestNotFoundErrorRequestConflictErrorRequestRateLimitedErrorRequestServerError
Mutation-specific configuration also lives in Interface.Meta:
create_operationupdate_operationdelete_operationrulescreate_serializerupdate_serializerresponse_serializer
Current limitations¶
The request interface is intentionally narrow in v1.
Current limitations include:
- no generic arbitrary endpoint invocation from managers
- no ORM-style universal filtering across all remote APIs
- retry policy, metrics, and trace hooks are framework-managed only for transports that go through
SharedRequestTransport.execute() - request transports are normalized, but you still own service-specific provider auth logic and response shaping
- observability is structured and sanitized, but metric/tracing backend wiring depends on the host application
Troubleshooting¶
RequestConfigurationErrorat class definition time usually meansInterface.Metacontains an invalidauth_provider,retry_policy, serializer, or legacy top-level request config.RequestSchemaErrormeans the transport or serializer returned the wrong payload shape. Check the upstream JSON body,response_normalizer, andresponse_serializer.- If retry behavior seems missing, confirm the integration uses
SharedRequestTransport.execute()and not a custom transport path that bypasses it. - If auth is missing, check both
Meta.auth_providerandtransport_config.auth_provider; the interface-level provider takes precedence.
Recommended pattern¶
For most integrations, this pattern works well:
- Start with one resource-oriented manager such as
RemoteProject. - Declare a small, explicit set of
RequestFieldclass attributes. - Declare only the filters the upstream service truly supports.
- Put filters, operations, transport config, auth provider, retry policy, rules, and serializers in
Interface.Meta. - Add a
"detail"and"list"operation first. - Add named operations such as
"search"only when the upstream API has a genuinely different endpoint shape. - Keep
transport.send()thin and move shared HTTP/auth/error logic into a reusable transport helper.
That keeps the GeneralManager API clean without hiding the real constraints of the remote service.