From d918e96674eac06edbea7a06d88872659aca9cba Mon Sep 17 00:00:00 2001 From: Addison Kline <77369109+addisonkline@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:57:48 -0400 Subject: [PATCH] fix: document request bodies and box query params in OpenAPI POST/PATCH handlers took only `request: Request` and parsed bodies by hand via validators.py, so FastAPI generated no requestBody schema and the bodies were absent from /docs and spec/openapi.yaml. Promote each request model to a typed handler parameter (Approach A) across the drafts, daemon, admin, lists, and auth routers; FastAPI now validates and documents all 17 bodies. /auth/refresh and /auth/logout take the refresh-token body as an optional Body(default=None) to preserve the cookie-only browser flow; _read_refresh_token is now sync and reads the parsed payload, keeping cookie-over-body precedence. Also declare BoxFilterParams as a typed Query() param on the inbox/outbox/ trash/drafts GET endpoints (same root cause, query side), so the limit/offset/ sort_by/order filters are documented too. Drop the now-unused body and query validators (path-param validators kept). Add the missing MAIL_REFRESH_TOKEN_EXPIRE_DAYS placeholder to generate_openapi.py so the spec regenerates standalone, and regenerate spec/openapi.yaml. Add a contract test asserting requestBody/query-param presence so the regression can't silently return. The 422 body shape for invalid bodies is now FastAPI's native structured detail instead of the previous string; status code is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/generate_openapi.py | 1 + spec/openapi.yaml | 714 ++++++++++++++++++ .../server/src/mail_server/routers/admin.py | 32 +- .../server/src/mail_server/routers/auth.py | 39 +- .../server/src/mail_server/routers/daemon.py | 18 +- .../server/src/mail_server/routers/drafts.py | 34 +- .../server/src/mail_server/routers/inbox.py | 11 +- .../server/src/mail_server/routers/lists.py | 23 +- .../server/src/mail_server/routers/outbox.py | 11 +- .../server/src/mail_server/routers/trash.py | 11 +- src/mail/server/src/mail_server/validators.py | 322 +------- tests/contract/test_openapi_request_bodies.py | 100 +++ 12 files changed, 931 insertions(+), 385 deletions(-) create mode 100644 tests/contract/test_openapi_request_bodies.py diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index 9836f45..3ac0f63 100644 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -23,6 +23,7 @@ def _set_import_defaults() -> None: os.environ.setdefault("MAIL_JWT_SECRET_KEY", "openapi-generation-only") os.environ.setdefault("MAIL_JWT_ALGORITHM", "HS256") os.environ.setdefault("MAIL_JWT_EXPIRE_MINUTES", "15") + os.environ.setdefault("MAIL_REFRESH_TOKEN_EXPIRE_DAYS", "30") def _load_schema() -> dict[str, Any]: diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 4677c33..632881b 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -36,7 +36,19 @@ paths: - authentication summary: Exchange a refresh token for a new access token (rotates the refresh token) + description: The refresh token is read from the httpOnly cookie when present + (browsers); the request body is the fallback for clients that cannot use the + cookie (e.g. the CLI). The body may be omitted entirely when the cookie carries + the token. operationId: post_auth_refresh_auth_refresh_post + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/AuthRefreshPostRequest' + - type: 'null' + title: Payload responses: '200': description: Successful Response @@ -44,12 +56,29 @@ paths: application/json: schema: $ref: '#/components/schemas/AuthRefreshPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /auth/logout: post: tags: - authentication summary: Revoke the presented refresh token's family and clear the cookie + description: The refresh token is read from the httpOnly cookie when present + (browsers), falling back to the request body. The body may be omitted entirely; + logout always succeeds and clears the cookie regardless. operationId: post_auth_logout_auth_logout_post + requestBody: + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/AuthRefreshPostRequest' + - type: 'null' + title: Payload responses: '200': description: Successful Response @@ -57,6 +86,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AuthLogoutPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /auth/whoami: get: tags: @@ -76,6 +111,12 @@ paths: - authentication summary: Reset the user-agent's password operationId: post_password_reset_auth_password_reset_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthPasswordResetRequest' + required: true responses: '200': description: Successful Response @@ -83,6 +124,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AuthPasswordResetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /swarms: get: tags: @@ -128,6 +175,44 @@ paths: - inbox summary: Get a list of inbox messages operationId: get_inbox_inbox_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 100 + exclusiveMinimum: 0 + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + - name: sort_by + in: query + required: false + schema: + enum: + - sent_at + - entered_at + type: string + default: entered_at + title: Sort By + - name: order + in: query + required: false + schema: + enum: + - asc + - desc + type: string + default: desc + title: Order responses: '200': description: Successful Response @@ -135,6 +220,12 @@ paths: application/json: schema: $ref: '#/components/schemas/InboxGetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /inbox/{message_id}: get: tags: @@ -166,6 +257,44 @@ paths: - outbox summary: Get a list of outbox messages operationId: get_outbox_outbox_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 100 + exclusiveMinimum: 0 + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + - name: sort_by + in: query + required: false + schema: + enum: + - sent_at + - entered_at + type: string + default: entered_at + title: Sort By + - name: order + in: query + required: false + schema: + enum: + - asc + - desc + type: string + default: desc + title: Order responses: '200': description: Successful Response @@ -173,6 +302,12 @@ paths: application/json: schema: $ref: '#/components/schemas/OutboxGetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /outbox/{message_id}: get: tags: @@ -192,6 +327,44 @@ paths: - drafts summary: Get a list of message drafts operationId: get_drafts_drafts_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 100 + exclusiveMinimum: 0 + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + - name: sort_by + in: query + required: false + schema: + enum: + - sent_at + - entered_at + type: string + default: entered_at + title: Sort By + - name: order + in: query + required: false + schema: + enum: + - asc + - desc + type: string + default: desc + title: Order responses: '200': description: Successful Response @@ -199,11 +372,23 @@ paths: application/json: schema: $ref: '#/components/schemas/DraftsGetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' post: tags: - drafts summary: Create a new message draft operationId: post_draft_drafts_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DraftPostRequest' responses: '200': description: Successful Response @@ -211,6 +396,12 @@ paths: application/json: schema: $ref: '#/components/schemas/DraftPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /drafts/{draft_id}: get: tags: @@ -241,6 +432,12 @@ paths: - drafts summary: Update a specific message draft by ID operationId: patch_draft_drafts__draft_id__patch + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DraftPatchRequest' + required: true responses: '200': description: Successful Response @@ -248,12 +445,24 @@ paths: application/json: schema: $ref: '#/components/schemas/DraftPatchResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /drafts/{draft_id}/send: post: tags: - drafts summary: Send a message from an existing draft by ID operationId: post_draft_send_drafts__draft_id__send_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DraftSendPostRequest' + required: true responses: '200': description: Successful Response @@ -261,12 +470,56 @@ paths: application/json: schema: $ref: '#/components/schemas/DraftSendPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /trash: get: tags: - trash summary: Get a list of messages in trash operationId: get_trashed_messages_trash_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 100 + exclusiveMinimum: 0 + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + title: Offset + - name: sort_by + in: query + required: false + schema: + enum: + - sent_at + - entered_at + type: string + default: entered_at + title: Sort By + - name: order + in: query + required: false + schema: + enum: + - asc + - desc + type: string + default: desc + title: Order responses: '200': description: Successful Response @@ -274,6 +527,12 @@ paths: application/json: schema: $ref: '#/components/schemas/TrashGetResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /trash/{message_id}: get: tags: @@ -344,6 +603,12 @@ paths: - daemon summary: Upload new messages to deliver from local agent(s) operationId: deliver_local_messages_daemon_deliver_local_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DaemonDeliverLocalRequest' + required: true responses: '200': description: Successful Response @@ -351,12 +616,24 @@ paths: application/json: schema: $ref: '#/components/schemas/DaemonDeliverLocalResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /daemon/deliver/remote: post: tags: - daemon summary: Upload new messages to deliver from remote agent(s) operationId: deliver_remote_messages_daemon_deliver_remote_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DaemonDeliverRemoteRequest' + required: true responses: '200': description: Successful Response @@ -364,6 +641,12 @@ paths: application/json: schema: $ref: '#/components/schemas/DaemonDeliverRemoteResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/agents: get: tags: @@ -382,6 +665,12 @@ paths: - admin summary: Create a new MAIL agent on this server operationId: post_agent_admin_agents_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AdminAgentPostRequest' + required: true responses: '200': description: Successful Response @@ -389,6 +678,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminAgentPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/agents/{local_address}: get: tags: @@ -466,6 +761,12 @@ paths: - admin summary: Create a new MAIL daemon on this server operationId: post_daemon_admin_daemons_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AdminDaemonPostRequest' + required: true responses: '200': description: Successful Response @@ -473,6 +774,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminDaemonPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/daemons/{worker_name}: get: tags: @@ -550,6 +857,12 @@ paths: - admin summary: Create a new MAIL user on this server operationId: post_user_admin_users_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AdminUserPostRequest' + required: true responses: '200': description: Successful Response @@ -557,6 +870,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminUserPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/users/{user_id}: get: tags: @@ -622,6 +941,12 @@ paths: - admin summary: Create a new MAIL swarm on this server operationId: post_swarm_admin_swarms_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AdminSwarmPostRequest' + required: true responses: '200': description: Successful Response @@ -629,6 +954,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminSwarmPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/swarms/{swarm_name}: delete: tags: @@ -677,6 +1008,12 @@ paths: - admin summary: Create a new webhook for this server operationId: post_webhook_admin_webhooks_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AdminWebhooksPostRequest' + required: true responses: '200': description: Successful Response @@ -684,6 +1021,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminWebhooksPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/webhooks/{webhook_id}: get: tags: @@ -730,6 +1073,12 @@ paths: - wh_123e4567-e89b-12d3-a456-426614174000 title: Webhook Id description: Webhook id (wh_). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AdminWebhooksPatchRequest' responses: '200': description: Successful Response @@ -790,6 +1139,12 @@ paths: - admin-lists summary: Create a new MAIL list on this server operationId: admin_post_list_admin_lists_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AdminListPostRequest' + required: true responses: '200': description: Successful Response @@ -797,6 +1152,12 @@ paths: application/json: schema: $ref: '#/components/schemas/AdminListPostResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /admin/lists/{local_address}: get: tags: @@ -847,6 +1208,12 @@ paths: title: Local Address description: 'List local address (name@swarm); the list: prefix and host are implied.' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AdminListPatchRequest' responses: '200': description: Successful Response @@ -910,6 +1277,12 @@ paths: title: Local Address description: 'List local address (name@swarm); the list: prefix and host are implied.' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ListMemberPostRequest' responses: '200': description: Successful Response @@ -1130,6 +1503,26 @@ components: description: 'Corresponds to `GET /admin/agents/{local_address}`. Contains a specific MAIL agent registered on this server.' + AdminAgentPostRequest: + properties: + agent_name: + type: string + title: Agent Name + swarm_name: + type: string + title: Swarm Name + agent_password: + type: string + title: Agent Password + type: object + required: + - agent_name + - swarm_name + - agent_password + title: AdminAgentPostRequest + description: 'Corresponds to `POST /admin/agents`. + + Contains an agent name, swarm, and password to register with.' AdminAgentPostResponse: properties: agent: @@ -1198,6 +1591,22 @@ components: description: 'Corresponds to `GET /admin/daemons/{worker_name}`. Contains a specific MAIL daemon registered on this server.' + AdminDaemonPostRequest: + properties: + worker_name: + type: string + title: Worker Name + daemon_password: + type: string + title: Daemon Password + type: object + required: + - worker_name + - daemon_password + title: AdminDaemonPostRequest + description: 'Corresponds to `POST /admin/daemons`. + + Contains a worker name and password to register with.' AdminDaemonPostResponse: properties: daemon: @@ -1261,6 +1670,22 @@ components: - metadata title: AdminListGetResponse description: Corresponds to `GET /admin/lists/{local_address}`. + AdminListPatchRequest: + properties: + policy: + anyOf: + - $ref: '#/components/schemas/MAILListPolicy' + - type: 'null' + type: object + title: AdminListPatchRequest + description: 'Corresponds to `PATCH /admin/lists/{local_address}`. + + + All fields are optional; only the policy is mutable at v1. The + + canonical address (name, swarm, host) is immutable for the life of + + the list — re-create + cut over if a rename is required.' AdminListPatchResponse: properties: mail_list: @@ -1275,6 +1700,38 @@ components: - metadata title: AdminListPatchResponse description: Corresponds to `PATCH /admin/lists/{local_address}`. + AdminListPostRequest: + properties: + name: + type: string + title: Name + swarm_name: + type: string + title: Swarm Name + owner: + type: string + title: Owner + members: + items: + type: string + type: array + title: Members + default: [] + policy: + $ref: '#/components/schemas/MAILListPolicy' + default: + visibility: public + join_policy: open + send_policy: open + type: object + required: + - name + - swarm_name + - owner + title: AdminListPostRequest + description: 'Corresponds to `POST /admin/lists`. + + Contains the descriptive fields needed to create a new MAIL list.' AdminListPostResponse: properties: mail_list: @@ -1322,6 +1779,28 @@ components: description: 'Corresponds to `DELETE /admin/swarms/{swarm_name}`. Contains info on the newly-deleted MAIL swarm on this server.' + AdminSwarmPostRequest: + properties: + name: + type: string + title: Name + description: + type: string + title: Description + keywords: + items: + type: string + type: array + title: Keywords + type: object + required: + - name + - description + - keywords + title: AdminSwarmPostRequest + description: 'Corresponds to `POST /admin/swarms`. + + Contains basic info necessary for new MAIL swarm creation.' AdminSwarmPostResponse: properties: swarm: @@ -1370,6 +1849,22 @@ components: description: 'Corresponds to `GET /admin/users/{user_id}`. Contains a specific MAIL user registered on this server.' + AdminUserPostRequest: + properties: + user_id: + type: string + title: User Id + user_password: + type: string + title: User Password + type: object + required: + - user_id + - user_password + title: AdminUserPostRequest + description: 'Corresponds to `POST /admin/users`. + + Contains a user ID and password to register with.' AdminUserPostResponse: properties: user: @@ -1456,6 +1951,22 @@ components: description: 'Corresponds to `GET /admin/webhooks`. Contains a list of existing webhooks by ID.' + AdminWebhooksPatchRequest: + properties: + url: + type: string + title: Url + secret: + type: string + title: Secret + type: object + required: + - url + - secret + title: AdminWebhooksPatchRequest + description: 'Corresponds to `PATCH /admin/webhooks`. + + Allows client to change URL or secret for an existing webhook.' AdminWebhooksPatchResponse: properties: webhook: @@ -1472,6 +1983,28 @@ components: description: 'Corresponds to `PATCH /admin/webhooks/{webhook_id}`. Contains information on the patched webhook.' + AdminWebhooksPostRequest: + properties: + url: + type: string + title: Url + events: + items: + type: string + type: array + title: Events + secret: + type: string + title: Secret + type: object + required: + - url + - events + - secret + title: AdminWebhooksPostRequest + description: 'Corresponds to `POST /admin/webhooks`. + + Contains info required for webhook setup.' AdminWebhooksPostResponse: properties: webhook: @@ -1501,6 +2034,22 @@ components: description: 'Corresponds to `POST /auth/logout`. Contains a message indicating operation success.' + AuthPasswordResetRequest: + properties: + current_password: + type: string + title: Current Password + new_password: + type: string + title: New Password + type: object + required: + - current_password + - new_password + title: AuthPasswordResetRequest + description: 'Corresponds to `POST /auth/password/reset`. + + Contains user''s current password and desired new password.' AuthPasswordResetResponse: properties: status: @@ -1514,6 +2063,22 @@ components: description: 'Corresponds to `POST /auth/password/reset`. Contains a message indicating operation success.' + AuthRefreshPostRequest: + properties: + refresh_token: + anyOf: + - type: string + - type: 'null' + title: Refresh Token + type: object + title: AuthRefreshPostRequest + description: 'Corresponds to `POST /auth/refresh`. + + Body fallback carrying the refresh token for clients that cannot use the + + ``httpOnly`` cookie (e.g. the CLI). Browsers send the token via cookie and + + may omit the body entirely.' AuthRefreshPostResponse: properties: access_token: @@ -1645,6 +2210,20 @@ components: - username - password title: Body_create_auth_token_auth_token_post + DaemonDeliverLocalRequest: + properties: + message_ids: + items: + type: string + type: array + title: Message Ids + type: object + required: + - message_ids + title: DaemonDeliverLocalRequest + description: 'Corresponds to `POST /daemon/deliver/local`. + + Contains a list of local message IDs to deliver to their intended local targets.' DaemonDeliverLocalResponse: properties: messages: @@ -1664,6 +2243,21 @@ components: description: 'Corresponds to `POST /daemon/deliver/local`. Contains the list of messages successfully delivered to server-local user-agents.' + DaemonDeliverRemoteRequest: + properties: + messages: + items: + $ref: '#/components/schemas/MAILMessage' + type: array + title: Messages + type: object + required: + - messages + title: DaemonDeliverRemoteRequest + description: 'Corresponds to `POST /daemon/deliver/remote`. + + Contains a list of remote MAIL messages to deliver to their intended local + targets.' DaemonDeliverRemoteResponse: properties: messages: @@ -1736,6 +2330,44 @@ components: description: 'Corresponds to `GET /drafts/{draft_id}`. Contains a specific message draft inside the user-agent''s drafts box.' + DraftPatchRequest: + properties: + subject: + anyOf: + - type: string + - type: 'null' + title: Subject + body: + anyOf: + - type: string + - type: 'null' + title: Body + reply_to: + anyOf: + - type: string + - type: 'null' + title: Reply To + tags: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Tags + type: object + title: DraftPatchRequest + description: 'Corresponds to `PATCH /drafts/{draft_id}`. + + Contains the fields to update on an existing MAIL message draft. + + + Every field is optional: a field left unset (``None``) is not modified, + + so callers can patch a single field without resending the rest. The one + + asymmetry is ``tags`` — sending ``tags: []`` clears all tags, while + + omitting ``tags`` leaves the existing tags untouched.' DraftPatchResponse: properties: entry: @@ -1752,6 +2384,40 @@ components: description: 'Corresponds to `PATCH /drafts/{draft_id}`. Contains the updated message draft in the user-agent''s drafts box.' + DraftPostRequest: + properties: + subject: + type: string + title: Subject + body: + type: string + title: Body + reply_to: + anyOf: + - type: string + - type: 'null' + title: Reply To + tags: + items: + type: string + type: array + title: Tags + default: [] + type: object + required: + - subject + - body + title: DraftPostRequest + description: 'Corresponds to `POST /drafts/`. + + Contains relevant information for creating a new MAIL message draft. + + + `reply_to` optionally references the `message_id` of the message this + + draft is replying to. `tags` is an optional list of sender-defined slug + + strings used to categorize the eventual message.' DraftPostResponse: properties: entry: @@ -1768,6 +2434,33 @@ components: description: 'Corresponds to `POST /drafts`. Contains the new entry in the user-agent''s drafts box.' + DraftSendPostRequest: + properties: + recipients: + items: + type: string + type: array + title: Recipients + tags: + items: + type: string + type: array + title: Tags + default: [] + type: object + required: + - recipients + title: DraftSendPostRequest + description: 'Corresponds to `POST /drafts/{draft_id}/send`. + + Contains relevant information for sending an existing draft as a MAIL message. + + + `tags` is an optional list of sender-defined slug strings; any tags + + supplied here are merged (union, order-preserving) with the tags already + + stored on the draft.' DraftSendPostResponse: properties: message: @@ -1911,6 +2604,27 @@ components: The updated list with the member removed (idempotent — removing a non-member is a no-op).' + ListMemberPostRequest: + properties: + member_address: + type: string + title: Member Address + type: object + required: + - member_address + title: ListMemberPostRequest + description: 'Corresponds to ``POST /lists/{local_address}/subscribe`` + + and ``POST /admin/lists/{local_address}/members``. + + + ``member_address`` is the address being added. For the public + + subscribe path, this must match the authenticated bearer + + (self-subscribe); for the admin add path, any valid MAIL address + + is accepted.' ListMemberPostResponse: properties: mail_list: diff --git a/src/mail/server/src/mail_server/routers/admin.py b/src/mail/server/src/mail_server/routers/admin.py index 38146e8..6cffb34 100644 --- a/src/mail/server/src/mail_server/routers/admin.py +++ b/src/mail/server/src/mail_server/routers/admin.py @@ -2,6 +2,14 @@ # Copyright (c) 2026 Addison Kline from fastapi import APIRouter, HTTPException, Path, Request +from mail_protocol.network.requests import ( + AdminAgentPostRequest, + AdminDaemonPostRequest, + AdminSwarmPostRequest, + AdminUserPostRequest, + AdminWebhooksPatchRequest, + AdminWebhooksPostRequest, +) from mail_protocol.network.responses import ( AdminAgentDeleteResponse, AdminAgentGetResponse, @@ -26,12 +34,6 @@ from mail_server.auth import validate_admin from mail_server.validators import ( - validate_admin_post_agent_request, - validate_admin_post_daemon_request, - validate_admin_post_swarm_request, - validate_admin_post_user_request, - validate_admin_webhook_patch_request, - validate_admin_webhook_post_request, validate_local_address_param, validate_swarm_name_param, validate_user_id_param, @@ -96,10 +98,10 @@ async def get_agent( ) async def post_agent( request: Request, + payload: AdminAgentPostRequest, ) -> AdminAgentPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - payload = await validate_admin_post_agent_request(request=request) try: result = await backend.admin_post_agent(admin=admin, payload=payload) except ValueError: @@ -193,10 +195,10 @@ async def get_daemon( ) async def post_daemon( request: Request, + payload: AdminDaemonPostRequest, ) -> AdminDaemonPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - payload = await validate_admin_post_daemon_request(request=request) try: result = await backend.admin_post_daemon(admin=admin, payload=payload) except ValueError: @@ -288,10 +290,10 @@ async def get_user( ) async def post_user( request: Request, + payload: AdminUserPostRequest, ) -> AdminUserPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - payload = await validate_admin_post_user_request(request=request) try: result = await backend.admin_post_user(admin=admin, payload=payload) except ValueError: @@ -337,10 +339,11 @@ async def delete_user( summary="Create a new MAIL swarm on this server", response_model=AdminSwarmPostResponse, ) -async def post_swarm(request: Request) -> AdminSwarmPostResponse: +async def post_swarm( + request: Request, payload: AdminSwarmPostRequest +) -> AdminSwarmPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - payload = await validate_admin_post_swarm_request(request=request) try: result = await backend.admin_post_swarm(admin=admin, payload=payload) except ValueError: @@ -428,10 +431,11 @@ async def get_webhook( summary="Create a new webhook for this server", response_model=AdminWebhooksPostResponse, ) -async def post_webhook(request: Request) -> AdminWebhooksPostResponse: +async def post_webhook( + request: Request, payload: AdminWebhooksPostRequest +) -> AdminWebhooksPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - payload = await validate_admin_webhook_post_request(request=request) result = await backend.admin_webhook_post(admin=admin, payload=payload) return AdminWebhooksPostResponse( @@ -447,6 +451,7 @@ async def post_webhook(request: Request) -> AdminWebhooksPostResponse: ) async def patch_webhook( request: Request, + payload: AdminWebhooksPatchRequest, webhook_id: str = Path( description="Webhook id (wh_).", examples=["wh_123e4567-e89b-12d3-a456-426614174000"], @@ -454,7 +459,6 @@ async def patch_webhook( ) -> AdminWebhooksPatchResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - payload = await validate_admin_webhook_patch_request(request=request) webhook_id = validate_webhook_id_param(webhook_id) try: result = await backend.admin_webhook_patch( diff --git a/src/mail/server/src/mail_server/routers/auth.py b/src/mail/server/src/mail_server/routers/auth.py index e77ce98..7dfc8a2 100644 --- a/src/mail/server/src/mail_server/routers/auth.py +++ b/src/mail/server/src/mail_server/routers/auth.py @@ -6,8 +6,12 @@ from typing import Annotated from uuid import uuid4 -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from fastapi.security.oauth2 import OAuth2PasswordRequestForm +from mail_protocol.network.requests import ( + AuthPasswordResetRequest, + AuthRefreshPostRequest, +) from mail_protocol.network.responses import ( AuthLogoutPostResponse, AuthPasswordResetResponse, @@ -28,10 +32,6 @@ set_refresh_cookie, validate_user_agent, ) -from mail_server.validators import ( - validate_auth_password_reset_request, - validate_auth_refresh_request, -) ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv("MAIL_JWT_EXPIRE_MINUTES") if ACCESS_TOKEN_EXPIRE_MINUTES is None: @@ -89,7 +89,9 @@ async def create_auth_token( ) -async def _read_refresh_token(request: Request) -> str | None: +def _read_refresh_token( + request: Request, payload: AuthRefreshPostRequest | None +) -> str | None: """ Extract the presented refresh token: the cookie (browsers) takes precedence, falling back to the request body (CLI / non-cookie clients). @@ -98,18 +100,24 @@ async def _read_refresh_token(request: Request) -> str | None: token = request.cookies.get(REFRESH_COOKIE_NAME) if token is not None: return token - payload = await validate_auth_refresh_request(request=request) - return payload.refresh_token + return payload.refresh_token if payload is not None else None @router.post( "/refresh", summary="Exchange a refresh token for a new access token (rotates the refresh token)", + description=( + "The refresh token is read from the httpOnly cookie when present " + "(browsers); the request body is the fallback for clients that cannot " + "use the cookie (e.g. the CLI). The body may be omitted entirely when " + "the cookie carries the token." + ), response_model=AuthRefreshPostResponse, ) async def post_auth_refresh( request: Request, response: Response, + payload: AuthRefreshPostRequest | None = Body(default=None), ) -> AuthRefreshPostResponse: backend = request.app.state.backend credentials_exception = HTTPException( @@ -118,7 +126,7 @@ async def post_auth_refresh( headers={"WWW-Authenticate": "Bearer"}, ) - token = await _read_refresh_token(request) + token = _read_refresh_token(request, payload) if token is None: raise credentials_exception @@ -165,17 +173,23 @@ async def post_auth_refresh( @router.post( "/logout", summary="Revoke the presented refresh token's family and clear the cookie", + description=( + "The refresh token is read from the httpOnly cookie when present " + "(browsers), falling back to the request body. The body may be omitted " + "entirely; logout always succeeds and clears the cookie regardless." + ), response_model=AuthLogoutPostResponse, ) async def post_auth_logout( request: Request, response: Response, + payload: AuthRefreshPostRequest | None = Body(default=None), ) -> AuthLogoutPostResponse: backend = request.app.state.backend # Idempotent: revoke the family if the token resolves, but always succeed and # clear the cookie so a stale/absent token still logs the client out. - token = await _read_refresh_token(request) + token = _read_refresh_token(request, payload) if token is not None: record = await backend.get_refresh_token(hash_refresh_token(token)) if record is not None: @@ -202,10 +216,11 @@ async def get_token_info(request: Request) -> AuthWhoamiGetResponse: summary="Reset the user-agent's password", response_model=AuthPasswordResetResponse, ) -async def post_password_reset(request: Request) -> AuthPasswordResetResponse: +async def post_password_reset( + request: Request, payload: AuthPasswordResetRequest +) -> AuthPasswordResetResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - payload = await validate_auth_password_reset_request(request=request) try: result = await backend.reset_password(user_agent=user_agent, payload=payload) except ValueError: diff --git a/src/mail/server/src/mail_server/routers/daemon.py b/src/mail/server/src/mail_server/routers/daemon.py index 8d2d772..1820169 100644 --- a/src/mail/server/src/mail_server/routers/daemon.py +++ b/src/mail/server/src/mail_server/routers/daemon.py @@ -2,6 +2,10 @@ # Copyright (c) 2026 Addison Kline from fastapi import APIRouter, Request +from mail_protocol.network.requests import ( + DaemonDeliverLocalRequest, + DaemonDeliverRemoteRequest, +) from mail_protocol.network.responses import ( DaemonDeliverLocalResponse, DaemonDeliverRemoteResponse, @@ -9,10 +13,6 @@ ) from mail_server.auth import validate_daemon -from mail_server.validators import ( - validate_deliver_local_request, - validate_deliver_remote_request, -) router = APIRouter(prefix="/daemon", tags=["daemon"]) @@ -39,10 +39,11 @@ async def clear_message_buffer( summary="Upload new messages to deliver from local agent(s)", response_model=DaemonDeliverLocalResponse, ) -async def deliver_local_messages(request: Request) -> DaemonDeliverLocalResponse: +async def deliver_local_messages( + request: Request, payload: DaemonDeliverLocalRequest +) -> DaemonDeliverLocalResponse: backend = request.app.state.backend daemon = await validate_daemon(backend=backend, request=request) - payload = await validate_deliver_local_request(request=request) result = await backend.daemon_deliver_local(daemon=daemon, payload=payload) return DaemonDeliverLocalResponse( messages=result, @@ -55,10 +56,11 @@ async def deliver_local_messages(request: Request) -> DaemonDeliverLocalResponse summary="Upload new messages to deliver from remote agent(s)", response_model=DaemonDeliverRemoteResponse, ) -async def deliver_remote_messages(request: Request) -> DaemonDeliverRemoteResponse: +async def deliver_remote_messages( + request: Request, payload: DaemonDeliverRemoteRequest +) -> DaemonDeliverRemoteResponse: backend = request.app.state.backend daemon = await validate_daemon(backend=backend, request=request) - payload = await validate_deliver_remote_request(request=request) result = await backend.daemon_deliver_remote(daemon=daemon, payload=payload) return DaemonDeliverRemoteResponse( messages=result, diff --git a/src/mail/server/src/mail_server/routers/drafts.py b/src/mail/server/src/mail_server/routers/drafts.py index 9a1f670..e3055cf 100644 --- a/src/mail/server/src/mail_server/routers/drafts.py +++ b/src/mail/server/src/mail_server/routers/drafts.py @@ -1,7 +1,15 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 Addison Kline -from fastapi import APIRouter, HTTPException, Request +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Request +from mail_protocol.network.requests import ( + BoxFilterParams, + DraftPatchRequest, + DraftPostRequest, + DraftSendPostRequest, +) from mail_protocol.network.responses import ( DraftDeleteResponse, DraftGetResponse, @@ -13,12 +21,6 @@ from mail_server.auth import validate_user_agent from mail_server.utils import build_box_metadata -from mail_server.validators import ( - validate_box_filter_params, - validate_patch_draft_request, - validate_post_draft_request, - validate_post_draft_send_request, -) router = APIRouter(prefix="/drafts", tags=["drafts"]) @@ -26,10 +28,11 @@ @router.get( "", summary="Get a list of message drafts", response_model=DraftsGetResponse ) -async def get_drafts(request: Request) -> DraftsGetResponse: +async def get_drafts( + request: Request, filters: Annotated[BoxFilterParams, Query()] +) -> DraftsGetResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - filters = await validate_box_filter_params(request) if filters.sort_by == "sent_at": raise HTTPException( status_code=422, @@ -49,10 +52,9 @@ async def get_drafts(request: Request) -> DraftsGetResponse: @router.post("", summary="Create a new message draft", response_model=DraftPostResponse) -async def post_draft(request: Request) -> DraftPostResponse: +async def post_draft(request: Request, payload: DraftPostRequest) -> DraftPostResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - payload = await validate_post_draft_request(request) result = await backend.post_draft(user_agent=user_agent, payload=payload) return DraftPostResponse( @@ -88,10 +90,11 @@ async def get_draft(request: Request) -> DraftGetResponse: summary="Update a specific message draft by ID", response_model=DraftPatchResponse, ) -async def patch_draft(request: Request) -> DraftPatchResponse: +async def patch_draft( + request: Request, payload: DraftPatchRequest +) -> DraftPatchResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - payload = await validate_patch_draft_request(request) draft_id = request.path_params.get("draft_id") try: result = await backend.patch_draft( @@ -135,10 +138,11 @@ async def delete_draft(request: Request) -> DraftDeleteResponse: summary="Send a message from an existing draft by ID", response_model=DraftSendPostResponse, ) -async def post_draft_send(request: Request) -> DraftSendPostResponse: +async def post_draft_send( + request: Request, payload: DraftSendPostRequest +) -> DraftSendPostResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - payload = await validate_post_draft_send_request(request) draft_id = request.path_params.get("draft_id") try: result = await backend.send_draft( diff --git a/src/mail/server/src/mail_server/routers/inbox.py b/src/mail/server/src/mail_server/routers/inbox.py index 3702737..5230850 100644 --- a/src/mail/server/src/mail_server/routers/inbox.py +++ b/src/mail/server/src/mail_server/routers/inbox.py @@ -1,7 +1,10 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 Addison Kline -from fastapi import APIRouter, HTTPException, Request +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Request +from mail_protocol.network.requests import BoxFilterParams from mail_protocol.network.responses import ( InboxGetResponse, InboxMessageDeleteResponse, @@ -10,7 +13,6 @@ from mail_server.auth import validate_user_agent from mail_server.utils import build_box_metadata -from mail_server.validators import validate_box_filter_params router = APIRouter(prefix="/inbox", tags=["inbox"]) @@ -20,10 +22,11 @@ summary="Get a list of inbox messages", response_model=InboxGetResponse, ) -async def get_inbox(request: Request) -> InboxGetResponse: +async def get_inbox( + request: Request, filters: Annotated[BoxFilterParams, Query()] +) -> InboxGetResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - filters = await validate_box_filter_params(request) try: entries, total = await backend.get_inbox(user_agent, filters) except ValueError: diff --git a/src/mail/server/src/mail_server/routers/lists.py b/src/mail/server/src/mail_server/routers/lists.py index 7eba721..f6e05cd 100644 --- a/src/mail/server/src/mail_server/routers/lists.py +++ b/src/mail/server/src/mail_server/routers/lists.py @@ -3,6 +3,11 @@ from fastapi import APIRouter, HTTPException, Path, Request from mail_protocol.core.lists import MAILListPolicy +from mail_protocol.network.requests import ( + AdminListPatchRequest, + AdminListPostRequest, + ListMemberPostRequest, +) from mail_protocol.network.responses import ( AdminListDeleteResponse, AdminListGetResponse, @@ -18,9 +23,6 @@ from mail_server.auth import validate_admin, validate_user_agent from mail_server.backends.base import MAILServerBackend from mail_server.validators import ( - validate_admin_patch_list_request, - validate_admin_post_list_request, - validate_list_member_post_request, validate_local_address_param, validate_member_address_param, ) @@ -125,10 +127,11 @@ async def admin_get_list( summary="Create a new MAIL list on this server", response_model=AdminListPostResponse, ) -async def admin_post_list(request: Request) -> AdminListPostResponse: +async def admin_post_list( + request: Request, payload: AdminListPostRequest +) -> AdminListPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) - payload = await validate_admin_post_list_request(request=request) _reject_unsupported_policy(payload.policy) try: result = await backend.admin_post_list(admin=admin, payload=payload) @@ -143,14 +146,15 @@ async def admin_post_list(request: Request) -> AdminListPostResponse: response_model=AdminListPatchResponse, ) async def admin_patch_list( - request: Request, local_address: str = _LOCAL_ADDRESS_PATH + request: Request, + payload: AdminListPatchRequest, + local_address: str = _LOCAL_ADDRESS_PATH, ) -> AdminListPatchResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) list_address = _full_list_address( backend, validate_local_address_param(local_address) ) - payload = await validate_admin_patch_list_request(request=request) _reject_unsupported_policy(payload.policy) try: result = await backend.admin_patch_list( @@ -187,7 +191,9 @@ async def admin_delete_list( response_model=ListMemberPostResponse, ) async def admin_add_list_member( - request: Request, local_address: str = _LOCAL_ADDRESS_PATH + request: Request, + payload: ListMemberPostRequest, + local_address: str = _LOCAL_ADDRESS_PATH, ) -> ListMemberPostResponse: backend = request.app.state.backend admin = await validate_admin(backend=backend, request=request) @@ -195,7 +201,6 @@ async def admin_add_list_member( list_address = _full_list_address( backend, validate_local_address_param(local_address) ) - payload = await validate_list_member_post_request(request=request) try: result = await backend.add_list_member( list_address=list_address, diff --git a/src/mail/server/src/mail_server/routers/outbox.py b/src/mail/server/src/mail_server/routers/outbox.py index f4e1722..4e721b9 100644 --- a/src/mail/server/src/mail_server/routers/outbox.py +++ b/src/mail/server/src/mail_server/routers/outbox.py @@ -1,12 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 Addison Kline -from fastapi import APIRouter, HTTPException, Request +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Request +from mail_protocol.network.requests import BoxFilterParams from mail_protocol.network.responses import OutboxGetResponse, OutboxMessageGetResponse from mail_server.auth import validate_user_agent from mail_server.utils import build_box_metadata -from mail_server.validators import validate_box_filter_params router = APIRouter(prefix="/outbox", tags=["outbox"]) @@ -14,10 +16,11 @@ @router.get( "", summary="Get a list of outbox messages", response_model=OutboxGetResponse ) -async def get_outbox(request: Request) -> OutboxGetResponse: +async def get_outbox( + request: Request, filters: Annotated[BoxFilterParams, Query()] +) -> OutboxGetResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - filters = await validate_box_filter_params(request) try: entries, total = await backend.get_outbox(user_agent, filters) except ValueError: diff --git a/src/mail/server/src/mail_server/routers/trash.py b/src/mail/server/src/mail_server/routers/trash.py index 6451b8d..0be09d4 100644 --- a/src/mail/server/src/mail_server/routers/trash.py +++ b/src/mail/server/src/mail_server/routers/trash.py @@ -1,7 +1,10 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 Addison Kline -from fastapi import APIRouter, HTTPException, Request +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Request +from mail_protocol.network.requests import BoxFilterParams from mail_protocol.network.responses import ( TrashClearPostResponse, TrashGetResponse, @@ -11,7 +14,6 @@ from mail_server.auth import validate_user_agent from mail_server.utils import build_box_metadata -from mail_server.validators import validate_box_filter_params router = APIRouter(prefix="/trash", tags=["trash"]) @@ -19,10 +21,11 @@ @router.get( "", summary="Get a list of messages in trash", response_model=TrashGetResponse ) -async def get_trashed_messages(request: Request) -> TrashGetResponse: +async def get_trashed_messages( + request: Request, filters: Annotated[BoxFilterParams, Query()] +) -> TrashGetResponse: backend = request.app.state.backend user_agent = await validate_user_agent(backend=backend, request=request) - filters = await validate_box_filter_params(request) try: entries, total = await backend.get_trash(user_agent, filters) except ValueError: diff --git a/src/mail/server/src/mail_server/validators.py b/src/mail/server/src/mail_server/validators.py index 9a7c6e6..9ebe531 100644 --- a/src/mail/server/src/mail_server/validators.py +++ b/src/mail/server/src/mail_server/validators.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2026 Addison Kline -from fastapi import HTTPException, Request +from fastapi import HTTPException from mail_protocol.core.validators import ( validate_daemon_worker_name, validate_local_address, @@ -10,50 +10,11 @@ validate_user_name, validate_webhook_id, ) -from mail_protocol.network.requests import ( - AdminAgentPostRequest, - AdminDaemonPostRequest, - AdminListPatchRequest, - AdminListPostRequest, - AdminSwarmPostRequest, - AdminUserPostRequest, - AdminWebhooksPatchRequest, - AdminWebhooksPostRequest, - AuthPasswordResetRequest, - AuthRefreshPostRequest, - BoxFilterParams, - DaemonDeliverLocalRequest, - DaemonDeliverRemoteRequest, - DraftPatchRequest, - DraftPostRequest, - DraftSendPostRequest, - ListMemberPostRequest, -) -# NOTE: the except clauses below catch ValueError, which covers both -# pydantic.ValidationError and json.JSONDecodeError — an unparseable -# body must 422 the same way an invalid one does. - - -# -# Query parameter validators -# -async def validate_box_filter_params(request: Request) -> BoxFilterParams: - """ - Ensure the query string is valid for the "GET box" endpoints - (`GET /inbox`, `GET /outbox`, `GET /trash`, `GET /drafts`). - - `BoxFilterParams` declares `extra="forbid"`, so an unknown query - parameter 422s the same way an out-of-bounds `limit` does. Values - arrive as strings; pydantic coerces and bounds-checks them. - """ - - try: - return BoxFilterParams.model_validate(dict(request.query_params)) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"query parameter validation failed: {e}" - ) +# Request bodies and query strings are validated by FastAPI from the typed +# parameters declared on each handler (the models live in +# ``mail_protocol.network.requests``). The helpers below cover only path +# parameters, which the handlers still validate explicitly. # @@ -63,8 +24,8 @@ async def validate_box_filter_params(request: Request) -> BoxFilterParams: # identifier — the host is implied by the server and the user-agent # prefix (``daemon:``/``user:``/``list:``) is implied by the route. # These helpers validate the shape of a path segment and 422 on -# malformed input, mirroring the body/query validators above. A -# well-formed-but-unknown id still 404s downstream. +# malformed input, mirroring the body/query validation FastAPI performs. +# A well-formed-but-unknown id still 404s downstream. # def validate_local_address_param(value: str) -> str: """ @@ -144,272 +105,3 @@ def validate_member_address_param(value: str) -> str: raise HTTPException( status_code=422, detail=f"invalid member address path parameter: {e}" ) - - -# -# Draft endpoint validators -# -async def validate_post_draft_request(request: Request) -> DraftPostRequest: - """ - Ensure the request payload is valid for `POST /drafts`. - """ - - try: - body = await request.json() - return DraftPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_patch_draft_request(request: Request) -> DraftPatchRequest: - """ - Ensure the request payload is valid for `PATCH /drafts/{draft_id}`. - """ - - try: - body = await request.json() - return DraftPatchRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_post_draft_send_request(request: Request) -> DraftSendPostRequest: - """ - Ensure the request payload is valid for `POST /drafts/{draft_id}/send`. - """ - - try: - body = await request.json() - return DraftSendPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -# -# Daemon endpoint validators -# -async def validate_deliver_local_request( - request: Request, -) -> DaemonDeliverLocalRequest: - """ - Ensure that the request payload is valid for `POST /daemon/deliver/local`. - """ - - try: - body = await request.json() - return DaemonDeliverLocalRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_deliver_remote_request( - request: Request, -) -> DaemonDeliverRemoteRequest: - """ - Ensure that the request payload is valid for `POST /daemon/deliver/remote`. - """ - - try: - body = await request.json() - return DaemonDeliverRemoteRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -# -# Admin endpoint validators -# -async def validate_admin_post_agent_request( - request: Request, -) -> AdminAgentPostRequest: - """ - Ensure that the request payload is valid for `POST /admin/agents`. - """ - - try: - body = await request.json() - return AdminAgentPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_admin_post_daemon_request( - request: Request, -) -> AdminDaemonPostRequest: - """ - Ensure that the request payload is valid for `POST /admin/daemons`. - """ - - try: - body = await request.json() - return AdminDaemonPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_admin_post_user_request( - request: Request, -) -> AdminUserPostRequest: - """ - Ensure that the request payload is valid for `POST /admin/users`. - """ - - try: - body = await request.json() - return AdminUserPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_admin_post_swarm_request( - request: Request, -) -> AdminSwarmPostRequest: - """ - Ensure that the request payload is valid for `POST /admin/swarms`. - """ - - try: - body = await request.json() - return AdminSwarmPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_admin_webhook_post_request( - request: Request, -) -> AdminWebhooksPostRequest: - """ - Ensure that the request payload is valid for `POST /admin/webhooks`. - """ - - try: - body = await request.json() - return AdminWebhooksPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_admin_webhook_patch_request( - request: Request, -) -> AdminWebhooksPatchRequest: - """ - Ensure that the given request payload is valid for `PATCH /admin/webhooks`. - """ - - try: - body = await request.json() - return AdminWebhooksPatchRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_admin_post_list_request( - request: Request, -) -> AdminListPostRequest: - """ - Ensure that the request payload is valid for `POST /admin/lists`. - """ - - try: - body = await request.json() - return AdminListPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_admin_patch_list_request( - request: Request, -) -> AdminListPatchRequest: - """ - Ensure that the request payload is valid for `PATCH /admin/lists/{local_address}`. - """ - - try: - body = await request.json() - return AdminListPatchRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_list_member_post_request( - request: Request, -) -> ListMemberPostRequest: - """ - Ensure that the request payload is valid for the member-add endpoints - (`POST /admin/lists/{local_address}/members` and the subscribe variant). - """ - - try: - body = await request.json() - return ListMemberPostRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -# -# Auth endpoint validators -# -async def validate_auth_password_reset_request( - request: Request, -) -> AuthPasswordResetRequest: - """ - Ensure that the request payload is valid for `POST /auth/password/reset`. - """ - - try: - body = await request.json() - return AuthPasswordResetRequest.model_validate(body) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) - - -async def validate_auth_refresh_request( - request: Request, -) -> AuthRefreshPostRequest: - """ - Ensure that the request payload is valid for `POST /auth/refresh`. - - An empty body is allowed: browsers carry the refresh token in the - ``httpOnly`` cookie and may send no body at all. A non-empty body must still - be valid JSON for the model, otherwise 422. - """ - - raw = await request.body() - if not raw: - return AuthRefreshPostRequest() - try: - return AuthRefreshPostRequest.model_validate_json(raw) - except ValueError as e: - raise HTTPException( - status_code=422, detail=f"request body validation failed: {e}" - ) diff --git a/tests/contract/test_openapi_request_bodies.py b/tests/contract/test_openapi_request_bodies.py new file mode 100644 index 0000000..3cc8a51 --- /dev/null +++ b/tests/contract/test_openapi_request_bodies.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Charon Labs (contribution PR) + +""" +Guards that every body-bearing POST/PATCH endpoint documents its request +body in the OpenAPI schema, and that the "GET box" endpoints document their +query parameters. + +Handlers that take only ``request: Request`` and parse the body/query string +by hand produce no schema for it, so the bodies silently vanish from +``/docs``. This test fails if that regression is reintroduced. +""" + +import pytest + +# (path, method) for every endpoint that MUST advertise a JSON request body. +ENDPOINTS_WITH_BODY = [ + ("/auth/refresh", "post"), + ("/auth/logout", "post"), + ("/auth/password/reset", "post"), + ("/drafts", "post"), + ("/drafts/{draft_id}", "patch"), + ("/drafts/{draft_id}/send", "post"), + ("/daemon/deliver/local", "post"), + ("/daemon/deliver/remote", "post"), + ("/admin/agents", "post"), + ("/admin/daemons", "post"), + ("/admin/users", "post"), + ("/admin/swarms", "post"), + ("/admin/webhooks", "post"), + ("/admin/webhooks/{webhook_id}", "patch"), + ("/admin/lists", "post"), + ("/admin/lists/{local_address}", "patch"), + ("/admin/lists/{local_address}/members", "post"), +] + +# Endpoints that intentionally take no body — the member is derived from the +# authenticated caller, or the action has no parameters. These must NOT grow +# a request body. +ENDPOINTS_WITHOUT_BODY = [ + ("/trash/clear", "post"), + ("/daemon/message-buffer/clear", "post"), + ("/lists/{local_address}/subscribe", "post"), + ("/lists/{local_address}/unsubscribe", "post"), +] + +# "GET box" endpoints whose BoxFilterParams query params must be documented. +BOX_GET_PATHS = ["/inbox", "/outbox", "/trash", "/drafts"] +BOX_QUERY_PARAMS = {"limit", "offset", "sort_by", "order"} + + +@pytest.fixture(scope="module") +def schema() -> dict: + from mail_server.server import app + + return app.openapi() + + +@pytest.mark.parametrize("path,method", ENDPOINTS_WITH_BODY) +def test_endpoint_documents_request_body(schema: dict, path: str, method: str) -> None: + operation = schema["paths"][path][method] + assert "requestBody" in operation, ( + f"{method.upper()} {path} has no documented request body — the handler " + "likely takes only `request: Request` and parses the body by hand. " + "Declare the request model as a typed parameter." + ) + content = operation["requestBody"].get("content", {}) + assert "application/json" in content, ( + f"{method.upper()} {path} request body is not application/json: " + f"{list(content)}" + ) + assert content["application/json"].get("schema"), ( + f"{method.upper()} {path} request body has no schema" + ) + + +@pytest.mark.parametrize("path,method", ENDPOINTS_WITHOUT_BODY) +def test_bodyless_endpoint_has_no_request_body( + schema: dict, path: str, method: str +) -> None: + operation = schema["paths"][path][method] + assert "requestBody" not in operation, ( + f"{method.upper()} {path} unexpectedly advertises a request body; " + "this endpoint is supposed to take no body." + ) + + +@pytest.mark.parametrize("path", BOX_GET_PATHS) +def test_box_get_documents_query_params(schema: dict, path: str) -> None: + params = { + p["name"] + for p in schema["paths"][path]["get"].get("parameters", []) + if p["in"] == "query" + } + missing = BOX_QUERY_PARAMS - params + assert not missing, ( + f"GET {path} is missing query params {missing} in the schema — the " + "handler likely parses the query string by hand instead of declaring " + "BoxFilterParams as a typed Query() parameter." + )