Repository navigation
feat(rest): serve and send the /{tenant} path prefix - #468
Conversation
6f5c904 to
87738f3
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
The problem is real and the change covers all eleven methods on the server and the client; CI is green. Requesting changes for the first inline comment only (a tenant that fails to decode falls back to "no tenant"). The other two inline comments and the note below are small.
Tenant isolation is not enforced. This makes the transport carry the tenant, but nothing in the default stack enforces it, and the description doesn't say so.
As far as I can see, DefaultRequestHandler only passes req.tenant into ExecutorContext and defaults a push config's tenant. InMemoryTaskStore doesn't scope by it. So GET /acme/tasks/t1 and GET /other/tasks/t1 return the same task unless the executor or store separates them. Please say that in the description and in the rest_router docs: tenant routes are served for any tenant string, and isolating tenants is the application's job.
The routes are also always on, like Python's Mount('/{tenant}') and unlike Go's opt-in NewTenantRESTHandler. That seems right, since the proto bindings are normative. The effect is that a gateway rule written for /tasks/** now also needs to match /{tenant}/tasks/**. A line in the docs would cover it. No code change is needed unless you'd prefer an opt-in switch. But I would prefer not to.
Thanks @gulldan
| Ok(req) => req, | ||
| Err(e) => return rest_payload_error_response(e), | ||
| }; | ||
| if let Ok(Path(path)) = tenant { |
There was a problem hiding this comment.
A tenant that fails to decode is treated as "no tenant" here.
Result<Path<TenantPath>, PathRejection> has two Err cases. MissingPathParams is the plain route, which is what this is meant to ignore. FailedToDeserializePathParams is a tenant segment that is not valid UTF-8 after percent-decoding (axum 0.8.9, InvalidUtf8InPathParam). if let Ok(..) and .ok() drop both, so POST /%FF/message:send runs as the default tenant instead of failing. That is the outcome the client half of this PR avoids ("never silently reaches the default one"). The TaskPath routes already answer 400 for the same input, so the two sets of routes disagree.
The same pattern is at the stream handler (233), handle_list_tasks (333) and handle_get_extended_agent_card (531).
Suggest treating only MissingPathParams as None and answering the other rejections, in one helper shared by the four handlers (send, stream, list, extended card):
fn path_tenant(
tenant: Result<Path<TenantPath>, PathRejection>,
) -> Result<Option<String>, Response> {
match tenant {
Ok(Path(path)) => Ok(Some(path.tenant)),
Err(PathRejection::MissingPathParams(_)) => Ok(None),
Err(rejection) => Err(rest_payload_error_response(rejection.body_text())),
}
}
and a case in test_every_method_is_routed_under_a_tenant for /%FF/message:send that expects 400 and no handler call. I read this in axum's source and did not run it.
| /// Every method is served on its proto `/{tenant}/…` binding as well, and | ||
| /// the path tenant reaches the request; the plain binding has none. | ||
| #[tokio::test] | ||
| async fn test_every_method_is_routed_under_a_tenant() { |
There was a problem hiding this comment.
The description says the path tenant overrides one in the body for SendMessage, SendStreamingMessage, CancelTask and push-config creation, but no test pins it. if tenant.is_some() in the cancel and create handlers is the kind of line a later refactor drops without a failure.
With take_tenant this is cheap: post a body that carries "tenant":"body" to /acme/message:send and /acme/tasks/t1:cancel and expect acme, then post the same bodies to the plain paths and expect body.
Smaller: the client test covers 3 of the 11 methods. They all go through tenant_path, so that is probably enough. A table over every method, like the server's, would catch one that forgets to call it.
There was a problem hiding this comment.
Based on the http transcoding docs: https://github.com/googleapis/googleapis/blob/master/google/api/http.proto it seems like if tenant is in the URL then it shouldn't be allowed in the body to avoid the server needing to handle this conflicting case. So perhaps it should be a BadRequest if tenant is in the URL and in the body?
There was a problem hiding this comment.
reconcile_tenant does what I suggested, thanks. One thing left: the description still says the path tenant overrides a body tenant, which is no longer true (a different value is a 400). Please update that line, and the others that describe the earlier design (the Result<Path<…>> extractor, "exactly the eleven bindings", the base commit).
There was a problem hiding this comment.
Agreed on the transcoding rule: with body: "*" the body maps the fields the path did not capture, so tenant has no place in the body of a /{tenant}/… request. A flat rejection would break both reference clients, though: a2a-python's REST transport posts MessageToDict(request) with tenant in it and a2a-go marshals the request with json:"tenant,omitempty", so each sends the same value in both places (and a2a-go's server fillTenant just overwrites the body with the path). The PR now rejects only a conflicting body tenant with a BadRequest on field tenant and accepts an equal one; a path tenant alone, including an empty cancel body, keeps working. Pinned in test_path_and_body_tenant.
There was a problem hiding this comment.
Description updated: the body-tenant rule (an equal value is accepted, a different one is a 400), the Option<Path<String>> extractor in place of Result<Path<…>>, "the eleven bindings, plus POST subscribe as on the plain route", and the base commit (main da0a00b). The patch-coverage figure is from the previous revision; I will refresh it when the re-run on 5f311f2 finishes. Thanks for the review.
There was a problem hiding this comment.
Re-run on 5f311f2 done: CI mirror all green (879 tests), patch coverage 380 coverable added lines, 0 missed. Description updated with the final numbers.
| axum::routing::post(handle_stream_message::<H>), | ||
| ) | ||
| .route( | ||
| "/{tenant}/tasks/{id}", |
There was a problem hiding this comment.
The description says the tenant routes are "exactly the eleven additional_bindings of the proto", but this route also serves POST /{tenant}/tasks/{id}:subscribe, and the proto has no such binding. SubscribeToTask has only get and additional_bindings { get: "/{tenant}/tasks/{id=*}:subscribe" }. The plain POST is a legacy alias, which the description says stays plain. Here it is carried over because handle_post_task_action dispatches on the :subscribe suffix and the route has to keep POST for :cancel.
Keeping it is harmless and matches the plain route. I'd reword the description ("the eleven bindings, plus POST subscribe as on the plain route") and add a comment at the route so it reads as deliberate. If you'd rather keep the tenant routes strictly to the proto, handle_post_task_action can refuse :subscribe when tenant.is_some().
ba806dd to
7662c4e
Compare
7662c4e to
fb27deb
Compare
The proto gives every HTTP+JSON method a /{tenant}/... binding next to
the plain one (additional_bindings), and §8.3.2 has the client send the
tenant of the selected interface on every request. rest_router served
only the plain paths, so POST /acme/message:send answered 404, and the
handlers built their requests with tenant: None; RestTransport left the
tenant out of every path. The router now serves each binding again
under /{tenant} and passes the path tenant into the request, where it
accepts the same tenant in the body and answers a different one with a
BadRequest on `tenant`; a tenant that fails to decode is a 400,
as on the task routes. The client puts a request's tenant,
percent-encoded like an id, in front of its path.
Signed-off-by: gulldan <5641375+gulldan@users.noreply.github.com>
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
fb27deb to
5f311f2
Compare
|
Thanks for the careful read. All four points, and @Tehsmash's note on the body tenant, are in the new revision, squashed to one commit on Tenant that fails to decode (blocking). Fixed, and your reading of axum was right: Path tenant and body tenant. Pinned in POST subscribe under Isolation and always-on routes. The From my side that covers everything raised in this thread. The CI mirror on |
Fixes #466.
Problem
The proto gives every HTTP+JSON method a tenant-prefixed binding next to the plain one (
additional_bindings, e.g.post: "/{tenant}/message:send",get: "/{tenant}/tasks/{id=*}"), and §8.3.2 rule 4 has the client send the selected interface's tenant on every request. On HTTP+JSON the tenant travels in the path.rest_routerserved only the plain paths, soPOST /acme/message:sendanswered 404, and every handler built its request withtenant: None.RestTransportleftreq.tenantout of every path. a2a-client: the selected AgentInterface's tenant is never echoed on requests (A2A §8.3.2) #199 fillstenantin requests, but on this binding it only reached POST bodies.Fix
/{tenant}: the elevenadditional_bindingsof the proto, plusPOST …:subscribeas on the plain route, sincehandle_post_task_actionserves:canceland:subscribeon the same POST route (a comment at the route says so). Legacy aliases stay plain. The task and push-config handlers read their path asTaskPath/PushConfigPathwith the tenant optional; the four handlers without a task id (SendMessage,SendStreamingMessage,ListTasks,GetExtendedAgentCard) takeOption<Path<String>>, which isNoneon the plain binding and, for a tenant that fails to decode (POST /%FF/message:send), a 400 before the handler runs, as the task routes already answer.CancelTaskstill reads its metadata from the body as fix(rest): pass CancelTask metadata from the request body #458 made it. The tenant routes sit inside theA2A-Versionroute layer from fix(server): enforce A2A-Version on the REST and gRPC bindings #443 and keep the extractor rejections from fix(rest): answer extractor rejections with google.rpc.Status #451.google/api/http.protohasbody: "*"map "all request fields not captured by the path pattern", so on a/{tenant}/…request the body should not carrytenant. The reference clients do carry it: a2a-python's REST transport postsMessageToDict(request)withtenantin it, a2a-go marshals the request withjson:"tenant,omitempty", and a2a-go's server (fillTenant) overwrites the body with the path. Soreconcile_tenant, shared by the four POST bodies that carry a tenant (SendMessage,SendStreamingMessage,CancelTask, push-config creation), takes the path tenant when the body has none (an empty cancel body included), accepts an equal body tenant, and answers a different one with 400: agoogle.rpc.Statuswhosegoogle.rpc.BadRequestnames fieldtenant("tenant in the body (body) does not match the tenant in the path (acme)"); nothing reaches the handler. The plain binding takes the body's tenant as before.Mount('/{tenant}'), and are served for any tenant string; the path tenant only reaches the request. The default stack (DefaultRequestHandler,InMemoryTaskStore) does not isolate tenants:GET /acme/tasks/t1andGET /other/tasks/t1return the same task unless the executor or the task store separates them, so isolation is the application's job. A gateway rule written for/tasks/**now also has to match/{tenant}/tasks/**. Therest_routerdocs say both.tenant_pathputs a non-emptyreq.tenant, percent-encoded with the samesegmentas the ids (fix(client): percent-encode task and config ids in REST paths #450), in front of the path of all eleven methods, including the legacy subscribe fallback, so a request for a tenant never silently reaches the default one.Tests
test_every_method_is_routed_under_a_tenant: all eleven/acme/…bindings (subscribe both asGETandPOST) reach the handler withtenant = "acme", and the plainPOST /message:sendwith none.CapturingHandlernow also records the tenant each method saw (take_tenant). Without the change every tenant request is a 404.test_an_undecodable_tenant_is_rejected:/%FF/message:send,/%FF/message:stream,/%FF/tasks,/%FF/tasks/t1and/%FF/extendedAgentCardanswer 400 and no handler runs. With the first revision of this PR the four routes without a task id ran as the default tenant.test_path_and_body_tenant, a table overmessage:send,message:stream,tasks/t1:cancelandtasks/t1/pushNotificationConfigs: under/acme/a body without a tenant (the empty cancel body included) or with"tenant":"acme"reaches the handler withacme; a body with"tenant":"body"is a 400 and the handler is not called, theBadRequestdetail naming fieldtenant; the same"tenant":"body"on the plain paths reaches the handler withbody.test_requests_with_a_tenant_use_the_tenant_path(a2a-client):GetTask,DeletePushConfigandGetExtendedAgentCardwith tenantacme eugo to/acme%20eu/…; an empty tenant keeps the plain path. Without the change every path lacks the tenant.mainda0a00b: all green (879 tests). Patch coverage ascoverage.ymlbuilds it: 380 coverable added lines, 0 missed.Overlap
mainda0a00b, after fix(rest): pass CancelTask metadata from the request body #458 (handle_cancel_task_innertakes both the body and the path tenant) and fix(server): keep the INPUT_REQUIRED question in task history #462–fix(server): reject a message that does not answer the request alone #464, fix(jsonrpc): read A2A-Version from the query parameter #469.🤖 Generated with Claude Code