From f5465b410ac944236c3bc626503cad45c9a7a8d4 Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 15:58:18 -0400 Subject: [PATCH 1/8] demo: wire in CLI generation (fern-cli) to local output --- fern/generators.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/fern/generators.yml b/fern/generators.yml index a5f3a44f..465e9711 100644 --- a/fern/generators.yml +++ b/fern/generators.yml @@ -88,6 +88,16 @@ groups: license = "MIT" homepage = "https://agentmail.to" smart-casing: false + cli: + generators: + - name: fernapi/fern-cli + version: 0.4.0 + output: + location: local-file-system + path: generated/cli + config: + binaryName: agentmail + smart-casing: false openapi: generators: - name: fernapi/fern-openapi From eb2c28e2a11aa6edbe954bb2c33b8f4a6db64e02 Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 16:31:44 -0400 Subject: [PATCH 2/8] interim --- fern/fern.config.json | 2 +- fern/generators.yml | 4 + fern/openapi.json | 6961 +++++++++++++++++++++++++++++++++++++++++ fern/overlay.yaml | 354 +++ 4 files changed, 7320 insertions(+), 1 deletion(-) create mode 100644 fern/openapi.json create mode 100644 fern/overlay.yaml diff --git a/fern/fern.config.json b/fern/fern.config.json index 37a78f6e..2849198d 100644 --- a/fern/fern.config.json +++ b/fern/fern.config.json @@ -1,4 +1,4 @@ { "organization": "agentmail", - "version": "4.63.0" + "version": "5.38.1" } diff --git a/fern/generators.yml b/fern/generators.yml index 465e9711..fc5fe01f 100644 --- a/fern/generators.yml +++ b/fern/generators.yml @@ -1,4 +1,8 @@ # yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: openapi.json + overrides: overlay.yaml default-group: local groups: local: diff --git a/fern/openapi.json b/fern/openapi.json new file mode 100644 index 00000000..cf92634b --- /dev/null +++ b/fern/openapi.json @@ -0,0 +1,6961 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "api", + "version": "" + }, + "paths": { + "/v0/inboxes": { + "get": { + "operationId": "inboxes_list", + "tags": [ + "Inboxes" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesListInboxesResponse" + } + } + } + } + }, + "summary": "List Inboxes", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "inboxes_create", + "tags": [ + "Inboxes" + ], + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesInbox" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "summary": "Create Inbox", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesCreateInboxRequest", + "nullable": true + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}": { + "get": { + "operationId": "inboxes_get", + "tags": [ + "Inboxes" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesInbox" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Inbox", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "patch": { + "operationId": "inboxes_update", + "tags": [ + "Inboxes" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesInbox" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Update Inbox", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesUpdateInboxRequest" + } + } + } + } + }, + "delete": { + "operationId": "inboxes_delete", + "tags": [ + "Inboxes" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Inbox", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods": { + "get": { + "operationId": "pods_list", + "tags": [ + "Pods" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/podsListPodsResponse" + } + } + } + } + }, + "summary": "List Pods", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "pods_create", + "tags": [ + "Pods" + ], + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/podsPod" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "summary": "Create Pod", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/podsCreatePodRequest" + } + } + } + } + } + }, + "/v0/pods/{pod_id}": { + "get": { + "operationId": "pods_get", + "tags": [ + "Pods" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/podsPod" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Pod", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "operationId": "pods_delete", + "tags": [ + "Pods" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Pod", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/webhooks": { + "get": { + "operationId": "webhooks_list", + "tags": [ + "Webhooks" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhooksListWebhooksResponse" + } + } + } + } + }, + "summary": "List Webhooks", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "webhooks_create", + "tags": [ + "Webhooks" + ], + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhooksWebhook" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "summary": "Create Webhook", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhooksCreateWebhookRequest" + } + } + } + } + } + }, + "/v0/webhooks/{webhook_id}": { + "get": { + "operationId": "webhooks_get", + "tags": [ + "Webhooks" + ], + "parameters": [ + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/webhooksWebhookId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhooksWebhook" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Webhook", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "patch": { + "operationId": "webhooks_update", + "tags": [ + "Webhooks" + ], + "parameters": [ + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/webhooksWebhookId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhooksWebhook" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Update Webhook", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/webhooksUpdateWebhookRequest" + } + } + } + } + }, + "delete": { + "operationId": "webhooks_delete", + "tags": [ + "Webhooks" + ], + "parameters": [ + { + "name": "webhook_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/webhooksWebhookId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Webhook", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/api-keys": { + "get": { + "operationId": "apiKeys_list", + "tags": [ + "ApiKeys" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListApiKeysResponse" + } + } + } + } + }, + "summary": "List API Keys", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "apiKeys_create", + "tags": [ + "ApiKeys" + ], + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "summary": "Create API Key", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateApiKeyRequest" + } + } + } + } + } + }, + "/v0/api-keys/{api_key}": { + "delete": { + "operationId": "apiKeys_delete", + "tags": [ + "ApiKeys" + ], + "parameters": [ + { + "name": "api_key", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ApiKeyId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete API Key", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/domains": { + "get": { + "operationId": "domains_list", + "tags": [ + "Domains" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDomainsResponse" + } + } + } + } + }, + "summary": "List Domains", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "domains_create", + "tags": [ + "Domains" + ], + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Domain" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "summary": "Create Domain", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDomainRequest" + } + } + } + } + } + }, + "/v0/domains/{domain_id}": { + "get": { + "operationId": "domains_get", + "tags": [ + "Domains" + ], + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DomainId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Domain" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Domain", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "operationId": "domains_delete", + "tags": [ + "Domains" + ], + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DomainId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Domain", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/domains/{domain_id}/zone-file": { + "get": { + "operationId": "domains_getZoneFile", + "tags": [ + "Domains" + ], + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DomainId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Zone File", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/domains/{domain_id}/verify": { + "post": { + "operationId": "domains_verify", + "tags": [ + "Domains" + ], + "parameters": [ + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DomainId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Verify Domain", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/drafts": { + "get": { + "operationId": "drafts_list", + "tags": [ + "Drafts" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Labels", + "nullable": true + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Before", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/After", + "nullable": true + } + }, + { + "name": "ascending", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Ascending", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDraftsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Drafts", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/drafts/{draft_id}": { + "get": { + "operationId": "drafts_get", + "tags": [ + "Drafts" + ], + "parameters": [ + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DraftId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Draft" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Draft", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/drafts": { + "get": { + "operationId": "inboxes_drafts_list", + "tags": [ + "InboxesDrafts" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Labels", + "nullable": true + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Before", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/After", + "nullable": true + } + }, + { + "name": "ascending", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Ascending", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDraftsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Drafts", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "inboxes_drafts_create", + "tags": [ + "InboxesDrafts" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Draft" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Create Draft", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDraftRequest" + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}/drafts/{draft_id}": { + "get": { + "operationId": "inboxes_drafts_get", + "tags": [ + "InboxesDrafts" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DraftId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Draft" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Draft", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "patch": { + "operationId": "inboxes_drafts_update", + "tags": [ + "InboxesDrafts" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DraftId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Draft" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Update Draft", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDraftRequest" + } + } + } + } + }, + "delete": { + "operationId": "inboxes_drafts_delete", + "tags": [ + "InboxesDrafts" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DraftId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Draft", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/drafts/{draft_id}/send": { + "post": { + "operationId": "inboxes_drafts_send", + "tags": [ + "InboxesDrafts" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DraftId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Send Draft", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMessageRequest" + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}/messages": { + "get": { + "operationId": "inboxes_messages_list", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Labels", + "nullable": true + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Before", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/After", + "nullable": true + } + }, + { + "name": "ascending", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Ascending", + "nullable": true + } + }, + { + "name": "include_spam", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/IncludeSpam", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMessagesResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Messages", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/messages/{message_id}": { + "get": { + "operationId": "inboxes_messages_get", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Message", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "patch": { + "operationId": "inboxes_messages_update", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Update Message", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMessageRequest" + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}": { + "get": { + "operationId": "inboxes_messages_getAttachment", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageId" + } + }, + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/AttachmentId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Attachment", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/messages/{message_id}/raw": { + "get": { + "operationId": "inboxes_messages_getRaw", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RawMessageResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Raw Message", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/messages/send": { + "post": { + "operationId": "inboxes_messages_send", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Send Message", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageRequest" + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}/messages/{message_id}/reply": { + "post": { + "operationId": "inboxes_messages_reply", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Reply To Message", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyToMessageRequest" + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all": { + "post": { + "operationId": "inboxes_messages_reply-all", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Reply All Message", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyAllMessageRequest" + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}/messages/{message_id}/forward": { + "post": { + "operationId": "inboxes_messages_forward", + "tags": [ + "InboxesMessages" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Forward Message", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageRequest" + } + } + } + } + } + }, + "/v0/inboxes/{inbox_id}/metrics": { + "get": { + "operationId": "inboxes_metrics_get", + "tags": [ + "InboxesMetrics" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "event_types", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/MetricEventTypes", + "nullable": true + } + }, + { + "name": "start_timestamp", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MetricStartTimestamp" + } + }, + { + "name": "end_timestamp", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MetricEndTimestamp" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMetricsResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Metrics", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/threads": { + "get": { + "operationId": "inboxes_threads_list", + "tags": [ + "InboxesThreads" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Labels", + "nullable": true + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Before", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/After", + "nullable": true + } + }, + { + "name": "ascending", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Ascending", + "nullable": true + } + }, + { + "name": "include_spam", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/IncludeSpam", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListThreadsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Threads", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/threads/{thread_id}": { + "get": { + "operationId": "inboxes_threads_get", + "tags": [ + "InboxesThreads" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "thread_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ThreadId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Thread" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Thread", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "operationId": "inboxes_threads_delete", + "tags": [ + "InboxesThreads" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "thread_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ThreadId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Thread", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}": { + "get": { + "operationId": "inboxes_threads_getAttachment", + "tags": [ + "InboxesThreads" + ], + "parameters": [ + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + }, + { + "name": "thread_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ThreadId" + } + }, + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/AttachmentId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Attachment", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/metrics": { + "get": { + "operationId": "metrics_list", + "tags": [ + "Metrics" + ], + "parameters": [ + { + "name": "event_types", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/MetricEventTypes", + "nullable": true + } + }, + { + "name": "start_timestamp", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MetricStartTimestamp" + } + }, + { + "name": "end_timestamp", + "in": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/MetricEndTimestamp" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListMetricsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Metrics", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/organizations": { + "get": { + "description": "Get the current organization.", + "operationId": "organizations_get", + "tags": [ + "Organizations" + ], + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Organization" + } + } + } + } + }, + "summary": "Get Organization", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods/{pod_id}/domains": { + "get": { + "operationId": "pods_domains_list", + "tags": [ + "PodsDomains" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDomainsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Domains", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "pods_domains_create", + "tags": [ + "PodsDomains" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Domain" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "summary": "Create Domain", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDomainRequest" + } + } + } + } + } + }, + "/v0/pods/{pod_id}/domains/{domain_id}": { + "delete": { + "operationId": "pods_domains_delete", + "tags": [ + "PodsDomains" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "domain_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DomainId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Domain", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods/{pod_id}/drafts": { + "get": { + "operationId": "pods_drafts_list", + "tags": [ + "PodsDrafts" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Labels", + "nullable": true + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Before", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/After", + "nullable": true + } + }, + { + "name": "ascending", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Ascending", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListDraftsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Drafts", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods/{pod_id}/drafts/{draft_id}": { + "get": { + "operationId": "pods_drafts_get", + "tags": [ + "PodsDrafts" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/DraftId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Draft" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Draft", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods/{pod_id}/inboxes": { + "get": { + "operationId": "pods_inboxes_list", + "tags": [ + "PodsInboxes" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesListInboxesResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Inboxes", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "post": { + "operationId": "pods_inboxes_create", + "tags": [ + "PodsInboxes" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesInbox" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + }, + "summary": "Create Inbox", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesCreateInboxRequest" + } + } + } + } + } + }, + "/v0/pods/{pod_id}/inboxes/{inbox_id}": { + "get": { + "operationId": "pods_inboxes_get", + "tags": [ + "PodsInboxes" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inboxesInbox" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Inbox", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + }, + "delete": { + "operationId": "pods_inboxes_delete", + "tags": [ + "PodsInboxes" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "inbox_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/inboxesInboxId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Delete Inbox", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods/{pod_id}/threads": { + "get": { + "operationId": "pods_threads_list", + "tags": [ + "PodsThreads" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Labels", + "nullable": true + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Before", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/After", + "nullable": true + } + }, + { + "name": "ascending", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Ascending", + "nullable": true + } + }, + { + "name": "include_spam", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/IncludeSpam", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListThreadsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Threads", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods/{pod_id}/threads/{thread_id}": { + "get": { + "operationId": "pods_threads_get", + "tags": [ + "PodsThreads" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "thread_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ThreadId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Thread" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Thread", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}": { + "get": { + "operationId": "pods_threads_getAttachment", + "tags": [ + "PodsThreads" + ], + "parameters": [ + { + "name": "pod_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/podsPodId" + } + }, + { + "name": "thread_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ThreadId" + } + }, + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/AttachmentId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Attachment", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/threads": { + "get": { + "operationId": "threads_list", + "tags": [ + "Threads" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Limit", + "nullable": true + } + }, + { + "name": "page_token", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + } + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Labels", + "nullable": true + } + }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Before", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/After", + "nullable": true + } + }, + { + "name": "ascending", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/Ascending", + "nullable": true + } + }, + { + "name": "include_spam", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/IncludeSpam", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListThreadsResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "List Threads", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/threads/{thread_id}": { + "get": { + "operationId": "threads_get", + "tags": [ + "Threads" + ], + "parameters": [ + { + "name": "thread_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ThreadId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Thread" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Thread", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + }, + "/v0/threads/{thread_id}/attachments/{attachment_id}": { + "get": { + "operationId": "threads_getAttachment", + "tags": [ + "Threads" + ], + "parameters": [ + { + "name": "thread_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ThreadId" + } + }, + { + "name": "attachment_id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/AttachmentId" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentResponse" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "summary": "Get Attachment", + "servers": [ + { + "url": "https://api.agentmail.to" + }, + { + "url": "https://api.agentmail.dev" + } + ], + "security": [ + { + "BearerAuth": [] + } + ] + } + } + }, + "components": { + "schemas": { + "Limit": { + "title": "Limit", + "type": "integer", + "description": "Limit of number of items returned." + }, + "Count": { + "title": "Count", + "type": "integer", + "description": "Number of items returned." + }, + "PageToken": { + "title": "PageToken", + "type": "string", + "description": "Page token for pagination." + }, + "Labels": { + "title": "Labels", + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels to filter by." + }, + "Before": { + "title": "Before", + "type": "string", + "format": "date-time", + "description": "Timestamp before which to filter by." + }, + "After": { + "title": "After", + "type": "string", + "format": "date-time", + "description": "Timestamp after which to filter by." + }, + "Ascending": { + "title": "Ascending", + "type": "boolean", + "description": "Sort in ascending temporal order." + }, + "IncludeSpam": { + "title": "IncludeSpam", + "type": "boolean", + "description": "Include spam in results." + }, + "OrganizationId": { + "title": "OrganizationId", + "type": "string", + "description": "ID of organization." + }, + "ErrorName": { + "title": "ErrorName", + "type": "string", + "description": "Name of error." + }, + "ErrorResponse": { + "title": "ErrorResponse", + "type": "object", + "properties": { + "name": { + "$ref": "#/components/schemas/ErrorName" + }, + "message": { + "type": "string", + "description": "Error message." + } + }, + "required": [ + "name", + "message" + ] + }, + "ValidationErrorResponse": { + "title": "ValidationErrorResponse", + "type": "object", + "properties": { + "name": { + "$ref": "#/components/schemas/ErrorName" + }, + "errors": { + "description": "Validation errors." + } + }, + "required": [ + "name", + "errors" + ] + }, + "inboxesInboxId": { + "title": "inboxesInboxId", + "type": "string", + "description": "ID of inbox." + }, + "inboxesDisplayName": { + "title": "inboxesDisplayName", + "type": "string", + "description": "Display name: `Display Name `." + }, + "inboxesClientId": { + "title": "inboxesClientId", + "type": "string", + "description": "Client ID of inbox." + }, + "inboxesInbox": { + "title": "inboxesInbox", + "type": "object", + "properties": { + "pod_id": { + "$ref": "#/components/schemas/podsPodId" + }, + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "display_name": { + "$ref": "#/components/schemas/inboxesDisplayName", + "nullable": true + }, + "client_id": { + "$ref": "#/components/schemas/inboxesClientId", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Time at which inbox was last updated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Time at which inbox was created." + } + }, + "required": [ + "pod_id", + "inbox_id", + "updated_at", + "created_at" + ] + }, + "inboxesListInboxesResponse": { + "title": "inboxesListInboxesResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "limit": { + "$ref": "#/components/schemas/Limit", + "nullable": true + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "inboxes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/inboxesInbox" + }, + "description": "Ordered by `created_at` descending." + } + }, + "required": [ + "count", + "inboxes" + ] + }, + "inboxesCreateInboxRequest": { + "title": "inboxesCreateInboxRequest", + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true, + "description": "Username of address. Randomly generated if not specified." + }, + "domain": { + "type": "string", + "nullable": true, + "description": "Domain of address. Must be verified domain. Defaults to `agentmail.to`." + }, + "display_name": { + "$ref": "#/components/schemas/inboxesDisplayName", + "nullable": true + }, + "client_id": { + "$ref": "#/components/schemas/inboxesClientId", + "nullable": true + } + } + }, + "inboxesUpdateInboxRequest": { + "title": "inboxesUpdateInboxRequest", + "type": "object", + "properties": { + "display_name": { + "$ref": "#/components/schemas/inboxesDisplayName" + } + }, + "required": [ + "display_name" + ] + }, + "podsPodId": { + "title": "podsPodId", + "type": "string", + "description": "ID of pod." + }, + "podsName": { + "title": "podsName", + "type": "string", + "description": "Name of pod." + }, + "podsClientId": { + "title": "podsClientId", + "type": "string", + "description": "Client ID of pod." + }, + "podsPod": { + "title": "podsPod", + "type": "object", + "properties": { + "pod_id": { + "$ref": "#/components/schemas/podsPodId" + }, + "name": { + "$ref": "#/components/schemas/podsName" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Time at which pod was last updated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Time at which pod was created." + }, + "client_id": { + "$ref": "#/components/schemas/podsClientId", + "nullable": true + } + }, + "required": [ + "pod_id", + "name", + "updated_at", + "created_at" + ] + }, + "podsListPodsResponse": { + "title": "podsListPodsResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "limit": { + "$ref": "#/components/schemas/Limit", + "nullable": true + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "pods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/podsPod" + }, + "description": "Ordered by `created_at` descending." + } + }, + "required": [ + "count", + "pods" + ] + }, + "podsCreatePodRequest": { + "title": "podsCreatePodRequest", + "type": "object", + "properties": { + "name": { + "$ref": "#/components/schemas/podsName", + "nullable": true + }, + "client_id": { + "$ref": "#/components/schemas/podsClientId", + "nullable": true + } + } + }, + "webhooksWebhookId": { + "title": "webhooksWebhookId", + "type": "string", + "description": "ID of webhook." + }, + "webhooksClientId": { + "title": "webhooksClientId", + "type": "string", + "description": "Client ID of webhook." + }, + "webhooksUrl": { + "title": "webhooksUrl", + "type": "string", + "description": "URL of webhook endpoint." + }, + "webhooksWebhook": { + "title": "webhooksWebhook", + "type": "object", + "properties": { + "webhook_id": { + "$ref": "#/components/schemas/webhooksWebhookId" + }, + "url": { + "$ref": "#/components/schemas/webhooksUrl" + }, + "event_types": { + "$ref": "#/components/schemas/EventTypes", + "nullable": true + }, + "pod_ids": { + "$ref": "#/components/schemas/PodIds", + "nullable": true + }, + "inbox_ids": { + "$ref": "#/components/schemas/InboxIds", + "nullable": true + }, + "secret": { + "type": "string", + "description": "Secret for webhook signature verification." + }, + "enabled": { + "type": "boolean", + "description": "Webhook is enabled." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Time at which webhook was last updated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Time at which webhook was created." + }, + "client_id": { + "$ref": "#/components/schemas/webhooksClientId", + "nullable": true + } + }, + "required": [ + "webhook_id", + "url", + "secret", + "enabled", + "updated_at", + "created_at" + ] + }, + "webhooksListWebhooksResponse": { + "title": "webhooksListWebhooksResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "limit": { + "$ref": "#/components/schemas/Limit", + "nullable": true + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "webhooks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/webhooksWebhook" + }, + "description": "Ordered by `created_at` descending." + } + }, + "required": [ + "count", + "webhooks" + ] + }, + "webhooksCreateWebhookRequest": { + "title": "webhooksCreateWebhookRequest", + "type": "object", + "properties": { + "url": { + "$ref": "#/components/schemas/webhooksUrl" + }, + "event_types": { + "$ref": "#/components/schemas/EventTypes" + }, + "pod_ids": { + "$ref": "#/components/schemas/PodIds", + "nullable": true + }, + "inbox_ids": { + "$ref": "#/components/schemas/InboxIds", + "nullable": true + }, + "client_id": { + "$ref": "#/components/schemas/webhooksClientId", + "nullable": true + } + }, + "required": [ + "url", + "event_types" + ] + }, + "webhooksUpdateWebhookRequest": { + "title": "webhooksUpdateWebhookRequest", + "type": "object", + "properties": { + "add_inbox_ids": { + "$ref": "#/components/schemas/InboxIds", + "nullable": true, + "description": "Inbox IDs to subscribe to the webhook." + }, + "remove_inbox_ids": { + "$ref": "#/components/schemas/InboxIds", + "nullable": true, + "description": "Inbox IDs to unsubscribe from the webhook." + }, + "add_pod_ids": { + "$ref": "#/components/schemas/PodIds", + "nullable": true, + "description": "Pod IDs to subscribe to the webhook." + }, + "remove_pod_ids": { + "$ref": "#/components/schemas/PodIds", + "nullable": true, + "description": "Pod IDs to unsubscribe from the webhook." + } + } + }, + "ApiKeyId": { + "title": "ApiKeyId", + "type": "string", + "description": "ID of api key." + }, + "Prefix": { + "title": "Prefix", + "type": "string", + "description": "Prefix of api key." + }, + "Name": { + "title": "Name", + "type": "string", + "description": "Name of api key." + }, + "CreatedAt": { + "title": "CreatedAt", + "type": "string", + "format": "date-time", + "description": "Time at which api key was created." + }, + "ApiKey": { + "title": "ApiKey", + "type": "object", + "properties": { + "api_key_id": { + "$ref": "#/components/schemas/ApiKeyId" + }, + "prefix": { + "$ref": "#/components/schemas/Prefix" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "used_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "Time at which api key was last used." + }, + "created_at": { + "$ref": "#/components/schemas/CreatedAt" + } + }, + "required": [ + "api_key_id", + "prefix", + "name", + "created_at" + ] + }, + "CreateApiKeyResponse": { + "title": "CreateApiKeyResponse", + "type": "object", + "properties": { + "api_key_id": { + "$ref": "#/components/schemas/ApiKeyId" + }, + "api_key": { + "type": "string", + "description": "API key." + }, + "prefix": { + "$ref": "#/components/schemas/Prefix" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "created_at": { + "$ref": "#/components/schemas/CreatedAt" + } + }, + "required": [ + "api_key_id", + "api_key", + "prefix", + "name", + "created_at" + ] + }, + "ListApiKeysResponse": { + "title": "ListApiKeysResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "api_keys": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKey" + }, + "description": "Ordered by `created_at` descending." + } + }, + "required": [ + "count", + "api_keys" + ] + }, + "CreateApiKeyRequest": { + "title": "CreateApiKeyRequest", + "type": "object", + "properties": { + "name": { + "$ref": "#/components/schemas/Name" + } + }, + "required": [ + "name" + ] + }, + "AttachmentId": { + "title": "AttachmentId", + "type": "string", + "description": "ID of attachment." + }, + "AttachmentFilename": { + "title": "AttachmentFilename", + "type": "string", + "description": "Filename of attachment." + }, + "AttachmentSize": { + "title": "AttachmentSize", + "type": "integer", + "description": "Size of attachment in bytes." + }, + "AttachmentContentType": { + "title": "AttachmentContentType", + "type": "string", + "description": "Content type of attachment." + }, + "AttachmentContentDisposition": { + "title": "AttachmentContentDisposition", + "type": "string", + "enum": [ + "inline", + "attachment" + ], + "description": "Content disposition of attachment." + }, + "AttachmentContentId": { + "title": "AttachmentContentId", + "type": "string", + "description": "Content ID of attachment." + }, + "Attachment": { + "title": "Attachment", + "type": "object", + "properties": { + "attachment_id": { + "$ref": "#/components/schemas/AttachmentId" + }, + "filename": { + "$ref": "#/components/schemas/AttachmentFilename", + "nullable": true + }, + "size": { + "$ref": "#/components/schemas/AttachmentSize" + }, + "content_type": { + "$ref": "#/components/schemas/AttachmentContentType", + "nullable": true + }, + "content_disposition": { + "$ref": "#/components/schemas/AttachmentContentDisposition", + "nullable": true + }, + "content_id": { + "$ref": "#/components/schemas/AttachmentContentId", + "nullable": true + } + }, + "required": [ + "attachment_id", + "size" + ] + }, + "AttachmentResponse": { + "title": "AttachmentResponse", + "type": "object", + "properties": { + "attachment_id": { + "$ref": "#/components/schemas/AttachmentId" + }, + "filename": { + "$ref": "#/components/schemas/AttachmentFilename", + "nullable": true + }, + "size": { + "$ref": "#/components/schemas/AttachmentSize" + }, + "content_type": { + "$ref": "#/components/schemas/AttachmentContentType", + "nullable": true + }, + "content_disposition": { + "$ref": "#/components/schemas/AttachmentContentDisposition", + "nullable": true + }, + "content_id": { + "$ref": "#/components/schemas/AttachmentContentId", + "nullable": true + }, + "download_url": { + "type": "string", + "description": "URL to download the attachment." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the download URL expires." + } + }, + "required": [ + "attachment_id", + "size", + "download_url", + "expires_at" + ] + }, + "SendAttachment": { + "title": "SendAttachment", + "type": "object", + "properties": { + "filename": { + "$ref": "#/components/schemas/AttachmentFilename", + "nullable": true + }, + "content_type": { + "$ref": "#/components/schemas/AttachmentContentType", + "nullable": true + }, + "content_disposition": { + "$ref": "#/components/schemas/AttachmentContentDisposition", + "nullable": true + }, + "content_id": { + "$ref": "#/components/schemas/AttachmentContentId", + "nullable": true + }, + "content": { + "type": "string", + "nullable": true, + "description": "Base64 encoded content of attachment." + }, + "url": { + "type": "string", + "nullable": true, + "description": "URL to the attachment." + } + } + }, + "DomainId": { + "title": "DomainId", + "type": "string", + "description": "The name of the domain. (e.g., \" your-domain.com\")" + }, + "DomainName": { + "title": "DomainName", + "type": "string", + "description": "The name of the domain. (e.g., \"example.com\")" + }, + "RecordType": { + "title": "RecordType", + "type": "string", + "enum": [ + "TXT", + "CNAME", + "MX" + ] + }, + "VerificationStatus": { + "title": "VerificationStatus", + "type": "string", + "enum": [ + "NOT_STARTED", + "PENDING", + "INVALID", + "FAILED", + "VERIFYING", + "VERIFIED" + ] + }, + "RecordStatus": { + "title": "RecordStatus", + "type": "string", + "enum": [ + "MISSING", + "INVALID", + "VALID" + ] + }, + "VerificationRecord": { + "title": "VerificationRecord", + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/RecordType", + "description": "The type of the DNS record." + }, + "name": { + "type": "string", + "description": "The name or host of the record." + }, + "value": { + "type": "string", + "description": "The value of the record." + }, + "status": { + "$ref": "#/components/schemas/RecordStatus", + "description": "The verification status of this specific record." + }, + "priority": { + "type": "integer", + "nullable": true, + "description": "The priority of the MX record." + } + }, + "required": [ + "type", + "name", + "value", + "status" + ] + }, + "FeedbackEnabled": { + "title": "FeedbackEnabled", + "type": "boolean", + "description": "Bounce and complaint notifications are sent to your inboxes." + }, + "ClientId": { + "title": "ClientId", + "type": "string", + "description": "Client ID of domain." + }, + "Domain": { + "title": "Domain", + "type": "object", + "properties": { + "pod_id": { + "$ref": "#/components/schemas/podsPodId", + "nullable": true + }, + "domain_id": { + "$ref": "#/components/schemas/DomainId" + }, + "status": { + "$ref": "#/components/schemas/VerificationStatus", + "description": "The verification status of the domain." + }, + "feedback_enabled": { + "$ref": "#/components/schemas/FeedbackEnabled" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VerificationRecord" + }, + "description": "A list of DNS records required to verify the domain." + }, + "client_id": { + "$ref": "#/components/schemas/ClientId", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the domain was last updated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the domain was created." + } + }, + "required": [ + "domain_id", + "status", + "feedback_enabled", + "records", + "updated_at", + "created_at" + ] + }, + "DomainItem": { + "title": "DomainItem", + "type": "object", + "properties": { + "pod_id": { + "$ref": "#/components/schemas/podsPodId", + "nullable": true + }, + "domain_id": { + "$ref": "#/components/schemas/DomainId" + }, + "feedback_enabled": { + "$ref": "#/components/schemas/FeedbackEnabled" + }, + "client_id": { + "$ref": "#/components/schemas/ClientId", + "nullable": true + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the domain was last updated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the domain was created." + } + }, + "required": [ + "domain_id", + "feedback_enabled", + "updated_at", + "created_at" + ] + }, + "ListDomainsResponse": { + "title": "ListDomainsResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "limit": { + "$ref": "#/components/schemas/Limit", + "nullable": true + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "domains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DomainItem" + }, + "description": "Ordered by `created_at` descending." + } + }, + "required": [ + "count", + "domains" + ] + }, + "CreateDomainRequest": { + "title": "CreateDomainRequest", + "type": "object", + "properties": { + "domain": { + "$ref": "#/components/schemas/DomainName" + }, + "feedback_enabled": { + "$ref": "#/components/schemas/FeedbackEnabled" + } + }, + "required": [ + "domain", + "feedback_enabled" + ] + }, + "DraftId": { + "title": "DraftId", + "type": "string", + "description": "ID of draft." + }, + "DraftClientId": { + "title": "DraftClientId", + "type": "string", + "description": "Client ID of draft." + }, + "DraftLabels": { + "title": "DraftLabels", + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels of draft." + }, + "DraftReplyTo": { + "title": "DraftReplyTo", + "type": "array", + "items": { + "type": "string" + }, + "description": "Reply-to addresses. In format `username@domain.com` or `Display Name `." + }, + "DraftTo": { + "title": "DraftTo", + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses of recipients. In format `username@domain.com` or `Display Name `." + }, + "DraftCc": { + "title": "DraftCc", + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses of CC recipients. In format `username@domain.com` or `Display Name `." + }, + "DraftBcc": { + "title": "DraftBcc", + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `." + }, + "DraftSubject": { + "title": "DraftSubject", + "type": "string", + "description": "Subject of draft." + }, + "DraftPreview": { + "title": "DraftPreview", + "type": "string", + "description": "Text preview of draft." + }, + "DraftText": { + "title": "DraftText", + "type": "string", + "description": "Plain text body of draft." + }, + "DraftHtml": { + "title": "DraftHtml", + "type": "string", + "description": "HTML body of draft." + }, + "DraftAttachments": { + "title": "DraftAttachments", + "type": "array", + "items": { + "$ref": "#/components/schemas/Attachment" + }, + "description": "Attachments in draft." + }, + "DraftInReplyTo": { + "title": "DraftInReplyTo", + "type": "string", + "description": "ID of message being replied to." + }, + "DraftSendStatus": { + "title": "DraftSendStatus", + "type": "string", + "enum": [ + "scheduled", + "sending", + "failed" + ], + "description": "Schedule send status of draft." + }, + "DraftSendAt": { + "title": "DraftSendAt", + "type": "string", + "format": "date-time", + "description": "Time at which to schedule send draft." + }, + "DraftUpdatedAt": { + "title": "DraftUpdatedAt", + "type": "string", + "format": "date-time", + "description": "Time at which draft was last updated." + }, + "DraftItem": { + "title": "DraftItem", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "draft_id": { + "$ref": "#/components/schemas/DraftId" + }, + "labels": { + "$ref": "#/components/schemas/DraftLabels" + }, + "to": { + "$ref": "#/components/schemas/DraftTo", + "nullable": true + }, + "cc": { + "$ref": "#/components/schemas/DraftCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/DraftBcc", + "nullable": true + }, + "subject": { + "$ref": "#/components/schemas/DraftSubject", + "nullable": true + }, + "preview": { + "$ref": "#/components/schemas/DraftPreview", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/DraftAttachments", + "nullable": true + }, + "send_status": { + "$ref": "#/components/schemas/DraftSendStatus", + "nullable": true + }, + "send_at": { + "$ref": "#/components/schemas/DraftSendAt", + "nullable": true + }, + "updated_at": { + "$ref": "#/components/schemas/DraftUpdatedAt" + } + }, + "required": [ + "inbox_id", + "thread_id", + "draft_id", + "labels", + "updated_at" + ] + }, + "Draft": { + "title": "Draft", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "draft_id": { + "$ref": "#/components/schemas/DraftId" + }, + "client_id": { + "$ref": "#/components/schemas/DraftClientId", + "nullable": true + }, + "labels": { + "$ref": "#/components/schemas/DraftLabels" + }, + "reply_to": { + "$ref": "#/components/schemas/DraftReplyTo", + "nullable": true + }, + "to": { + "$ref": "#/components/schemas/DraftTo", + "nullable": true + }, + "cc": { + "$ref": "#/components/schemas/DraftCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/DraftBcc", + "nullable": true + }, + "subject": { + "$ref": "#/components/schemas/DraftSubject", + "nullable": true + }, + "preview": { + "$ref": "#/components/schemas/DraftPreview", + "nullable": true + }, + "text": { + "$ref": "#/components/schemas/DraftText", + "nullable": true + }, + "html": { + "$ref": "#/components/schemas/DraftHtml", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/DraftAttachments", + "nullable": true + }, + "in_reply_to": { + "$ref": "#/components/schemas/DraftInReplyTo", + "nullable": true + }, + "references": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "IDs of previous messages in thread." + }, + "send_status": { + "$ref": "#/components/schemas/DraftSendStatus", + "nullable": true + }, + "send_at": { + "$ref": "#/components/schemas/DraftSendAt", + "nullable": true + }, + "updated_at": { + "$ref": "#/components/schemas/DraftUpdatedAt" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Time at which draft was created." + } + }, + "required": [ + "inbox_id", + "thread_id", + "draft_id", + "labels", + "updated_at", + "created_at" + ] + }, + "ListDraftsResponse": { + "title": "ListDraftsResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "limit": { + "$ref": "#/components/schemas/Limit", + "nullable": true + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "drafts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DraftItem" + }, + "description": "Ordered by `updated_at` descending." + } + }, + "required": [ + "count", + "drafts" + ] + }, + "CreateDraftRequest": { + "title": "CreateDraftRequest", + "type": "object", + "properties": { + "labels": { + "$ref": "#/components/schemas/DraftLabels", + "nullable": true + }, + "reply_to": { + "$ref": "#/components/schemas/DraftReplyTo", + "nullable": true + }, + "to": { + "$ref": "#/components/schemas/DraftTo", + "nullable": true + }, + "cc": { + "$ref": "#/components/schemas/DraftCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/DraftBcc", + "nullable": true + }, + "subject": { + "$ref": "#/components/schemas/DraftSubject", + "nullable": true + }, + "text": { + "$ref": "#/components/schemas/DraftText", + "nullable": true + }, + "html": { + "$ref": "#/components/schemas/DraftHtml", + "nullable": true + }, + "in_reply_to": { + "$ref": "#/components/schemas/DraftInReplyTo", + "nullable": true + }, + "send_at": { + "$ref": "#/components/schemas/DraftSendAt", + "nullable": true + }, + "client_id": { + "$ref": "#/components/schemas/DraftClientId", + "nullable": true + } + } + }, + "UpdateDraftRequest": { + "title": "UpdateDraftRequest", + "type": "object", + "properties": { + "reply_to": { + "$ref": "#/components/schemas/DraftReplyTo", + "nullable": true + }, + "to": { + "$ref": "#/components/schemas/DraftTo", + "nullable": true + }, + "cc": { + "$ref": "#/components/schemas/DraftCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/DraftBcc", + "nullable": true + }, + "subject": { + "$ref": "#/components/schemas/DraftSubject", + "nullable": true + }, + "text": { + "$ref": "#/components/schemas/DraftText", + "nullable": true + }, + "html": { + "$ref": "#/components/schemas/DraftHtml", + "nullable": true + }, + "send_at": { + "$ref": "#/components/schemas/DraftSendAt", + "nullable": true + } + } + }, + "EventType": { + "title": "EventType", + "type": "string", + "enum": [ + "message.received", + "message.sent", + "message.delivered", + "message.bounced", + "message.complained", + "message.rejected", + "domain.verified" + ] + }, + "EventTypes": { + "title": "EventTypes", + "type": "array", + "items": { + "$ref": "#/components/schemas/EventType" + }, + "description": "Event types for which to send events." + }, + "PodIds": { + "title": "PodIds", + "type": "array", + "items": { + "type": "string" + }, + "description": "Pods for which to send events. Maximum 10 per webhook." + }, + "InboxIds": { + "title": "InboxIds", + "type": "array", + "items": { + "type": "string" + }, + "description": "Inboxes for which to send events. Maximum 10 per webhook." + }, + "EventId": { + "title": "EventId", + "type": "string", + "description": "ID of event." + }, + "Timestamp": { + "title": "Timestamp", + "type": "string", + "format": "date-time", + "description": "Timestamp of event." + }, + "Recipient": { + "title": "Recipient", + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Recipient address." + }, + "status": { + "type": "string", + "description": "Recipient status." + } + }, + "required": [ + "address", + "status" + ] + }, + "Send": { + "title": "Send", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "timestamp": { + "$ref": "#/components/schemas/Timestamp" + }, + "recipients": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Sent recipients." + } + }, + "required": [ + "inbox_id", + "thread_id", + "message_id", + "timestamp", + "recipients" + ] + }, + "Delivery": { + "title": "Delivery", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "timestamp": { + "$ref": "#/components/schemas/Timestamp" + }, + "recipients": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Delivered recipients." + } + }, + "required": [ + "inbox_id", + "thread_id", + "message_id", + "timestamp", + "recipients" + ] + }, + "Bounce": { + "title": "Bounce", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "timestamp": { + "$ref": "#/components/schemas/Timestamp" + }, + "type": { + "type": "string", + "description": "Bounce type." + }, + "sub_type": { + "type": "string", + "description": "Bounce sub-type." + }, + "recipients": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Recipient" + }, + "description": "Bounced recipients." + } + }, + "required": [ + "inbox_id", + "thread_id", + "message_id", + "timestamp", + "type", + "sub_type", + "recipients" + ] + }, + "Complaint": { + "title": "Complaint", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "timestamp": { + "$ref": "#/components/schemas/Timestamp" + }, + "type": { + "type": "string", + "description": "Complaint type." + }, + "sub_type": { + "type": "string", + "description": "Complaint sub-type." + }, + "recipients": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Complained recipients." + } + }, + "required": [ + "inbox_id", + "thread_id", + "message_id", + "timestamp", + "type", + "sub_type", + "recipients" + ] + }, + "Reject": { + "title": "Reject", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "timestamp": { + "$ref": "#/components/schemas/Timestamp" + }, + "reason": { + "type": "string", + "description": "Reject reason." + } + }, + "required": [ + "inbox_id", + "thread_id", + "message_id", + "timestamp", + "reason" + ] + }, + "MessageReceivedEvent": { + "title": "MessageReceivedEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event" + }, + "event_type": { + "type": "string", + "const": "message.received" + }, + "event_id": { + "$ref": "#/components/schemas/EventId" + }, + "message": { + "$ref": "#/components/schemas/Message" + }, + "thread": { + "$ref": "#/components/schemas/ThreadItem" + } + }, + "required": [ + "type", + "event_type", + "event_id", + "message", + "thread" + ] + }, + "MessageSentEvent": { + "title": "MessageSentEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event" + }, + "event_type": { + "type": "string", + "const": "message.sent" + }, + "event_id": { + "$ref": "#/components/schemas/EventId" + }, + "send": { + "$ref": "#/components/schemas/Send" + } + }, + "required": [ + "type", + "event_type", + "event_id", + "send" + ] + }, + "MessageDeliveredEvent": { + "title": "MessageDeliveredEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event" + }, + "event_type": { + "type": "string", + "const": "message.delivered" + }, + "event_id": { + "$ref": "#/components/schemas/EventId" + }, + "delivery": { + "$ref": "#/components/schemas/Delivery" + } + }, + "required": [ + "type", + "event_type", + "event_id", + "delivery" + ] + }, + "MessageBouncedEvent": { + "title": "MessageBouncedEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event" + }, + "event_type": { + "type": "string", + "const": "message.bounced" + }, + "event_id": { + "$ref": "#/components/schemas/EventId" + }, + "bounce": { + "$ref": "#/components/schemas/Bounce" + } + }, + "required": [ + "type", + "event_type", + "event_id", + "bounce" + ] + }, + "MessageComplainedEvent": { + "title": "MessageComplainedEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event" + }, + "event_type": { + "type": "string", + "const": "message.complained" + }, + "event_id": { + "$ref": "#/components/schemas/EventId" + }, + "complaint": { + "$ref": "#/components/schemas/Complaint" + } + }, + "required": [ + "type", + "event_type", + "event_id", + "complaint" + ] + }, + "MessageRejectedEvent": { + "title": "MessageRejectedEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event" + }, + "event_type": { + "type": "string", + "const": "message.rejected" + }, + "event_id": { + "$ref": "#/components/schemas/EventId" + }, + "reject": { + "$ref": "#/components/schemas/Reject" + } + }, + "required": [ + "type", + "event_type", + "event_id", + "reject" + ] + }, + "DomainVerifiedEvent": { + "title": "DomainVerifiedEvent", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event" + }, + "event_type": { + "type": "string", + "const": "domain.verified" + }, + "event_id": { + "$ref": "#/components/schemas/EventId" + }, + "domain": { + "$ref": "#/components/schemas/Domain" + } + }, + "required": [ + "type", + "event_type", + "event_id", + "domain" + ] + }, + "MessageId": { + "title": "MessageId", + "type": "string", + "description": "ID of message." + }, + "MessageLabels": { + "title": "MessageLabels", + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels of message." + }, + "MessageTimestamp": { + "title": "MessageTimestamp", + "type": "string", + "format": "date-time", + "description": "Time at which message was sent or drafted." + }, + "MessageFrom": { + "title": "MessageFrom", + "type": "string", + "description": "Address of sender. In format `username@domain.com` or `Display Name `." + }, + "MessageReplyTo": { + "title": "MessageReplyTo", + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses of reply-to recipients. In format `username@domain.com` or `Display Name `." + }, + "MessageTo": { + "title": "MessageTo", + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses of recipients. In format `username@domain.com` or `Display Name `." + }, + "MessageCc": { + "title": "MessageCc", + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses of CC recipients. In format `username@domain.com` or `Display Name `." + }, + "MessageBcc": { + "title": "MessageBcc", + "type": "array", + "items": { + "type": "string" + }, + "description": "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `." + }, + "MessageSubject": { + "title": "MessageSubject", + "type": "string", + "description": "Subject of message." + }, + "MessagePreview": { + "title": "MessagePreview", + "type": "string", + "description": "Text preview of message." + }, + "MessageText": { + "title": "MessageText", + "type": "string", + "description": "Plain text body of message." + }, + "MessageHtml": { + "title": "MessageHtml", + "type": "string", + "description": "HTML body of message." + }, + "MessageAttachments": { + "title": "MessageAttachments", + "type": "array", + "items": { + "$ref": "#/components/schemas/Attachment" + }, + "description": "Attachments in message." + }, + "MessageInReplyTo": { + "title": "MessageInReplyTo", + "type": "string", + "description": "ID of message being replied to." + }, + "MessageReferences": { + "title": "MessageReferences", + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of previous messages in thread." + }, + "MessageHeaders": { + "title": "MessageHeaders", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Headers in message." + }, + "MessageSize": { + "title": "MessageSize", + "type": "integer", + "description": "Size of message in bytes." + }, + "MessageUpdatedAt": { + "title": "MessageUpdatedAt", + "type": "string", + "format": "date-time", + "description": "Time at which message was last updated." + }, + "MessageCreatedAt": { + "title": "MessageCreatedAt", + "type": "string", + "format": "date-time", + "description": "Time at which message was created." + }, + "MessageItem": { + "title": "MessageItem", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "labels": { + "$ref": "#/components/schemas/MessageLabels" + }, + "timestamp": { + "$ref": "#/components/schemas/MessageTimestamp" + }, + "from": { + "$ref": "#/components/schemas/MessageFrom" + }, + "to": { + "$ref": "#/components/schemas/MessageTo" + }, + "cc": { + "$ref": "#/components/schemas/MessageCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/MessageBcc", + "nullable": true + }, + "subject": { + "$ref": "#/components/schemas/MessageSubject", + "nullable": true + }, + "preview": { + "$ref": "#/components/schemas/MessagePreview", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/MessageAttachments", + "nullable": true + }, + "in_reply_to": { + "$ref": "#/components/schemas/MessageInReplyTo", + "nullable": true + }, + "references": { + "$ref": "#/components/schemas/MessageReferences", + "nullable": true + }, + "headers": { + "$ref": "#/components/schemas/MessageHeaders", + "nullable": true + }, + "size": { + "$ref": "#/components/schemas/MessageSize" + }, + "updated_at": { + "$ref": "#/components/schemas/MessageUpdatedAt" + }, + "created_at": { + "$ref": "#/components/schemas/MessageCreatedAt" + } + }, + "required": [ + "inbox_id", + "thread_id", + "message_id", + "labels", + "timestamp", + "from", + "to", + "size", + "updated_at", + "created_at" + ] + }, + "Message": { + "title": "Message", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "labels": { + "$ref": "#/components/schemas/MessageLabels" + }, + "timestamp": { + "$ref": "#/components/schemas/MessageTimestamp" + }, + "from": { + "$ref": "#/components/schemas/MessageFrom" + }, + "reply_to": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "Reply-to addresses. In format `username@domain.com` or `Display Name `." + }, + "to": { + "$ref": "#/components/schemas/MessageTo" + }, + "cc": { + "$ref": "#/components/schemas/MessageCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/MessageBcc", + "nullable": true + }, + "subject": { + "$ref": "#/components/schemas/MessageSubject", + "nullable": true + }, + "preview": { + "$ref": "#/components/schemas/MessagePreview", + "nullable": true + }, + "text": { + "$ref": "#/components/schemas/MessageText", + "nullable": true + }, + "html": { + "$ref": "#/components/schemas/MessageHtml", + "nullable": true + }, + "extracted_text": { + "type": "string", + "nullable": true, + "description": "Extracted new text content." + }, + "extracted_html": { + "type": "string", + "nullable": true, + "description": "Extracted new HTML content." + }, + "attachments": { + "$ref": "#/components/schemas/MessageAttachments", + "nullable": true + }, + "in_reply_to": { + "$ref": "#/components/schemas/MessageInReplyTo", + "nullable": true + }, + "references": { + "$ref": "#/components/schemas/MessageReferences", + "nullable": true + }, + "headers": { + "$ref": "#/components/schemas/MessageHeaders", + "nullable": true + }, + "size": { + "$ref": "#/components/schemas/MessageSize" + }, + "updated_at": { + "$ref": "#/components/schemas/MessageUpdatedAt" + }, + "created_at": { + "$ref": "#/components/schemas/MessageCreatedAt" + } + }, + "required": [ + "inbox_id", + "thread_id", + "message_id", + "labels", + "timestamp", + "from", + "to", + "size", + "updated_at", + "created_at" + ] + }, + "ListMessagesResponse": { + "title": "ListMessagesResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "limit": { + "$ref": "#/components/schemas/Limit", + "nullable": true + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageItem" + }, + "description": "Ordered by `timestamp` descending." + } + }, + "required": [ + "count", + "messages" + ] + }, + "Addresses": { + "title": "Addresses", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "SendMessageReplyTo": { + "title": "SendMessageReplyTo", + "$ref": "#/components/schemas/Addresses", + "description": "Reply-to address or addresses." + }, + "SendMessageTo": { + "title": "SendMessageTo", + "$ref": "#/components/schemas/Addresses", + "description": "Recipient address or addresses." + }, + "SendMessageCc": { + "title": "SendMessageCc", + "$ref": "#/components/schemas/Addresses", + "description": "CC recipient address or addresses." + }, + "SendMessageBcc": { + "title": "SendMessageBcc", + "$ref": "#/components/schemas/Addresses", + "description": "BCC recipient address or addresses." + }, + "SendMessageAttachments": { + "title": "SendMessageAttachments", + "type": "array", + "items": { + "$ref": "#/components/schemas/SendAttachment" + }, + "description": "Attachments to include in message." + }, + "SendMessageHeaders": { + "title": "SendMessageHeaders", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Headers to include in message." + }, + "SendMessageRequest": { + "title": "SendMessageRequest", + "type": "object", + "properties": { + "labels": { + "$ref": "#/components/schemas/MessageLabels", + "nullable": true + }, + "reply_to": { + "$ref": "#/components/schemas/SendMessageReplyTo", + "nullable": true + }, + "to": { + "$ref": "#/components/schemas/SendMessageTo", + "nullable": true + }, + "cc": { + "$ref": "#/components/schemas/SendMessageCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/SendMessageBcc", + "nullable": true + }, + "subject": { + "$ref": "#/components/schemas/MessageSubject", + "nullable": true + }, + "text": { + "$ref": "#/components/schemas/MessageText", + "nullable": true + }, + "html": { + "$ref": "#/components/schemas/MessageHtml", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/SendMessageAttachments", + "nullable": true + }, + "headers": { + "$ref": "#/components/schemas/SendMessageHeaders", + "nullable": true + } + } + }, + "SendMessageResponse": { + "title": "SendMessageResponse", + "type": "object", + "properties": { + "message_id": { + "$ref": "#/components/schemas/MessageId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + } + }, + "required": [ + "message_id", + "thread_id" + ] + }, + "RawMessageResponse": { + "title": "RawMessageResponse", + "type": "object", + "description": "S3 presigned URL to download the raw .eml file.", + "properties": { + "message_id": { + "$ref": "#/components/schemas/MessageId", + "description": "ID of the message." + }, + "size": { + "$ref": "#/components/schemas/MessageSize", + "description": "Size of the raw message in bytes." + }, + "download_url": { + "type": "string", + "description": "S3 presigned URL to download the raw message. Expires at expires_at." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the download URL expires." + } + }, + "required": [ + "message_id", + "size", + "download_url", + "expires_at" + ] + }, + "ReplyAll": { + "title": "ReplyAll", + "type": "boolean", + "description": "Reply to all recipients of the original message." + }, + "ReplyToMessageRequest": { + "title": "ReplyToMessageRequest", + "type": "object", + "properties": { + "labels": { + "$ref": "#/components/schemas/MessageLabels", + "nullable": true + }, + "reply_to": { + "$ref": "#/components/schemas/SendMessageReplyTo", + "nullable": true + }, + "to": { + "$ref": "#/components/schemas/SendMessageTo", + "nullable": true + }, + "cc": { + "$ref": "#/components/schemas/SendMessageCc", + "nullable": true + }, + "bcc": { + "$ref": "#/components/schemas/SendMessageBcc", + "nullable": true + }, + "reply_all": { + "$ref": "#/components/schemas/ReplyAll", + "nullable": true + }, + "text": { + "$ref": "#/components/schemas/MessageText", + "nullable": true + }, + "html": { + "$ref": "#/components/schemas/MessageHtml", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/SendMessageAttachments", + "nullable": true + }, + "headers": { + "$ref": "#/components/schemas/SendMessageHeaders", + "nullable": true + } + } + }, + "ReplyAllMessageRequest": { + "title": "ReplyAllMessageRequest", + "type": "object", + "properties": { + "labels": { + "$ref": "#/components/schemas/MessageLabels", + "nullable": true + }, + "reply_to": { + "$ref": "#/components/schemas/SendMessageReplyTo", + "nullable": true + }, + "text": { + "$ref": "#/components/schemas/MessageText", + "nullable": true + }, + "html": { + "$ref": "#/components/schemas/MessageHtml", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/SendMessageAttachments", + "nullable": true + }, + "headers": { + "$ref": "#/components/schemas/SendMessageHeaders", + "nullable": true + } + } + }, + "UpdateMessageRequest": { + "title": "UpdateMessageRequest", + "type": "object", + "properties": { + "add_labels": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "Labels to add to message." + }, + "remove_labels": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "description": "Labels to remove from message." + } + } + }, + "MetricEventType": { + "title": "MetricEventType", + "type": "string", + "enum": [ + "message.sent", + "message.delivered", + "message.bounced", + "message.delayed", + "message.rejected", + "message.complained", + "message.received" + ], + "description": "Type of metric event." + }, + "MetricTimestamp": { + "title": "MetricTimestamp", + "type": "string", + "format": "date-time", + "description": "Timestamp when the metric event occurred." + }, + "MetricEventTypes": { + "title": "MetricEventTypes", + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricEventType" + }, + "description": "List of metric event types to filter by." + }, + "MetricStartTimestamp": { + "title": "MetricStartTimestamp", + "type": "string", + "format": "date-time", + "description": "Start timestamp for the metrics query range." + }, + "MetricEndTimestamp": { + "title": "MetricEndTimestamp", + "type": "string", + "format": "date-time", + "description": "End timestamp for the metrics query range." + }, + "MessageMetrics": { + "title": "MessageMetrics", + "type": "object", + "properties": { + "sent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricTimestamp" + }, + "nullable": true, + "description": "Timestamps when messages were sent." + }, + "delivered": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricTimestamp" + }, + "nullable": true, + "description": "Timestamps when messages were delivered." + }, + "bounced": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricTimestamp" + }, + "nullable": true, + "description": "Timestamps when messages bounced." + }, + "delayed": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricTimestamp" + }, + "nullable": true, + "description": "Timestamps when messages were delayed." + }, + "rejected": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricTimestamp" + }, + "nullable": true, + "description": "Timestamps when messages were rejected." + }, + "complained": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricTimestamp" + }, + "nullable": true, + "description": "Timestamps when messages received complaints." + }, + "received": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MetricTimestamp" + }, + "nullable": true, + "description": "Timestamps when messages were received." + } + } + }, + "ListMetricsResponse": { + "title": "ListMetricsResponse", + "type": "object", + "properties": { + "message": { + "$ref": "#/components/schemas/MessageMetrics", + "nullable": true, + "description": "Message metrics grouped by event type." + } + } + }, + "Organization": { + "title": "Organization", + "type": "object", + "description": "Organization details with usage limits and counts.", + "properties": { + "organization_id": { + "$ref": "#/components/schemas/OrganizationId" + }, + "inbox_count": { + "type": "integer", + "description": "Current number of inboxes." + }, + "domain_count": { + "type": "integer", + "description": "Current number of domains." + }, + "inbox_limit": { + "type": "integer", + "nullable": true, + "description": "Maximum number of inboxes allowed." + }, + "domain_limit": { + "type": "integer", + "nullable": true, + "description": "Maximum number of domains allowed." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Time at which organization was last updated." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "Time at which organization was created." + } + }, + "required": [ + "organization_id", + "inbox_count", + "domain_count", + "updated_at", + "created_at" + ] + }, + "ThreadId": { + "title": "ThreadId", + "type": "string", + "description": "ID of thread." + }, + "ThreadLabels": { + "title": "ThreadLabels", + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels of thread." + }, + "ThreadTimestamp": { + "title": "ThreadTimestamp", + "type": "string", + "format": "date-time", + "description": "Timestamp of last sent or received message." + }, + "ThreadReceivedTimestamp": { + "title": "ThreadReceivedTimestamp", + "type": "string", + "format": "date-time", + "description": "Timestamp of last received message." + }, + "ThreadSentTimestamp": { + "title": "ThreadSentTimestamp", + "type": "string", + "format": "date-time", + "description": "Timestamp of last sent message." + }, + "ThreadSenders": { + "title": "ThreadSenders", + "type": "array", + "items": { + "type": "string" + }, + "description": "Senders in thread. In format `username@domain.com` or `Display Name `." + }, + "ThreadRecipients": { + "title": "ThreadRecipients", + "type": "array", + "items": { + "type": "string" + }, + "description": "Recipients in thread. In format `username@domain.com` or `Display Name `." + }, + "ThreadSubject": { + "title": "ThreadSubject", + "type": "string", + "description": "Subject of thread." + }, + "ThreadPreview": { + "title": "ThreadPreview", + "type": "string", + "description": "Text preview of last message in thread." + }, + "ThreadAttachments": { + "title": "ThreadAttachments", + "type": "array", + "items": { + "$ref": "#/components/schemas/Attachment" + }, + "description": "Attachments in thread." + }, + "ThreadLastMessageId": { + "title": "ThreadLastMessageId", + "type": "string", + "description": "ID of last message in thread." + }, + "ThreadMessageCount": { + "title": "ThreadMessageCount", + "type": "integer", + "description": "Number of messages in thread." + }, + "ThreadSize": { + "title": "ThreadSize", + "type": "integer", + "description": "Size of thread in bytes." + }, + "ThreadUpdatedAt": { + "title": "ThreadUpdatedAt", + "type": "string", + "format": "date-time", + "description": "Time at which thread was last updated." + }, + "ThreadCreatedAt": { + "title": "ThreadCreatedAt", + "type": "string", + "format": "date-time", + "description": "Time at which thread was created." + }, + "ThreadItem": { + "title": "ThreadItem", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "labels": { + "$ref": "#/components/schemas/ThreadLabels" + }, + "timestamp": { + "$ref": "#/components/schemas/ThreadTimestamp" + }, + "received_timestamp": { + "$ref": "#/components/schemas/ThreadReceivedTimestamp", + "nullable": true + }, + "sent_timestamp": { + "$ref": "#/components/schemas/ThreadSentTimestamp", + "nullable": true + }, + "senders": { + "$ref": "#/components/schemas/ThreadSenders" + }, + "recipients": { + "$ref": "#/components/schemas/ThreadRecipients" + }, + "subject": { + "$ref": "#/components/schemas/ThreadSubject", + "nullable": true + }, + "preview": { + "$ref": "#/components/schemas/ThreadPreview", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/ThreadAttachments", + "nullable": true + }, + "last_message_id": { + "$ref": "#/components/schemas/ThreadLastMessageId" + }, + "message_count": { + "$ref": "#/components/schemas/ThreadMessageCount" + }, + "size": { + "$ref": "#/components/schemas/ThreadSize" + }, + "updated_at": { + "$ref": "#/components/schemas/ThreadUpdatedAt" + }, + "created_at": { + "$ref": "#/components/schemas/ThreadCreatedAt" + } + }, + "required": [ + "inbox_id", + "thread_id", + "labels", + "timestamp", + "senders", + "recipients", + "last_message_id", + "message_count", + "size", + "updated_at", + "created_at" + ] + }, + "Thread": { + "title": "Thread", + "type": "object", + "properties": { + "inbox_id": { + "$ref": "#/components/schemas/inboxesInboxId" + }, + "thread_id": { + "$ref": "#/components/schemas/ThreadId" + }, + "labels": { + "$ref": "#/components/schemas/ThreadLabels" + }, + "timestamp": { + "$ref": "#/components/schemas/ThreadTimestamp" + }, + "received_timestamp": { + "$ref": "#/components/schemas/ThreadReceivedTimestamp", + "nullable": true + }, + "sent_timestamp": { + "$ref": "#/components/schemas/ThreadSentTimestamp", + "nullable": true + }, + "senders": { + "$ref": "#/components/schemas/ThreadSenders" + }, + "recipients": { + "$ref": "#/components/schemas/ThreadRecipients" + }, + "subject": { + "$ref": "#/components/schemas/ThreadSubject", + "nullable": true + }, + "preview": { + "$ref": "#/components/schemas/ThreadPreview", + "nullable": true + }, + "attachments": { + "$ref": "#/components/schemas/ThreadAttachments", + "nullable": true + }, + "last_message_id": { + "$ref": "#/components/schemas/ThreadLastMessageId" + }, + "message_count": { + "$ref": "#/components/schemas/ThreadMessageCount" + }, + "size": { + "$ref": "#/components/schemas/ThreadSize" + }, + "updated_at": { + "$ref": "#/components/schemas/ThreadUpdatedAt" + }, + "created_at": { + "$ref": "#/components/schemas/ThreadCreatedAt" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message" + }, + "description": "Messages in thread. Ordered by `timestamp` ascending." + } + }, + "required": [ + "inbox_id", + "thread_id", + "labels", + "timestamp", + "senders", + "recipients", + "last_message_id", + "message_count", + "size", + "updated_at", + "created_at", + "messages" + ] + }, + "ListThreadsResponse": { + "title": "ListThreadsResponse", + "type": "object", + "properties": { + "count": { + "$ref": "#/components/schemas/Count" + }, + "limit": { + "$ref": "#/components/schemas/Limit", + "nullable": true + }, + "next_page_token": { + "$ref": "#/components/schemas/PageToken", + "nullable": true + }, + "threads": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadItem" + }, + "description": "Ordered by `timestamp` descending." + } + }, + "required": [ + "count", + "threads" + ] + }, + "webhooksSvixId": { + "title": "webhooksSvixId", + "type": "string", + "description": "ID of webhook message." + }, + "webhooksSvixTimestamp": { + "title": "webhooksSvixTimestamp", + "type": "string", + "format": "date-time", + "description": "Timestamp of webhook message." + }, + "webhooksSvixSignature": { + "title": "webhooksSvixSignature", + "type": "string", + "description": "Signature of webhook message." + }, + "Subscribe": { + "title": "Subscribe", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "subscribe" + }, + "event_types": { + "$ref": "#/components/schemas/EventTypes", + "nullable": true + }, + "inbox_ids": { + "$ref": "#/components/schemas/InboxIds", + "nullable": true + }, + "pod_ids": { + "$ref": "#/components/schemas/PodIds", + "nullable": true + } + }, + "required": [ + "type" + ] + }, + "Subscribed": { + "title": "Subscribed", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "subscribed" + }, + "event_types": { + "$ref": "#/components/schemas/EventTypes", + "nullable": true + }, + "inbox_ids": { + "$ref": "#/components/schemas/InboxIds", + "nullable": true + }, + "pod_ids": { + "$ref": "#/components/schemas/PodIds", + "nullable": true + } + }, + "required": [ + "type" + ] + } + }, + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer" + } + } + }, + "servers": [ + { + "url": "https://api.agentmail.to", + "description": "Prod (US)" + }, + { + "url": "https://api.agentmail.eu", + "description": "Prod (EU)" + } + ] +} \ No newline at end of file diff --git a/fern/overlay.yaml b/fern/overlay.yaml new file mode 100644 index 00000000..4150a900 --- /dev/null +++ b/fern/overlay.yaml @@ -0,0 +1,354 @@ +# Auto-generated overlay that maps AgentMail operationIds → nested CLI +# group + method names. Without this, the CLI surface ends up stuttered +# (e.g. `agentmail inboxes-messages inboxes-messages-list`) because the +# upstream spec uses flat tag names like `InboxesMessages` instead of +# x-fern-sdk-group-name. Regenerate from the spec by running the inline +# script in this directory's README. +# +overlay: 1.0.0 +info: + title: AgentMail CLI group/method overlay + version: 1.0.0 +actions: +- target: $.paths['/v0/inboxes'].get + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes'].post + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: create +- target: $.paths['/v0/inboxes/{inbox_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}'].patch + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: update +- target: $.paths['/v0/inboxes/{inbox_id}'].delete + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: delete +- target: $.paths['/v0/pods'].get + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods'].post + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: create +- target: $.paths['/v0/pods/{pod_id}'].get + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}'].delete + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: delete +- target: $.paths['/v0/webhooks'].get + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: list +- target: $.paths['/v0/webhooks'].post + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: create +- target: $.paths['/v0/webhooks/{webhook_id}'].get + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: get +- target: $.paths['/v0/webhooks/{webhook_id}'].patch + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: update +- target: $.paths['/v0/webhooks/{webhook_id}'].delete + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: delete +- target: $.paths['/v0/api-keys'].get + update: + x-fern-sdk-group-name: + - apiKeys + x-fern-sdk-method-name: list +- target: $.paths['/v0/api-keys'].post + update: + x-fern-sdk-group-name: + - apiKeys + x-fern-sdk-method-name: create +- target: $.paths['/v0/api-keys/{api_key}'].delete + update: + x-fern-sdk-group-name: + - apiKeys + x-fern-sdk-method-name: delete +- target: $.paths['/v0/domains'].get + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: list +- target: $.paths['/v0/domains'].post + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: create +- target: $.paths['/v0/domains/{domain_id}'].get + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: get +- target: $.paths['/v0/domains/{domain_id}'].delete + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: delete +- target: $.paths['/v0/domains/{domain_id}/zone-file'].get + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: getZoneFile +- target: $.paths['/v0/domains/{domain_id}/verify'].post + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: verify +- target: $.paths['/v0/drafts'].get + update: + x-fern-sdk-group-name: + - drafts + x-fern-sdk-method-name: list +- target: $.paths['/v0/drafts/{draft_id}'].get + update: + x-fern-sdk-group-name: + - drafts + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/drafts'].get + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes/{inbox_id}/drafts'].post + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: create +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].patch + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: update +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].delete + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: delete +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}/send'].post + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: send +- target: $.paths['/v0/inboxes/{inbox_id}/messages'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}'].patch + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: update +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: getAttachment +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/raw'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: getRaw +- target: $.paths['/v0/inboxes/{inbox_id}/messages/send'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: send +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/reply'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: reply +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: reply-all +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/forward'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: forward +- target: $.paths['/v0/inboxes/{inbox_id}/metrics'].get + update: + x-fern-sdk-group-name: + - inboxes + - metrics + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/threads'].get + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}'].delete + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: delete +- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: getAttachment +- target: $.paths['/v0/metrics'].get + update: + x-fern-sdk-group-name: + - metrics + x-fern-sdk-method-name: list +- target: $.paths['/v0/organizations'].get + update: + x-fern-sdk-group-name: + - organizations + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/domains'].get + update: + x-fern-sdk-group-name: + - pods + - domains + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/domains'].post + update: + x-fern-sdk-group-name: + - pods + - domains + x-fern-sdk-method-name: create +- target: $.paths['/v0/pods/{pod_id}/domains/{domain_id}'].delete + update: + x-fern-sdk-group-name: + - pods + - domains + x-fern-sdk-method-name: delete +- target: $.paths['/v0/pods/{pod_id}/drafts'].get + update: + x-fern-sdk-group-name: + - pods + - drafts + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/drafts/{draft_id}'].get + update: + x-fern-sdk-group-name: + - pods + - drafts + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/inboxes'].get + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/inboxes'].post + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: create +- target: $.paths['/v0/pods/{pod_id}/inboxes/{inbox_id}'].get + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/inboxes/{inbox_id}'].delete + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: delete +- target: $.paths['/v0/pods/{pod_id}/threads'].get + update: + x-fern-sdk-group-name: + - pods + - threads + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/threads/{thread_id}'].get + update: + x-fern-sdk-group-name: + - pods + - threads + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - pods + - threads + x-fern-sdk-method-name: getAttachment +- target: $.paths['/v0/threads'].get + update: + x-fern-sdk-group-name: + - threads + x-fern-sdk-method-name: list +- target: $.paths['/v0/threads/{thread_id}'].get + update: + x-fern-sdk-group-name: + - threads + x-fern-sdk-method-name: get +- target: $.paths['/v0/threads/{thread_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - threads + x-fern-sdk-method-name: getAttachment From 99aaf12a96615411dbaecc73e798dec991a2d2b6 Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 16:37:07 -0400 Subject: [PATCH 3/8] demo: generate agentmail CLI to checked-in cli/ from merged spec --- .gitignore | 3 + cli/Cargo.lock | 2816 ++++++++ cli/Cargo.toml | 80 + cli/LICENSE | 202 + cli/cli/agentmail/main.rs | 11 + cli/cli/agentmail/openapi0.json | 1 + cli/dist-workspace.toml | 34 + cli/src/auth/builder.rs | 974 +++ cli/src/auth/compose.rs | 589 ++ cli/src/auth/credential.rs | 549 ++ cli/src/auth/error.rs | 190 + cli/src/auth/mod.rs | 59 + cli/src/auth/oauth2.rs | 1210 ++++ cli/src/auth/provider.rs | 192 + cli/src/auth/schemes.rs | 433 ++ cli/src/auth/test_helpers.rs | 53 + cli/src/cli_args.rs | 165 + cli/src/completions.rs | 169 + cli/src/custom_commands.rs | 328 + cli/src/early_intercept.rs | 185 + cli/src/error.rs | 206 + cli/src/formatter.rs | 923 +++ cli/src/graphql/app.rs | 696 ++ cli/src/graphql/commands.rs | 385 + cli/src/graphql/discovery.rs | 145 + cli/src/graphql/executor.rs | 909 +++ cli/src/graphql/help.rs | 361 + cli/src/graphql/mod.rs | 9 + cli/src/graphql/parser.rs | 974 +++ cli/src/http.rs | 845 +++ cli/src/lib.rs | 63 + cli/src/logging.rs | 123 + cli/src/man.rs | 101 + cli/src/openapi/__fixtures__/openapi.json | 63 + cli/src/openapi/app.rs | 3913 +++++++++++ cli/src/openapi/commands.rs | 1554 +++++ cli/src/openapi/discovery.rs | 1031 +++ cli/src/openapi/executor.rs | 6771 ++++++++++++++++++ cli/src/openapi/help.rs | 518 ++ cli/src/openapi/mod.rs | 11 + cli/src/openapi/overlay.rs | 1898 +++++ cli/src/openapi/parser.rs | 7711 +++++++++++++++++++++ cli/src/output.rs | 231 + cli/src/text.rs | 327 + cli/src/validate.rs | 839 +++ cli/src/websocket/auth.rs | 532 ++ cli/src/websocket/client.rs | 667 ++ cli/src/websocket/error.rs | 247 + cli/src/websocket/mod.rs | 48 + cli/tests/auth_routing_wire.rs | 728 ++ cli/tests/common/mod.rs | 260 + cli/tests/lib_api.rs | 40 + cli/tests/openapi_streaming_wire.rs | 392 ++ cli/tests/tls_env_vars.rs | 339 + cli/tests/websocket_wire.rs | 900 +++ cli/tests/x_name_server_alias_wire.rs | 140 + fern/generators.yml | 3 +- fern/openapi.json | 404 +- fern/overlay.yaml | 354 - 59 files changed, 43486 insertions(+), 418 deletions(-) create mode 100644 cli/Cargo.lock create mode 100644 cli/Cargo.toml create mode 100644 cli/LICENSE create mode 100644 cli/cli/agentmail/main.rs create mode 100644 cli/cli/agentmail/openapi0.json create mode 100644 cli/dist-workspace.toml create mode 100644 cli/src/auth/builder.rs create mode 100644 cli/src/auth/compose.rs create mode 100644 cli/src/auth/credential.rs create mode 100644 cli/src/auth/error.rs create mode 100644 cli/src/auth/mod.rs create mode 100644 cli/src/auth/oauth2.rs create mode 100644 cli/src/auth/provider.rs create mode 100644 cli/src/auth/schemes.rs create mode 100644 cli/src/auth/test_helpers.rs create mode 100644 cli/src/cli_args.rs create mode 100644 cli/src/completions.rs create mode 100644 cli/src/custom_commands.rs create mode 100644 cli/src/early_intercept.rs create mode 100644 cli/src/error.rs create mode 100644 cli/src/formatter.rs create mode 100644 cli/src/graphql/app.rs create mode 100644 cli/src/graphql/commands.rs create mode 100644 cli/src/graphql/discovery.rs create mode 100644 cli/src/graphql/executor.rs create mode 100644 cli/src/graphql/help.rs create mode 100644 cli/src/graphql/mod.rs create mode 100644 cli/src/graphql/parser.rs create mode 100644 cli/src/http.rs create mode 100644 cli/src/lib.rs create mode 100644 cli/src/logging.rs create mode 100644 cli/src/man.rs create mode 100644 cli/src/openapi/__fixtures__/openapi.json create mode 100644 cli/src/openapi/app.rs create mode 100644 cli/src/openapi/commands.rs create mode 100644 cli/src/openapi/discovery.rs create mode 100644 cli/src/openapi/executor.rs create mode 100644 cli/src/openapi/help.rs create mode 100644 cli/src/openapi/mod.rs create mode 100644 cli/src/openapi/overlay.rs create mode 100644 cli/src/openapi/parser.rs create mode 100644 cli/src/output.rs create mode 100644 cli/src/text.rs create mode 100644 cli/src/validate.rs create mode 100644 cli/src/websocket/auth.rs create mode 100644 cli/src/websocket/client.rs create mode 100644 cli/src/websocket/error.rs create mode 100644 cli/src/websocket/mod.rs create mode 100644 cli/tests/auth_routing_wire.rs create mode 100644 cli/tests/common/mod.rs create mode 100644 cli/tests/lib_api.rs create mode 100644 cli/tests/openapi_streaming_wire.rs create mode 100644 cli/tests/tls_env_vars.rs create mode 100644 cli/tests/websocket_wire.rs create mode 100644 cli/tests/x_name_server_alias_wire.rs delete mode 100644 fern/overlay.yaml diff --git a/.gitignore b/.gitignore index 99bc799e..3e794c47 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ node_modules/ **/generated/ .DS_Store *-openapi.json + +# Generated CLI build artifacts +/cli/target/ diff --git a/cli/Cargo.lock b/cli/Cargo.lock new file mode 100644 index 00000000..1283bd3b --- /dev/null +++ b/cli/Cargo.lock @@ -0,0 +1,2816 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fern-cli-sdk" +version = "0.18.1" +dependencies = [ + "anyhow", + "base64", + "bytes", + "clap", + "clap_complete", + "clap_mangen", + "dotenvy", + "futures-util", + "hmac", + "httpdate", + "libc", + "percent-encoding", + "reqwest", + "secrecy", + "serde", + "serde_json", + "serde_json_path", + "serde_qs", + "serde_yaml", + "serial_test", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "wiremock", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_path" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33" +dependencies = [ + "inventory", + "nom", + "regex", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_json_path_core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde67d8dfe7d4967b5a95e247d4148368ddd1e753e500adb34b3ffe40c6bc1bc" +dependencies = [ + "inventory", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517acfa7f77ddaf5c43d5f119c44a683774e130b4247b7d3210f8924506cfac8" +dependencies = [ + "inventory", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_qs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332" +dependencies = [ + "itoa", + "percent-encoding", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 00000000..c77c375d --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,80 @@ +[package] +name = "fern-cli-sdk" +version = "0.18.1" +edition = "2021" +description = "CLI generator — dynamic command surface from OpenAPI and GraphQL schemas" +license = "Apache-2.0" +repository = "https://github.com/fern-api/cli-sdk" +homepage = "https://github.com/fern-api/cli-sdk" +authors = ["Fern "] +keywords = ["cli", "openapi", "graphql", "fern", "codegen"] +categories = ["command-line-utilities", "web-programming"] + +[lib] +name = "fern_cli_sdk" +path = "src/lib.rs" + +[[bin]] +name = "agentmail" +path = "cli/agentmail/main.rs" + +[features] +# TLS backend selection. +# +# default = ["native-tls"] +# Uses the OS's native TLS stack (Secure Transport on macOS, SChannel on +# Windows, OpenSSL on Linux). Honors the OS keychain / cert store — +# what users typically expect for an interactive CLI. +# +# ["rustls"] (cargo build --no-default-features --features rustls) +# Uses the pure-Rust rustls crate with Mozilla's bundled webpki roots. +# Produces self-contained static binaries that don't depend on system +# OpenSSL — preferred for distribution to varied Linux servers, scratch +# Docker images, and cross-compiled musl/ARM builds. Does NOT read the +# OS keychain; users must use `_CA_BUNDLE` for custom roots. +default = ["native-tls"] +native-tls = ["reqwest/native-tls", "tokio-tungstenite/native-tls"] +rustls = ["reqwest/rustls-tls-native-roots", "tokio-tungstenite/rustls-tls-native-roots"] + +[dependencies] +anyhow = "1" +base64 = "0.22" +bytes = "1" +clap = { version = "4", features = ["derive", "string", "env"] } +clap_complete = "4" +clap_mangen = "0.2" +hmac = "0.12" +dotenvy = "0.15" +futures-util = "0.3" +httpdate = "1" +libc = "0.2" +percent-encoding = "2.3.2" +reqwest = { version = "0.12", features = ["json", "stream"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_json_path = "0.7" +serde_yaml = "0.9.34" +secrecy = "0.10" +serde_qs = "1.1.1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake"] } +tokio-util = { version = "0.7", features = ["io"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" + +[package.metadata.dist] +dist = true + +# The profile that 'cargo dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" + +[dev-dependencies] +serial_test = "3.4.0" +tempfile = "3" +wiremock = "0.6" +tokio = { version = "1", features = ["full"] } diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/cli/cli/agentmail/main.rs b/cli/cli/agentmail/main.rs new file mode 100644 index 00000000..26cc6931 --- /dev/null +++ b/cli/cli/agentmail/main.rs @@ -0,0 +1,11 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::openapi::CliApp; + +fn main() { + CliApp::new("agentmail") + .spec(include_str!("openapi0.json")) + .auth_scheme_env("BearerAuth", "AGENTMAIL_TOKEN") + .run() +} diff --git a/cli/cli/agentmail/openapi0.json b/cli/cli/agentmail/openapi0.json new file mode 100644 index 00000000..7bd0d7d6 --- /dev/null +++ b/cli/cli/agentmail/openapi0.json @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"api","version":""},"paths":{"/v0/inboxes":{"get":{"operationId":"inboxes_list","tags":["Inboxes"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"inboxes_create","tags":["Inboxes"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest","nullable":true}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}":{"get":{"operationId":"inboxes_get","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"inboxes_update","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesUpdateInboxRequest"}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"update"},"delete":{"operationId":"inboxes_delete","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods":{"get":{"operationId":"pods_list","tags":["Pods"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsListPodsResponse"}}}}},"summary":"List Pods","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"pods_create","tags":["Pods"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsCreatePodRequest"}}}},"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}":{"get":{"operationId":"pods_get","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"pods_delete","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"delete"}},"/v0/webhooks":{"get":{"operationId":"webhooks_list","tags":["Webhooks"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksListWebhooksResponse"}}}}},"summary":"List Webhooks","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"webhooks_create","tags":["Webhooks"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksCreateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"create"}},"/v0/webhooks/{webhook_id}":{"get":{"operationId":"webhooks_get","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"webhooks_update","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"update"},"delete":{"operationId":"webhooks_delete","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"delete"}},"/v0/api-keys":{"get":{"operationId":"apiKeys_list","tags":["ApiKeys"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"apiKeys_create","tags":["ApiKeys"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"create"}},"/v0/api-keys/{api_key}":{"delete":{"operationId":"apiKeys_delete","tags":["ApiKeys"],"parameters":[{"name":"api_key","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"delete"}},"/v0/domains":{"get":{"operationId":"domains_list","tags":["Domains"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"domains_create","tags":["Domains"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"create"}},"/v0/domains/{domain_id}":{"get":{"operationId":"domains_get","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"domains_delete","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"delete"}},"/v0/domains/{domain_id}/zone-file":{"get":{"operationId":"domains_getZoneFile","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Zone File","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"getZoneFile"}},"/v0/domains/{domain_id}/verify":{"post":{"operationId":"domains_verify","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Verify Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"verify"}},"/v0/drafts":{"get":{"operationId":"drafts_list","tags":["Drafts"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"list"}},"/v0/drafts/{draft_id}":{"get":{"operationId":"drafts_get","tags":["Drafts"],"parameters":[{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"get"}},"/v0/inboxes/{inbox_id}/drafts":{"get":{"operationId":"inboxes_drafts_list","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"inboxes_drafts_create","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}":{"get":{"operationId":"inboxes_drafts_get","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"inboxes_drafts_update","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"update"},"delete":{"operationId":"inboxes_drafts_delete","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}/send":{"post":{"operationId":"inboxes_drafts_send","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/messages":{"get":{"operationId":"inboxes_messages_list","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMessagesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/messages/{message_id}":{"get":{"operationId":"inboxes_messages_get","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"inboxes_messages_update","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"update"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}":{"get":{"operationId":"inboxes_messages_getAttachment","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/raw":{"get":{"operationId":"inboxes_messages_getRaw","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RawMessageResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Raw Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"getRaw"}},"/v0/inboxes/{inbox_id}/messages/send":{"post":{"operationId":"inboxes_messages_send","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply":{"post":{"operationId":"inboxes_messages_reply","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply To Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyToMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all":{"post":{"operationId":"inboxes_messages_reply-all","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply All Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyAllMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply-all"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/forward":{"post":{"operationId":"inboxes_messages_forward","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Forward Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"forward"}},"/v0/inboxes/{inbox_id}/metrics":{"get":{"operationId":"inboxes_metrics_get","tags":["InboxesMetrics"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricStartTimestamp"}},{"name":"end_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricEndTimestamp"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Metrics","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","metrics"],"x-fern-sdk-method-name":"get"}},"/v0/inboxes/{inbox_id}/threads":{"get":{"operationId":"inboxes_threads_list","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}":{"get":{"operationId":"inboxes_threads_get","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"inboxes_threads_delete","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"operationId":"inboxes_threads_getAttachment","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/metrics":{"get":{"operationId":"metrics_list","tags":["Metrics"],"parameters":[{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricStartTimestamp"}},{"name":"end_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricEndTimestamp"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMetricsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Metrics","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["metrics"],"x-fern-sdk-method-name":"list"}},"/v0/organizations":{"get":{"description":"Get the current organization.","operationId":"organizations_get","tags":["Organizations"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}}},"summary":"Get Organization","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["organizations"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/domains":{"get":{"operationId":"pods_domains_list","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"pods_domains_create","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/domains/{domain_id}":{"delete":{"operationId":"pods_domains_delete","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/drafts":{"get":{"operationId":"pods_drafts_list","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/drafts/{draft_id}":{"get":{"operationId":"pods_drafts_get","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/inboxes":{"get":{"operationId":"pods_inboxes_list","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"pods_inboxes_create","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest"}}}},"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/inboxes/{inbox_id}":{"get":{"operationId":"pods_inboxes_get","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"pods_inboxes_delete","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/threads":{"get":{"operationId":"pods_threads_list","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/threads/{thread_id}":{"get":{"operationId":"pods_threads_get","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"operationId":"pods_threads_getAttachment","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/threads":{"get":{"operationId":"threads_list","tags":["Threads"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"list"}},"/v0/threads/{thread_id}":{"get":{"operationId":"threads_get","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"get"}},"/v0/threads/{thread_id}/attachments/{attachment_id}":{"get":{"operationId":"threads_getAttachment","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"getAttachment"}}},"components":{"schemas":{"Limit":{"title":"Limit","type":"integer","description":"Limit of number of items returned."},"Count":{"title":"Count","type":"integer","description":"Number of items returned."},"PageToken":{"title":"PageToken","type":"string","description":"Page token for pagination."},"Labels":{"title":"Labels","type":"array","items":{"type":"string"},"description":"Labels to filter by."},"Before":{"title":"Before","type":"string","format":"date-time","description":"Timestamp before which to filter by."},"After":{"title":"After","type":"string","format":"date-time","description":"Timestamp after which to filter by."},"Ascending":{"title":"Ascending","type":"boolean","description":"Sort in ascending temporal order."},"IncludeSpam":{"title":"IncludeSpam","type":"boolean","description":"Include spam in results."},"OrganizationId":{"title":"OrganizationId","type":"string","description":"ID of organization."},"ErrorName":{"title":"ErrorName","type":"string","description":"Name of error."},"ErrorResponse":{"title":"ErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"message":{"type":"string","description":"Error message."}},"required":["name","message"]},"ValidationErrorResponse":{"title":"ValidationErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"errors":{"description":"Validation errors."}},"required":["name","errors"]},"inboxesInboxId":{"title":"inboxesInboxId","type":"string","description":"ID of inbox."},"inboxesDisplayName":{"title":"inboxesDisplayName","type":"string","description":"Display name: `Display Name `."},"inboxesClientId":{"title":"inboxesClientId","type":"string","description":"Client ID of inbox."},"inboxesInbox":{"title":"inboxesInbox","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which inbox was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which inbox was created."}},"required":["pod_id","inbox_id","updated_at","created_at"]},"inboxesListInboxesResponse":{"title":"inboxesListInboxesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"inboxes":{"type":"array","items":{"$ref":"#/components/schemas/inboxesInbox"},"description":"Ordered by `created_at` descending."}},"required":["count","inboxes"]},"inboxesCreateInboxRequest":{"title":"inboxesCreateInboxRequest","type":"object","properties":{"username":{"type":"string","nullable":true,"description":"Username of address. Randomly generated if not specified."},"domain":{"type":"string","nullable":true,"description":"Domain of address. Must be verified domain. Defaults to `agentmail.to`."},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true}}},"inboxesUpdateInboxRequest":{"title":"inboxesUpdateInboxRequest","type":"object","properties":{"display_name":{"$ref":"#/components/schemas/inboxesDisplayName"}},"required":["display_name"]},"podsPodId":{"title":"podsPodId","type":"string","description":"ID of pod."},"podsName":{"title":"podsName","type":"string","description":"Name of pod."},"podsClientId":{"title":"podsClientId","type":"string","description":"Client ID of pod."},"podsPod":{"title":"podsPod","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"name":{"$ref":"#/components/schemas/podsName"},"updated_at":{"type":"string","format":"date-time","description":"Time at which pod was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which pod was created."},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}},"required":["pod_id","name","updated_at","created_at"]},"podsListPodsResponse":{"title":"podsListPodsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"pods":{"type":"array","items":{"$ref":"#/components/schemas/podsPod"},"description":"Ordered by `created_at` descending."}},"required":["count","pods"]},"podsCreatePodRequest":{"title":"podsCreatePodRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/podsName","nullable":true},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}}},"webhooksWebhookId":{"title":"webhooksWebhookId","type":"string","description":"ID of webhook."},"webhooksClientId":{"title":"webhooksClientId","type":"string","description":"Client ID of webhook."},"webhooksUrl":{"title":"webhooksUrl","type":"string","description":"URL of webhook endpoint."},"webhooksWebhook":{"title":"webhooksWebhook","type":"object","properties":{"webhook_id":{"$ref":"#/components/schemas/webhooksWebhookId"},"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"secret":{"type":"string","description":"Secret for webhook signature verification."},"enabled":{"type":"boolean","description":"Webhook is enabled."},"updated_at":{"type":"string","format":"date-time","description":"Time at which webhook was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which webhook was created."},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true}},"required":["webhook_id","url","secret","enabled","updated_at","created_at"]},"webhooksListWebhooksResponse":{"title":"webhooksListWebhooksResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/webhooksWebhook"},"description":"Ordered by `created_at` descending."}},"required":["count","webhooks"]},"webhooksCreateWebhookRequest":{"title":"webhooksCreateWebhookRequest","type":"object","properties":{"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/EventTypes"},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true}},"required":["url","event_types"]},"webhooksUpdateWebhookRequest":{"title":"webhooksUpdateWebhookRequest","type":"object","properties":{"add_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to subscribe to the webhook."},"remove_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to unsubscribe from the webhook."},"add_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to subscribe to the webhook."},"remove_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to unsubscribe from the webhook."}}},"ApiKeyId":{"title":"ApiKeyId","type":"string","description":"ID of api key."},"Prefix":{"title":"Prefix","type":"string","description":"Prefix of api key."},"Name":{"title":"Name","type":"string","description":"Name of api key."},"CreatedAt":{"title":"CreatedAt","type":"string","format":"date-time","description":"Time at which api key was created."},"ApiKey":{"title":"ApiKey","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"used_at":{"type":"string","format":"date-time","nullable":true,"description":"Time at which api key was last used."},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","prefix","name","created_at"]},"CreateApiKeyResponse":{"title":"CreateApiKeyResponse","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"api_key":{"type":"string","description":"API key."},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","api_key","prefix","name","created_at"]},"ListApiKeysResponse":{"title":"ListApiKeysResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"api_keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"},"description":"Ordered by `created_at` descending."}},"required":["count","api_keys"]},"CreateApiKeyRequest":{"title":"CreateApiKeyRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/Name"}},"required":["name"]},"AttachmentId":{"title":"AttachmentId","type":"string","description":"ID of attachment."},"AttachmentFilename":{"title":"AttachmentFilename","type":"string","description":"Filename of attachment."},"AttachmentSize":{"title":"AttachmentSize","type":"integer","description":"Size of attachment in bytes."},"AttachmentContentType":{"title":"AttachmentContentType","type":"string","description":"Content type of attachment."},"AttachmentContentDisposition":{"title":"AttachmentContentDisposition","type":"string","enum":["inline","attachment"],"description":"Content disposition of attachment."},"AttachmentContentId":{"title":"AttachmentContentId","type":"string","description":"Content ID of attachment."},"Attachment":{"title":"Attachment","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true}},"required":["attachment_id","size"]},"AttachmentResponse":{"title":"AttachmentResponse","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"download_url":{"type":"string","description":"URL to download the attachment."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["attachment_id","size","download_url","expires_at"]},"SendAttachment":{"title":"SendAttachment","type":"object","properties":{"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"content":{"type":"string","nullable":true,"description":"Base64 encoded content of attachment."},"url":{"type":"string","nullable":true,"description":"URL to the attachment."}}},"DomainId":{"title":"DomainId","type":"string","description":"The name of the domain. (e.g., \" your-domain.com\")"},"DomainName":{"title":"DomainName","type":"string","description":"The name of the domain. (e.g., \"example.com\")"},"RecordType":{"title":"RecordType","type":"string","enum":["TXT","CNAME","MX"]},"VerificationStatus":{"title":"VerificationStatus","type":"string","enum":["NOT_STARTED","PENDING","INVALID","FAILED","VERIFYING","VERIFIED"]},"RecordStatus":{"title":"RecordStatus","type":"string","enum":["MISSING","INVALID","VALID"]},"VerificationRecord":{"title":"VerificationRecord","type":"object","properties":{"type":{"$ref":"#/components/schemas/RecordType","description":"The type of the DNS record."},"name":{"type":"string","description":"The name or host of the record."},"value":{"type":"string","description":"The value of the record."},"status":{"$ref":"#/components/schemas/RecordStatus","description":"The verification status of this specific record."},"priority":{"type":"integer","nullable":true,"description":"The priority of the MX record."}},"required":["type","name","value","status"]},"FeedbackEnabled":{"title":"FeedbackEnabled","type":"boolean","description":"Bounce and complaint notifications are sent to your inboxes."},"ClientId":{"title":"ClientId","type":"string","description":"Client ID of domain."},"Domain":{"title":"Domain","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"status":{"$ref":"#/components/schemas/VerificationStatus","description":"The verification status of the domain."},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"records":{"type":"array","items":{"$ref":"#/components/schemas/VerificationRecord"},"description":"A list of DNS records required to verify the domain."},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","status","feedback_enabled","records","updated_at","created_at"]},"DomainItem":{"title":"DomainItem","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","feedback_enabled","updated_at","created_at"]},"ListDomainsResponse":{"title":"ListDomainsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"domains":{"type":"array","items":{"$ref":"#/components/schemas/DomainItem"},"description":"Ordered by `created_at` descending."}},"required":["count","domains"]},"CreateDomainRequest":{"title":"CreateDomainRequest","type":"object","properties":{"domain":{"$ref":"#/components/schemas/DomainName"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"}},"required":["domain","feedback_enabled"]},"DraftId":{"title":"DraftId","type":"string","description":"ID of draft."},"DraftClientId":{"title":"DraftClientId","type":"string","description":"Client ID of draft."},"DraftLabels":{"title":"DraftLabels","type":"array","items":{"type":"string"},"description":"Labels of draft."},"DraftReplyTo":{"title":"DraftReplyTo","type":"array","items":{"type":"string"},"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"DraftTo":{"title":"DraftTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"DraftCc":{"title":"DraftCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"DraftBcc":{"title":"DraftBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"DraftSubject":{"title":"DraftSubject","type":"string","description":"Subject of draft."},"DraftPreview":{"title":"DraftPreview","type":"string","description":"Text preview of draft."},"DraftText":{"title":"DraftText","type":"string","description":"Plain text body of draft."},"DraftHtml":{"title":"DraftHtml","type":"string","description":"HTML body of draft."},"DraftAttachments":{"title":"DraftAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in draft."},"DraftInReplyTo":{"title":"DraftInReplyTo","type":"string","description":"ID of message being replied to."},"DraftSendStatus":{"title":"DraftSendStatus","type":"string","enum":["scheduled","sending","failed"],"description":"Schedule send status of draft."},"DraftSendAt":{"title":"DraftSendAt","type":"string","format":"date-time","description":"Time at which to schedule send draft."},"DraftUpdatedAt":{"title":"DraftUpdatedAt","type":"string","format":"date-time","description":"Time at which draft was last updated."},"DraftItem":{"title":"DraftItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"labels":{"$ref":"#/components/schemas/DraftLabels"},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"}},"required":["inbox_id","thread_id","draft_id","labels","updated_at"]},"Draft":{"title":"Draft","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true},"labels":{"$ref":"#/components/schemas/DraftLabels"},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"references":{"type":"array","items":{"type":"string"},"nullable":true,"description":"IDs of previous messages in thread."},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"},"created_at":{"type":"string","format":"date-time","description":"Time at which draft was created."}},"required":["inbox_id","thread_id","draft_id","labels","updated_at","created_at"]},"ListDraftsResponse":{"title":"ListDraftsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"drafts":{"type":"array","items":{"$ref":"#/components/schemas/DraftItem"},"description":"Ordered by `updated_at` descending."}},"required":["count","drafts"]},"CreateDraftRequest":{"title":"CreateDraftRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/DraftLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true}}},"UpdateDraftRequest":{"title":"UpdateDraftRequest","type":"object","properties":{"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true}}},"EventType":{"title":"EventType","type":"string","enum":["message.received","message.sent","message.delivered","message.bounced","message.complained","message.rejected","domain.verified"]},"EventTypes":{"title":"EventTypes","type":"array","items":{"$ref":"#/components/schemas/EventType"},"description":"Event types for which to send events."},"PodIds":{"title":"PodIds","type":"array","items":{"type":"string"},"description":"Pods for which to send events. Maximum 10 per webhook."},"InboxIds":{"title":"InboxIds","type":"array","items":{"type":"string"},"description":"Inboxes for which to send events. Maximum 10 per webhook."},"EventId":{"title":"EventId","type":"string","description":"ID of event."},"Timestamp":{"title":"Timestamp","type":"string","format":"date-time","description":"Timestamp of event."},"Recipient":{"title":"Recipient","type":"object","properties":{"address":{"type":"string","description":"Recipient address."},"status":{"type":"string","description":"Recipient status."}},"required":["address","status"]},"Send":{"title":"Send","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Sent recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"]},"Delivery":{"title":"Delivery","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Delivered recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"]},"Bounce":{"title":"Bounce","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Bounce type."},"sub_type":{"type":"string","description":"Bounce sub-type."},"recipients":{"type":"array","items":{"$ref":"#/components/schemas/Recipient"},"description":"Bounced recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Complaint":{"title":"Complaint","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Complaint type."},"sub_type":{"type":"string","description":"Complaint sub-type."},"recipients":{"type":"array","items":{"type":"string"},"description":"Complained recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Reject":{"title":"Reject","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"reason":{"type":"string","description":"Reject reason."}},"required":["inbox_id","thread_id","message_id","timestamp","reason"]},"MessageReceivedEvent":{"title":"MessageReceivedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.received"},"event_id":{"$ref":"#/components/schemas/EventId"},"message":{"$ref":"#/components/schemas/Message"},"thread":{"$ref":"#/components/schemas/ThreadItem"}},"required":["type","event_type","event_id","message","thread"]},"MessageSentEvent":{"title":"MessageSentEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.sent"},"event_id":{"$ref":"#/components/schemas/EventId"},"send":{"$ref":"#/components/schemas/Send"}},"required":["type","event_type","event_id","send"]},"MessageDeliveredEvent":{"title":"MessageDeliveredEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.delivered"},"event_id":{"$ref":"#/components/schemas/EventId"},"delivery":{"$ref":"#/components/schemas/Delivery"}},"required":["type","event_type","event_id","delivery"]},"MessageBouncedEvent":{"title":"MessageBouncedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.bounced"},"event_id":{"$ref":"#/components/schemas/EventId"},"bounce":{"$ref":"#/components/schemas/Bounce"}},"required":["type","event_type","event_id","bounce"]},"MessageComplainedEvent":{"title":"MessageComplainedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.complained"},"event_id":{"$ref":"#/components/schemas/EventId"},"complaint":{"$ref":"#/components/schemas/Complaint"}},"required":["type","event_type","event_id","complaint"]},"MessageRejectedEvent":{"title":"MessageRejectedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.rejected"},"event_id":{"$ref":"#/components/schemas/EventId"},"reject":{"$ref":"#/components/schemas/Reject"}},"required":["type","event_type","event_id","reject"]},"DomainVerifiedEvent":{"title":"DomainVerifiedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"domain.verified"},"event_id":{"$ref":"#/components/schemas/EventId"},"domain":{"$ref":"#/components/schemas/Domain"}},"required":["type","event_type","event_id","domain"]},"MessageId":{"title":"MessageId","type":"string","description":"ID of message."},"MessageLabels":{"title":"MessageLabels","type":"array","items":{"type":"string"},"description":"Labels of message."},"MessageTimestamp":{"title":"MessageTimestamp","type":"string","format":"date-time","description":"Time at which message was sent or drafted."},"MessageFrom":{"title":"MessageFrom","type":"string","description":"Address of sender. In format `username@domain.com` or `Display Name `."},"MessageReplyTo":{"title":"MessageReplyTo","type":"array","items":{"type":"string"},"description":"Addresses of reply-to recipients. In format `username@domain.com` or `Display Name `."},"MessageTo":{"title":"MessageTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"MessageCc":{"title":"MessageCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"MessageBcc":{"title":"MessageBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"MessageSubject":{"title":"MessageSubject","type":"string","description":"Subject of message."},"MessagePreview":{"title":"MessagePreview","type":"string","description":"Text preview of message."},"MessageText":{"title":"MessageText","type":"string","description":"Plain text body of message."},"MessageHtml":{"title":"MessageHtml","type":"string","description":"HTML body of message."},"MessageAttachments":{"title":"MessageAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in message."},"MessageInReplyTo":{"title":"MessageInReplyTo","type":"string","description":"ID of message being replied to."},"MessageReferences":{"title":"MessageReferences","type":"array","items":{"type":"string"},"description":"IDs of previous messages in thread."},"MessageHeaders":{"title":"MessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers in message."},"MessageSize":{"title":"MessageSize","type":"integer","description":"Size of message in bytes."},"MessageUpdatedAt":{"title":"MessageUpdatedAt","type":"string","format":"date-time","description":"Time at which message was last updated."},"MessageCreatedAt":{"title":"MessageCreatedAt","type":"string","format":"date-time","description":"Time at which message was created."},"MessageItem":{"title":"MessageItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"Message":{"title":"Message","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"reply_to":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"extracted_text":{"type":"string","nullable":true,"description":"Extracted new text content."},"extracted_html":{"type":"string","nullable":true,"description":"Extracted new HTML content."},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"ListMessagesResponse":{"title":"ListMessagesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MessageItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","messages"]},"Addresses":{"title":"Addresses","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"SendMessageReplyTo":{"title":"SendMessageReplyTo","$ref":"#/components/schemas/Addresses","description":"Reply-to address or addresses."},"SendMessageTo":{"title":"SendMessageTo","$ref":"#/components/schemas/Addresses","description":"Recipient address or addresses."},"SendMessageCc":{"title":"SendMessageCc","$ref":"#/components/schemas/Addresses","description":"CC recipient address or addresses."},"SendMessageBcc":{"title":"SendMessageBcc","$ref":"#/components/schemas/Addresses","description":"BCC recipient address or addresses."},"SendMessageAttachments":{"title":"SendMessageAttachments","type":"array","items":{"$ref":"#/components/schemas/SendAttachment"},"description":"Attachments to include in message."},"SendMessageHeaders":{"title":"SendMessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers to include in message."},"SendMessageRequest":{"title":"SendMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"SendMessageResponse":{"title":"SendMessageResponse","type":"object","properties":{"message_id":{"$ref":"#/components/schemas/MessageId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"}},"required":["message_id","thread_id"]},"RawMessageResponse":{"title":"RawMessageResponse","type":"object","description":"S3 presigned URL to download the raw .eml file.","properties":{"message_id":{"$ref":"#/components/schemas/MessageId","description":"ID of the message."},"size":{"$ref":"#/components/schemas/MessageSize","description":"Size of the raw message in bytes."},"download_url":{"type":"string","description":"S3 presigned URL to download the raw message. Expires at expires_at."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["message_id","size","download_url","expires_at"]},"ReplyAll":{"title":"ReplyAll","type":"boolean","description":"Reply to all recipients of the original message."},"ReplyToMessageRequest":{"title":"ReplyToMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"reply_all":{"$ref":"#/components/schemas/ReplyAll","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"ReplyAllMessageRequest":{"title":"ReplyAllMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"UpdateMessageRequest":{"title":"UpdateMessageRequest","type":"object","properties":{"add_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to add to message."},"remove_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to remove from message."}}},"MetricEventType":{"title":"MetricEventType","type":"string","enum":["message.sent","message.delivered","message.bounced","message.delayed","message.rejected","message.complained","message.received"],"description":"Type of metric event."},"MetricTimestamp":{"title":"MetricTimestamp","type":"string","format":"date-time","description":"Timestamp when the metric event occurred."},"MetricEventTypes":{"title":"MetricEventTypes","type":"array","items":{"$ref":"#/components/schemas/MetricEventType"},"description":"List of metric event types to filter by."},"MetricStartTimestamp":{"title":"MetricStartTimestamp","type":"string","format":"date-time","description":"Start timestamp for the metrics query range."},"MetricEndTimestamp":{"title":"MetricEndTimestamp","type":"string","format":"date-time","description":"End timestamp for the metrics query range."},"MessageMetrics":{"title":"MessageMetrics","type":"object","properties":{"sent":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were sent."},"delivered":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were delivered."},"bounced":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages bounced."},"delayed":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were delayed."},"rejected":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were rejected."},"complained":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages received complaints."},"received":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were received."}}},"ListMetricsResponse":{"title":"ListMetricsResponse","type":"object","properties":{"message":{"$ref":"#/components/schemas/MessageMetrics","nullable":true,"description":"Message metrics grouped by event type."}}},"Organization":{"title":"Organization","type":"object","description":"Organization details with usage limits and counts.","properties":{"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"inbox_count":{"type":"integer","description":"Current number of inboxes."},"domain_count":{"type":"integer","description":"Current number of domains."},"inbox_limit":{"type":"integer","nullable":true,"description":"Maximum number of inboxes allowed."},"domain_limit":{"type":"integer","nullable":true,"description":"Maximum number of domains allowed."},"updated_at":{"type":"string","format":"date-time","description":"Time at which organization was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which organization was created."}},"required":["organization_id","inbox_count","domain_count","updated_at","created_at"]},"ThreadId":{"title":"ThreadId","type":"string","description":"ID of thread."},"ThreadLabels":{"title":"ThreadLabels","type":"array","items":{"type":"string"},"description":"Labels of thread."},"ThreadTimestamp":{"title":"ThreadTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent or received message."},"ThreadReceivedTimestamp":{"title":"ThreadReceivedTimestamp","type":"string","format":"date-time","description":"Timestamp of last received message."},"ThreadSentTimestamp":{"title":"ThreadSentTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent message."},"ThreadSenders":{"title":"ThreadSenders","type":"array","items":{"type":"string"},"description":"Senders in thread. In format `username@domain.com` or `Display Name `."},"ThreadRecipients":{"title":"ThreadRecipients","type":"array","items":{"type":"string"},"description":"Recipients in thread. In format `username@domain.com` or `Display Name `."},"ThreadSubject":{"title":"ThreadSubject","type":"string","description":"Subject of thread."},"ThreadPreview":{"title":"ThreadPreview","type":"string","description":"Text preview of last message in thread."},"ThreadAttachments":{"title":"ThreadAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in thread."},"ThreadLastMessageId":{"title":"ThreadLastMessageId","type":"string","description":"ID of last message in thread."},"ThreadMessageCount":{"title":"ThreadMessageCount","type":"integer","description":"Number of messages in thread."},"ThreadSize":{"title":"ThreadSize","type":"integer","description":"Size of thread in bytes."},"ThreadUpdatedAt":{"title":"ThreadUpdatedAt","type":"string","format":"date-time","description":"Time at which thread was last updated."},"ThreadCreatedAt":{"title":"ThreadCreatedAt","type":"string","format":"date-time","description":"Time at which thread was created."},"ThreadItem":{"title":"ThreadItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at"]},"Thread":{"title":"Thread","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"},"description":"Messages in thread. Ordered by `timestamp` ascending."}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at","messages"]},"ListThreadsResponse":{"title":"ListThreadsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"threads":{"type":"array","items":{"$ref":"#/components/schemas/ThreadItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","threads"]},"webhooksSvixId":{"title":"webhooksSvixId","type":"string","description":"ID of webhook message."},"webhooksSvixTimestamp":{"title":"webhooksSvixTimestamp","type":"string","format":"date-time","description":"Timestamp of webhook message."},"webhooksSvixSignature":{"title":"webhooksSvixSignature","type":"string","description":"Signature of webhook message."},"Subscribe":{"title":"Subscribe","type":"object","properties":{"type":{"type":"string","const":"subscribe"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]},"Subscribed":{"title":"Subscribed","type":"object","properties":{"type":{"type":"string","const":"subscribed"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]}},"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer"}}},"servers":[{"url":"https://api.agentmail.to","description":"Prod (US)"},{"url":"https://api.agentmail.eu","description":"Prod (EU)"}]} \ No newline at end of file diff --git a/cli/dist-workspace.toml b/cli/dist-workspace.toml new file mode 100644 index 00000000..db954148 --- /dev/null +++ b/cli/dist-workspace.toml @@ -0,0 +1,34 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.31.0" +# CI backends to support +ci = "github" +# Build each app with `cargo build --package` instead of a workspace-wide +# build. Without this, cargo-dist also compiles the non-distributed root +# crate, whose default features pull in native-tls/OpenSSL and break the +# musl builds. +precise-builds = true +# The installers to generate for each app +installers = ["shell", "powershell", "npm"] +# Whether to enable GitHub Attestations +github-attestations = true +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] +# Which actions to run on pull requests +pr-run-mode = "plan" +# Publish jobs to run (npm publishing deferred until pipeline is validated) +publish-jobs = [] +# Don't overwrite release.yml on `dist init` (preserves customizations) +allow-dirty = ["ci"] +# The archive format to use for windows builds (defaults .zip) +windows-archive = ".zip" +# The archive format to use for non-windows builds (defaults .tar.xz) +unix-archive = ".tar.gz" +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false diff --git a/cli/src/auth/builder.rs b/cli/src/auth/builder.rs new file mode 100644 index 00000000..5ab6fcb1 --- /dev/null +++ b/cli/src/auth/builder.rs @@ -0,0 +1,974 @@ +//! Builder bindings: how the `CliApp` builder records "bind credential X to +//! scheme Y" before the doc is parsed, and how those bindings are lowered +//! into a concrete [`DynAuthProvider`] once the doc is available. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::auth::compose::{AllAuthProvider, AnyAuthProvider, RoutingAuthProvider}; +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::{DynAuthProvider, NoAuthProvider}; +use crate::auth::schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; + +/// How the bound auth schemes should compose into a single +/// [`DynAuthProvider`]. Generators that already know their API's auth +/// model can pick the right strategy explicitly; hand-written CLIs can +/// rely on `Auto` and let the spec decide. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AuthStrategy { + /// Default: derive the strategy from the spec. If any operation + /// declares per-endpoint `security:`, use [`Routing`](Self::Routing); + /// otherwise use [`Any`](Self::Any). Matches the behaviour from before + /// `auth_strategy()` existed. + #[default] + Auto, + /// Try each scheme in registration order; first one with credentials + /// applies. The "any of" semantics — common when an API accepts + /// multiple equivalent auth methods (e.g., bearer or API key). + Any, + /// Apply *every* scheme to every request. The "and" semantics — used + /// when an API requires multiple schemes simultaneously (e.g., HMAC + /// signature plus an API key). + All, + /// Per-endpoint dispatch via the operation's `security_requirements`. + /// Falls back to an [`AnyAuthProvider`] over the bound schemes for + /// operations that didn't declare requirements. If the spec has no + /// per-endpoint security at all, this behaves identically to `Any`. + Routing, +} + +/// How a builder caller has bound credentials to a scheme name. +#[derive(Clone)] +pub enum SchemeBinding { + /// Single-value source — bearer / apiKey / oauth2 schemes. + Token(AuthCredentialSource), + /// Two-value source — http basic. Both must resolve for the provider + /// to claim credentials. + Basic { + username: AuthCredentialSource, + password: AuthCredentialSource, + }, + /// Single-value source bound to the *username* half of http basic; + /// the password is sent as the empty string. Common for APIs that + /// accept an API key in the basic-auth username slot. Lowers to + /// [`BasicAuthProvider::username_only`], whose `has_credentials()` + /// only requires the username to resolve. + BasicUsernameOnly(AuthCredentialSource), + /// Single-value source bound to the *password* half of http basic; + /// the username is sent as the empty string. Symmetric counterpart + /// to [`SchemeBinding::BasicUsernameOnly`]. Lowers to + /// [`BasicAuthProvider::password_only`]. + BasicPasswordOnly(AuthCredentialSource), + /// Caller built their own provider. Used as-is. Bypasses the + /// spec→provider lowering, so the binding's `name` is purely a routing + /// key into [`RoutingAuthProvider`]. + Custom(DynAuthProvider), +} + +impl std::fmt::Debug for SchemeBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SchemeBinding::Token(s) => f.debug_tuple("Token").field(s).finish(), + SchemeBinding::Basic { .. } => f.write_str("Basic { .. }"), + SchemeBinding::BasicUsernameOnly(_) => f.write_str("BasicUsernameOnly { .. }"), + SchemeBinding::BasicPasswordOnly(_) => f.write_str("BasicPasswordOnly { .. }"), + SchemeBinding::Custom(p) => write!(f, "Custom({})", p.name()), + } + } +} + +impl SchemeBinding { + /// Walk the binding's credential sources for every CLI arg name they + /// reference. CliApp uses this before clap parsing to register the + /// corresponding global `--` flags. `Custom` bindings are opaque + /// (the user owns the provider) so they contribute nothing here. + pub fn cli_args(&self) -> Vec<&str> { + match self { + SchemeBinding::Token(src) => src.cli_args(), + SchemeBinding::Basic { username, password } => { + let mut out = username.cli_args(); + out.extend(password.cli_args()); + out + } + SchemeBinding::BasicUsernameOnly(src) | SchemeBinding::BasicPasswordOnly(src) => { + src.cli_args() + } + SchemeBinding::Custom(_) => Vec::new(), + } + } + + /// Finalize the binding's credential sources against the parsed clap + /// matches — replaces any `Cli(name)` variants with closures that read + /// from `matches`. Pass-through for `Custom` (the user already owns + /// the resolution path). + pub fn finalize(self, matches: &Arc) -> Self { + match self { + SchemeBinding::Token(src) => SchemeBinding::Token(src.finalize(matches)), + SchemeBinding::Basic { username, password } => SchemeBinding::Basic { + username: username.finalize(matches), + password: password.finalize(matches), + }, + SchemeBinding::BasicUsernameOnly(src) => { + SchemeBinding::BasicUsernameOnly(src.finalize(matches)) + } + SchemeBinding::BasicPasswordOnly(src) => { + SchemeBinding::BasicPasswordOnly(src.finalize(matches)) + } + SchemeBinding::Custom(p) => SchemeBinding::Custom(p), + } + } +} + +/// Render a human-readable "Authentication:" section for `--help` +/// describing each binding's scheme name and where it reads its value +/// from. Returns `None` when there are no bindings (caller can omit the +/// section entirely). +/// +/// The output looks like: +/// +/// ```text +/// Authentication: +/// bearerAuth API_TOKEN env var +/// apiKey --api-key flag / API_KEY env var / ~/.api/key file +/// ``` +/// +/// CLI flags and file paths are described in human terms. Closures and +/// the `Custom` binding are reported as "custom" — their source isn't +/// inspectable. +pub fn render_auth_help_section(bindings: &[(String, SchemeBinding)]) -> Option { + if bindings.is_empty() { + return None; + } + let max_name = bindings + .iter() + .map(|(n, _)| n.len()) + .max() + .unwrap_or(0) + .max(8); + + let mut out = String::from("Authentication:\n"); + for (name, binding) in bindings { + let sources = describe_binding_sources(binding); + let _ = std::fmt::Write::write_fmt( + &mut out, + format_args!(" {name: String { + match binding { + SchemeBinding::Token(src) => describe_credential_source(src), + SchemeBinding::Basic { username, password } => { + format!( + "basic auth · username: {} · password: {}", + describe_credential_source(username), + describe_credential_source(password), + ) + } + SchemeBinding::BasicUsernameOnly(src) => { + format!( + "basic auth (username only) · username: {}", + describe_credential_source(src), + ) + } + SchemeBinding::BasicPasswordOnly(src) => { + format!( + "basic auth (password only) · password: {}", + describe_credential_source(src), + ) + } + SchemeBinding::Custom(_) => "custom auth provider".to_string(), + } +} + +fn describe_credential_source(src: &AuthCredentialSource) -> String { + match src { + AuthCredentialSource::Env(name) => format!("{name} env var"), + AuthCredentialSource::Cli(arg) => format!("--{arg} flag"), + AuthCredentialSource::File(path) => format!("{} file", path.display()), + AuthCredentialSource::Literal(_) => "built-in literal".to_string(), + AuthCredentialSource::Closure(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" / "), + AuthCredentialSource::Missing => "(unbound)".to_string(), + } +} + +/// Walk every binding in `bindings` and collect the union of CLI arg +/// names they reference. Deduplicated while preserving first-seen order. +pub fn collect_binding_cli_args(bindings: &[(String, SchemeBinding)]) -> Vec { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut out: Vec = Vec::new(); + for (_, b) in bindings { + for arg in b.cli_args() { + if seen.insert(arg.to_string()) { + out.push(arg.to_string()); + } + } + } + out +} + +/// Finalize every binding against `matches`. Returns a new `Vec`; the +/// originals are consumed. +pub fn finalize_bindings( + bindings: Vec<(String, SchemeBinding)>, + matches: &Arc, +) -> Vec<(String, SchemeBinding)> { + bindings + .into_iter() + .map(|(name, b)| (name, b.finalize(matches))) + .collect() +} + +/// Lower a single binding to a concrete provider, given the spec scheme +/// declaration that names it (or `None` if the binding references a scheme +/// not declared in `components.securitySchemes`). +/// +/// Undeclared schemes (`declared == None`) default to bearer for token +/// bindings and basic for two-value bindings — sensible defaults for +/// callers who don't have a spec to lean on (e.g., GraphQL CLIs). +/// +/// When a binding shape doesn't match its declared scheme (e.g., a Token +/// bound to `HttpBasic`), the binding is dropped with a `tracing::warn!` +/// so the misconfiguration shows up in the structured logs rather than +/// silently sending requests with no auth. +fn provider_for_binding( + scheme_name: &str, + binding: &SchemeBinding, + declared: Option<&crate::openapi::discovery::SecurityScheme>, +) -> Option { + use crate::openapi::discovery::SecurityScheme as S; + match binding { + SchemeBinding::Custom(p) => Some(p.clone()), + SchemeBinding::Token(source) => match declared { + // Bearer/OAuth2 → standard Authorization: Bearer . + // Undeclared schemes default to bearer (legacy parity). + Some(S::HttpBearer) | Some(S::OAuth2) | None => Some(Arc::new( + BearerAuthProvider::new(scheme_name, source.clone()), + )), + Some(S::ApiKeyHeader { name }) => Some(Arc::new(HeaderAuthProvider::new( + scheme_name, + name, + source.clone(), + false, + ))), + Some(S::ApiKeyQuery { .. }) => { + tracing::warn!( + scheme = scheme_name, + "auth_scheme: apiKey-in-query schemes are not yet supported; binding ignored", + ); + None + } + Some(S::HttpBasic) => { + tracing::warn!( + scheme = scheme_name, + "auth_scheme: scheme is HTTP Basic but a single-value Token binding was supplied; \ + use auth_basic_scheme instead", + ); + None + } + Some(S::Other(kind)) => { + tracing::warn!( + scheme = scheme_name, + kind = kind, + "auth_scheme: unsupported scheme type; bind via auth_provider with a custom \ + provider instead", + ); + None + } + }, + SchemeBinding::Basic { username, password } => match declared { + Some(S::HttpBasic) | None => Some(Arc::new(BasicAuthProvider::new( + scheme_name, + username.clone(), + password.clone(), + ))), + _ => { + tracing::warn!( + scheme = scheme_name, + "auth_basic_scheme: scheme is not HTTP Basic; binding ignored", + ); + None + } + }, + SchemeBinding::BasicUsernameOnly(src) => match declared { + Some(S::HttpBasic) | None => Some(Arc::new(BasicAuthProvider::username_only( + scheme_name, + src.clone(), + ))), + _ => { + tracing::warn!( + scheme = scheme_name, + "auth_basic_scheme_username_only: scheme is not HTTP Basic; binding ignored", + ); + None + } + }, + SchemeBinding::BasicPasswordOnly(src) => match declared { + Some(S::HttpBasic) | None => Some(Arc::new(BasicAuthProvider::password_only( + scheme_name, + src.clone(), + ))), + _ => { + tracing::warn!( + scheme = scheme_name, + "auth_basic_scheme_password_only: scheme is not HTTP Basic; binding ignored", + ); + None + } + }, + } +} + +/// Walk a `RestDescription` and decide whether any operation declares +/// per-endpoint security requirements. Used to choose between +/// `AnyAuthProvider` (no spec-level routing needed) and +/// `RoutingAuthProvider` (some endpoints require specific schemes). +fn doc_has_per_endpoint_security(doc: &crate::openapi::discovery::RestDescription) -> bool { + fn walk(res: &crate::openapi::discovery::RestResource) -> bool { + if res + .methods + .values() + .any(|m| m.security_requirements.is_some()) + { + return true; + } + res.resources.values().any(walk) + } + doc.resources.values().any(walk) +} + +/// Protocol-agnostic provider construction. Used directly by GraphQL +/// (which has no spec-declared schemes and no per-endpoint metadata) and +/// indirectly by [`build_provider_from_doc`] for OpenAPI. +/// +/// Equivalent to [`build_provider_with_strategy`] called with +/// [`AuthStrategy::Auto`]: the strategy is derived from +/// `has_per_endpoint_security`. Use [`build_provider_with_strategy`] +/// directly if your generator wants explicit control (e.g., the all-auth +/// case the spec doesn't express). +pub fn build_provider_from_bindings( + bindings: &[(String, SchemeBinding)], + security_schemes: &HashMap, + has_per_endpoint_security: bool, +) -> DynAuthProvider { + build_provider_with_strategy( + bindings, + security_schemes, + AuthStrategy::Auto, + has_per_endpoint_security, + ) +} + +/// Strategy-aware provider construction. The fully general factory. +/// +/// Construction outline: +/// 1. Each binding is lowered to a concrete provider, using `security_schemes` +/// (if non-empty) to pick between Bearer / Header / Basic. +/// 2. Bindings are deduplicated by scheme name — last registration wins for +/// both the routing map and the AnyAuth fallback list, so the two views +/// can never disagree. +/// 3. Insertion order is preserved across the dedup so the `Any` and `All` +/// strategies see schemes in registration order. +/// 4. The `strategy` chooses how the lowered providers compose: +/// - `Auto` → `Routing` if `has_per_endpoint_security`, else `Any`. +/// - `Any` → `AnyAuthProvider`. First with credentials applies. +/// - `All` → `AllAuthProvider`. Every scheme applies, every request. +/// - `Routing` → `RoutingAuthProvider` with `AnyAuthProvider` as default. +/// 5. With no bindings at all, returns a [`NoAuthProvider`] sentinel — +/// independent of `strategy`. `All` / `Routing` with zero bindings would +/// otherwise produce a degenerate composite (an empty `AllAuthProvider` +/// that vacuously claims credentials, or a `RoutingAuthProvider` whose +/// only contribution is its default fallback). Collapsing to +/// `NoAuthProvider` keeps the unauthenticated-CLI case unambiguous. +pub fn build_provider_with_strategy( + bindings: &[(String, SchemeBinding)], + security_schemes: &HashMap, + strategy: AuthStrategy, + has_per_endpoint_security: bool, +) -> DynAuthProvider { + if bindings.is_empty() { + return Arc::new(NoAuthProvider); + } + + let mut by_name: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + for (name, binding) in bindings { + let declared = security_schemes.get(name); + // Surface typos: if the spec declared *some* schemes but this + // binding's name isn't among them, the binding will silently never + // route — no operation's `security:` block can match a name that + // isn't in the registry. Don't warn when there are no declared + // schemes (that's legacy-style usage with no spec security). + if declared.is_none() && !security_schemes.is_empty() { + let declared_names: Vec<&str> = + security_schemes.keys().map(String::as_str).collect(); + tracing::warn!( + scheme = name.as_str(), + declared = ?declared_names, + "auth scheme name is not declared in components.securitySchemes; \ + check for typos — operations referencing a different name won't \ + receive this credential", + ); + } + let Some(provider) = provider_for_binding(name, binding, declared) else { + continue; + }; + if !by_name.contains_key(name) { + order.push(name.clone()); + } + by_name.insert(name.clone(), provider); + } + + let ordered: Vec = order.iter().map(|n| by_name[n].clone()).collect(); + + let resolved = match strategy { + AuthStrategy::Auto => { + if has_per_endpoint_security { + AuthStrategy::Routing + } else { + AuthStrategy::Any + } + } + explicit => explicit, + }; + + match resolved { + AuthStrategy::Auto => unreachable!("Auto resolved above"), + AuthStrategy::Any => Arc::new(AnyAuthProvider::new(ordered)), + AuthStrategy::All => Arc::new(AllAuthProvider::new(ordered)), + AuthStrategy::Routing => { + // The default for unspecified endpoints is still AnyAuth over + // all schemes — preserves the "use whatever works" fallback + // for operations the spec didn't pin. + let any: DynAuthProvider = Arc::new(AnyAuthProvider::new(ordered)); + Arc::new(RoutingAuthProvider::new(by_name).with_default(any)) + } + } +} + +/// OpenAPI-flavored convenience: pulls `security_schemes` and the +/// per-endpoint flag out of a parsed [`RestDescription`][rd]. +/// +/// [rd]: crate::openapi::discovery::RestDescription +pub fn build_provider_from_doc( + doc: &crate::openapi::discovery::RestDescription, + bindings: &[(String, SchemeBinding)], +) -> DynAuthProvider { + build_provider_from_bindings( + bindings, + &doc.security_schemes, + doc_has_per_endpoint_security(doc), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::provider::EndpointAuthMetadata; + use crate::auth::test_helpers::{auth_header, header, req}; + + fn doc_with_schemes( + schemes: &[(&str, crate::openapi::discovery::SecurityScheme)], + ) -> crate::openapi::discovery::RestDescription { + let mut d = crate::openapi::discovery::RestDescription::default(); + for (name, scheme) in schemes { + d.security_schemes + .insert((*name).to_string(), scheme.clone()); + } + d + } + + fn doc_with_method_requirement( + schemes: &[(&str, crate::openapi::discovery::SecurityScheme)], + requirement: HashMap>, + ) -> crate::openapi::discovery::RestDescription { + let mut d = doc_with_schemes(schemes); + let method = crate::openapi::discovery::RestMethod { + security_requirements: Some(vec![requirement]), + ..Default::default() + }; + let mut resource = crate::openapi::discovery::RestResource::default(); + resource.methods.insert("op".to_string(), method); + d.resources.insert("group".to_string(), resource); + d + } + + #[test] + fn no_bindings_returns_noop() { + let doc = crate::openapi::discovery::RestDescription::default(); + let p = build_provider_from_doc(&doc, &[]); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn bearer_scheme_routes_to_bearer_provider() { + let doc = doc_with_schemes(&[( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )]); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert_eq!(p.name(), "any"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn apikey_header_uses_declared_header_name() { + let doc = doc_with_schemes(&[( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + )]); + let bindings = vec![( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("k")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn basic_scheme_routes_to_basic_provider() { + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Basic { + username: AuthCredentialSource::literal("alice"), + password: AuthCredentialSource::literal("hunter2"), + }, + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YWxpY2U6aHVudGVyMg=="), + ); + } + + #[tokio::test] + async fn basic_username_only_binding_sends_authorization_with_empty_password() { + // The Close pattern: API key in the username slot, password + // unused. Previously expressed as `Basic { from_env, literal("") }`, + // which silently dropped the header because `literal("")` resolves + // to `None`. The specialized binding lowers to + // `BasicAuthProvider::username_only`, which only requires the + // username to resolve. + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::BasicUsernameOnly(AuthCredentialSource::literal("api_key_123")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64("api_key_123:") = "YXBpX2tleV8xMjM6" + assert_eq!(auth_header(r).as_deref(), Some("Basic YXBpX2tleV8xMjM6")); + } + + #[tokio::test] + async fn basic_password_only_binding_sends_authorization_with_empty_username() { + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::BasicPasswordOnly(AuthCredentialSource::literal("the_secret")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64(":the_secret") = "OnRoZV9zZWNyZXQ=" + assert_eq!(auth_header(r).as_deref(), Some("Basic OnRoZV9zZWNyZXQ=")); + } + + #[test] + fn basic_username_only_against_non_basic_scheme_is_skipped() { + let doc = doc_with_schemes(&[( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )]); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::BasicUsernameOnly(AuthCredentialSource::literal("oops")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert!(!p.has_credentials()); + } + + #[test] + fn token_binding_for_basic_scheme_is_skipped() { + // Token form can't satisfy HttpBasic (which needs two values). + // The binding should be silently dropped — provider has no creds. + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("oops")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn uses_routing_when_doc_has_per_endpoint_security() { + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[ + ( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + ), + ( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + ), + ], + req_map.clone(), + ); + let bindings = vec![ + ( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("k")), + ), + ]; + let p = build_provider_from_doc(&doc, &bindings); + assert_eq!(p.name(), "routing"); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_map]); + let r = p.apply(req(), &endpoint).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + assert!(built.headers().get("authorization").is_none()); + } + + #[tokio::test] + async fn routing_falls_back_to_any_for_unspecified_endpoint() { + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn duplicate_binding_uses_last_write_consistently() { + // Two bindings to the same scheme name. The user almost certainly + // didn't mean it, but if it happens, both the AnyAuth fallback and + // the RoutingAuth map must agree on which provider wins. + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + )], + req_map.clone(), + ); + let bindings = vec![ + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("first")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("second")), + ), + ]; + let p = build_provider_from_doc(&doc, &bindings); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_map]); + let r = p.apply(req(), &endpoint).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("second")); + let r2 = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r2, "x-api-key").as_deref(), Some("second")); + } + + #[tokio::test] + async fn from_bindings_works_without_doc() { + // GraphQL path: no security_schemes registry, no per-endpoint + // metadata, but the same builder API. Should still produce a + // working AnyAuthProvider. + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("g")), + )]; + let p = build_provider_from_bindings(&bindings, &HashMap::new(), false); + assert_eq!(p.name(), "any"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer g")); + } + + #[tokio::test] + async fn strategy_all_applies_every_scheme_unconditionally() { + // Generator knows the API requires bearer AND apiKey on every + // request. Spec might not express this; the strategy override + // does. + let bindings = vec![ + ( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Custom(crate::auth::test_helpers::api_key( + "apiKey", + "X-Api-Key", + "k", + )), + ), + ]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::All, + false, + ); + assert_eq!(p.name(), "all"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[test] + fn strategy_any_overrides_spec_routing() { + // Spec has per-endpoint security (would auto-pick Routing), but + // the generator forces Any anyway. Verifies the override actually + // wins. + let mut req_map = HashMap::new(); + req_map.insert("bearer".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearer", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &doc.security_schemes, + AuthStrategy::Any, + true, // doc has per-endpoint security, but Any wins + ); + assert_eq!(p.name(), "any"); + } + + #[test] + fn strategy_routing_used_even_without_per_endpoint_security() { + // Generator wants routing semantics regardless of what the spec + // says. Falls back to AnyAuthProvider default for any op without + // requirements. + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::Routing, + false, // no per-endpoint security in the spec + ); + assert_eq!(p.name(), "routing"); + } + + #[test] + fn strategy_auto_picks_routing_when_spec_has_per_endpoint_security() { + let mut req_map = HashMap::new(); + req_map.insert("bearer".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearer", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = + build_provider_with_strategy(&bindings, &doc.security_schemes, AuthStrategy::Auto, true); + assert_eq!(p.name(), "routing"); + } + + #[test] + fn strategy_routing_with_zero_bindings_returns_no_auth() { + // Explicit Routing strategy + no bindings collapses to NoAuthProvider. + // Confirms the early-return at the top of build_provider_with_strategy + // applies regardless of `strategy` — a Routing wrapper around zero + // schemes would have only its (also empty) default to fall back on, + // which isn't a useful state to expose. + let p = build_provider_with_strategy( + &[], + &HashMap::new(), + AuthStrategy::Routing, + true, + ); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[test] + fn strategy_all_with_zero_bindings_returns_no_auth() { + // Same contract for All. An empty AllAuthProvider would vacuously + // claim no credentials anyway, but collapsing to NoAuthProvider + // keeps the unauthenticated case uniform across strategies. + let p = build_provider_with_strategy( + &[], + &HashMap::new(), + AuthStrategy::All, + false, + ); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[test] + fn strategy_auto_picks_any_when_no_per_endpoint_security() { + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::Auto, + false, + ); + assert_eq!(p.name(), "any"); + } + + // -------- render_auth_help_section -------- + + #[test] + fn render_auth_help_section_none_for_empty_bindings() { + assert!(render_auth_help_section(&[]).is_none()); + } + + #[test] + fn render_auth_help_section_describes_env_var() { + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("API_TOKEN")), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("Authentication:")); + assert!(out.contains("bearerAuth")); + assert!(out.contains("API_TOKEN env var")); + } + + #[test] + fn render_auth_help_section_describes_chain() { + let bindings = vec![( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::any([ + AuthCredentialSource::cli("api-key"), + AuthCredentialSource::from_env("API_KEY"), + AuthCredentialSource::file("~/.api/key"), + ])), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("--api-key flag")); + assert!(out.contains("API_KEY env var")); + assert!(out.contains("~/.api/key file")); + assert!(out.contains(" / ")); + } + + #[test] + fn render_auth_help_section_describes_basic_pair() { + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Basic { + username: AuthCredentialSource::from_env("API_USER"), + password: AuthCredentialSource::from_env("API_PASS"), + }, + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("basic")); + assert!(out.contains("username")); + assert!(out.contains("password")); + assert!(out.contains("API_USER env var")); + assert!(out.contains("API_PASS env var")); + } + + #[test] + fn render_auth_help_section_marks_custom_provider_opaque() { + let bindings = vec![( + "x".to_string(), + SchemeBinding::Custom(crate::auth::test_helpers::bearer("x", "tok")), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("custom auth provider")); + } + + #[tokio::test] + async fn custom_binding_used_as_is() { + let custom: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "custom", + "X-Custom", + AuthCredentialSource::literal("c"), + false, + )); + let doc = crate::openapi::discovery::RestDescription::default(); + let bindings = vec![("custom".to_string(), SchemeBinding::Custom(custom))]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-custom").as_deref(), Some("c")); + } +} diff --git a/cli/src/auth/compose.rs b/cli/src/auth/compose.rs new file mode 100644 index 00000000..dd06d236 --- /dev/null +++ b/cli/src/auth/compose.rs @@ -0,0 +1,589 @@ +//! Composition wrappers: [`AnyAuthProvider`] (OR semantics) and +//! [`RoutingAuthProvider`] (per-endpoint dispatch via the operation's +//! `security_requirements`). + +use std::collections::HashMap; + +use crate::auth::provider::{AuthProvider, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// AnyAuthProvider — OR semantics. +// --------------------------------------------------------------------------- + +/// Try each child provider in order. The first one with credentials applies +/// its headers and the wrapper returns. If no child has credentials, the +/// request goes out unauthenticated. +/// +/// Mirrors the TS `AnyAuthProvider`. Used when the CLI declares multiple +/// schemes but the OpenAPI operations don't pin one per endpoint. +#[derive(Debug, Clone)] +pub struct AnyAuthProvider { + name: String, + providers: Vec, +} + +impl AnyAuthProvider { + pub fn new(providers: Vec) -> Self { + Self { + name: "any".to_string(), + providers, + } + } +} + +impl AuthProvider for AnyAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.providers.iter().any(|p| p.has_credentials()) + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + self.providers + .iter() + .any(|p| p.has_credentials_for(endpoint)) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Endpoint-aware filter: lets nested `RoutingAuthProvider` children + // tell us they can't satisfy *this* endpoint even though they have + // credentials for some scheme. Leaf providers (Bearer/Basic/Header) + // ignore the endpoint, so this degenerates to `has_credentials()` + // for them. + for provider in &self.providers { + if provider.has_credentials_for(endpoint) { + return provider.apply(request, endpoint); + } + } + Ok(request) + } +} + +// --------------------------------------------------------------------------- +// AllAuthProvider — AND semantics. Every scheme is applied to every request. +// --------------------------------------------------------------------------- + +/// Apply *every* child provider's headers to the request, in registration +/// order. The "all auth" strategy: when an API requires multiple schemes +/// simultaneously on every operation (e.g., `Authorization: Bearer X` AND +/// `X-Api-Key: Y`), and the spec doesn't express that via per-operation +/// security blocks. +/// +/// `has_credentials()` is `true` only when *all* children have credentials — +/// the request can't be satisfied otherwise. If a child fails to apply +/// (e.g., malformed token bytes), the error short-circuits. +#[derive(Debug, Clone)] +pub struct AllAuthProvider { + name: String, + providers: Vec, +} + +impl AllAuthProvider { + pub fn new(providers: Vec) -> Self { + Self { + name: "all".to_string(), + providers, + } + } +} + +impl AuthProvider for AllAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + // All-auth means every scheme must contribute. If any is missing, + // the request can't be authenticated as the API requires. + !self.providers.is_empty() && self.providers.iter().all(|p| p.has_credentials()) + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + !self.providers.is_empty() + && self + .providers + .iter() + .all(|p| p.has_credentials_for(endpoint)) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Short-circuit when the requirement can't be fully satisfied. The + // all-auth contract is "every scheme contributes"; sending a partial + // request with only some headers attached would let the request hit + // the wire half-authed and leak whichever bound credentials we do + // have. The friendly-error path catches this on the response side, + // but pre-emptively dropping the headers keeps stray tokens off the + // wire too. + if !self.has_credentials_for(endpoint) { + return Ok(request); + } + let mut req = request; + for provider in &self.providers { + req = provider.apply(req, endpoint)?; + } + Ok(req) + } +} + +// --------------------------------------------------------------------------- +// RoutingAuthProvider — per-endpoint security map. +// --------------------------------------------------------------------------- + +/// Dispatch by the endpoint's security requirements. The OpenAPI `security` +/// field is an OR of ANDs: `[{schemeA: []}, {schemeB: [], schemeC: []}]` +/// means "schemeA alone, OR (schemeB AND schemeC)". +/// +/// At call time: +/// 1. If the endpoint has no requirements, the wrapper falls through to its +/// `default` policy (typically an [`AnyAuthProvider`]) so unauthenticated +/// operations stay unauthenticated and unlabeled operations still get a +/// sensible default header. +/// 2. Otherwise, find the first requirement whose every scheme has a +/// registered provider with credentials, and apply each provider in turn +/// (their headers compose). +/// 3. If no requirement is satisfiable, return the request unchanged. The +/// server will respond 401/403 and `handle_error_response` +/// will surface a helpful "no credentials configured" message. +#[derive(Debug, Clone)] +pub struct RoutingAuthProvider { + name: String, + schemes: HashMap, + /// Fallback for endpoints with no `security` declared. Typically an + /// [`AnyAuthProvider`] over all configured schemes. + default: Option, +} + +impl RoutingAuthProvider { + pub fn new(schemes: HashMap) -> Self { + Self { + name: "routing".to_string(), + schemes, + default: None, + } + } + + pub fn with_default(mut self, default: DynAuthProvider) -> Self { + self.default = Some(default); + self + } +} + +impl AuthProvider for RoutingAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.schemes.values().any(|p| p.has_credentials()) + || self.default.as_ref().is_some_and(|p| p.has_credentials()) + } + + /// Endpoint-aware credential check. + /// + /// - **No requirements declared**: defer to the wrapper's `default` + /// (typically an `AnyAuthProvider`), which decides based on its own + /// children. If there's no default, fall back to `has_credentials()` + /// over our schemes — that's the closest we can get. + /// - **Explicit anonymous (`security: []`)**: the endpoint doesn't need + /// auth, so report `true` to suppress the friendly "no creds" message + /// on a 401 — that response would be a server-side mismatch, not a + /// user config issue. + /// - **Concrete requirements**: report whether any requirement's + /// schemes are all bound *and* hold credentials — same predicate + /// `apply` uses to find a satisfiable requirement. If yes, we + /// attached headers; if no, the 401 is the user's missing-creds + /// problem and the friendly error fires. + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + match &endpoint.security_requirements { + None => match &self.default { + Some(d) => d.has_credentials_for(endpoint), + None => self.has_credentials(), + }, + Some(reqs) if reqs.is_empty() => true, + Some(reqs) => reqs.iter().any(|req| { + req.keys().all(|name| { + self.schemes + .get(name) + .is_some_and(|p| p.has_credentials()) + }) + }), + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + let requirements = match &endpoint.security_requirements { + // Operation didn't pin a policy: defer to the default. + None => { + return match &self.default { + Some(d) => d.apply(request, endpoint), + None => Ok(request), + }; + } + // `security: []` — explicit anonymous, attach nothing. + Some(reqs) if reqs.is_empty() => return Ok(request), + Some(reqs) => reqs, + }; + + let satisfiable = requirements.iter().find(|req| { + req.keys().all(|name| { + self.schemes + .get(name) + .is_some_and(|p| p.has_credentials()) + }) + }); + + let Some(requirement) = satisfiable else { + // No declared requirement is satisfiable. Diverges from the TS + // generator (which throws): we let the request go out unauthed + // so the server's 401/403 + `handle_error_response` + // can surface a friendly "no credentials configured" message. + return Ok(request); + }; + + let mut req = request; + // Sort the requirement's scheme names so multi-scheme requirements + // apply in a stable order regardless of `HashMap` iteration. Each + // provider sets a distinct header so order doesn't affect the wire + // payload, but reproducibility matters for tracing and snapshot + // tests. + let mut scheme_names: Vec<&String> = requirement.keys().collect(); + scheme_names.sort(); + for scheme_name in scheme_names { + // Safe: `satisfiable` filtered to requirements where every key + // has a registered provider. + let provider = &self.schemes[scheme_name]; + req = provider.apply(req, endpoint)?; + } + Ok(req) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use crate::auth::credential::AuthCredentialSource; + use crate::auth::schemes::{BearerAuthProvider, HeaderAuthProvider}; + use crate::auth::test_helpers::{api_key, auth_header, bearer, header, req}; + + // -------- AnyAuthProvider -------- + + #[tokio::test] + async fn any_auth_picks_first_with_credentials() { + let a: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "a", + AuthCredentialSource::Missing, + )); + let b: DynAuthProvider = api_key("b", "X-Api-Key", "k"); + let any = AnyAuthProvider::new(vec![a, b]); + assert!(any.has_credentials()); + let r = any.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn any_auth_skips_routing_child_that_cant_satisfy_endpoint() { + // Nested-composition guard: an `AnyAuthProvider` whose first child + // is a `RoutingAuthProvider` that *has some credentials* but can't + // satisfy this specific endpoint must fall through to the next + // child rather than calling apply on the routing child. + // + // Without the endpoint-aware filter, the first child's + // `has_credentials()` returns true and `apply` short-circuits — even + // though that child would attach nothing to the request. + let mut routing_schemes: HashMap = HashMap::new(); + routing_schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + let routing_child: DynAuthProvider = std::sync::Arc::new( + RoutingAuthProvider::new(routing_schemes), + ); + let bearer_child: DynAuthProvider = bearer("bearer", "tok"); + let any = AnyAuthProvider::new(vec![routing_child, bearer_child]); + + // Endpoint demands `bearer`; routing child only has `apiKey`. + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + + let r = any.apply(req(), &endpoint).unwrap(); + // Bearer should have been attached by the second child. + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn any_auth_no_credentials_is_passthrough() { + let any = AnyAuthProvider::new(vec![Arc::new(BearerAuthProvider::new( + "x", + AuthCredentialSource::Missing, + ))]); + assert!(!any.has_credentials()); + let r = any.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + } + + // -------- AllAuthProvider -------- + + #[tokio::test] + async fn all_auth_applies_every_provider() { + let a: DynAuthProvider = bearer("a", "tok"); + let b: DynAuthProvider = api_key("b", "X-Api-Key", "k"); + let all = AllAuthProvider::new(vec![a, b]); + assert!(all.has_credentials()); + let r = all.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[test] + fn all_auth_has_credentials_requires_every_child() { + let a: DynAuthProvider = bearer("a", "tok"); + let b: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "b", + AuthCredentialSource::Missing, + )); + let all = AllAuthProvider::new(vec![a, b]); + // One missing → all auth can't be satisfied. + assert!(!all.has_credentials()); + } + + #[test] + fn all_auth_empty_provider_list_is_no_credentials() { + // Vacuous truth would say "all of zero providers have creds = true", + // but for the all-auth strategy that's misleading: no providers + // means no auth gets attached, which isn't what the user asked for. + let all = AllAuthProvider::new(Vec::new()); + assert!(!all.has_credentials()); + } + + #[tokio::test] + async fn all_auth_short_circuits_on_provider_error() { + // Bearer with a token containing CTL chars errors in apply. + let bad: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bad", + AuthCredentialSource::literal("bad\ntoken"), + )); + let good: DynAuthProvider = api_key("good", "X-Api-Key", "k"); + // Order matters: bad first → error before good ever runs. + let all = AllAuthProvider::new(vec![bad, good]); + let err = all + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + } + + // -------- RoutingAuthProvider -------- + + fn routing_setup() -> RoutingAuthProvider { + let mut schemes: HashMap = HashMap::new(); + schemes.insert("bearer".to_string(), bearer("bearer", "tok")); + schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + RoutingAuthProvider::new(schemes) + } + + #[tokio::test] + async fn routing_unspecified_no_default_is_passthrough() { + let r = routing_setup(); + assert!(r.has_credentials()); + let out = r + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(out), None); + } + + #[tokio::test] + async fn routing_explicit_anonymous_skips_auth_even_with_default() { + // `security: []` on the operation means the endpoint is explicitly + // unauthenticated. Even with a default provider that would happily + // attach a bearer header, the routing wrapper must respect the + // operation's opt-out. + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + let out = routing + .apply(req(), &EndpointAuthMetadata::explicit_anonymous()) + .unwrap(); + assert_eq!(auth_header(out), None); + } + + #[tokio::test] + async fn routing_picks_satisfiable_requirement() { + let routing = routing_setup(); + let mut req_a = HashMap::new(); + req_a.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_a]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(header(out, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn routing_falls_back_to_or_alternative() { + let routing = routing_setup(); + let mut req1 = HashMap::new(); + req1.insert("nonexistent".to_string(), Vec::::new()); + let mut req2 = HashMap::new(); + req2.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req1, req2]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(auth_header(out).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn routing_combines_anded_schemes_in_one_requirement() { + let routing = routing_setup(); + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + requirement.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + let out = routing.apply(req(), &endpoint).unwrap(); + let built = out.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[tokio::test] + async fn routing_uses_default_when_endpoint_has_no_requirements() { + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + let out = routing + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(out).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn routing_no_satisfiable_requirement_is_passthrough() { + let mut schemes: HashMap = HashMap::new(); + schemes.insert( + "bearer".to_string(), + Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )), + ); + let routing = RoutingAuthProvider::new(schemes); + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(auth_header(out), None); + } + + // -------- has_credentials_for(endpoint) -------- + + #[test] + fn routing_has_credentials_for_unspecified_with_no_default_uses_general_check() { + let r = routing_setup(); + // No default → falls back to has_credentials() over schemes. + assert!(r.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + #[test] + fn routing_has_credentials_for_explicit_anonymous_is_true() { + // `security: []` means "no creds needed" — report true so a 401 + // doesn't trigger the friendly "no creds" message (the response + // would be a server-side mismatch, not a user config issue). + let r = routing_setup(); + assert!(r.has_credentials_for(&EndpointAuthMetadata::explicit_anonymous())); + } + + #[test] + fn routing_has_credentials_for_satisfiable_requirement_is_true() { + let r = routing_setup(); + let mut req = HashMap::new(); + req.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req]); + assert!(r.has_credentials_for(&endpoint)); + } + + #[test] + fn routing_has_credentials_for_unsatisfiable_requirement_is_false() { + // The endpoint requires `bearer` but bearer's source is Missing. + let mut schemes: HashMap = HashMap::new(); + schemes.insert( + "bearer".to_string(), + Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )), + ); + // Also bind apiKey with creds — proving has_credentials_for is + // *endpoint-aware*, not just "any scheme has creds". + schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + let routing = RoutingAuthProvider::new(schemes); + let mut req = HashMap::new(); + req.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req]); + // Even though `apiKey` has creds, the endpoint demands `bearer` — + // and bearer has none. So the friendly error path should fire. + assert!(!routing.has_credentials_for(&endpoint)); + // But the coarse `has_credentials()` returns true. This is the + // delta the new method exists to fix. + assert!(routing.has_credentials()); + } + + #[test] + fn routing_has_credentials_for_unspecified_delegates_to_default() { + // Default present + has creds → endpoint-aware check inherits. + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + assert!(routing.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + #[test] + fn routing_has_credentials_for_unspecified_propagates_default_false() { + // Default present but its provider has no creds → we should + // honestly report no creds, not just "yes, a default exists". + // Pins that the delegation actually consults the default's + // predicate rather than treating "is there a default" as a yes/no. + let empty_default: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )); + let routing = RoutingAuthProvider::new(HashMap::new()).with_default(empty_default); + assert!(!routing.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + // Sanity-check that routing_setup produces a HeaderAuthProvider with the + // expected name when looked up by scheme key — guards against an + // accidental shape change in the test helper. + #[test] + fn routing_setup_registers_provider_named_apikey() { + let r = routing_setup(); + assert_eq!(r.schemes["apiKey"].name(), "apiKey"); + assert_eq!(r.schemes["bearer"].name(), "bearer"); + // Silence dead-code lint on HeaderAuthProvider import path. + let _: &dyn AuthProvider = &HeaderAuthProvider::new( + "x", + "Y", + AuthCredentialSource::Missing, + false, + ); + } +} diff --git a/cli/src/auth/credential.rs b/cli/src/auth/credential.rs new file mode 100644 index 00000000..68734684 --- /dev/null +++ b/cli/src/auth/credential.rs @@ -0,0 +1,549 @@ +//! `AuthCredentialSource` — the lazy-supplier model for credential values. +//! +//! Mirrors the TypeScript SDK's `Supplier` and grows it into a full +//! resolution graph. Each binding holds a description of *where* its value +//! comes from — env var, CLI flag, file, literal, fallback chain, or +//! arbitrary closure — without coupling that to the auth provider that +//! consumes the resolved string. +//! +//! Resolution happens at request time so env-var changes between +//! invocations Just Work, files re-read on every call, and fallback chains +//! can mix any of the source kinds. +//! +//! # CLI flag wiring +//! +//! [`AuthCredentialSource::Cli`] holds the *name* of a clap arg — the SDK's +//! `CliApp::run_async` walks every registered binding before parsing, +//! auto-registers a global `--` flag for each `Cli` variant, and then +//! finalizes the bindings post-parse so that resolution reads from the +//! captured matches. None of this is visible to the binding's author — +//! they just write `AuthCredentialSource::cli("api-token")` and the flag +//! shows up. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use secrecy::SecretString; + +type CredentialClosure = Arc Option + Send + Sync>; + +/// How an auth credential's value is resolved at request time. +#[derive(Clone)] +pub enum AuthCredentialSource { + /// Read from a process environment variable. Surrounding whitespace is + /// trimmed; returns `None` if unset, empty, or whitespace-only — + /// matching the trimming behaviour of [`File`](Self::File) so chained + /// sources behave the same regardless of which one supplies the value. + Env(String), + /// Read from a clap CLI arg. The string is the arg's *name* (clap's + /// internal id), not the `--flag` form — `cli("api-token")` corresponds + /// to a `--api-token` flag. Leading `--` / `-` are stripped for + /// convenience, so `cli("--api-token")` works too. + /// + /// Until the binding is finalized via [`finalize`](Self::finalize) (i.e. + /// before clap parses), this variant always resolves to `None` — + /// CliApp does the finalization automatically before any request runs. + Cli(String), + /// Read the contents of a file. `~` and `~/` are expanded to the + /// process's `$HOME`. Trailing whitespace is trimmed; a missing file + /// or empty content resolves to `None`. + File(PathBuf), + /// A literal value embedded at build time. + Literal(String), + /// Fallback chain. Each child is tried in order; the first to return + /// `Some` wins. Empty results count as "missing" — useful for + /// "CLI flag, then env var, then file" patterns. + Chain(Vec), + /// A user-supplied closure invoked on every request. The escape hatch + /// for any source the built-in variants don't cover (token refresh, + /// shell-out, OS keychain, etc.). + Closure(CredentialClosure), + /// No source bound. The provider will report itself as unable to + /// satisfy requests. + Missing, +} + +impl AuthCredentialSource { + pub fn from_env(var_name: impl Into) -> Self { + AuthCredentialSource::Env(var_name.into()) + } + + /// Bind to a clap CLI arg. Accepts either `"api-token"` or + /// `"--api-token"` — leading dashes are stripped. + pub fn cli(arg_name: impl Into) -> Self { + let raw = arg_name.into(); + let name = raw.trim_start_matches('-').to_string(); + AuthCredentialSource::Cli(name) + } + + /// Bind to a file path. `~` and `~/` expand against `$HOME`. + pub fn file(path: impl AsRef) -> Self { + AuthCredentialSource::File(path.as_ref().to_path_buf()) + } + + pub fn literal(value: impl Into) -> Self { + AuthCredentialSource::Literal(value.into()) + } + + /// Try each source in order; the first non-empty value wins. + pub fn any(sources: impl IntoIterator) -> Self { + AuthCredentialSource::Chain(sources.into_iter().collect()) + } + + pub fn closure(f: F) -> Self + where + F: Fn() -> Option + Send + Sync + 'static, + { + AuthCredentialSource::Closure(Arc::new(f)) + } + + /// Resolve the value, if available. Empty strings are treated as + /// missing — they would otherwise produce an empty header, which is + /// almost never what a caller intends. + /// + /// Returns a [`SecretString`] so the value can't accidentally leak via + /// `Debug`/`Display`/panic messages. Callers that need the raw `&str` + /// (to build a `HeaderValue`, base64-encode for basic auth, etc.) + /// must opt in explicitly via [`ExposeSecret::expose_secret`]. + pub fn resolve(&self) -> Option { + match self { + AuthCredentialSource::Env(name) => std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .map(SecretString::from), + AuthCredentialSource::Cli(_) => None, // resolved post-finalize + AuthCredentialSource::File(path) => read_credential_file(path), + AuthCredentialSource::Literal(v) if v.is_empty() => None, + AuthCredentialSource::Literal(v) => Some(SecretString::from(v.clone())), + AuthCredentialSource::Chain(sources) => sources.iter().find_map(|s| s.resolve()), + AuthCredentialSource::Closure(f) => f().filter(|v| !v.is_empty()).map(SecretString::from), + AuthCredentialSource::Missing => None, + } + } + + /// Recursively collect every CLI arg name this source references. + /// CliApp uses this before clap parsing to register the corresponding + /// global `--` flags. + pub fn cli_args(&self) -> Vec<&str> { + let mut out = Vec::new(); + self.collect_cli_args(&mut out); + out + } + + fn collect_cli_args<'a>(&'a self, out: &mut Vec<&'a str>) { + // Enumerate every variant explicitly so adding a future variant + // (especially one that nests sources or carries an arg name) is a + // compile error rather than a silent miss. + match self { + AuthCredentialSource::Cli(name) => out.push(name.as_str()), + AuthCredentialSource::Chain(sources) => { + for s in sources { + s.collect_cli_args(out); + } + } + AuthCredentialSource::Env(_) + | AuthCredentialSource::File(_) + | AuthCredentialSource::Literal(_) + | AuthCredentialSource::Closure(_) + | AuthCredentialSource::Missing => {} + } + } + + /// Replace every `Cli(name)` variant in this source with a `Closure` + /// that reads the matched value out of `matches`. Called by CliApp + /// after clap parses, so that subsequent `resolve()` calls can see the + /// CLI-supplied values. + /// + /// Pass-through for non-`Cli` variants. Recurses into `Chain`. + pub fn finalize(self, matches: &Arc) -> Self { + match self { + AuthCredentialSource::Cli(name) => { + let m = Arc::clone(matches); + AuthCredentialSource::Closure(Arc::new(move || { + m.try_get_one::(&name).ok().flatten().cloned() + })) + } + AuthCredentialSource::Chain(sources) => { + AuthCredentialSource::Chain( + sources.into_iter().map(|s| s.finalize(matches)).collect(), + ) + } + other => other, + } + } +} + +impl std::fmt::Debug for AuthCredentialSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthCredentialSource::Env(name) => write!(f, "Env({name})"), + AuthCredentialSource::Cli(name) => write!(f, "Cli({name})"), + AuthCredentialSource::File(path) => write!(f, "File({})", path.display()), + AuthCredentialSource::Literal(_) => write!(f, "Literal()"), + AuthCredentialSource::Chain(sources) => f.debug_tuple("Chain").field(sources).finish(), + AuthCredentialSource::Closure(_) => write!(f, "Closure"), + AuthCredentialSource::Missing => write!(f, "Missing"), + } + } +} + +/// Read a credential file: expand `~`, trim trailing whitespace, treat +/// empty content / missing files as `None`. Result is wrapped in +/// [`SecretString`] so the file contents can't leak through Debug. +fn read_credential_file(path: &Path) -> Option { + let expanded = expand_home(path); + let raw = std::fs::read_to_string(&expanded).ok()?; + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(SecretString::from(trimmed)) + } +} + +/// Expand a leading `~` or `~/` against the user's home directory. On +/// Unix that's `$HOME`; on Windows we fall back to `%USERPROFILE%` since +/// `$HOME` is typically unset there. Other forms (`~user`, embedded `~`) +/// are left as-is — uncommon for credential paths and surprising to +/// silently rewrite. +fn expand_home(path: &Path) -> PathBuf { + let s = match path.to_str() { + Some(s) => s, + None => return path.to_path_buf(), + }; + if s == "~" { + return home_dir().unwrap_or_else(|| path.to_path_buf()); + } + if let Some(rest) = s.strip_prefix("~/") { + if let Some(home) = home_dir() { + return home.join(rest); + } + } + path.to_path_buf() +} + +/// Cross-platform home directory lookup: `$HOME` first (set on Unix and +/// honored on Windows under WSL/MSYS shells), then `%USERPROFILE%` as the +/// native Windows fallback. +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use secrecy::ExposeSecret; + + /// Test helper: resolve the source and expose the secret so assertions + /// can compare against plain strings. Production code should keep the + /// `SecretString` wrapper as long as possible. + fn resolved(s: &AuthCredentialSource) -> Option { + s.resolve().map(|v| v.expose_secret().to_string()) + } + + // -------- Env -------- + + #[test] + fn literal_resolves() { + assert_eq!( + resolved(&AuthCredentialSource::literal("abc")), + Some("abc".to_string()), + ); + } + + #[test] + fn env_returns_none_when_unset() { + let s = AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_DEFINITELY_UNSET"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn env_treats_empty_as_missing() { + let key = "FERN_CLI_AUTH_TEST_EMPTY"; + std::env::set_var(key, ""); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), None); + std::env::remove_var(key); + } + + #[test] + fn env_treats_whitespace_only_as_missing() { + // Parity with `File` (which trims). A whitespace-only env var would + // otherwise produce a header value of " ", which is almost never + // what the user intended and breaks `Chain` fallthrough. + let key = "FERN_CLI_AUTH_TEST_WHITESPACE"; + std::env::set_var(key, " \t \n"); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), None); + std::env::remove_var(key); + } + + #[test] + fn env_trims_surrounding_whitespace() { + let key = "FERN_CLI_AUTH_TEST_TRIM"; + std::env::set_var(key, " tok \n"); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), Some("tok".to_string())); + std::env::remove_var(key); + } + + // -------- Closure -------- + + #[test] + fn closure_resolves() { + let s = AuthCredentialSource::closure(|| Some("zzz".to_string())); + assert_eq!(resolved(&s), Some("zzz".to_string())); + } + + #[test] + fn closure_returning_none_is_missing() { + let s = AuthCredentialSource::closure(|| None); + assert_eq!(resolved(&s), None); + } + + #[test] + fn closure_returning_empty_string_is_missing() { + let s = AuthCredentialSource::closure(|| Some(String::new())); + assert_eq!(resolved(&s), None); + } + + #[test] + fn missing_resolves_to_none() { + assert_eq!(resolved(&AuthCredentialSource::Missing), None); + } + + // -------- File -------- + + #[test] + fn file_reads_and_trims_contents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + std::fs::write(&path, " my-token \n").unwrap(); + let s = AuthCredentialSource::file(&path); + assert_eq!(resolved(&s), Some("my-token".to_string())); + } + + #[test] + fn file_missing_resolves_to_none() { + let s = AuthCredentialSource::file("/definitely/not/a/real/path-xyz"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn file_empty_content_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty"); + std::fs::write(&path, " \n\n").unwrap(); + let s = AuthCredentialSource::file(&path); + assert_eq!(resolved(&s), None); + } + + #[test] + fn literal_empty_string_resolves_to_none() { + // Consistency with Env / Closure variants: empty values aren't + // sent as headers. Also makes `Chain([literal(""), env(...)])` + // fall through to the env source as a user would expect. + assert_eq!(resolved(&AuthCredentialSource::literal("")), None); + } + + #[test] + fn chain_with_empty_literal_falls_through() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::literal(""), + AuthCredentialSource::literal("backup"), + ]); + assert_eq!(resolved(&s), Some("backup".to_string())); + } + + #[test] + fn home_dir_falls_back_to_userprofile_when_home_unset() { + // Save/restore both env vars to keep test isolated. + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + + std::env::remove_var("HOME"); + std::env::set_var("USERPROFILE", "/win-home"); + assert_eq!(home_dir(), Some(PathBuf::from("/win-home"))); + + // Restore. + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + + #[test] + fn expand_home_resolves_tilde_prefix() { + std::env::set_var("HOME", "/tmp/test-home"); + assert_eq!( + expand_home(Path::new("~/foo/bar")), + PathBuf::from("/tmp/test-home/foo/bar"), + ); + assert_eq!(expand_home(Path::new("~")), PathBuf::from("/tmp/test-home")); + // Non-tilde paths pass through. + assert_eq!( + expand_home(Path::new("/etc/passwd")), + PathBuf::from("/etc/passwd"), + ); + // Embedded ~ left alone. + assert_eq!( + expand_home(Path::new("/foo/~bar")), + PathBuf::from("/foo/~bar"), + ); + } + + // -------- Chain -------- + + #[test] + fn chain_picks_first_with_value() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::Missing, + AuthCredentialSource::literal("second"), + AuthCredentialSource::literal("third"), + ]); + assert_eq!(resolved(&s), Some("second".to_string())); + } + + #[test] + fn chain_returns_none_when_all_missing() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::Missing, + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_DEFINITELY_UNSET"), + ]); + assert_eq!(resolved(&s), None); + } + + // -------- Cli -------- + + #[test] + fn cli_strips_leading_dashes() { + match AuthCredentialSource::cli("--api-token") { + AuthCredentialSource::Cli(n) => assert_eq!(n, "api-token"), + _ => panic!("expected Cli variant"), + } + match AuthCredentialSource::cli("api-token") { + AuthCredentialSource::Cli(n) => assert_eq!(n, "api-token"), + _ => panic!("expected Cli variant"), + } + } + + #[test] + fn cli_resolves_to_none_before_finalize() { + let s = AuthCredentialSource::cli("api-token"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn cli_args_collects_recursively_through_chain() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("flag-a"), + AuthCredentialSource::from_env("X"), + AuthCredentialSource::any([AuthCredentialSource::cli("flag-b")]), + ]); + let args = s.cli_args(); + assert_eq!(args, vec!["flag-a", "flag-b"]); + } + + #[test] + fn cli_args_empty_when_no_cli_variants() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::from_env("X"), + AuthCredentialSource::literal("y"), + ]); + assert!(s.cli_args().is_empty()); + } + + fn build_matches(arg_name: &'static str, value: Option<&str>) -> Arc { + let cmd = clap::Command::new("test").arg( + clap::Arg::new(arg_name) + .long(arg_name) + .num_args(1), + ); + let argv: Vec = match value { + Some(v) => vec![ + "test".to_string(), + format!("--{arg_name}"), + v.to_string(), + ], + None => vec!["test".to_string()], + }; + Arc::new(cmd.try_get_matches_from(argv).unwrap()) + } + + #[test] + fn finalize_replaces_cli_with_closure_reading_matches() { + let matches = build_matches("api-token", Some("supplied-on-cli")); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(resolved(&s), Some("supplied-on-cli".to_string())); + } + + #[test] + fn finalize_cli_returns_none_when_flag_absent() { + let matches = build_matches("api-token", None); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(resolved(&s), None); + } + + #[test] + fn finalize_recurses_into_chain_with_cli_fallback_to_env() { + // Chain: --api-token (not passed) -> env var (set) -> file (missing) + let matches = build_matches("api-token", None); + std::env::set_var("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK", "from-env"); + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK"), + ]) + .finalize(&matches); + assert_eq!(resolved(&s), Some("from-env".to_string())); + std::env::remove_var("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK"); + } + + #[test] + fn finalize_chain_cli_wins_over_env() { + // CLI is registered FIRST in the chain — when both are present, + // CLI's value takes precedence. + let matches = build_matches("api-token", Some("from-cli")); + std::env::set_var("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE", "from-env"); + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE"), + ]) + .finalize(&matches); + assert_eq!(resolved(&s), Some("from-cli".to_string())); + std::env::remove_var("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE"); + } + + #[test] + fn finalize_passes_through_non_cli_variants() { + let matches = build_matches("ignored", None); + let s = AuthCredentialSource::literal("constant").finalize(&matches); + assert_eq!(resolved(&s), Some("constant".to_string())); + } + + #[test] + fn resolved_secret_does_not_leak_through_debug() { + // SecretString redacts its inner value in Debug — defense in + // depth against accidentally panic-printing or logging tokens. + let s = AuthCredentialSource::literal("super-secret-token"); + let secret = s.resolve().unwrap(); + let dbg = format!("{secret:?}"); + assert!(!dbg.contains("super-secret-token")); + } + + #[test] + fn debug_redacts_literal_value() { + let s = AuthCredentialSource::literal("super-secret"); + let dbg = format!("{s:?}"); + assert!(!dbg.contains("super-secret")); + assert!(dbg.contains("redacted")); + } +} diff --git a/cli/src/auth/error.rs b/cli/src/auth/error.rs new file mode 100644 index 00000000..0c34ba04 --- /dev/null +++ b/cli/src/auth/error.rs @@ -0,0 +1,190 @@ +//! Auth-aware HTTP error mapping. +//! +//! On a 401/403 response, we want to surface a friendly "no credentials" +//! message when the request actually went out without working auth (the +//! user just needs to set their env var / file / flag), but pass the raw +//! server error through when the request *did* carry credentials (the +//! server is rejecting them — a real backend problem). +//! +//! Per-endpoint awareness comes from +//! [`AuthProvider::has_credentials_for`][hcf]: a routing wrapper can have +//! credentials for *some* schemes but not the one this specific endpoint +//! demanded, and the friendly path should still fire. +//! +//! [hcf]: crate::auth::AuthProvider::has_credentials_for + +use serde_json::Value; + +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +/// Map an HTTP error response to a [`CliError`], honoring whether the +/// provider could have authenticated *this specific endpoint*. +/// +/// When `status` is 401/403 and the provider reports it couldn't satisfy +/// the endpoint's auth requirements, returns a friendly +/// [`CliError::Auth`] hinting the user to check their configured auth +/// source. Otherwise, parses the response body as a structured +/// `{ "error": { code, message, errors[].reason | reason } }` envelope +/// and returns [`CliError::Api`]; falls back to wrapping the raw body if +/// the response isn't JSON. +pub fn handle_error_response( + status: reqwest::StatusCode, + error_body: &str, + provider: &dyn AuthProvider, + endpoint: &EndpointAuthMetadata, +) -> Result { + if (status.as_u16() == 401 || status.as_u16() == 403) + && !provider.has_credentials_for(endpoint) + { + return Err(CliError::Auth( + "Access denied. This request was sent without authentication \ + credentials. Check that the configured auth source for this CLI \ + (environment variable, --flag, or credential file) has a value set." + .to_string(), + )); + } + Err(parse_api_error(status, error_body)) +} + +/// Shared parsing for the auth-aware error handler. Returns a structured +/// [`CliError::Api`] whether or not the body was JSON. +fn parse_api_error(status: reqwest::StatusCode, error_body: &str) -> CliError { + if let Ok(error_json) = serde_json::from_str::(error_body) { + if let Some(err_obj) = error_json.get("error") { + let code = err_obj + .get("code") + .and_then(|c| c.as_u64()) + .unwrap_or(status.as_u16() as u64) as u16; + let message = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error") + .to_string(); + let reason = err_obj + .get("errors") + .and_then(|e| e.as_array()) + .and_then(|arr| arr.first()) + .and_then(|e| e.get("reason")) + .and_then(|r| r.as_str()) + .or_else(|| err_obj.get("reason").and_then(|r| r.as_str())) + .unwrap_or("unknown") + .to_string(); + return CliError::Api { + code, + message, + reason, + }; + } + } + CliError::Api { + code: status.as_u16(), + message: error_body.to_string(), + reason: "httpError".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::credential::AuthCredentialSource; + use crate::auth::schemes::BearerAuthProvider; + use serde_json::json; + + #[test] + fn friendly_when_provider_has_no_credentials_for_endpoint() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::Missing); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => assert!(msg.contains("Access denied")), + _ => panic!("Expected Auth"), + } + } + + #[test] + fn passes_through_when_credentials_present() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":401,"message":"bad","reason":"x"}}"#, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + assert!(matches!(err, CliError::Api { .. })); + } + + #[test] + fn parses_structured_error_envelope() { + let json_err = json!({ + "error": { + "code": 401, + "message": "Request had invalid authentication credentials.", + "errors": [{ "reason": "authError" }] + } + }) + .to_string(); + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + &json_err, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { code, message, reason } => { + assert_eq!(code, 401); + assert!(message.contains("invalid authentication credentials")); + assert_eq!(reason, "authError"); + } + other => panic!("Expected Api, got: {other:?}"), + } + } + + #[test] + fn handles_top_level_reason_field() { + let json_err = json!({ + "error": { "code": 403, "message": "Forbidden", "reason": "accessDenied" } + }) + .to_string(); + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::FORBIDDEN, + &json_err, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { reason, .. } => assert_eq!(reason, "accessDenied"), + _ => panic!("Expected Api"), + } + } + + #[test] + fn falls_back_to_raw_body_when_non_json() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error Text", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { code, message, reason } => { + assert_eq!(code, 500); + assert_eq!(message, "Internal Server Error Text"); + assert_eq!(reason, "httpError"); + } + _ => panic!("Expected Api"), + } + } +} diff --git a/cli/src/auth/mod.rs b/cli/src/auth/mod.rs new file mode 100644 index 00000000..89627b66 --- /dev/null +++ b/cli/src/auth/mod.rs @@ -0,0 +1,59 @@ +//! Authentication provider architecture. +//! +//! Modeled on the Fern TypeScript SDK generator's `core.AuthProvider` contract: +//! every auth scheme implements [`AuthProvider`], which mutates an outgoing +//! [`reqwest::RequestBuilder`] with the appropriate headers. Composition +//! wrappers let multiple schemes coexist: +//! +//! - [`AnyAuthProvider`] — OR semantics. Tries each child provider; the first +//! that contributes headers wins. Used when a CLI is configured with several +//! schemes but no per-endpoint security map (the default fallback). +//! - [`RoutingAuthProvider`] — per-endpoint dispatch. Reads the operation's +//! `security_requirements` (`security: [...]` in OpenAPI), finds the first +//! requirement that all registered providers can satisfy, and merges their +//! headers (AND inside a requirement, OR across requirements). +//! +//! Each scheme provider is parameterized by an [`AuthCredentialSource`] — a +//! lazy supplier that resolves a value from an env var, a literal, or a +//! closure. This mirrors the TS generator's `Supplier`. +//! +//! # Module layout +//! +//! - [`credential`] — `AuthCredentialSource` (lazy-supplier model with +//! env, CLI flag, file, literal, chain, and closure variants). +//! - [`provider`] — the [`AuthProvider`] trait, [`EndpointAuthMetadata`], +//! [`DynAuthProvider`] alias, and the [`NoAuthProvider`] sentinel. +//! - [`schemes`] — concrete [`BearerAuthProvider`], [`BasicAuthProvider`], +//! and [`HeaderAuthProvider`] implementations. +//! - [`compose`] — composition wrappers: [`AnyAuthProvider`], +//! [`AllAuthProvider`], [`RoutingAuthProvider`]. +//! - [`builder`] — [`SchemeBinding`], [`AuthStrategy`], and the +//! `build_provider_*` factories that `CliApp` calls. +//! - [`error`] — auth-aware HTTP error mapping (`handle_error_response`). +//! +//! All public types and functions are re-exported at the module root. + +pub mod builder; +pub mod compose; +pub mod credential; +pub mod error; +pub mod oauth2; +pub mod provider; +pub mod schemes; + +#[cfg(test)] +pub(crate) mod test_helpers; + +pub use builder::{ + build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, + collect_binding_cli_args, finalize_bindings, render_auth_help_section, AuthStrategy, + SchemeBinding, +}; +pub use error::handle_error_response; +pub use compose::{AllAuthProvider, AnyAuthProvider, RoutingAuthProvider}; +pub use credential::AuthCredentialSource; +pub use provider::{ + no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, +}; +pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/cli/src/auth/oauth2.rs b/cli/src/auth/oauth2.rs new file mode 100644 index 00000000..9f761ea6 --- /dev/null +++ b/cli/src/auth/oauth2.rs @@ -0,0 +1,1210 @@ +//! OAuth 2.0 auth provider with persistent token storage. +//! +//! [`OAuth2TokenProvider`] implements [`AuthProvider`] so it plugs directly into +//! the `auth_provider()` builder method on `CliApp`. On first `apply()`: +//! +//! 1. Check the on-disk credential cache (`~/.config//credentials.json`). +//! If a cached access token exists and hasn't expired, use it. +//! 2. If the cache holds a refresh token, exchange it for a new access token +//! (RFC 6749 §6) and update the cache. +//! 3. Otherwise fall back to the configured grant (client credentials or +//! refresh token from env) and persist the result. +//! +//! This mirrors the token persistence patterns used by `gcloud`, `gh`, and +//! `aws sso`. Tokens are stored as JSON with owner-only file permissions +//! (0600) and written atomically via temp-file-then-rename. +//! +//! For the async token fetch to work inside the synchronous `apply()` +//! method, the provider uses `tokio::task::block_in_place` + +//! `Handle::current().block_on()`. This is safe because `CliApp::run` +//! creates a multi-threaded tokio runtime. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Token response parsing +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct TokenSuccessBody { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +#[derive(Deserialize)] +struct TokenErrorBody { + error: Option, + #[serde(rename = "error_description")] + error_description: Option, +} + +fn parse_oauth_error_json(body: &str) -> Option { + let err: TokenErrorBody = serde_json::from_str(body).ok()?; + match (err.error, err.error_description) { + (Some(e), Some(d)) => Some(format!("{e}: {d}")), + (Some(e), None) => Some(e), + (None, Some(d)) => Some(d), + (None, None) => None, + } +} + +fn truncate_body_for_error(body: &str) -> String { + const MAX: usize = 512; + let char_count = body.chars().count(); + if char_count <= MAX { + body.to_string() + } else { + let truncated: String = body.chars().take(MAX).collect(); + format!("{truncated}…") + } +} + +fn token_http_client() -> Result { + reqwest::Client::builder() + .build() + .map_err(|e| CliError::Auth(format!("Failed to build HTTP client for OAuth2: {e}"))) +} + +fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +// --------------------------------------------------------------------------- +// On-disk token cache +// --------------------------------------------------------------------------- + +/// A single cached token entry, keyed by token_url in the JSON map. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedToken { + access_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + /// Epoch seconds when the access token expires. `None` = no expiry known. + #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option, +} + +/// On-disk credential store at `~/.config//credentials.json`. +/// +/// The file is a JSON object keyed by token_url: +/// ```json +/// { +/// "https://identity.example.com/connect/token": { +/// "access_token": "...", +/// "refresh_token": "...", +/// "expires_at": 1715550000 +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct TokenCache { + path: PathBuf, +} + +/// Buffer subtracted from `expires_in` before writing `expires_at`, +/// so we refresh before the token actually expires. 2 minutes matches +/// the TS SDK's BUFFER_IN_MINUTES constant. +const EXPIRY_BUFFER_SECS: u64 = 120; + +type TokenMap = std::collections::HashMap; + +impl TokenCache { + /// Build a cache path at `~/.config//credentials.json`. + pub fn for_cli(cli_name: &str) -> Option { + let home = home_dir()?; + let dir = config_dir(&home); + Some(Self { + path: dir.join(cli_name).join("credentials.json"), + }) + } + + /// Build a cache at an explicit path (for testing). + #[cfg(test)] + fn at_path(path: PathBuf) -> Self { + Self { path } + } + + fn read_map(&self) -> TokenMap { + let data = match std::fs::read_to_string(&self.path) { + Ok(d) => d, + Err(_) => return TokenMap::new(), + }; + serde_json::from_str(&data).unwrap_or_default() + } + + fn write_map(&self, map: &TokenMap) -> Result<(), CliError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Auth(format!( + "Failed to create token cache directory {}: {e}", + parent.display() + )) + })?; + } + + let json = serde_json::to_string_pretty(map).map_err(|e| { + CliError::Auth(format!("Failed to serialize token cache: {e}")) + })?; + + atomic_write(&self.path, json.as_bytes()) + } + + /// Load a non-expired cached token for the given token_url. + fn load(&self, token_url: &str) -> Option { + let map = self.read_map(); + let entry = map.get(token_url)?; + if let Some(expires_at) = entry.expires_at { + if now_epoch_secs() >= expires_at { + return None; + } + } + Some(entry.clone()) + } + + /// Persist a token response to disk. + fn store( + &self, + token_url: &str, + access_token: &str, + refresh_token: Option<&str>, + expires_in: Option, + ) -> Result<(), CliError> { + let mut map = self.read_map(); + let expires_at = expires_in.map(|ei| { + let buffered = ei.saturating_sub(EXPIRY_BUFFER_SECS); + now_epoch_secs() + buffered + }); + // Preserve existing refresh_token if the new response didn't include one + let prev_refresh = map.get(token_url).and_then(|e| e.refresh_token.clone()); + map.insert( + token_url.to_string(), + CachedToken { + access_token: access_token.to_string(), + refresh_token: refresh_token + .map(|s| s.to_string()) + .or(prev_refresh), + expires_at, + }, + ); + self.write_map(&map) + } + + /// Remove the cached entry for a token_url (e.g., on refresh failure). + fn remove(&self, token_url: &str) { + let mut map = self.read_map(); + if map.remove(token_url).is_some() { + let _ = self.write_map(&map); + } + } +} + +/// Write `data` to `path` atomically: write a sibling temp file, set +/// owner-only permissions (0600 on Unix), then rename over the target. +fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data).map_err(|e| { + CliError::Auth(format!( + "Failed to write token cache {}: {e}", + tmp.display() + )) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&tmp, perms); + } + + std::fs::rename(&tmp, path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + CliError::Auth(format!( + "Failed to rename token cache {}: {e}", + tmp.display() + )) + }) +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +/// Platform-appropriate config directory. +fn config_dir(home: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + { + home.join("Library").join("Application Support") + } + #[cfg(target_os = "windows")] + { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Roaming")) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".config")) + } +} + +// --------------------------------------------------------------------------- +// Grant configuration +// --------------------------------------------------------------------------- + +/// Which OAuth2 grant type to use. +#[derive(Debug, Clone)] +pub enum OAuth2Grant { + /// Client credentials grant (RFC 6749 §4.4). + ClientCredentials { + /// Env var name for the client ID. + client_id_env: String, + /// Env var name for the client secret. + client_secret_env: String, + /// Optional space-delimited scope string. + scope: Option, + }, + /// Refresh token grant (RFC 6749 §6). + RefreshToken { + /// Env var name for the client ID. + client_id_env: String, + /// Env var name for the client secret. + client_secret_env: String, + /// Env var name for the refresh token. + refresh_token_env: String, + }, +} + +// --------------------------------------------------------------------------- +// Form bodies (serde) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ClientCredentialsForm<'a> { + grant_type: &'static str, + client_id: &'a str, + client_secret: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option<&'a str>, +} + +#[derive(Serialize)] +struct RefreshTokenForm<'a> { + grant_type: &'static str, + client_id: &'a str, + client_secret: &'a str, + refresh_token: &'a str, +} + +// --------------------------------------------------------------------------- +// Token fetch +// --------------------------------------------------------------------------- + +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +async fn fetch_token(token_url: &str, grant: &OAuth2Grant) -> Result { + if token_url.trim().is_empty() { + return Err(CliError::Validation( + "OAuth2: token_url must not be empty".to_string(), + )); + } + + let http = token_http_client()?; + + let response = match grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + scope, + } => { + let client_id = read_env(client_id_env, "client_id")?; + let client_secret = read_env(client_secret_env, "client_secret")?; + http.post(token_url) + .form(&ClientCredentialsForm { + grant_type: "client_credentials", + client_id: &client_id, + client_secret: &client_secret, + scope: scope.as_deref(), + }) + .send() + .await + } + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => { + let client_id = read_env(client_id_env, "client_id")?; + let client_secret = read_env(client_secret_env, "client_secret")?; + let refresh_token = read_env(refresh_token_env, "refresh_token")?; + http.post(token_url) + .form(&RefreshTokenForm { + grant_type: "refresh_token", + client_id: &client_id, + client_secret: &client_secret, + refresh_token: &refresh_token, + }) + .send() + .await + } + } + .map_err(|e| CliError::Auth(format!("OAuth2 token request failed: {e}")))?; + + parse_token_response(response).await +} + +/// Exchange a cached refresh token for a new access token. +async fn refresh_cached_token( + token_url: &str, + client_id: &str, + client_secret: &str, + refresh_token: &str, +) -> Result { + let http = token_http_client()?; + let response = http + .post(token_url) + .form(&RefreshTokenForm { + grant_type: "refresh_token", + client_id, + client_secret, + refresh_token, + }) + .send() + .await + .map_err(|e| CliError::Auth(format!("OAuth2 token refresh failed: {e}")))?; + parse_token_response(response).await +} + +async fn parse_token_response(response: reqwest::Response) -> Result { + let status = response.status(); + let body_text = response + .text() + .await + .map_err(|e| CliError::Auth(format!("OAuth2 token response body: {e}")))?; + + if !status.is_success() { + let detail = parse_oauth_error_json(&body_text) + .unwrap_or_else(|| truncate_body_for_error(&body_text)); + return Err(CliError::Auth(format!( + "OAuth2 token endpoint returned HTTP {status}: {detail}" + ))); + } + + let parsed: TokenSuccessBody = serde_json::from_str(&body_text).map_err(|e| { + CliError::Auth(format!( + "OAuth2 token response is not valid JSON with access_token: {e}" + )) + })?; + + if parsed.access_token.is_empty() { + return Err(CliError::Auth( + "OAuth2 token response contained an empty access_token".to_string(), + )); + } + + Ok(TokenResponse { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + expires_in: parsed.expires_in, + }) +} + +fn read_env(var: &str, label: &str) -> Result { + let val = std::env::var(var).map_err(|_| { + CliError::Auth(format!( + "Missing environment variable {var} (OAuth2 {label})" + )) + })?; + if val.is_empty() { + return Err(CliError::Auth(format!( + "Environment variable {var} (OAuth2 {label}) must be non-empty" + ))); + } + Ok(val) +} + +// --------------------------------------------------------------------------- +// OAuth2TokenProvider +// --------------------------------------------------------------------------- + +/// OAuth2 auth provider with on-disk token persistence. +/// +/// Resolution order on each `apply()`: +/// 1. In-process cache (`OnceLock`) — already resolved this invocation. +/// 2. On-disk cache — non-expired access token from a previous invocation. +/// 3. Cached refresh token — exchange for a new access token. +/// 4. Configured grant (client credentials or env-based refresh token). +/// +/// New tokens are persisted to `~/.config//credentials.json` (Linux), +/// `~/Library/Application Support//credentials.json` (macOS), or +/// `%APPDATA%//credentials.json` (Windows). +pub struct OAuth2TokenProvider { + scheme_name: String, + token_url: String, + grant: OAuth2Grant, + cache: Option, + cached_token: OnceLock>, +} + +impl std::fmt::Debug for OAuth2TokenProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuth2TokenProvider") + .field("scheme_name", &self.scheme_name) + .field("token_url", &self.token_url) + .field("grant", &self.grant) + .finish() + } +} + +impl OAuth2TokenProvider { + pub fn new( + scheme_name: impl Into, + token_url: impl Into, + grant: OAuth2Grant, + ) -> Self { + Self { + scheme_name: scheme_name.into(), + token_url: token_url.into(), + grant, + cache: None, + cached_token: OnceLock::new(), + } + } + + /// Enable on-disk token persistence. `cli_name` is the binary name + /// (e.g., `"myapi"`) — tokens are stored under the platform config dir. + pub fn with_cache(mut self, cli_name: &str) -> Self { + self.cache = TokenCache::for_cli(cli_name); + self + } + + /// Enable on-disk token persistence with a pre-built [`TokenCache`]. + pub fn with_token_cache(mut self, cache: TokenCache) -> Self { + self.cache = Some(cache); + self + } + + fn resolve_token(&self) -> Result<&SecretString, CliError> { + let result = self.cached_token.get_or_init(|| { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.resolve_token_async()) + .map(SecretString::from) + .map_err(|e| e.to_string()) + }) + }); + match result { + Ok(token) => Ok(token), + Err(msg) => Err(CliError::Auth(msg.clone())), + } + } + + async fn resolve_token_async(&self) -> Result { + // 1. Check on-disk cache for a valid access token + if let Some(cache) = &self.cache { + if let Some(cached) = cache.load(&self.token_url) { + tracing::debug!("Using cached OAuth2 access token for {}", self.token_url); + return Ok(cached.access_token); + } + + // 2. Try refreshing with a cached refresh token + if let Some(token_resp) = self.try_cached_refresh(cache).await { + return Ok(token_resp); + } + } + + // 3. Fall back to the configured grant + let resp = fetch_token(&self.token_url, &self.grant).await?; + self.persist_response(&resp); + Ok(resp.access_token) + } + + /// Attempt to use a cached refresh token. Returns the new access token + /// on success, or None if there's no cached refresh token or the refresh + /// fails (in which case we fall through to the configured grant). + async fn try_cached_refresh(&self, cache: &TokenCache) -> Option { + let map = cache.read_map(); + let entry = map.get(&self.token_url)?; + let refresh_token = entry.refresh_token.as_deref()?; + + // We need client_id and client_secret to do the refresh + let (client_id_env, client_secret_env) = match &self.grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } => (client_id_env.as_str(), client_secret_env.as_str()), + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + .. + } => (client_id_env.as_str(), client_secret_env.as_str()), + }; + + let client_id = match read_env(client_id_env, "client_id") { + Ok(v) => v, + Err(_) => { + tracing::debug!( + "Cannot refresh cached token: {} not set", + client_id_env + ); + return None; + } + }; + let client_secret = match read_env(client_secret_env, "client_secret") { + Ok(v) => v, + Err(_) => { + tracing::debug!( + "Cannot refresh cached token: {} not set", + client_secret_env + ); + return None; + } + }; + + tracing::debug!( + "Attempting cached refresh token grant for {}", + self.token_url + ); + + match refresh_cached_token( + &self.token_url, + &client_id, + &client_secret, + refresh_token, + ) + .await + { + Ok(resp) => { + self.persist_response(&resp); + Some(resp.access_token) + } + Err(e) => { + tracing::debug!("Cached refresh token failed, falling through: {e}"); + cache.remove(&self.token_url); + None + } + } + } + + fn persist_response(&self, resp: &TokenResponse) { + if let Some(cache) = &self.cache { + if let Err(e) = cache.store( + &self.token_url, + &resp.access_token, + resp.refresh_token.as_deref(), + resp.expires_in, + ) { + tracing::warn!("Failed to persist OAuth2 token to cache: {e}"); + } + } + } +} + +impl AuthProvider for OAuth2TokenProvider { + fn name(&self) -> &str { + &self.scheme_name + } + + fn has_credentials(&self) -> bool { + // Check disk cache first — if we have a cached token, we have creds + if let Some(cache) = &self.cache { + if cache.load(&self.token_url).is_some() { + return true; + } + // Also check if we have a cached refresh token (even if access expired) + let map = cache.read_map(); + if let Some(entry) = map.get(&self.token_url) { + if entry.refresh_token.is_some() { + return true; + } + } + } + // Fall back to env var check + match &self.grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } => env_is_set(client_id_env) && env_is_set(client_secret_env), + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => { + env_is_set(client_id_env) + && env_is_set(client_secret_env) + && env_is_set(refresh_token_env) + } + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let token = self.resolve_token()?; + let mut value = String::with_capacity(7 + token.expose_secret().len()); + value.push_str("Bearer "); + value.push_str(token.expose_secret()); + let mut header = reqwest::header::HeaderValue::from_str(&value) + .map_err(|e| CliError::Auth(format!("Invalid OAuth2 bearer token: {e}")))?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +fn env_is_set(var: &str) -> bool { + std::env::var(var) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, req}; + use serial_test::serial; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn client_credentials_fetches_and_caches_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "cc-token-123", + "token_type": "Bearer" + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_CC_ID", "my-id"); + std::env::set_var("TEST_CC_SECRET", "my-secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + format!("{}/token", server.uri()), + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CC_ID".to_string(), + client_secret_env: "TEST_CC_SECRET".to_string(), + scope: None, + }, + ); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer cc-token-123")); + + // Second call uses in-process cache (wiremock expect(1) would fail otherwise) + let r2 = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r2).as_deref(), Some("Bearer cc-token-123")); + + std::env::remove_var("TEST_CC_ID"); + std::env::remove_var("TEST_CC_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn client_credentials_no_creds_when_env_unset() { + std::env::remove_var("MISSING_CC_ID_XYZ"); + std::env::remove_var("MISSING_CC_SECRET_XYZ"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://unused.example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "MISSING_CC_ID_XYZ".to_string(), + client_secret_env: "MISSING_CC_SECRET_XYZ".to_string(), + scope: None, + }, + ); + + assert!(!provider.has_credentials()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn refresh_token_fetches_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-token-456", + "token_type": "Bearer" + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_RT_ID", "my-id"); + std::env::set_var("TEST_RT_SECRET", "my-secret"); + std::env::set_var("TEST_RT_REFRESH", "my-refresh-token"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + format!("{}/token", server.uri()), + OAuth2Grant::RefreshToken { + client_id_env: "TEST_RT_ID".to_string(), + client_secret_env: "TEST_RT_SECRET".to_string(), + refresh_token_env: "TEST_RT_REFRESH".to_string(), + }, + ); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer refreshed-token-456")); + + std::env::remove_var("TEST_RT_ID"); + std::env::remove_var("TEST_RT_SECRET"); + std::env::remove_var("TEST_RT_REFRESH"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn refresh_token_no_creds_without_refresh_env() { + std::env::set_var("TEST_RT_ID2", "id"); + std::env::set_var("TEST_RT_SECRET2", "secret"); + std::env::remove_var("MISSING_RT_XYZ"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://unused.example.com/token", + OAuth2Grant::RefreshToken { + client_id_env: "TEST_RT_ID2".to_string(), + client_secret_env: "TEST_RT_SECRET2".to_string(), + refresh_token_env: "MISSING_RT_XYZ".to_string(), + }, + ); + + assert!(!provider.has_credentials()); + + std::env::remove_var("TEST_RT_ID2"); + std::env::remove_var("TEST_RT_SECRET2"); + } + + #[test] + fn parse_oauth_error_prefers_error_and_description() { + let body = r#"{"error":"invalid_client","error_description":"bad secret"}"#; + assert_eq!( + parse_oauth_error_json(body).as_deref(), + Some("invalid_client: bad secret") + ); + } + + #[test] + fn truncate_body_long() { + let s = "x".repeat(600); + let t = truncate_body_for_error(&s); + assert!(t.len() < s.len()); + assert!(t.ends_with('…')); + } + + #[test] + fn truncate_body_multibyte_utf8_no_panic() { + let s = "é".repeat(600); + let t = truncate_body_for_error(&s); + assert!(t.chars().count() <= 513); // 512 chars + '…' + assert!(t.ends_with('…')); + } + + // ---- Token cache tests ---- + + #[test] + fn token_cache_store_and_load() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store( + "https://example.com/token", + "access-abc", + Some("refresh-xyz"), + Some(3600), + ) + .unwrap(); + + let loaded = cache.load("https://example.com/token").unwrap(); + assert_eq!(loaded.access_token, "access-abc"); + assert_eq!(loaded.refresh_token.as_deref(), Some("refresh-xyz")); + assert!(loaded.expires_at.is_some()); + } + + #[test] + fn token_cache_expired_token_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Store a token with 0 seconds expiry (immediately expired after buffer) + cache + .store("https://example.com/token", "expired", None, Some(0)) + .unwrap(); + + assert!(cache.load("https://example.com/token").is_none()); + } + + #[test] + fn token_cache_no_expiry_always_valid() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "forever", None, None) + .unwrap(); + + let loaded = cache.load("https://example.com/token").unwrap(); + assert_eq!(loaded.access_token, "forever"); + assert!(loaded.expires_at.is_none()); + } + + #[test] + fn token_cache_remove() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "abc", None, Some(3600)) + .unwrap(); + assert!(cache.load("https://example.com/token").is_some()); + + cache.remove("https://example.com/token"); + assert!(cache.load("https://example.com/token").is_none()); + } + + #[test] + fn token_cache_preserves_refresh_token_on_update() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Initial store with refresh token + cache + .store("https://ex.com/t", "old-access", Some("my-refresh"), Some(3600)) + .unwrap(); + + // Update with new access token but no refresh token in response + cache + .store("https://ex.com/t", "new-access", None, Some(3600)) + .unwrap(); + + let loaded = cache.load("https://ex.com/t").unwrap(); + assert_eq!(loaded.access_token, "new-access"); + assert_eq!(loaded.refresh_token.as_deref(), Some("my-refresh")); + } + + #[cfg(unix)] + #[test] + fn token_cache_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + let cache = TokenCache::at_path(path.clone()); + + cache + .store("https://example.com/token", "secret", None, None) + .unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "Token cache should be owner-only"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_uses_disk_cache() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Pre-populate the cache + cache + .store("https://example.com/token", "cached-token", None, Some(3600)) + .unwrap(); + + // Provider should not hit the network (no MockServer needed) + std::env::set_var("TEST_CACHE_ID", "id"); + std::env::set_var("TEST_CACHE_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CACHE_ID".to_string(), + client_secret_env: "TEST_CACHE_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer cached-token")); + + std::env::remove_var("TEST_CACHE_ID"); + std::env::remove_var("TEST_CACHE_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_persists_token_to_disk() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "new-token", + "refresh_token": "new-refresh", + "expires_in": 3600 + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_PERSIST_ID", "id"); + std::env::set_var("TEST_PERSIST_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_PERSIST_ID".to_string(), + client_secret_env: "TEST_PERSIST_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer new-token")); + + // Verify it was persisted + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "new-token"); + assert_eq!(loaded.refresh_token.as_deref(), Some("new-refresh")); + + std::env::remove_var("TEST_PERSIST_ID"); + std::env::remove_var("TEST_PERSIST_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_uses_cached_refresh_token() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + // Pre-populate cache with expired access + valid refresh + { + let mut map = TokenMap::new(); + map.insert( + token_url.clone(), + CachedToken { + access_token: "expired".to_string(), + refresh_token: Some("cached-refresh".to_string()), + expires_at: Some(0), // already expired + }, + ); + let json = serde_json::to_string_pretty(&map).unwrap(); + std::fs::write(dir.path().join("credentials.json"), json).unwrap(); + } + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-from-cache", + "refresh_token": "new-refresh", + "expires_in": 7200 + })), + ) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_CREFRESH_ID", "id"); + std::env::set_var("TEST_CREFRESH_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CREFRESH_ID".to_string(), + client_secret_env: "TEST_CREFRESH_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!( + auth_header(r).as_deref(), + Some("Bearer refreshed-from-cache") + ); + + // Verify the new tokens were persisted + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "refreshed-from-cache"); + assert_eq!(loaded.refresh_token.as_deref(), Some("new-refresh")); + + std::env::remove_var("TEST_CREFRESH_ID"); + std::env::remove_var("TEST_CREFRESH_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_falls_through_when_cached_refresh_fails() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + // Pre-populate cache with expired access + stale refresh + { + let mut map = TokenMap::new(); + map.insert( + token_url.clone(), + CachedToken { + access_token: "expired".to_string(), + refresh_token: Some("stale-refresh".to_string()), + expires_at: Some(0), + }, + ); + let json = serde_json::to_string_pretty(&map).unwrap(); + std::fs::write(dir.path().join("credentials.json"), json).unwrap(); + } + + // First call (refresh) fails, second call (client credentials) succeeds + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(move |_req: &wiremock::Request| { + let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + // Refresh fails + ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "refresh token expired" + })) + } else { + // Client credentials succeeds + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fresh-cc-token", + "expires_in": 3600 + })) + } + }) + .expect(2) + .mount(&server) + .await; + + std::env::set_var("TEST_FALLTHRU_ID", "id"); + std::env::set_var("TEST_FALLTHRU_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_FALLTHRU_ID".to_string(), + client_secret_env: "TEST_FALLTHRU_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer fresh-cc-token")); + + // The stale refresh token should have been removed + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "fresh-cc-token"); + assert!(loaded.refresh_token.is_none()); + + std::env::remove_var("TEST_FALLTHRU_ID"); + std::env::remove_var("TEST_FALLTHRU_SECRET"); + } + + #[test] + fn has_credentials_true_when_cache_has_valid_token() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "valid", None, Some(3600)) + .unwrap(); + + std::env::remove_var("NO_SUCH_ID_XYZ_TOKEN_TEST"); + std::env::remove_var("NO_SUCH_SECRET_XYZ_TOKEN_TEST"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "NO_SUCH_ID_XYZ_TOKEN_TEST".to_string(), + client_secret_env: "NO_SUCH_SECRET_XYZ_TOKEN_TEST".to_string(), + scope: None, + }, + ) + .with_token_cache(cache); + + // has_credentials is true because of disk cache, even though env vars are unset + assert!(provider.has_credentials()); + } +} diff --git a/cli/src/auth/provider.rs b/cli/src/auth/provider.rs new file mode 100644 index 00000000..9ab3470d --- /dev/null +++ b/cli/src/auth/provider.rs @@ -0,0 +1,192 @@ +//! The [`AuthProvider`] trait, its per-request metadata +//! ([`EndpointAuthMetadata`]), the [`DynAuthProvider`] handle alias, and +//! the [`NoAuthProvider`] sentinel. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::error::CliError; + +/// Per-request context the executor passes to providers. Maps directly to +/// the TS generator's `endpointMetadata` argument. +/// +/// Three states encode OpenAPI's three semantics: +/// - `None` — the operation didn't pin a security policy. The composition +/// wrapper's default (typically `AnyAuthProvider`) handles it. +/// - `Some(vec![])` — explicitly anonymous (`security: []` in the spec). +/// The provider must not attach any auth, even if credentials are available. +/// - `Some(vec![req1, req2, ...])` — OR-of-ANDs: satisfy any one requirement. +#[derive(Debug, Clone, Default)] +pub struct EndpointAuthMetadata { + pub security_requirements: Option>>>, +} + +impl EndpointAuthMetadata { + /// No security policy declared on the operation — let the wrapper's + /// default policy decide. + pub fn unspecified() -> Self { + Self::default() + } + + /// `security: []` in the spec — operation is explicitly unauthenticated. + pub fn explicit_anonymous() -> Self { + Self { + security_requirements: Some(Vec::new()), + } + } + + pub fn with_requirements(reqs: Vec>>) -> Self { + Self { + security_requirements: Some(reqs), + } + } + + /// True when the operation pinned `security: []` — the spec's "this + /// endpoint is explicitly unauthenticated" signal. The executor uses + /// this to short-circuit `apply` so credentials never leak onto an + /// opt-out endpoint, regardless of which provider is configured. + pub fn is_explicit_anonymous(&self) -> bool { + matches!(&self.security_requirements, Some(reqs) if reqs.is_empty()) + } +} + +/// A pluggable authentication scheme. +/// +/// Implementors mutate `request` with the appropriate headers (or other +/// modifications) for an outgoing API call. Returning the request unchanged +/// is the right behaviour when the provider can't satisfy this request and +/// composition wrappers should fall through to the next provider. +/// +/// # Repeated credential resolution +/// +/// Composition wrappers (`AnyAuthProvider`, `AllAuthProvider`, +/// `RoutingAuthProvider`) call `has_credentials` / `has_credentials_for` +/// before `apply` on each request, so an +/// [`AuthCredentialSource`](crate::auth::AuthCredentialSource) backing a +/// leaf provider can be resolved twice (or more, through nested wrappers). +/// For `Env` / `Literal` / `Cli` sources this is free; for `File` it means +/// a re-read on each call and for `Closure` it means re-invocation. This +/// is acceptable for the CLI workload (one request per process invocation), +/// but provider implementations that wrap an expensive source — token +/// refresh, keychain access, network round-trips — should memoize +/// internally rather than expect the trait to deduplicate calls. +pub trait AuthProvider: Send + Sync + std::fmt::Debug { + /// Stable identifier. Used by [`RoutingAuthProvider`][rap] to look up + /// the provider for a security requirement and by error messages. Should + /// match the scheme name from the OpenAPI spec where applicable. + /// + /// [rap]: crate::auth::RoutingAuthProvider + fn name(&self) -> &str; + + /// Whether this provider currently has *any* credential available. + /// Used by composition wrappers to decide whether to try this provider + /// at all (e.g., `AnyAuthProvider` skips children whose + /// `has_credentials()` is false). + /// + /// For "could this provider have authenticated *this specific + /// endpoint*?" — used by the friendly-error path on 401/403 — see + /// [`has_credentials_for`](Self::has_credentials_for) instead. + fn has_credentials(&self) -> bool; + + /// Whether this provider can satisfy *this specific endpoint*'s auth + /// requirements. Used by the error path to decide whether a 401/403 is + /// the user's fault (no creds for this endpoint → friendly error) or + /// actually a server problem (creds were sent → surface raw error). + /// + /// The default delegates to [`has_credentials`](Self::has_credentials), + /// which is correct for leaf providers (bearer, basic, header) and for + /// `AnyAuthProvider` (any provider with creds will be tried regardless + /// of endpoint). Composition wrappers that route by endpoint — + /// notably [`RoutingAuthProvider`] — should override this to inspect + /// the endpoint's `security_requirements` and check whether any + /// requirement is satisfiable. + fn has_credentials_for(&self, _endpoint: &EndpointAuthMetadata) -> bool { + self.has_credentials() + } + + /// Apply the scheme to `request`. Implementations should be a no-op if + /// they can't satisfy the request (e.g., no env var set), so wrappers can + /// fall through. Hard errors (malformed token bytes) are surfaced via + /// [`CliError::Auth`]. + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result; +} + +/// Boxed handle the rest of the codebase passes around. +pub type DynAuthProvider = Arc; + +/// Construct a no-op [`AuthProvider`] handle. Use this in tests and in +/// custom command handlers that want to bypass auth for a one-off call. +pub fn no_auth_provider() -> DynAuthProvider { + Arc::new(NoAuthProvider) +} + +/// No-op provider. Used when the CLI hasn't configured auth at all. +#[derive(Debug, Clone, Default)] +pub struct NoAuthProvider; + +impl AuthProvider for NoAuthProvider { + fn name(&self) -> &str { + "none" + } + + fn has_credentials(&self) -> bool { + false + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + Ok(request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, req}; + + #[tokio::test] + async fn no_auth_provider_emits_no_headers() { + let p = NoAuthProvider; + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + assert!(!p.has_credentials()); + assert_eq!(p.name(), "none"); + } + + #[test] + fn endpoint_metadata_three_states() { + // `unspecified` and `default` agree. + assert!(EndpointAuthMetadata::unspecified() + .security_requirements + .is_none()); + assert!(EndpointAuthMetadata::default() + .security_requirements + .is_none()); + + // `explicit_anonymous` is `Some(empty)`. + let anon = EndpointAuthMetadata::explicit_anonymous(); + assert_eq!( + anon.security_requirements.as_ref().map(|v| v.len()), + Some(0), + ); + + // `with_requirements` carries them through. + let reqs = vec![{ + let mut m = HashMap::new(); + m.insert("a".to_string(), Vec::::new()); + m + }]; + let with = EndpointAuthMetadata::with_requirements(reqs); + assert_eq!( + with.security_requirements.as_ref().map(|v| v.len()), + Some(1), + ); + } +} diff --git a/cli/src/auth/schemes.rs b/cli/src/auth/schemes.rs new file mode 100644 index 00000000..db98d297 --- /dev/null +++ b/cli/src/auth/schemes.rs @@ -0,0 +1,433 @@ +//! Concrete auth-scheme providers: bearer tokens, HTTP basic, and arbitrary +//! header-bound credentials. Each one is a small wrapper around an +//! [`AuthCredentialSource`] that knows how to format the resolved value as +//! an outgoing header. +//! +//! # Secret-handling tradeoff +//! +//! Each `apply` formats the outgoing header by `expose_secret`-ing the +//! resolved [`SecretString`] into a transient `String` buffer (e.g. +//! `"Bearer " + token`). That buffer is not zeroized on drop. We accept the +//! transient unprotected copy because the `HeaderValue` it lowers into +//! (and the resulting on-the-wire `reqwest::Request` body) is not zeroized +//! either — adding zeroization here without doing it end-to-end would be +//! security theater. The mitigations still in force: `set_sensitive(true)` +//! on every produced `HeaderValue` so reqwest redacts it in `Debug`, and +//! `SecretString`'s redacting `Debug`/`Display` impl at the source. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use secrecy::ExposeSecret; + +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// BearerAuthProvider — Authorization: Bearer +// --------------------------------------------------------------------------- + +/// `Authorization: Bearer ` (RFC 6750). +#[derive(Debug, Clone)] +pub struct BearerAuthProvider { + name: String, + token: AuthCredentialSource, +} + +impl BearerAuthProvider { + pub fn new(name: impl Into, token: AuthCredentialSource) -> Self { + Self { + name: name.into(), + token, + } + } +} + +impl AuthProvider for BearerAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.token.resolve().is_some() + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let Some(token) = self.token.resolve() else { + return Ok(request); + }; + // Avoid `RequestBuilder::bearer_auth` — it panics on tokens with + // bytes that can't be a HeaderValue (CTL chars, NUL, non-ASCII). + // AGENTS.md flags adversarial inputs explicitly. + let mut value = String::with_capacity(7 + token.expose_secret().len()); + value.push_str("Bearer "); + value.push_str(token.expose_secret()); + let mut header = reqwest::header::HeaderValue::from_str(&value) + .map_err(|e| CliError::Auth(format!("Invalid bearer token: {e}")))?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +// --------------------------------------------------------------------------- +// BasicAuthProvider — Authorization: Basic base64(user:pass) +// --------------------------------------------------------------------------- + +/// `Authorization: Basic base64(username:password)` (RFC 7617). +/// +/// Three construction modes: +/// +/// | Constructor | `has_credentials` requires | Omitted field sent as | +/// |---|---|---| +/// | [`new`](Self::new) | both username **and** password | — | +/// | [`username_only`](Self::username_only) | username | password = `""` | +/// | [`password_only`](Self::password_only) | password | username = `""` | +#[derive(Debug, Clone)] +pub struct BasicAuthProvider { + name: String, + username: AuthCredentialSource, + password: AuthCredentialSource, + mode: BasicAuthMode, +} + +/// Controls which credentials [`BasicAuthProvider`] requires. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BasicAuthMode { + /// Both username and password must resolve. + Full, + /// Only the username must resolve; password is sent as `""`. + UsernameOnly, + /// Only the password must resolve; username is sent as `""`. + PasswordOnly, +} + +impl BasicAuthProvider { + /// Standard HTTP Basic auth — both username and password are required. + pub fn new( + name: impl Into, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username, + password, + mode: BasicAuthMode::Full, + } + } + + /// Username-only Basic auth (empty password). Common for APIs that + /// accept an API key as the HTTP Basic username. + pub fn username_only( + name: impl Into, + username: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username, + password: AuthCredentialSource::Missing, + mode: BasicAuthMode::UsernameOnly, + } + } + + /// Password-only Basic auth (empty username). Used by APIs that + /// expect the token in the password field of HTTP Basic. + pub fn password_only( + name: impl Into, + password: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username: AuthCredentialSource::Missing, + password, + mode: BasicAuthMode::PasswordOnly, + } + } +} + +impl AuthProvider for BasicAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + match self.mode { + BasicAuthMode::Full => { + self.username.resolve().is_some() && self.password.resolve().is_some() + } + BasicAuthMode::UsernameOnly => self.username.resolve().is_some(), + BasicAuthMode::PasswordOnly => self.password.resolve().is_some(), + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let u = self.username.resolve(); + let p = self.password.resolve(); + + // In Full mode both must be present; in partial modes the + // omitted half is sent as the empty string. + match self.mode { + BasicAuthMode::Full if u.is_none() || p.is_none() => return Ok(request), + BasicAuthMode::UsernameOnly if u.is_none() => return Ok(request), + BasicAuthMode::PasswordOnly if p.is_none() => return Ok(request), + _ => {} + } + + let u_ref = u.as_ref().map(|s| s.expose_secret()).unwrap_or(""); + let p_ref = p.as_ref().map(|s| s.expose_secret()).unwrap_or(""); + + let mut combined = String::with_capacity(u_ref.len() + 1 + p_ref.len()); + combined.push_str(u_ref); + combined.push(':'); + combined.push_str(p_ref); + let encoded = BASE64.encode(&combined); + let value = format!("Basic {encoded}"); + let mut header = + reqwest::header::HeaderValue::from_str(&value).map_err(|e| { + CliError::Auth(format!("Invalid basic-auth credentials: {e}")) + })?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +// --------------------------------------------------------------------------- +// HeaderAuthProvider — raw or bearer-prefixed token in a named header. +// --------------------------------------------------------------------------- + +/// Send the token verbatim in a named header. Used by APIs that pass the +/// raw token in `Authorization` (no `Bearer ` prefix) and any custom +/// `X-Api-Key` style scheme. +/// +/// If `bearer_prefix` is true, the value is prefixed with `Bearer ` — +/// equivalent to a [`BearerAuthProvider`] but on a non-`Authorization` +/// header. +#[derive(Debug, Clone)] +pub struct HeaderAuthProvider { + name: String, + header_name: String, + token: AuthCredentialSource, + bearer_prefix: bool, +} + +impl HeaderAuthProvider { + pub fn new( + name: impl Into, + header_name: impl Into, + token: AuthCredentialSource, + bearer_prefix: bool, + ) -> Self { + Self { + name: name.into(), + header_name: header_name.into(), + token, + bearer_prefix, + } + } +} + +impl AuthProvider for HeaderAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.token.resolve().is_some() + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let Some(token) = self.token.resolve() else { + return Ok(request); + }; + let value = if self.bearer_prefix { + let mut s = String::with_capacity(7 + token.expose_secret().len()); + s.push_str("Bearer "); + s.push_str(token.expose_secret()); + s + } else { + token.expose_secret().to_string() + }; + let mut header_value = + reqwest::header::HeaderValue::from_str(&value).map_err(|e| { + CliError::Auth(format!( + "Invalid token for header '{}': {e}", + self.header_name + )) + })?; + header_value.set_sensitive(true); + Ok(request.header(self.header_name.as_str(), header_value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, header, req}; + + // -------- BearerAuthProvider -------- + + #[tokio::test] + async fn bearer_provider_emits_authorization_bearer() { + let p = BearerAuthProvider::new("bearerAuth", AuthCredentialSource::literal("tok")); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn bearer_provider_no_token_is_noop() { + let p = BearerAuthProvider::new("bearerAuth", AuthCredentialSource::Missing); + assert!(!p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + } + + #[tokio::test] + async fn bearer_provider_rejects_invalid_token_bytes() { + // A token containing a newline is not a valid HeaderValue. + // We must error, not panic — adversarial inputs are called out in + // AGENTS.md. + let p = BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::literal("bad\ntoken"), + ); + let err = p + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + } + + // -------- BasicAuthProvider -------- + + #[tokio::test] + async fn basic_provider_emits_base64_authorization() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::literal("alice"), + AuthCredentialSource::literal("hunter2"), + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64("alice:hunter2") = "YWxpY2U6aHVudGVyMg==" + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YWxpY2U6aHVudGVyMg=="), + ); + } + + #[test] + fn basic_provider_full_missing_password_is_no_credentials() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::literal("alice"), + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + #[test] + fn basic_provider_full_missing_username_is_no_credentials() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::Missing, + AuthCredentialSource::literal("pass"), + ); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn basic_provider_username_only_sends_empty_password() { + let p = BasicAuthProvider::username_only( + "basicAuth", + AuthCredentialSource::literal("api_key_123"), + ); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64("api_key_123:") — colon present, empty password + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YXBpX2tleV8xMjM6"), + ); + } + + #[test] + fn basic_provider_username_only_missing_is_no_credentials() { + let p = BasicAuthProvider::username_only( + "basicAuth", + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn basic_provider_password_only_sends_empty_username() { + let p = BasicAuthProvider::password_only( + "basicAuth", + AuthCredentialSource::literal("secret_token"), + ); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64(":secret_token") + assert_eq!( + auth_header(r).as_deref(), + Some("Basic OnNlY3JldF90b2tlbg=="), + ); + } + + #[test] + fn basic_provider_password_only_missing_is_no_credentials() { + let p = BasicAuthProvider::password_only( + "basicAuth", + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + // -------- HeaderAuthProvider -------- + + #[tokio::test] + async fn header_provider_raw_value_no_prefix() { + let p = HeaderAuthProvider::new( + "linearKey", + "Authorization", + AuthCredentialSource::literal("lin_api_xxx"), + false, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("lin_api_xxx")); + } + + #[tokio::test] + async fn header_provider_bearer_prefix_named_header() { + let p = HeaderAuthProvider::new( + "squareKey", + "X-Auth", + AuthCredentialSource::literal("tok"), + true, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-auth").as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn header_provider_custom_header_name() { + let p = HeaderAuthProvider::new( + "apiKey", + "X-Api-Key", + AuthCredentialSource::literal("k"), + false, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } +} diff --git a/cli/src/auth/test_helpers.rs b/cli/src/auth/test_helpers.rs new file mode 100644 index 00000000..e1b9dd6c --- /dev/null +++ b/cli/src/auth/test_helpers.rs @@ -0,0 +1,53 @@ +//! Shared test fixtures used across the `auth` submodules. Compiled only +//! under `#[cfg(test)]`. + +use std::sync::Arc; + +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::DynAuthProvider; +use crate::auth::schemes::{BearerAuthProvider, HeaderAuthProvider}; + +/// A bare `RequestBuilder` pointing at example.com. Tests only inspect the +/// resulting headers — the URL doesn't matter. +pub fn req() -> reqwest::RequestBuilder { + reqwest::Client::new().post("https://example.com/") +} + +/// Read the `Authorization` header back off a built request, if present. +pub fn auth_header(req: reqwest::RequestBuilder) -> Option { + let built = req.build().unwrap(); + built + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +/// Read an arbitrary header value back off a built request. +pub fn header(req: reqwest::RequestBuilder, name: &str) -> Option { + let built = req.build().unwrap(); + built + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +/// Pre-built bearer provider with a literal token. Used as a fixture +/// for tests that need a credential-bearing provider. +pub fn bearer(name: &str, token: &str) -> DynAuthProvider { + Arc::new(BearerAuthProvider::new( + name, + AuthCredentialSource::literal(token), + )) +} + +/// Pre-built header provider — convenience for the apiKey-style tests. +pub fn api_key(name: &str, header_name: &str, value: &str) -> DynAuthProvider { + Arc::new(HeaderAuthProvider::new( + name, + header_name, + AuthCredentialSource::literal(value), + false, + )) +} diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs new file mode 100644 index 00000000..9ad68962 --- /dev/null +++ b/cli/src/cli_args.rs @@ -0,0 +1,165 @@ +//! CLI argument helpers shared across protocol modules. +//! +//! Pure functions that operate on raw `&[String]` args or `clap::ArgMatches` +//! and have no protocol-specific dependencies. + +use crate::error::CliError; + +/// True for `--version`, `-V`, or the bare `version` subcommand. +pub fn is_version_flag(arg: &str) -> bool { + matches!(arg, "--version" | "-V" | "version") +} + +/// Resolve the API base URL override from the `--base-url` flag and the +/// `{NAME}_BASE_URL` env var (flag wins). Validates the flag value for +/// dangerous characters; the env var is treated as trusted. +pub fn resolve_base_url_override( + matches: &clap::ArgMatches, + app_name: &str, +) -> Result, CliError> { + let base_url_flag = matches.get_one::("base-url").cloned(); + if let Some(ref url) = base_url_flag { + crate::output::reject_dangerous_chars(url, "--base-url")?; + } + let env_var_name = format!("{}_BASE_URL", app_name.to_uppercase().replace('-', "_")); + let base_url_env_var = std::env::var(env_var_name).ok(); + Ok(base_url_flag.or(base_url_env_var)) +} + +/// Returns true when raw args contain both a help flag and `--format json`. +/// +/// Triggered before clap parses so agents can request machine-readable help via +/// `--help --format json` without clap intercepting. +pub fn wants_json_help(args: &[String]) -> bool { + let has_help = args.iter().any(|a| a == "--help" || a == "-h"); + let has_json_format = args.iter().enumerate().any(|(i, a)| { + a.eq_ignore_ascii_case("--format=json") + || (a == "--format" + && args.get(i + 1).map(|s| s.to_lowercase() == "json") == Some(true)) + }); + has_help && has_json_format +} + +/// Extracts the subcommand path from raw args (non-flag tokens after the binary +/// name). Skips global flags (and their values) that may appear before the +/// subcommand, so they don't terminate the `take_while(!starts_with('-'))` +/// scan that follows. +/// +/// Currently elided global flags: `--format ` (and its `--format=VALUE` +/// equals form). +/// +/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +pub fn extract_subcommand_path(args: &[String]) -> Vec { + let mut skip_next = false; + args.iter() + .skip(1) // skip binary name + .filter(|a| { + if skip_next { + skip_next = false; + return false; + } + if a.as_str() == "--format" { + skip_next = true; + return false; + } + if a.starts_with("--format=") { + return false; + } + true + }) + .take_while(|a| !a.starts_with('-')) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_is_version_flag() { + assert!(is_version_flag("--version")); + assert!(is_version_flag("-V")); + assert!(is_version_flag("version")); + assert!(!is_version_flag("--ver")); + } + + #[test] + fn test_wants_json_help_space_separated() { + assert!(wants_json_help(&args(&[ + "myapi", "issues", "--help", "--format", "json", + ]))); + } + + #[test] + fn test_wants_json_help_equals() { + assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + } + + #[test] + fn test_wants_json_help_short_flag() { + assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + } + + #[test] + fn test_wants_json_help_case_insensitive() { + assert!(wants_json_help(&args(&[ + "myapi", "--help", "--format", "JSON", + ]))); + assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + } + + #[test] + fn test_no_json_help_without_format() { + assert!(!wants_json_help(&args(&["myapi", "--help"]))); + } + + #[test] + fn test_no_json_help_without_help_flag() { + assert!(!wants_json_help(&args(&[ + "myapi", "issues", "get", "--format", "json", + ]))); + } + + #[test] + fn test_extract_subcommand_path() { + assert_eq!( + extract_subcommand_path(&args(&[ + "myapi", "issues", "get", "--help", "--format", "json", + ])), + vec!["issues", "get"], + ); + } + + #[test] + fn test_extract_subcommand_path_root() { + assert_eq!( + extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + Vec::::new(), + ); + } + + #[test] + fn test_extract_subcommand_path_format_before_subcommand() { + assert_eq!( + extract_subcommand_path(&args(&[ + "myapi", "--format", "json", "issues", "--help", + ])), + vec!["issues"], + ); + } + + #[test] + fn test_extract_subcommand_path_format_equals_before_subcommand() { + assert_eq!( + extract_subcommand_path(&args(&[ + "myapi", "--format=json", "issues", "get", "--help", + ])), + vec!["issues", "get"], + ); + } +} diff --git a/cli/src/completions.rs b/cli/src/completions.rs new file mode 100644 index 00000000..7d21ea16 --- /dev/null +++ b/cli/src/completions.rs @@ -0,0 +1,169 @@ +//! Shell completion generation. +//! +//! Shared infrastructure for emitting shell completion scripts. Sits above +//! both protocol paths (`openapi/` and `graphql/`) and has no +//! protocol-specific dependencies. + +use clap::Command; +use clap_complete::{generate, Shell}; + +/// Returns `true` when `args` contains `"completion"` as the first +/// positional token (i.e. the subcommand position). This allows early +/// interception before normal API dispatch — avoiding collision with an +/// API resource that might also be named `completion`. +/// +/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// not mistaken for a completion request (`completion` there is the +/// value of `--base-url`, not a subcommand). Boolean flags like +/// `--dry-run` are recognised and do NOT consume the next token. +pub fn wants_completion(args: &[String]) -> bool { + crate::early_intercept::first_positional_is(args, "completion") +} + +/// Generate a shell completion script for `cmd` and write it to stdout. +/// +/// `bin_name` is the name the user types to invoke the CLI (e.g. `"myapi"`). +/// The caller is responsible for building a `Command` that mirrors the full +/// CLI surface (subcommands, flags, etc.) so the generated script is complete. +/// +/// Returns an IO error if writing to stdout fails. +pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { + let mut buf = Vec::new(); + generate(shell, cmd, bin_name, &mut buf); + use std::io::Write; + std::io::stdout().write_all(&buf) +} + +/// Parse a shell name string into a [`Shell`] enum variant. +/// +/// Matching is case-sensitive, consistent with `clap_complete::Shell`'s +/// `FromStr` implementation and the `value_parser` on +/// [`completion_command`]. Returns `None` for unrecognized values +/// (including case mismatches like `"BASH"`). +pub fn parse_shell(s: &str) -> Option { + match s { + "bash" => Some(Shell::Bash), + "zsh" => Some(Shell::Zsh), + "fish" => Some(Shell::Fish), + "powershell" => Some(Shell::PowerShell), + "elvish" => Some(Shell::Elvish), + _ => None, + } +} + +/// Build the `completion` subcommand definition. Registered at the root +/// of the command tree so ` completion ` works. +pub fn completion_command() -> Command { + Command::new("completion") + .about("Generate shell completion scripts") + .arg_required_else_help(true) + .after_help( + "EXAMPLES:\n \ + # bash\n \ + completion bash > /etc/bash_completion.d/\n \ + # zsh\n \ + completion zsh > \"${fpath[1]}/_\"\n \ + # fish\n \ + completion fish > ~/.config/fish/completions/.fish", + ) + .arg( + clap::Arg::new("shell") + .required(true) + .value_parser(["bash", "zsh", "fish", "powershell", "elvish"]) + .help("Target shell (bash, zsh, fish, powershell, elvish)"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn wants_completion_detects_subcommand() { + assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); + assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + } + + #[test] + fn wants_completion_false_for_normal_commands() { + assert!(!wants_completion(&args(&["myapi", "files", "get"]))); + assert!(!wants_completion(&args(&["myapi", "--help"]))); + } + + #[test] + fn wants_completion_false_when_nested() { + assert!(!wants_completion(&args(&[ + "myapi", "files", "completion", "bash" + ]))); + } + + #[test] + fn wants_completion_false_when_flag_value() { + assert!(!wants_completion(&args(&[ + "myapi", + "--base-url", + "completion", + "files", + ]))); + } + + #[test] + fn wants_completion_true_after_eq_flag() { + assert!(wants_completion(&args(&[ + "myapi", + "--base-url=http://localhost", + "completion", + "bash", + ]))); + } + + #[test] + fn wants_completion_with_boolean_flag() { + // --dry-run is a boolean flag (SetTrue) and must NOT consume the + // next token; "completion" is the subcommand, not the flag's value. + assert!(wants_completion(&args(&[ + "myapi", + "--dry-run", + "completion", + "bash", + ]))); + } + + #[test] + fn wants_completion_with_multiple_boolean_flags() { + assert!(wants_completion(&args(&[ + "myapi", + "--dry-run", + "--no-retry", + "completion", + "zsh", + ]))); + } + + #[test] + fn parse_shell_valid() { + assert_eq!(parse_shell("bash"), Some(Shell::Bash)); + assert_eq!(parse_shell("zsh"), Some(Shell::Zsh)); + assert_eq!(parse_shell("fish"), Some(Shell::Fish)); + assert_eq!(parse_shell("powershell"), Some(Shell::PowerShell)); + assert_eq!(parse_shell("elvish"), Some(Shell::Elvish)); + } + + #[test] + fn parse_shell_rejects_uppercase() { + // parse_shell must be case-sensitive, matching clap's value_parser. + assert_eq!(parse_shell("BASH"), None); + assert_eq!(parse_shell("Zsh"), None); + assert_eq!(parse_shell("FISH"), None); + } + + #[test] + fn parse_shell_invalid() { + assert_eq!(parse_shell("nushell"), None); + assert_eq!(parse_shell(""), None); + } +} diff --git a/cli/src/custom_commands.rs b/cli/src/custom_commands.rs new file mode 100644 index 00000000..6b487b4a --- /dev/null +++ b/cli/src/custom_commands.rs @@ -0,0 +1,328 @@ +//! Protocol-agnostic registry for custom CLI subcommands grafted onto a +//! spec-derived command tree. +//! +//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register +//! handlers for subcommands that live alongside spec-generated commands +//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). +//! The grafting and dispatch logic is identical across protocols — only +//! the per-handler context type differs — so it lives here, generic over +//! the context type `C`. + +use crate::error::CliError; + +/// A custom command handler function. +/// +/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the +/// per-protocol context `C` (typically the protocol's `AppContext`). +pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; + +/// A registered custom command: parent path, leaf [`clap::Command`], and +/// its handler. +type Entry = (Vec, clap::Command, HandlerFn); + +/// Registry of custom subcommands keyed by their parent path in the +/// spec-derived command tree. Empty path = top-level. +pub struct CustomCommandRegistry { + entries: Vec>, +} + +impl CustomCommandRegistry { + pub fn new() -> Self { + Self { entries: Vec::new() } + } + + /// Register a top-level custom subcommand. + pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { + self.register_under::<&str>(&[], cmd, handler); + } + + /// Register a custom subcommand under `path`. Empty path = top-level. + pub fn register_under>( + &mut self, + path: &[S], + cmd: clap::Command, + handler: HandlerFn, + ) { + let owned: Vec = path.iter().map(|s| s.as_ref().to_string()).collect(); + self.entries.push((owned, cmd, handler)); + } + + /// Graft every registered command into `cli`, returning the augmented + /// command tree. Custom commands replace spec-generated leaves on + /// name collisions. + pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { + for (path, cmd, _) in &self.entries { + cli = graft_subcommand(cli, path, cmd.clone()); + } + cli + } + + /// Walk the parsed `matches` tree along each registered command's + /// path. If one matches, invoke its handler with `ctx` and return + /// `Some(handler_result)`. Returns `None` if no custom command was + /// invoked. + pub fn dispatch( + &self, + matches: &clap::ArgMatches, + ctx: &C, + ) -> Option> { + for (path, cmd, handler) in &self.entries { + if let Some(target) = walk_matches_to_custom(matches, path, cmd.get_name()) { + return Some(handler(target, ctx)); + } + } + None + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Crate-internal accessor used by unit tests in the protocol modules + /// to verify registration shape. + #[cfg(test)] + #[doc(hidden)] + pub(crate) fn entries(&self) -> &[Entry] { + &self.entries + } +} + +impl Default for CustomCommandRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Graft a custom `clap::Command` into an existing command tree under +/// `parent_path`. The leaf name is `cmd.get_name()`. +/// +/// Behavior: +/// - Walks down `parent_path` using `mut_subcommand`, recursively grafting. +/// - At any level where the named parent doesn't exist, creates it as a +/// bare subcommand so the path is reachable. +/// - At the leaf level, if a subcommand with the same name already exists +/// it is replaced by `cmd` (custom-wins on leaf collision). +pub fn graft_subcommand( + parent: clap::Command, + parent_path: &[String], + cmd: clap::Command, +) -> clap::Command { + if parent_path.is_empty() { + let leaf_name = cmd.get_name().to_string(); + if parent.find_subcommand(&leaf_name).is_some() { + parent.mut_subcommand(leaf_name, move |_existing| cmd) + } else { + parent.subcommand(cmd) + } + } else { + let head = parent_path[0].clone(); + let rest: Vec = parent_path[1..].to_vec(); + if parent.find_subcommand(&head).is_some() { + parent.mut_subcommand(head, move |sub| graft_subcommand(sub, &rest, cmd)) + } else { + let new_parent = clap::Command::new(head) + .subcommand_required(true) + .arg_required_else_help(true); + let new_parent = graft_subcommand(new_parent, &rest, cmd); + parent.subcommand(new_parent) + } + } +} + +/// Walk a parsed `ArgMatches` tree along `parent_path` and return the leaf +/// matches if the final subcommand equals `leaf_name`. Returns `None` if +/// any segment along the path doesn't match. +pub fn walk_matches_to_custom<'a>( + matches: &'a clap::ArgMatches, + parent_path: &[String], + leaf_name: &str, +) -> Option<&'a clap::ArgMatches> { + let mut current = matches; + for seg in parent_path { + let (name, sub) = current.subcommand()?; + if name != seg { + return None; + } + current = sub; + } + let (name, sub) = current.subcommand()?; + if name == leaf_name { + Some(sub) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct DummyCtx; + + fn dummy_handler(_m: &clap::ArgMatches, _c: &DummyCtx) -> Result<(), CliError> { + Ok(()) + } + + #[test] + fn graft_top_level_adds_command() { + let cli = clap::Command::new("root").subcommand(clap::Command::new("existing")); + let custom = clap::Command::new("custom"); + let grafted = graft_subcommand(cli, &[], custom); + assert!(grafted.find_subcommand("existing").is_some()); + assert!(grafted.find_subcommand("custom").is_some()); + } + + #[test] + fn graft_top_level_collision_replaces_leaf() { + let cli = clap::Command::new("root") + .subcommand(clap::Command::new("dup").about("from spec")); + let custom = clap::Command::new("dup").about("from custom"); + let grafted = graft_subcommand(cli, &[], custom); + let dup = grafted.find_subcommand("dup").unwrap(); + assert_eq!(dup.get_about().map(|s| s.to_string()).as_deref(), Some("from custom")); + } + + #[test] + fn graft_into_existing_parent_keeps_siblings() { + let webhooks = clap::Command::new("webhooks") + .subcommand(clap::Command::new("list")) + .subcommand(clap::Command::new("create")); + let cli = clap::Command::new("root").subcommand(webhooks); + + let verify = clap::Command::new("verify").about("custom"); + let grafted = graft_subcommand(cli, &["webhooks".to_string()], verify); + + let webhooks = grafted.find_subcommand("webhooks").unwrap(); + assert!(webhooks.find_subcommand("list").is_some()); + assert!(webhooks.find_subcommand("create").is_some()); + assert!(webhooks.find_subcommand("verify").is_some()); + } + + #[test] + fn graft_leaf_collision_under_parent_replaces() { + let webhooks = clap::Command::new("webhooks") + .subcommand(clap::Command::new("list").about("spec")); + let cli = clap::Command::new("root").subcommand(webhooks); + let custom_list = clap::Command::new("list").about("custom"); + let grafted = graft_subcommand(cli, &["webhooks".to_string()], custom_list); + let leaf = grafted + .find_subcommand("webhooks") + .unwrap() + .find_subcommand("list") + .unwrap(); + assert_eq!(leaf.get_about().map(|s| s.to_string()).as_deref(), Some("custom")); + } + + #[test] + fn graft_creates_missing_intermediate_parent() { + let cli = clap::Command::new("root"); + let leaf = clap::Command::new("verify"); + let grafted = graft_subcommand(cli, &["new-parent".to_string()], leaf); + let parent = grafted.find_subcommand("new-parent").unwrap(); + assert!(parent.find_subcommand("verify").is_some()); + } + + #[test] + fn walk_matches_finds_leaf() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("webhooks").subcommand(clap::Command::new("verify"))); + let matches = cmd.get_matches_from(vec!["root", "webhooks", "verify"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_some()); + } + + #[test] + fn walk_matches_misses_when_path_diverges() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("webhooks").subcommand(clap::Command::new("list"))); + let matches = cmd.get_matches_from(vec!["root", "webhooks", "list"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_none()); + } + + #[test] + fn walk_matches_misses_when_parent_diverges() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("other").subcommand(clap::Command::new("verify"))); + let matches = cmd.get_matches_from(vec!["root", "other", "verify"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_none()); + } + + #[test] + fn registry_registers_top_level_command() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register(clap::Command::new("custom"), dummy_handler); + assert_eq!(reg.len(), 1); + assert!(reg.entries()[0].0.is_empty()); + assert_eq!(reg.entries()[0].1.get_name(), "custom"); + } + + #[test] + fn registry_registers_under_path() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + assert_eq!(reg.len(), 1); + assert_eq!(reg.entries()[0].0, vec!["webhooks".to_string()]); + assert_eq!(reg.entries()[0].1.get_name(), "verify"); + } + + #[test] + fn registry_graft_into_grafts_all_entries() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register(clap::Command::new("alpha"), dummy_handler); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + + let cli = clap::Command::new("root"); + let grafted = reg.graft_into(cli); + + assert!(grafted.find_subcommand("alpha").is_some()); + let webhooks = grafted.find_subcommand("webhooks").unwrap(); + assert!(webhooks.find_subcommand("verify").is_some()); + } + + #[test] + fn registry_dispatch_invokes_matching_handler() { + use std::cell::Cell; + // Use thread-local state so the fn pointer (which can't capture) + // can record that it ran. + thread_local! { + static CALLED: Cell = const { Cell::new(false) }; + } + fn handler(_m: &clap::ArgMatches, _c: &DummyCtx) -> Result<(), CliError> { + CALLED.with(|c| c.set(true)); + Ok(()) + } + + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), handler); + + let cli = clap::Command::new("root"); + let cli = reg.graft_into(cli); + let matches = cli.get_matches_from(vec!["root", "webhooks", "verify"]); + + let result = reg.dispatch(&matches, &DummyCtx); + assert!(result.is_some()); + assert!(result.unwrap().is_ok()); + assert!(CALLED.with(|c| c.get())); + } + + #[test] + fn registry_dispatch_returns_none_when_no_custom_invoked() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + + // Build a tree that has both a custom and a non-custom path. + let cli = clap::Command::new("root") + .subcommand(clap::Command::new("other").subcommand(clap::Command::new("thing"))); + let cli = reg.graft_into(cli); + let matches = cli.get_matches_from(vec!["root", "other", "thing"]); + + let result = reg.dispatch(&matches, &DummyCtx); + assert!(result.is_none()); + } +} diff --git a/cli/src/early_intercept.rs b/cli/src/early_intercept.rs new file mode 100644 index 00000000..41f02e7f --- /dev/null +++ b/cli/src/early_intercept.rs @@ -0,0 +1,185 @@ +//! Shared infrastructure for early-intercept subcommands (`completion`, `man`). +//! +//! These subcommands are intercepted *before* normal API dispatch so that +//! an API resource that happens to share the same name doesn't collide. +//! This module houses the constants and helpers shared by both intercept +//! paths. + +/// Long flag names (without the `--` prefix) that are boolean +/// (`action(SetTrue)`) and therefore do NOT consume the next token. +/// Kept in sync with the flags registered in `commands::build_cli`. +pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ + "dry-run", + "page-all", + "no-extract", + "no-retry", + "no-stream", + "help", +]; + +/// Returns `true` when `args` contains `target` as the first positional +/// token (i.e. the subcommand position). Skips `--flag value` pairs so +/// `myapi --base-url files` is not mistaken for the subcommand. +/// Boolean flags like `--dry-run` are recognised and do NOT consume the +/// next token. +pub(crate) fn first_positional_is(args: &[String], target: &str) -> bool { + let mut skip_next = false; + for arg in args.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if arg.starts_with('-') { + if arg.contains('=') { + // --flag=value — value is consumed inline, no skip. + continue; + } + // Strip leading dashes to get the bare name. + let bare = arg.trim_start_matches('-'); + if !BOOLEAN_FLAGS.contains(&bare) { + // Value-taking flag — next token is its argument. + skip_next = true; + } + continue; + } + return arg == target; + } + false +} + +/// Returns the n-th positional argument (0-indexed, ignoring argv[0]), +/// correctly skipping value-taking flags' arguments per [`BOOLEAN_FLAGS`]. +/// +/// This is the multi-positional generalization of [`first_positional_is`]: +/// `first_positional_is(args, target)` is equivalent to +/// `nth_positional(args, 0) == Some(target)`. +/// +/// Used by the completion early-intercept path to extract the shell name +/// (positional #1, since `completion` is positional #0) while correctly +/// skipping value-taking flag arguments like `--base-url `. +pub(crate) fn nth_positional(args: &[String], n: usize) -> Option<&str> { + let mut skip_next = false; + let mut count = 0; + for arg in args.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if arg.starts_with('-') { + if arg.contains('=') { + // --flag=value — value is consumed inline, no skip. + continue; + } + // Strip leading dashes to get the bare name. + let bare = arg.trim_start_matches('-'); + if !BOOLEAN_FLAGS.contains(&bare) { + // Value-taking flag — next token is its argument. + skip_next = true; + } + continue; + } + if count == n { + return Some(arg.as_str()); + } + count += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn first_positional_basic() { + assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); + assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + } + + #[test] + fn first_positional_false_for_other_subcommand() { + assert!(!first_positional_is(&args(&["myapi", "files", "get"]), "completion")); + } + + #[test] + fn first_positional_false_when_flag_value() { + assert!(!first_positional_is( + &args(&["myapi", "--base-url", "man", "files"]), + "man", + )); + } + + #[test] + fn first_positional_true_after_eq_flag() { + assert!(first_positional_is( + &args(&["myapi", "--base-url=http://localhost", "man"]), + "man", + )); + } + + #[test] + fn first_positional_true_after_boolean_flag() { + assert!(first_positional_is( + &args(&["myapi", "--dry-run", "completion", "bash"]), + "completion", + )); + } + + #[test] + fn first_positional_true_after_multiple_boolean_flags() { + assert!(first_positional_is( + &args(&["myapi", "--dry-run", "--no-retry", "man"]), + "man", + )); + } + + // --- nth_positional --- + + #[test] + fn nth_positional_skips_value_flag() { + // `--base-url` is value-taking, so "X" is its argument, not a + // positional. "completion" is positional #0, "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn nth_positional_with_boolean_flag() { + // `--dry-run` is boolean, so "completion" is positional #0 and + // "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn nth_positional_out_of_range() { + assert_eq!( + nth_positional(&args(&["myapi", "completion", "bash"]), 5), + None, + ); + } + + #[test] + fn nth_positional_zeroth() { + assert_eq!( + nth_positional(&args(&["myapi", "completion", "bash"]), 0), + Some("completion"), + ); + } + + #[test] + fn nth_positional_eq_flag() { + assert_eq!( + nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + Some("bash"), + ); + } +} diff --git a/cli/src/error.rs b/cli/src/error.rs new file mode 100644 index 00000000..7a8af322 --- /dev/null +++ b/cli/src/error.rs @@ -0,0 +1,206 @@ +//! Structured Error Types +//! +//! Provides error types and structured JSON error output for the CLI. + +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CliError { + #[error("{message}")] + Api { + code: u16, + message: String, + reason: String, + }, + + #[error("{0}")] + Validation(String), + + #[error("{0}")] + Auth(String), + + #[error("{0}")] + Discovery(String), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + + +impl CliError { + pub const EXIT_CODE_API: i32 = 1; + pub const EXIT_CODE_AUTH: i32 = 2; + pub const EXIT_CODE_VALIDATION: i32 = 3; + pub const EXIT_CODE_DISCOVERY: i32 = 4; + pub const EXIT_CODE_OTHER: i32 = 5; + + pub fn exit_code(&self) -> i32 { + match self { + CliError::Api { .. } => Self::EXIT_CODE_API, + CliError::Auth(_) => Self::EXIT_CODE_AUTH, + CliError::Validation(_) => Self::EXIT_CODE_VALIDATION, + CliError::Discovery(_) => Self::EXIT_CODE_DISCOVERY, + CliError::Other(_) => Self::EXIT_CODE_OTHER, + } + } + + pub fn to_json(&self) -> serde_json::Value { + match self { + CliError::Api { + code, + message, + reason, + } => json!({ + "error": { + "code": code, + "message": message, + "reason": reason, + } + }), + CliError::Validation(msg) => json!({ + "error": { + "code": 400, + "message": msg, + "reason": "validationError", + } + }), + CliError::Auth(msg) => json!({ + "error": { + "code": 401, + "message": msg, + "reason": "authError", + } + }), + CliError::Discovery(msg) => json!({ + "error": { + "code": 500, + "message": msg, + "reason": "discoveryError", + } + }), + CliError::Other(e) => json!({ + "error": { + "code": 500, + "message": format!("{e:#}"), + "reason": "internalError", + } + }), + } + } +} + +use crate::output::{colorize, sanitize_for_terminal}; + +fn error_label(err: &CliError) -> String { + match err { + CliError::Api { .. } => colorize("error[api]:", "31"), + CliError::Auth(_) => colorize("error[auth]:", "31"), + CliError::Validation(_) => colorize("error[validation]:", "33"), + CliError::Discovery(_) => colorize("error[discovery]:", "31"), + CliError::Other(_) => colorize("error:", "31"), + } +} + +pub fn print_error_json(err: &CliError) { + let json = err.to_json(); + println!( + "{}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + eprintln!( + "{} {}", + error_label(err), + sanitize_for_terminal(&err.to_string()) + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exit_codes_are_distinct() { + let codes = [ + CliError::EXIT_CODE_API, + CliError::EXIT_CODE_AUTH, + CliError::EXIT_CODE_VALIDATION, + CliError::EXIT_CODE_DISCOVERY, + CliError::EXIT_CODE_OTHER, + ]; + let unique: std::collections::HashSet = codes.iter().copied().collect(); + assert_eq!(unique.len(), codes.len()); + } + + #[test] + fn test_error_to_json_api() { + let err = CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + }; + let json = err.to_json(); + assert_eq!(json["error"]["code"], 404); + assert_eq!(json["error"]["message"], "Not Found"); + } + + #[test] + fn test_error_to_json_validation() { + let err = CliError::Validation("Invalid input".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 400); + } + + #[test] + fn test_exit_codes_all_variants() { + assert_eq!( + CliError::Api { code: 404, message: String::new(), reason: String::new() }.exit_code(), + CliError::EXIT_CODE_API + ); + assert_eq!(CliError::Auth(String::new()).exit_code(), CliError::EXIT_CODE_AUTH); + assert_eq!(CliError::Validation(String::new()).exit_code(), CliError::EXIT_CODE_VALIDATION); + assert_eq!(CliError::Discovery(String::new()).exit_code(), CliError::EXIT_CODE_DISCOVERY); + assert_eq!( + CliError::Other(anyhow::anyhow!("oops")).exit_code(), + CliError::EXIT_CODE_OTHER + ); + } + + #[test] + fn test_to_json_auth() { + let err = CliError::Auth("bad creds".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 401); + assert_eq!(json["error"]["reason"], "authError"); + } + + #[test] + fn test_to_json_discovery() { + let err = CliError::Discovery("spec not found".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 500); + assert_eq!(json["error"]["reason"], "discoveryError"); + assert_eq!(json["error"]["message"], "spec not found"); + } + + #[test] + fn test_to_json_other() { + let err = CliError::Other(anyhow::anyhow!("something broke")); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 500); + assert_eq!(json["error"]["reason"], "internalError"); + } + + #[test] + fn test_print_error_json_all_variants_no_panic() { + print_error_json(&CliError::Api { + code: 500, + message: "oops".to_string(), + reason: "err".to_string(), + }); + print_error_json(&CliError::Validation("bad input".to_string())); + print_error_json(&CliError::Auth("no auth".to_string())); + print_error_json(&CliError::Discovery("no spec".to_string())); + print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); + } +} diff --git a/cli/src/formatter.rs b/cli/src/formatter.rs new file mode 100644 index 00000000..91c56fef --- /dev/null +++ b/cli/src/formatter.rs @@ -0,0 +1,923 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Output Formatting +//! +//! Transforms JSON API responses into human-readable formats (table, YAML, CSV). + +use serde_json::Value; +use std::fmt::Write; + +/// Color emission mode. +/// +/// Resolved from CLI flags and environment in [`OutputPipeline::from_matches`]. +/// `Auto` means "let the resolver decide based on TTY / `NO_COLOR` / `CI` / etc." +/// (Resolver is implemented in Step 2; for now `Auto` is just stored.) +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ColorMode { + #[default] + Auto, + Always, + Never, +} + +/// Errors that can occur while constructing or running the output pipeline. +#[derive(Debug, thiserror::Error)] +pub enum FormatError { + #[error("unknown output format: {0}")] + UnknownFormat(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +/// Composable output pipeline. +/// +/// Built once at dispatch time from CLI matches, then threaded through the +/// executor and applied per response (or per page during `--page-all`). +/// +/// In Step 1 it carries only `format` and `color_mode` and behaves identically +/// to the prior `&OutputFormat` threading. Later steps layer in field +/// projection, jq filtering, and template rendering. +#[derive(Debug, Clone, Default)] +pub struct OutputPipeline { + pub format: OutputFormat, + pub color_mode: ColorMode, +} + +impl OutputPipeline { + /// Build a pipeline from parsed CLI matches. + /// + /// Unknown `--format` values emit a warning on stderr and fall back to + /// JSON, matching the prior behavior at `src/openapi/app.rs`. + pub fn from_matches(matches: &clap::ArgMatches) -> Result { + let format = match matches.get_one::("format") { + Some(s) => match OutputFormat::parse(s) { + Ok(fmt) => fmt, + Err(unknown) => { + eprintln!( + "warning: unknown output format '{unknown}'; falling back to json" + ); + OutputFormat::Json + } + }, + None => OutputFormat::default(), + }; + Ok(Self { + format, + color_mode: ColorMode::Auto, + }) + } + + /// Render `value` to `out`, appending a trailing newline. + /// + /// When `paginated` is true the compact NDJSON form is used (one JSON + /// object per line); otherwise the pretty form is used. `is_first_page` + /// controls per-format first-page concerns (CSV headers, YAML separators, + /// table headers — see `format_value_paginated`). + pub fn emit( + &self, + out: &mut W, + value: &Value, + paginated: bool, + is_first_page: bool, + ) -> Result<(), FormatError> { + let rendered = if paginated { + format_value_paginated(value, &self.format, is_first_page) + } else { + format_value(value, &self.format) + }; + writeln!(out, "{rendered}")?; + Ok(()) + } +} + +/// Supported output formats. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum OutputFormat { + /// Pretty-printed JSON (default). + #[default] + Json, + /// Aligned text table. + Table, + /// YAML. + Yaml, + /// Comma-separated values. + Csv, +} + +impl OutputFormat { + /// Parse from a string argument. + /// + /// Returns `Ok(format)` for known values, or `Err(unknown_value)` if the + /// string is not recognised. Call sites should warn the user on `Err` and + /// decide whether to fall back to JSON or surface an error. + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(Self::Json), + "table" => Ok(Self::Table), + "yaml" | "yml" => Ok(Self::Yaml), + "csv" => Ok(Self::Csv), + other => Err(other.to_string()), + } + } + + /// Parse from a string argument, falling back to JSON for unknown values. + /// + /// Prefer `parse()` at call sites where you want to surface a warning. + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + Self::parse(s).unwrap_or(Self::Json) + } +} + +/// Format a JSON value according to the specified output format. +pub fn format_value(value: &Value, format: &OutputFormat) -> String { + match format { + OutputFormat::Json => serde_json::to_string_pretty(value).unwrap_or_default(), + OutputFormat::Table => format_table(value), + OutputFormat::Yaml => format_yaml(value), + OutputFormat::Csv => format_csv(value), + } +} + +/// Format a JSON value for a paginated page. +/// +/// When auto-paginating with `--page-all`, CSV and table formats should only +/// emit column headers on the **first** page so that each subsequent page +/// contains only data rows, making the combined output machine-parseable. +/// +/// For JSON the output is compact (one JSON object per line / NDJSON). +/// For YAML each page is prefixed with a `---` document separator so the +/// combined stream is a valid YAML multi-document file. +pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String { + match format { + OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(), + OutputFormat::Csv => format_csv_page(value, is_first_page), + OutputFormat::Table => format_table_page(value, is_first_page), + // Prefix every page with a YAML document separator so that the + // concatenated stream is parseable as a multi-document YAML file. + OutputFormat::Yaml => format!("---\n{}", format_yaml(value)), + } +} + +/// Extract a "data array" from a typical API list response. +/// APIs often return lists as `{ "collection": [...], "pagination": {...} }` +/// where the array key varies by resource type. +fn extract_items(value: &Value) -> Option<(&str, &Vec)> { + if let Value::Object(obj) = value { + for (key, val) in obj { + if key == "nextPageToken" || key == "kind" || key.starts_with('_') { + continue; + } + if let Value::Array(arr) = val { + if !arr.is_empty() { + return Some((key, arr)); + } + } + } + } + None +} + +fn format_table(value: &Value) -> String { + format_table_page(value, true) +} + +/// Recursively flatten a JSON object into `(dot.notation.key, string_value)` pairs. +/// +/// Nested objects become `parent.child` key names so that `--format table` can +/// render them as individual columns instead of raw JSON blobs. +fn flatten_object(obj: &serde_json::Map, prefix: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (key, val) in obj { + let full_key = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + match val { + Value::Object(nested) => { + out.extend(flatten_object(nested, &full_key)); + } + _ => { + out.push((full_key, value_to_cell(val))); + } + } + } + out +} + +/// Format as a text table, optionally omitting the header row. +/// +/// Pass `emit_header = false` for continuation pages when using `--page-all` +/// so the combined terminal output doesn't repeat column names and separator +/// lines between pages. +fn format_table_page(value: &Value, emit_header: bool) -> String { + // Try to extract a list of items from standard API response + let items = extract_items(value); + + if let Some((_key, arr)) = items { + format_array_as_table(arr, emit_header) + } else if let Value::Array(arr) = value { + format_array_as_table(arr, emit_header) + } else if let Value::Object(obj) = value { + // Single object: key/value table — flatten nested objects first + let mut output = String::new(); + let flat = flatten_object(obj, ""); + let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + for (key, val_str) in &flat { + let _ = writeln!(output, "{key:max_key_len$} {val_str}"); + } + output + } else { + value.to_string() + } +} + +fn format_array_as_table(arr: &[Value], emit_header: bool) -> String { + if arr.is_empty() { + return "(empty)\n".to_string(); + } + + // Flatten each row so nested objects become dot-notation columns. + let flat_rows: Vec> = arr + .iter() + .map(|item| match item { + Value::Object(obj) => flatten_object(obj, ""), + _ => vec![(String::new(), value_to_cell(item))], + }) + .collect(); + + // Collect all unique column names (preserving insertion order). + let mut columns: Vec = Vec::new(); + for row in &flat_rows { + for (key, _) in row { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + + if columns.is_empty() { + // Array of non-objects + let mut output = String::new(); + for item in arr { + let _ = writeln!(output, "{}", value_to_cell(item)); + } + return output; + } + + // Build lookup: row_index -> column_name -> cell_value + let row_maps: Vec> = flat_rows + .iter() + .map(|pairs| { + pairs + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect() + }) + .collect(); + + // Calculate column widths (char-count, not byte-count). + let mut widths: Vec = columns.iter().map(|c| c.chars().count()).collect(); + let rows: Vec> = row_maps + .iter() + .map(|row| { + columns + .iter() + .enumerate() + .map(|(i, col)| { + let cell = row.get(col.as_str()).copied().unwrap_or("").to_string(); + let char_len = cell.chars().count(); + if char_len > widths[i] { + widths[i] = char_len; + } + // Cap column width at 60 chars + if widths[i] > 60 { + widths[i] = 60; + } + cell + }) + .collect() + }) + .collect(); + + let mut output = String::new(); + + if emit_header { + // Header + let header: Vec = columns + .iter() + .enumerate() + .map(|(i, c)| format!("{:width$}", c, width = widths[i])) + .collect(); + let _ = writeln!(output, "{}", header.join(" ")); + + // Separator + let sep: Vec = widths.iter().map(|w| "─".repeat(*w)).collect(); + let _ = writeln!(output, "{}", sep.join(" ")); + } + + // Rows — truncate by char count to avoid panicking on multi-byte UTF-8. + for row in &rows { + let cells: Vec = row + .iter() + .enumerate() + .map(|(i, c)| { + let char_len = c.chars().count(); + let truncated = if char_len > widths[i] { + // Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis. + let truncated_str: String = c.chars().take(widths[i] - 1).collect(); + format!("{truncated_str}…") + } else { + c.clone() + }; + // Pad to column width (by char count) + let pad = widths[i].saturating_sub(truncated.chars().count()); + format!("{truncated}{}", " ".repeat(pad)) + }) + .collect(); + let _ = writeln!(output, "{}", cells.join(" ")); + } + + output +} + +fn format_yaml(value: &Value) -> String { + json_to_yaml(value, 0) +} + +fn json_to_yaml(value: &Value, indent: usize) -> String { + let prefix = " ".repeat(indent); + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => { + if s.contains('\n') { + // Genuine multi-line content: block scalar is the most readable choice. + format!( + "|\n{}", + s.lines() + .map(|l| format!("{prefix} {l}")) + .collect::>() + .join("\n") + ) + } else { + // Single-line strings: always double-quote so that characters like + // `#` (comment marker) and `:` (mapping indicator) are never + // misinterpreted by YAML parsers. Escape backslashes and double + // quotes to keep the output valid. + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") + } + } + Value::Array(arr) => { + if arr.is_empty() { + return "[]".to_string(); + } + let mut out = String::new(); + for item in arr { + let val_str = json_to_yaml(item, indent + 1); + let _ = write!(out, "\n{prefix}- {val_str}"); + } + out + } + Value::Object(obj) => { + if obj.is_empty() { + return "{}".to_string(); + } + let mut out = String::new(); + for (key, val) in obj { + match val { + Value::Object(_) | Value::Array(_) => { + let val_str = json_to_yaml(val, indent + 1); + let _ = write!(out, "\n{prefix}{key}:{val_str}"); + } + _ => { + let val_str = json_to_yaml(val, indent); + let _ = write!(out, "\n{prefix}{key}: {val_str}"); + } + } + } + out + } + } +} + +fn format_csv(value: &Value) -> String { + format_csv_page(value, true) +} + +/// Format as CSV, optionally omitting the header row. +/// +/// Pass `emit_header = false` for all pages after the first when using +/// `--page-all`, so the combined output has a single header line. +fn format_csv_page(value: &Value, emit_header: bool) -> String { + let items = extract_items(value); + + let arr = if let Some((_key, arr)) = items { + arr.as_slice() + } else if let Value::Array(arr) = value { + arr.as_slice() + } else { + // Single value — just output it + return value_to_cell(value); + }; + + if arr.is_empty() { + return String::new(); + } + + // Array of non-objects + if !arr.iter().any(|v| v.is_object()) { + let mut output = String::new(); + for item in arr { + if let Value::Array(inner) = item { + let cells: Vec = inner + .iter() + .map(|v| csv_escape(&value_to_cell(v))) + .collect(); + let _ = writeln!(output, "{}", cells.join(",")); + } else { + let _ = writeln!(output, "{}", csv_escape(&value_to_cell(item))); + } + } + return output; + } + + // Collect columns + let mut columns: Vec = Vec::new(); + for item in arr { + if let Value::Object(obj) = item { + for key in obj.keys() { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + } + + let mut output = String::new(); + + // Header (omitted on continuation pages) + if emit_header { + let _ = writeln!(output, "{}", columns.join(",")); + } + + // Rows + for item in arr { + let cells: Vec = columns + .iter() + .map(|col| { + if let Value::Object(obj) = item { + csv_escape(&value_to_cell(obj.get(col).unwrap_or(&Value::Null))) + } else { + String::new() + } + }) + .collect(); + let _ = writeln!(output, "{}", cells.join(",")); + } + + output +} + +fn csv_escape(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +fn value_to_cell(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::Array(arr) => { + let items: Vec = arr.iter().map(value_to_cell).collect(); + items.join(", ") + } + Value::Object(_) => serde_json::to_string(value).unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_output_format_from_str() { + assert_eq!(OutputFormat::from_str("json"), OutputFormat::Json); + assert_eq!(OutputFormat::from_str("table"), OutputFormat::Table); + assert_eq!(OutputFormat::from_str("yaml"), OutputFormat::Yaml); + assert_eq!(OutputFormat::from_str("yml"), OutputFormat::Yaml); + assert_eq!(OutputFormat::from_str("csv"), OutputFormat::Csv); + assert_eq!(OutputFormat::from_str("unknown"), OutputFormat::Json); + } + + #[test] + fn test_output_format_parse_known() { + assert_eq!(OutputFormat::parse("json"), Ok(OutputFormat::Json)); + assert_eq!(OutputFormat::parse("table"), Ok(OutputFormat::Table)); + assert_eq!(OutputFormat::parse("yaml"), Ok(OutputFormat::Yaml)); + assert_eq!(OutputFormat::parse("yml"), Ok(OutputFormat::Yaml)); + assert_eq!(OutputFormat::parse("csv"), Ok(OutputFormat::Csv)); + // Case-insensitive + assert_eq!(OutputFormat::parse("JSON"), Ok(OutputFormat::Json)); + assert_eq!(OutputFormat::parse("TABLE"), Ok(OutputFormat::Table)); + } + + #[test] + fn test_output_format_parse_unknown_returns_err() { + assert!(OutputFormat::parse("bogus").is_err()); + assert_eq!(OutputFormat::parse("bogus").unwrap_err(), "bogus"); + assert!(OutputFormat::parse("").is_err()); + } + + #[test] + fn test_format_json() { + let val = json!({"name": "test"}); + let output = format_value(&val, &OutputFormat::Json); + assert!(output.contains("\"name\"")); + assert!(output.contains("\"test\"")); + } + + #[test] + fn test_format_table_array_of_objects() { + let val = json!({ + "files": [ + {"id": "1", "name": "hello.txt"}, + {"id": "2", "name": "world.txt"} + ] + }); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("id")); + assert!(output.contains("name")); + assert!(output.contains("hello.txt")); + assert!(output.contains("world.txt")); + // Check separator line + assert!(output.contains("──")); + } + + #[test] + fn test_format_table_single_object() { + let val = json!({"id": "abc", "name": "test"}); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("id")); + assert!(output.contains("abc")); + } + + #[test] + fn test_format_table_nested_object_flattened() { + // Nested objects should become dot-notation columns, not raw JSON blobs. + let val = json!({ + "user": { + "displayName": "Alice", + "emailAddress": "alice@example.com" + }, + "storageQuota": { + "limit": "1000", + "usage": "500" + } + }); + let output = format_value(&val, &OutputFormat::Table); + // Should contain dot-notation keys + assert!( + output.contains("user.displayName"), + "expected flattened key in output:\n{output}" + ); + assert!( + output.contains("user.emailAddress"), + "expected flattened key in output:\n{output}" + ); + assert!( + output.contains("Alice"), + "expected value in output:\n{output}" + ); + // Should NOT contain raw JSON blobs + assert!( + !output.contains("{\"displayName"), + "should not have raw JSON blob:\n{output}" + ); + } + + #[test] + fn test_format_table_nested_objects_in_array() { + let val = json!([ + {"id": "1", "owner": {"name": "Alice"}}, + {"id": "2", "owner": {"name": "Bob"}} + ]); + let output = format_value(&val, &OutputFormat::Table); + assert!( + output.contains("owner.name"), + "expected flattened column:\n{output}" + ); + assert!(output.contains("Alice"), "expected value:\n{output}"); + assert!(output.contains("Bob"), "expected value:\n{output}"); + } + + #[test] + fn test_format_table_multibyte_truncation_does_not_panic() { + // Column width cap is 60 chars, so a long string with multi-byte chars + // must be safely truncated without a byte-boundary panic. + let long_emoji = "😀".repeat(70); // each emoji is 4 bytes + let val = json!([{"col": long_emoji}]); + // Should not panic + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("col"), "column name must appear:\n{output}"); + } + + #[test] + fn test_format_table_multibyte_exact_boundary() { + // Multi-byte chars at various positions must not panic or produce garbled output. + let val = json!([{"name": "café résumé naïve"}]); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("name"), "column must appear:\n{output}"); + } + + #[test] + fn test_format_csv() { + let val = json!({ + "files": [ + {"id": "1", "name": "hello"}, + {"id": "2", "name": "world"} + ] + }); + let output = format_value(&val, &OutputFormat::Csv); + assert!(output.contains("id,name")); + assert!(output.contains("1,hello")); + assert!(output.contains("2,world")); + } + + #[test] + fn test_format_csv_array_of_arrays() { + // Sheets API returns {"values": [["col1","col2"], ["a","b"]]} + let val = json!({ + "values": [ + ["Student Name", "Gender", "Class Level"], + ["Alexandra", "Female", "4. Senior"], + ["Andrew", "Male", "1. Freshman"] + ] + }); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "Student Name,Gender,Class Level"); + assert_eq!(lines[1], "Alexandra,Female,4. Senior"); + assert_eq!(lines[2], "Andrew,Male,1. Freshman"); + } + + #[test] + fn test_format_csv_flat_scalars() { + // Flat array of non-object, non-array values → one value per line + let val = json!(["apple", "banana", "cherry"]); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "apple"); + assert_eq!(lines[1], "banana"); + assert_eq!(lines[2], "cherry"); + } + + #[test] + fn test_format_csv_flat_scalars_with_escaping() { + // Scalars that contain commas/quotes must be CSV-escaped + let val = json!(["plain", "has,comma", "has\"quote"]); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "plain"); + assert_eq!(lines[1], "\"has,comma\""); + assert_eq!(lines[2], "\"has\"\"quote\""); + } + + #[test] + fn test_format_csv_escape() { + assert_eq!(csv_escape("simple"), "simple"); + assert_eq!(csv_escape("has,comma"), "\"has,comma\""); + assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\""); + } + + #[test] + fn test_format_yaml() { + let val = json!({"name": "test", "count": 42}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!(output.contains("name: \"test\"")); + assert!(output.contains("count: 42")); + } + + #[test] + fn test_format_table_empty_array() { + let val = json!({"files": []}); + // No items to extract, falls back to single-object table + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("files")); + } + + #[test] + fn test_extract_items() { + let val = json!({"files": [{"id": "1"}], "nextPageToken": "abc"}); + let (key, items) = extract_items(&val).unwrap(); + assert_eq!(key, "files"); + assert_eq!(items.len(), 1); + } + + #[test] + fn test_extract_items_none() { + let val = json!({"status": "ok"}); + assert!(extract_items(&val).is_none()); + } + + // --- YAML block-scalar regression tests --- + + #[test] + fn test_format_yaml_hash_in_string_is_quoted_not_block() { + // `drive#file` contains `#` which is a YAML comment marker; the + // serialiser must quote it rather than emit a block scalar. + let val = json!({"kind": "drive#file", "id": "123"}); + let output = format_value(&val, &OutputFormat::Yaml); + // Must be a double-quoted string, not a block scalar (`|`). + assert!( + output.contains("kind: \"drive#file\""), + "expected double-quoted kind, got:\n{output}" + ); + assert!( + !output.contains("kind: |"), + "kind must not use block scalar, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_colon_in_string_is_quoted() { + let val = json!({"url": "https://example.com/path"}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("url: \"https://example.com/path\""), + "expected double-quoted url, got:\n{output}" + ); + assert!(!output.contains("url: |"), "url must not use block scalar"); + } + + #[test] + fn test_format_yaml_multiline_still_uses_block() { + let val = json!({"body": "line one\nline two"}); + let output = format_value(&val, &OutputFormat::Yaml); + // Multi-line content should still use block scalar. + assert!( + output.contains("body: |"), + "multiline string must use block scalar, got:\n{output}" + ); + } + + // --- Paginated format tests --- + + #[test] + fn test_format_value_paginated_csv_first_page_has_header() { + let val = json!({ + "files": [ + {"id": "1", "name": "a.txt"}, + {"id": "2", "name": "b.txt"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Csv, true); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "id,name", "first page must start with header"); + assert_eq!(lines[1], "1,a.txt"); + } + + #[test] + fn test_format_value_paginated_csv_continuation_no_header() { + let val = json!({ + "files": [ + {"id": "3", "name": "c.txt"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Csv, false); + let lines: Vec<&str> = output.lines().collect(); + // The first (and only) line must be a data row, not the header. + assert_eq!(lines[0], "3,c.txt", "continuation page must have no header"); + assert!( + !output.contains("id,name"), + "header must be absent on continuation pages" + ); + } + + #[test] + fn test_format_value_paginated_table_first_page_has_header() { + let val = json!({ + "items": [ + {"id": "1", "name": "foo"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Table, true); + assert!( + output.contains("id"), + "table header must appear on first page" + ); + assert!(output.contains("──"), "separator must appear on first page"); + } + + #[test] + fn test_format_value_paginated_table_continuation_no_header() { + let val = json!({ + "items": [ + {"id": "2", "name": "bar"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Table, false); + assert!(output.contains("bar"), "data row must be present"); + assert!( + !output.contains("──"), + "separator must be absent on continuation pages" + ); + } + + #[test] + fn test_format_value_paginated_yaml_has_document_separator() { + let val = json!({"files": [{"id": "1", "name": "foo"}]}); + let first = format_value_paginated(&val, &OutputFormat::Yaml, true); + let second = format_value_paginated(&val, &OutputFormat::Yaml, false); + assert!( + first.starts_with("---\n"), + "first YAML page must start with ---" + ); + assert!( + second.starts_with("---\n"), + "continuation YAML pages must also start with ---" + ); + } + + // ----------------------------------------------------------------------- + // OutputPipeline (Step 1: abstraction only — format + color_mode) + // ----------------------------------------------------------------------- + + fn matches_for(args: &[&str]) -> clap::ArgMatches { + clap::Command::new("test") + .arg( + clap::Arg::new("format") + .long("format") + .value_name("FORMAT"), + ) + .try_get_matches_from(args) + .expect("clap parse should succeed in tests") + } + + #[test] + fn pipeline_from_matches_defaults_to_json_auto() { + let matches = matches_for(&["test"]); + let pipeline = OutputPipeline::from_matches(&matches).unwrap(); + assert_eq!(pipeline.format, OutputFormat::Json); + assert_eq!(pipeline.color_mode, ColorMode::Auto); + } + + #[test] + fn pipeline_from_matches_reads_explicit_format() { + let matches = matches_for(&["test", "--format", "yaml"]); + let pipeline = OutputPipeline::from_matches(&matches).unwrap(); + assert_eq!(pipeline.format, OutputFormat::Yaml); + } + + #[test] + fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + let matches = matches_for(&["test", "--format", "garbage"]); + let pipeline = OutputPipeline::from_matches(&matches).unwrap(); + assert_eq!(pipeline.format, OutputFormat::Json); + } + + #[test] + fn pipeline_emit_single_page_json_is_pretty_with_trailing_newline() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + }; + let val = json!({"name": "test", "n": 1}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, false, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + // pretty JSON spans multiple lines + assert!(s.contains("\"name\": \"test\""), "expected pretty JSON, got: {s}"); + assert!(s.contains('\n'), "expected indented (multi-line) JSON"); + assert!(s.ends_with('\n'), "expected trailing newline"); + } + + #[test] + fn pipeline_emit_paginated_json_is_compact_one_line() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + }; + let val = json!({"name": "test", "n": 1}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, true, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + // compact form: exactly one newline (the trailing one); no pretty + // indentation; suitable for NDJSON. + let body = s.strip_suffix('\n').expect("trailing newline"); + assert!(!body.contains('\n'), "expected single-line NDJSON, got: {s}"); + assert!(!body.contains(" "), "expected no indentation, got: {s}"); + assert!(body.contains("\"name\":\"test\""), "expected compact JSON, got: {s}"); + } +} diff --git a/cli/src/graphql/app.rs b/cli/src/graphql/app.rs new file mode 100644 index 00000000..e673f9dd --- /dev/null +++ b/cli/src/graphql/app.rs @@ -0,0 +1,696 @@ +//! High-level API for building CLIs from GraphQL schemas. +//! +//! [`CliApp`] provides a builder-style API that lets consumers create a +//! fully-functional CLI in just a few lines. [`AppContext`] exposes the +//! loaded spec and executor so that custom command handlers can call the +//! API programmatically. + +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; +use crate::cli_args; +use crate::custom_commands::CustomCommandRegistry; +use crate::error::{print_error_json, CliError}; +use crate::formatter; +use crate::graphql::commands; +use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; +use crate::graphql::executor; + +/// A custom command handler function. +/// +/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an +/// [`AppContext`] that provides access to the spec, auth token, and +/// executor. +pub type HandlerFn = crate::custom_commands::HandlerFn; + +/// Builder for a schema-driven CLI application (GraphQL). +pub struct CliApp { + pub(crate) name: String, + pub(crate) spec_json: Option, + pub(crate) endpoint_url: Option, + /// Auth bindings; mirrors the OpenAPI variant. GraphQL introspection + /// JSON doesn't carry per-operation security metadata, so the + /// constructed provider is `Any` by default — generators can flip + /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that + /// require multiple schemes simultaneously. + auth_bindings: Vec<(String, SchemeBinding)>, + auth_strategy: AuthStrategy, + /// Trust roots parsed at builder-call time. Storing parsed certs (not + /// raw bytes) means the validation error message lives in one place + /// — at the call site of `extra_root_cert`, where it's most useful. + extra_root_certs: Vec, + /// Raw PEM bytes for each trust root added via `extra_root_cert`, kept + /// alongside the parsed `extra_root_certs` above. Threaded through to + /// `HttpConfig::with_parsed_root_certs` so transport-neutral callers + /// (`HttpConfig::resolve`) can hand PEM to non-reqwest TLS connectors. + extra_root_certs_pem: Vec>, + pub(crate) custom_commands: CustomCommandRegistry, +} + +impl CliApp { + /// Create a new CLI application with the given binary name. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + spec_json: None, + endpoint_url: None, + auth_bindings: Vec::new(), + auth_strategy: AuthStrategy::Auto, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + custom_commands: CustomCommandRegistry::new(), + } + } + + /// Set the GraphQL introspection JSON schema string. Typically used with `include_str!`. + pub fn spec(mut self, json: &str) -> Self { + self.spec_json = Some(json.to_string()); + self + } + + /// Set the GraphQL endpoint URL. + pub fn endpoint(mut self, url: &str) -> Self { + self.endpoint_url = Some(url.to_string()); + self + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::from_env(env))`. + pub fn auth_scheme_env(self, scheme_name: &str, env_var: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::from_env(env_var)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::cli(arg_name))`. + /// Auto-registers a global `--` flag at run time. + pub fn auth_scheme_cli(self, scheme_name: &str, arg_name: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::cli(arg_name)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::file(path))`. + pub fn auth_scheme_file(self, scheme_name: &str, path: impl AsRef) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::file(path)) + } + + /// Bind a credential source to a named auth scheme. See + /// [`crate::openapi::CliApp::auth_scheme`] for the OpenAPI version's + /// detailed semantics — the GraphQL variant differs only in that there + /// is no spec-declared scheme metadata, so single-value bindings always + /// produce an `Authorization: Bearer ` provider. + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Token(source))); + self + } + + /// Bind separate username and password sources to a basic-auth scheme. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Basic { username, password }, + )); + self + } + + /// Username-only basic auth (password sent as `""`). See + /// [`OpenApiCliApp::auth_basic_scheme_username_only`][a] for rationale. + /// + /// [a]: crate::openapi::CliApp::auth_basic_scheme_username_only + pub fn auth_basic_scheme_username_only( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::BasicUsernameOnly(username), + )); + self + } + + /// Password-only basic auth (username sent as `""`). See + /// [`OpenApiCliApp::auth_basic_scheme_password_only`][a] for rationale. + /// + /// [a]: crate::openapi::CliApp::auth_basic_scheme_password_only + pub fn auth_basic_scheme_password_only( + mut self, + scheme_name: &str, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::BasicPasswordOnly(password), + )); + self + } + + /// Plug in a fully-custom [`AuthProvider`][crate::auth::AuthProvider] for + /// a scheme name. Wraps the provider in [`Arc`] internally; use + /// [`auth_provider_shared`](Self::auth_provider_shared) if you already + /// have a `DynAuthProvider`. + pub fn auth_provider

(self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_provider_shared(scheme_name, std::sync::Arc::new(provider)) + } + + /// Variant of [`auth_provider`](Self::auth_provider) that takes an + /// already-built [`DynAuthProvider`]. + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: DynAuthProvider, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Custom(provider), + )); + self + } + + /// Pin how the bound auth schemes compose. See + /// [`crate::openapi::CliApp::auth_strategy`] for details. GraphQL has + /// no per-endpoint security metadata, so [`AuthStrategy::Routing`] + /// degenerates to `Any` here. + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.auth_strategy = strategy; + self + } + + /// Register a custom top-level subcommand with its handler function. + /// + /// Equivalent to [`command_under`](Self::command_under) with an empty path. + pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { + self.custom_commands.register(cmd, handler); + self + } + + /// Register a custom subcommand under an existing path in the spec-derived + /// command tree. Useful for adding a new leaf alongside spec-generated + /// commands. + /// + /// - `path` — the parent path the command should be grafted under. An + /// empty path registers the command at the top level. Intermediate + /// parents that do not yet exist are auto-created. + /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final + /// segment of the path. + /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and + /// the [`AppContext`]. + /// + /// If a subcommand with the same leaf name already exists at the target + /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — + /// custom commands take precedence on leaf collisions. + pub fn command_under>( + mut self, + path: &[S], + cmd: clap::Command, + handler: HandlerFn, + ) -> Self { + self.custom_commands.register_under(path, cmd, handler); + self + } + + /// Register an extra trust root that this CLI will accept on top of the + /// system's default roots. `pem` must be a PEM-encoded certificate (or + /// concatenated PEM bundle), typically loaded with `include_bytes!`. + /// + /// Useful for distributing a CLI inside an organization where every + /// machine should trust the company's internal CA out of the box, without + /// asking each user to set `_CA_BUNDLE`. + /// + /// ```ignore + /// # // ignored: needs a real PEM file at the include path. + /// CliApp::new("internal-tool") + /// .spec(include_str!("schema.json")) + /// .endpoint("https://internal.example.com/graphql") + /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) + /// .run() + /// ``` + /// + /// Panics if the bytes don't parse as PEM, or if the PEM contains no + /// certificates. Failing fast at startup is preferable to silently + /// shipping a CLI that ignores its bundled cert. + pub fn extra_root_cert(mut self, pem: &[u8]) -> Self { + // Share the validation path with `HttpConfig::with_extra_root_cert` + // so error wording stays in sync between the panicking builder API + // and the Result-returning lower-level API. + let certs = crate::http::parse_extra_root_cert(pem) + .unwrap_or_else(|e| panic!("CliApp::extra_root_cert: {e}")); + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.push(pem.to_vec()); + self + } + + /// Run the CLI application. This is the main entry point. + /// + /// Builds a tokio runtime internally so the caller's `main()` does not + /// need to be async. + pub fn run(self) { + // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't + // panic. Must happen before any I/O. + crate::reset_sigpipe(); + + // Load .env file if present (silently ignored if missing) + let _ = dotenvy::dotenv(); + + // Initialize structured logging (no-op if env vars are unset) + crate::init_logging(&self.name); + + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + if let Err(err) = rt.block_on(self.run_async()) { + print_error_json(&err); + std::process::exit(err.exit_code()); + } + } + + /// Build the full CLI command tree including spec-derived subcommands, + /// custom commands, `completion`, `man`, and auth-bound global flags. + /// + /// Called from the `wants_completion` / `wants_man` early-intercept + /// blocks AND the normal-dispatch path so all three see the same tree. + fn build_full_cli( + &self, + doc: &crate::graphql::discovery::GraphQLSchema, + ) -> clap::Command { + let mut cli = self + .custom_commands + .graft_into(commands::build_cli(doc)) + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_command()); + + // Register CLI-arg-bound credential sources as global flags. + for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { + cli = cli.arg( + clap::Arg::new(arg_name.clone()) + .long(arg_name.clone()) + .global(true) + .value_name(arg_name.to_uppercase().replace('-', "_")) + .help(format!("Credential value for auth source `{arg_name}`")), + ); + } + + cli + } + + /// The async implementation of the CLI run loop. + async fn run_async(mut self) -> Result<(), CliError> { + let args: Vec = std::env::args().collect(); + + // Handle --version early (before loading spec) + if args.iter().any(|a| cli_args::is_version_flag(a)) { + println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + // Build the HTTP config once per run. Holds the binary name (used to + // scope env-var lookups) and any compile-time trust roots. The roots + // were already validated at builder time; we just thread the parsed + // certs through. + let http_config = crate::http::HttpConfig::new(&self.name)? + .with_parsed_root_certs( + self.extra_root_certs.iter().cloned(), + self.extra_root_certs_pem.iter().cloned(), + ); + + // Load the GraphQL schema + let json = self.spec_json.as_deref().ok_or_else(|| { + CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) + })?; + let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { + CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) + })?; + let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; + + // Intercept --help --format json before clap parses, to emit machine-readable output + if cli_args::wants_json_help(&args) { + let path = cli_args::extract_subcommand_path(&args); + return crate::graphql::help::render_json_help(&doc, &path); + } + + // Intercept ` completion ` early — before normal API + // dispatch — so a spec resource named "completion" doesn't collide. + // Builds the full command tree (including global flags) so the + // generated script covers the entire CLI surface. + if crate::completions::wants_completion(&args) { + let raw_shell_arg: Option<&str> = + crate::early_intercept::nth_positional(&args, 1); + + match raw_shell_arg { + Some(s) => match crate::completions::parse_shell(s) { + Some(shell) => { + let mut full_cmd = self.build_full_cli(&doc); + crate::completions::generate_completion( + shell, + &mut full_cmd, + &self.name, + ) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(()); + } + None => { + return Err(CliError::Validation(format!( + "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" + ))); + } + }, + None => { + let mut full_cmd = self.build_full_cli(&doc); + if let Some(sub) = full_cmd.find_subcommand_mut("completion") { + sub.print_help().ok(); + } + return Ok(()); + } + } + } + + // Intercept ` man` early — same pattern as completion above. + // If `--help` / `-h` appears after `man`, fall through to normal + // clap dispatch so the subcommand help (with EXAMPLES) is shown + // instead of generating the man page. + if crate::man::wants_man(&args) { + let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) + .any(|a| a == "--help" || a == "-h"); + let mut full_cmd = self.build_full_cli(&doc); + if has_help { + if let Some(sub) = full_cmd.find_subcommand_mut("man") { + sub.print_help().ok(); + } + return Ok(()); + } + crate::man::generate_man(full_cmd, &self.name) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(()); + } + + // Build the full command tree (same tree the intercept blocks use) + // for normal dispatch. `completion` and `man` subcommands are + // included so they appear in `--help`. + let cli = self.build_full_cli(&doc); + + // Parse args (clap handles --help automatically via arg_required_else_help) + let matches = cli.try_get_matches_from(&args).map_err(|e| { + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + || e.kind() == clap::error::ErrorKind::DisplayVersion + { + print!("{e}"); + std::process::exit(0); + } + CliError::Validation(e.to_string()) + })?; + + // Finalize auth bindings against the parsed matches. + if !self.auth_bindings.is_empty() { + let matches_arc = std::sync::Arc::new(matches.clone()); + self.auth_bindings = crate::auth::finalize_bindings( + std::mem::take(&mut self.auth_bindings), + &matches_arc, + ); + } + + // Dispatch to a custom command if one was invoked. + if !self.custom_commands.is_empty() { + let auth_provider = self.build_auth_provider(); + let ctx = AppContext { + doc: doc.clone(), + auth_provider, + http_config: http_config.clone(), + }; + if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { + return result; + } + } + + // Build the output pipeline (format + color + later: --fields/--jq/--template). + let pipeline = formatter::OutputPipeline::from_matches(&matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + + // Walk the subcommand tree to find the target method + let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; + + let params_override = matched_args + .get_one::("params") + .map(|s| s.as_str()); + let params = collect_params_from_flags(matched_args, method, params_override)?; + let params_json_string = serde_json::to_string(¶ms) + .map_err(|e| CliError::Validation(format!("Failed to serialize params: {e}")))?; + let params_json: Option<&str> = if params.is_empty() { + None + } else { + Some(¶ms_json_string) + }; + let body_json = matched_args + .try_get_one::("json") + .ok() + .flatten() + .map(|s| s.as_str()); + + let dry_run = matched_args.get_flag("dry-run"); + + // Build pagination config + let pagination = build_pagination_config(matched_args); + + let auth_provider = self.build_auth_provider(); + + // --base-url flag wins; otherwise {NAME}_BASE_URL env var. + let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; + let base_url_override = base_url_override_owned.as_deref(); + + // Execute + executor::execute_method( + &doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &pipeline, + false, + base_url_override, + &http_config, + ) + .await + .map(|_| ()) + } + + /// Construct the [`DynAuthProvider`] used for this run from the + /// registered bindings. GraphQL has no spec-declared schemes; with no + /// bindings, returns a `NoAuthProvider`. + fn build_auth_provider(&self) -> DynAuthProvider { + crate::auth::build_provider_with_strategy( + &self.auth_bindings, + &std::collections::HashMap::new(), + self.auth_strategy, + false, + ) + } +} + +/// Runtime context passed to custom command handlers. +/// +/// Provides access to the loaded API spec and the constructed auth +/// provider. +pub struct AppContext { + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, +} + +impl AppContext { + /// Execute an API method by name, using the same executor as built-in + /// commands. + pub fn execute( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + output_format: &formatter::OutputFormat, + ) -> Result<(), CliError> { + let pagination = executor::PaginationConfig::default(); + let pipeline = formatter::OutputPipeline { + format: output_format.clone(), + color_mode: formatter::ColorMode::default(), + }; + + tokio::runtime::Handle::current() + .block_on(executor::execute_method( + &self.doc, + method, + params_json, + body_json, + &self.auth_provider, + false, + &pagination, + &pipeline, + false, + None, + &self.http_config, + )) + .map(|_| ()) + } + + /// Returns a reference to the loaded API spec. + pub fn spec(&self) -> &RestDescription { + &self.doc + } + + /// Returns a reference to the HTTP/TLS configuration for this CLI run. + /// + /// See [`crate::openapi::AppContext::http_config`] for the design + /// rationale and how non-reqwest transports consume this. + pub fn http_config(&self) -> &crate::http::HttpConfig { + &self.http_config + } +} + +/// Recursively walks clap ArgMatches to find the leaf method and its matches. +pub fn resolve_method_from_matches<'a>( + doc: &'a RestDescription, + matches: &'a clap::ArgMatches, +) -> Result<(&'a RestMethod, &'a clap::ArgMatches), CliError> { + let mut path: Vec<&str> = Vec::new(); + let mut current_matches = matches; + + while let Some((sub_name, sub_matches)) = current_matches.subcommand() { + path.push(sub_name); + current_matches = sub_matches; + } + + if path.is_empty() { + return Err(CliError::Validation( + "No resource or method specified".to_string(), + )); + } + + let resource_name = path[0]; + let resource = doc + .resources + .get(resource_name) + .ok_or_else(|| CliError::Validation(format!("Resource '{resource_name}' not found")))?; + + let mut current_resource = resource; + + for &name in &path[1..path.len() - 1] { + if let Some(sub) = current_resource.resources.get(name) { + current_resource = sub; + } else { + return Err(CliError::Validation(format!( + "Sub-resource '{name}' not found" + ))); + } + } + + let method_name = path[path.len() - 1]; + + if let Some(method) = current_resource.methods.get(method_name) { + return Ok((method, current_matches)); + } + + Err(CliError::Validation(format!( + "Method '{method_name}' not found on resource. Available methods: {:?}", + current_resource.methods.keys().collect::>() + ))) +} + +/// Collect individual flag values into a params map. +/// Values from --params JSON override individual flags. +fn collect_params_from_flags( + matched_args: &clap::ArgMatches, + method: &crate::graphql::discovery::GraphQLOperation, + params_override: Option<&str>, +) -> Result, CliError> { + let mut params = serde_json::Map::new(); + + // Collect values from individual flags + for param_name in method.parameters.keys() { + if let Some(value) = matched_args.get_one::(param_name) { + params.insert(param_name.clone(), serde_json::Value::String(value.clone())); + } + } + + // Override with --params JSON if provided (--params wins) + if let Some(json_str) = params_override { + let overrides: serde_json::Map = + serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))?; + for (key, value) in overrides { + params.insert(key, value); + } + } + + Ok(params) +} + +fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { + executor::PaginationConfig { + page_all: matches.get_flag("page-all"), + page_limit: matches + .get_one::("page-limit") + .copied() + .unwrap_or(10), + page_delay_ms: matches + .get_one::("page-delay") + .copied() + .unwrap_or(100), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_graphql_cli_app_builder() { + let app = CliApp::new("test").spec("{}"); + assert_eq!(app.name, "test"); + assert!(app.spec_json.is_some()); + } + + #[test] + fn test_graphql_auth_scheme_records_binding() { + let app = CliApp::new("t") + .spec("{}") + .auth_scheme("bearerAuth", AuthCredentialSource::from_env("T")); + assert_eq!(app.auth_bindings.len(), 1); + } + + #[test] + fn test_graphql_cli_app_endpoint() { + let app = CliApp::new("graphql-fixture") + .spec("{}") + .endpoint("https://example.com/graphql"); + assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); + } + + #[test] + fn test_graphql_cli_app_custom_command_top_level() { + fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { + Ok(()) + } + let app = CliApp::new("test") + .spec("{}") + .command(clap::Command::new("custom"), handler); + assert_eq!(app.custom_commands.len(), 1); + assert!(app.custom_commands.entries()[0].0.is_empty()); + assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); + } + + #[test] + fn test_graphql_cli_app_command_under_records_path() { + fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { + Ok(()) + } + let app = CliApp::new("test") + .spec("{}") + .command_under(&["webhooks"], clap::Command::new("verify"), handler); + assert_eq!(app.custom_commands.len(), 1); + assert_eq!( + app.custom_commands.entries()[0].0, + vec!["webhooks".to_string()] + ); + assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); + } +} diff --git a/cli/src/graphql/commands.rs b/cli/src/graphql/commands.rs new file mode 100644 index 00000000..308ceca6 --- /dev/null +++ b/cli/src/graphql/commands.rs @@ -0,0 +1,385 @@ +//! CLI Command Builder +//! +//! Builds a dynamic `clap::Command` tree from the internal API representation. + +use clap::builder::PossibleValuesParser; +use clap::{Arg, Command}; + +use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLResource as RestResource}; +use crate::text::to_kebab_flag; + +/// Names of built-in flags that must not be duplicated by parameter-derived flags. +const BUILTIN_FLAG_NAMES: &[&str] = &[ + "params", + "json", + "format", + "dry-run", + "base-url", + "page-all", + "page-limit", + "page-delay", + "help", +]; + +/// Builds the full CLI command tree from an API description. +pub fn build_cli(doc: &RestDescription) -> Command { + let about_text = doc + .title + .clone() + .unwrap_or_else(|| format!("{} CLI", doc.name)); + let mut root = Command::new(doc.name.clone()) + .about(about_text) + .term_width(200) + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + clap::Arg::new("dry-run") + .long("dry-run") + .help("Validate the request locally without sending it to the API") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json (default), table, yaml, csv") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ); + + // Add resource subcommands + let mut resource_names: Vec<_> = doc.resources.keys().collect(); + resource_names.sort(); + for name in resource_names { + let resource = &doc.resources[name]; + if let Some(cmd) = build_resource_command(name, resource) { + root = root.subcommand(cmd); + } + } + + root +} + +/// Recursively builds a Command for a resource. +/// Returns None if the resource has no methods or sub-resources. +fn build_resource_command(name: &str, resource: &RestResource) -> Option { + let mut cmd = Command::new(name.to_string()) + .about(format!("Operations on '{name}'")) + .subcommand_required(true) + .arg_required_else_help(true); + + let mut has_children = false; + + // Add method subcommands + let mut method_names: Vec<_> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + + has_children = true; + + let about = crate::text::truncate_description( + method.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + + let mut method_cmd = Command::new(method_name.to_string()) + .about(about) + .arg( + Arg::new("params") + .long("params") + .help("Additional parameters as JSON (overrides individual flags)") + .value_name("JSON"), + ) + .arg( + Arg::new("json") + .long("json") + .help("JSON string for the request body") + .value_name("JSON"), + ); + + // Pagination flags + method_cmd = method_cmd + .arg( + Arg::new("page-all") + .long("page-all") + .help("Auto-paginate through all results (NDJSON)") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("page-limit") + .long("page-limit") + .help("Maximum number of pages to fetch (default: 10)") + .value_name("N") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("page-delay") + .long("page-delay") + .help("Delay in milliseconds between page fetches (default: 100)") + .value_name("MS") + .value_parser(clap::value_parser!(u64)), + ); + + // Generate individual flags from method parameters + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for param_name in param_names { + let kebab_name = to_kebab_flag(param_name); + if BUILTIN_FLAG_NAMES.contains(&kebab_name.as_str()) { + continue; + } + + let param = &method.parameters[param_name]; + + let value_name = match param.param_type.as_deref() { + Some("string") => "STRING", + Some("integer") => "NUMBER", + Some("number") => "NUMBER", + Some("boolean") => "BOOLEAN", + _ => "VALUE", + }; + + let help_text = crate::text::truncate_description( + param.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + + let mut arg = Arg::new(param_name.clone()) + .long(kebab_name) + .value_name(value_name) + .help(help_text); + + // Don't promote introspection defaults to clap defaults for flattened + // GraphQL input fields. Per the GraphQL spec, `defaultValue` on an input + // field describes the *server's* fallback when the client omits the field + // — it is not a value the client should always send. Materializing it as a + // clap default makes the flag look user-supplied, which forces the parent + // input object to materialize as a variable even when the user passed + // nothing for it, producing arguments the server may reject. + let is_graphql_input_field = param.graphql_input_arg.is_some(); + if let Some(ref default) = param.default { + if !is_graphql_input_field { + arg = arg.default_value(default.clone()); + } + } + + if let Some(ref enum_values) = param.enum_values { + arg = arg.value_parser(PossibleValuesParser::new(enum_values.clone())); + } + + method_cmd = method_cmd.arg(arg); + } + + cmd = cmd.subcommand(method_cmd); + } + + // Add sub-resource subcommands (recursive) + let mut sub_names: Vec<_> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub_resource = &resource.resources[sub_name]; + if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource) { + has_children = true; + cmd = cmd.subcommand(sub_cmd); + } + } + + if has_children { + Some(cmd) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graphql::discovery::{MethodParameter, GraphQLOperation as RestMethod, GraphQLResource as RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert("list".to_string(), RestMethod::default()); + methods.insert("delete".to_string(), RestMethod::default()); + + let mut resources = HashMap::new(); + resources.insert( + "files".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_all_commands_always_shown() { + let doc = make_doc(); + let cmd = build_cli(&doc); + + let files_cmd = cmd + .find_subcommand("files") + .expect("files resource missing"); + + assert!(files_cmd.find_subcommand("list").is_some()); + assert!(files_cmd.find_subcommand("delete").is_some()); + } + + #[test] + fn test_root_uses_doc_name() { + let doc = make_doc(); + let cmd = build_cli(&doc); + assert_eq!(cmd.get_name(), "test-cli"); + } + + #[test] + fn test_method_params_become_flags() { + let mut params = HashMap::new(); + params.insert( + "uuid".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user UUID".to_string()), + required: true, + ..Default::default() + }, + ); + params.insert( + "status".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by status".to_string()), + enum_values: Some(vec!["active".to_string(), "inactive".to_string()]), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get-user".to_string(), + RestMethod { + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let users_cmd = cmd.find_subcommand("users").expect("users resource missing"); + let get_user_cmd = users_cmd + .find_subcommand("get-user") + .expect("get-user method missing"); + + // Verify individual flags exist + let args: Vec = get_user_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!(args.contains(&"uuid".to_string()), "uuid flag missing"); + assert!(args.contains(&"status".to_string()), "status flag missing"); + assert!(args.contains(&"params".to_string()), "params flag missing"); + } + + #[test] + fn test_builtin_flag_names_not_duplicated() { + let mut params = HashMap::new(); + params.insert( + "format".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Response format".to_string()), + ..Default::default() + }, + ); + params.insert( + "real_param".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("A real param".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "test-method".to_string(), + RestMethod { + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + // This should not panic from duplicate arg names + let cmd = build_cli(&doc); + let things_cmd = cmd + .find_subcommand("things") + .expect("things resource missing"); + let test_cmd = things_cmd + .find_subcommand("test-method") + .expect("test-method missing"); + + let args: Vec = test_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // "format" should NOT appear as a duplicated param flag, + // but "real_param" should be present. + assert!( + args.contains(&"real_param".to_string()), + "real_param flag missing" + ); + + // Count occurrences of "format" — should be at most 1 (from the global flag) + let format_count = args.iter().filter(|a| *a == "format").count(); + assert!( + format_count <= 1, + "format flag duplicated: found {format_count}" + ); + } +} diff --git a/cli/src/graphql/discovery.rs b/cli/src/graphql/discovery.rs new file mode 100644 index 00000000..0f7c72a0 --- /dev/null +++ b/cli/src/graphql/discovery.rs @@ -0,0 +1,145 @@ +//! Internal GraphQL Representation +//! +//! Data structures the parser produces from a GraphQL introspection JSON +//! and the command builder + executor consume. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// Top-level GraphQL schema description. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GraphQLSchema { + pub name: String, + pub version: String, + pub title: Option, + pub description: Option, + /// Endpoint URL the executor POSTs queries to. + pub root_url: String, + #[serde(default)] + pub resources: HashMap, +} + +/// A resource which can contain operations and nested sub-resources. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLResource { + #[serde(default)] + pub methods: HashMap, + #[serde(default)] + pub resources: HashMap, +} + +/// A single GraphQL operation (query or mutation). +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GraphQLOperation { + pub id: Option, + pub description: Option, + #[serde(default)] + pub parameters: HashMap, + /// GraphQL operation metadata: query/mutation kind, field name, args, return shape. + pub graphql: Option, + /// Per-method base URL (populated from the spec's server URL during parsing). + /// When non-empty, takes priority over doc.root_url in URL construction. + #[serde(default)] + pub root_url: String, +} + +/// Metadata for a GraphQL operation. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLMethodInfo { + /// "query" or "mutation". + pub operation_type: String, + /// The original field name in the schema (e.g., "issueCreate"). + pub field_name: String, + /// Default selection set as a GraphQL fragment string (e.g., "{ id title createdAt }"). + pub default_selection: String, + /// Ordered list of top-level arguments, used to build `$var: Type` declarations. + #[serde(default)] + pub args: Vec, +} + +/// One argument of a GraphQL operation. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLArgDef { + /// camelCase argument name as it appears in the schema (e.g., "id", "input"). + pub name: String, + /// kebab-case CLI flag key used to look this argument up in the params map. + pub flag_key: String, + /// Full GraphQL type string including nullability (e.g., "String!", "IssueCreateInput"). + pub gql_type: String, + /// True when this arg takes an input object whose fields were flattened into CLI flags. + pub is_input: bool, + /// True when the argument's GraphQL type is a list (e.g., `[IssueSortInput!]`). + /// Used at variable-build time to wrap the reconstructed input object in a JSON array. + #[serde(default)] + pub is_list: bool, +} + +/// A CLI parameter derived from a GraphQL argument or flattened input field. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MethodParameter { + /// JSON-Schema-flavored type used for value coercion (string/integer/number/boolean). + #[serde(rename = "type")] + pub param_type: Option, + pub description: Option, + #[serde(default)] + pub required: bool, + pub default: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + /// For flattened input fields: the camelCase name of the top-level argument. + /// E.g., a field flattened from `input: IssueCreateInput` has + /// `graphql_input_arg = Some("input")`. + #[serde(default)] + pub graphql_input_arg: Option, + /// Dotted camelCase path within the input argument for nested input fields. + /// E.g., a field at `input.dateRange.start` has + /// `graphql_field_path = Some("dateRange.start")`. When absent, the path is + /// derived from the flag key (top-level flattened field). + #[serde(default)] + pub graphql_field_path: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_graphql_schema() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/graphql", + "resources": { + "issue": { + "methods": { + "get": {} + } + } + } + }"#; + + let doc: GraphQLSchema = serde_json::from_str(json).unwrap(); + assert_eq!(doc.name, "test"); + assert_eq!(doc.root_url, "https://api.example.com/graphql"); + + let issue = doc.resources.get("issue").expect("issue resource missing"); + assert!(issue.methods.contains_key("get")); + } + + #[test] + fn test_deserialize_defaults() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/graphql" + }"#; + + let doc: GraphQLSchema = serde_json::from_str(json).unwrap(); + assert!(doc.resources.is_empty()); + } + +} diff --git a/cli/src/graphql/executor.rs b/cli/src/graphql/executor.rs new file mode 100644 index 00000000..6a13ffb8 --- /dev/null +++ b/cli/src/graphql/executor.rs @@ -0,0 +1,909 @@ +//! GraphQL Request Execution +//! +//! Builds and dispatches POST requests carrying GraphQL operations. +//! Handles auth, response unwrapping (`data` envelope and `errors`), +//! and cursor-based pagination via `pageInfo.endCursor`. + +use std::collections::HashMap; + +use anyhow::Context; +use serde_json::{json, Map, Value}; + +use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; +use crate::graphql::discovery::{ + GraphQLArgDef, GraphQLMethodInfo, GraphQLOperation, GraphQLSchema, MethodParameter, +}; + +/// Configuration for cursor-based auto-pagination. +#[derive(Debug, Clone)] +pub struct PaginationConfig { + /// Whether to auto-paginate through all pages. + pub page_all: bool, + /// Maximum number of pages to fetch (default: 10). + pub page_limit: u32, + /// Delay between page fetches in milliseconds (default: 100). + pub page_delay_ms: u64, +} + +impl Default for PaginationConfig { + fn default() -> Self { + Self { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + } + } +} + +/// Parsed inputs ready for request execution. +#[derive(Debug)] +struct ExecutionInput { + params: Map, + body: Value, + full_url: String, +} + +fn parse_and_validate_inputs( + doc: &GraphQLSchema, + method: &GraphQLOperation, + params_json: Option<&str>, + body_json: Option<&str>, + base_url_override: Option<&str>, +) -> Result { + let params: Map = if let Some(p) = params_json { + serde_json::from_str(p) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))? + } else { + Map::new() + }; + + let gql = method.graphql.as_ref().ok_or_else(|| { + CliError::Discovery("GraphQL method info missing from spec".to_string()) + })?; + + for (param_name, param_def) in &method.parameters { + if param_def.required + && !params.contains_key(param_name) + && param_def.graphql_input_arg.is_none() + { + return Err(CliError::Validation(format!( + "Required parameter '{param_name}' is missing" + ))); + } + } + + let body = build_graphql_body(gql, ¶ms, body_json, &method.parameters, None)?; + let full_url = base_url_override + .map(|u| u.trim_end_matches('/').to_string()) + .unwrap_or_else(|| doc.root_url.clone()); + + Ok(ExecutionInput { params, body, full_url }) +} + +/// Build a POST request with auth and a JSON GraphQL body. +fn build_http_request( + client: &reqwest::Client, + input: &ExecutionInput, + auth_provider: &DynAuthProvider, +) -> Result { + let request = client.post(&input.full_url); + // GraphQL has no per-operation security metadata in the introspection + // schema, so the metadata is always "unspecified" — the provider's own + // default policy decides what to attach. + let request = auth_provider.apply(request, &EndpointAuthMetadata::unspecified())?; + let request = request + .header("Content-Type", "application/json") + .json(&input.body); + Ok(request) +} + +/// Parse a GraphQL response body: surface `errors` and unwrap the `data` envelope. +/// +/// GraphQL allows partial results: a response may have both `data` and `errors` +/// (common in federation). When both are present, errors are printed to stderr +/// and the partial data is returned. Only when there is no `data` at all do we +/// treat the errors as fatal. +fn parse_graphql_response(body_text: &str) -> Result { + let json_val: Value = serde_json::from_str(body_text).map_err(|e| CliError::Api { + code: 400, + message: format!("Invalid GraphQL response: {e}"), + reason: "graphql_parse_error".to_string(), + })?; + + let has_data = json_val + .get("data") + .map(|d| !d.is_null()) + .unwrap_or(false); + + if let Some(errors) = json_val.get("errors").and_then(|e| e.as_array()) { + if !errors.is_empty() { + let message = errors + .iter() + .filter_map(|e| e.get("message").and_then(|m| m.as_str())) + .collect::>() + .join("; "); + if has_data { + eprintln!("GraphQL partial errors: {message}"); + } else { + return Err(CliError::Api { + code: 400, + message, + reason: "graphql_error".to_string(), + }); + } + } + } + + let unwrapped = if let Some(data) = json_val.get("data").filter(|d| !d.is_null()) { + if let Value::Object(map) = data { + if map.len() == 1 { + map.values().next().unwrap().clone() + } else { + data.clone() + } + } else { + data.clone() + } + } else { + json_val + }; + + serde_json::to_string(&unwrapped).map_err(|e| CliError::Api { + code: 500, + message: format!("Failed to serialize GraphQL response: {e}"), + reason: "graphql_serialize_error".to_string(), + }) +} + +/// Print or capture a JSON response and bump the page counter. +async fn handle_json_response( + body_text: &str, + pipeline: &crate::formatter::OutputPipeline, + pages_fetched: &mut u32, + page_all: bool, + capture_output: bool, + captured: &mut Vec, +) -> Result<(), CliError> { + if let Ok(json_val) = serde_json::from_str::(body_text) { + *pages_fetched += 1; + + if capture_output { + captured.push(json_val); + } else if page_all { + let is_first_page = *pages_fetched == 1; + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &json_val, true, is_first_page) + .context("Failed to write output")?; + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &json_val, false, true) + .context("Failed to write output")?; + } + } else if !capture_output && !body_text.is_empty() { + println!("{body_text}"); + } + Ok(()) +} + + +/// Executes a GraphQL operation. +/// +/// Posts the rendered query to the schema's endpoint, unwraps the `data` envelope, +/// and continues paginating via `pageInfo.endCursor` until the page limit is hit. +#[allow(clippy::too_many_arguments)] +pub async fn execute_method( + doc: &GraphQLSchema, + method: &GraphQLOperation, + params_json: Option<&str>, + body_json: Option<&str>, + auth_provider: &DynAuthProvider, + dry_run: bool, + pagination: &PaginationConfig, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, + base_url_override: Option<&str>, + http_config: &crate::http::HttpConfig, +) -> Result, CliError> { + let mut input = + parse_and_validate_inputs(doc, method, params_json, body_json, base_url_override)?; + + if dry_run { + let dry_run_info = json!({ + "dry_run": true, + "url": input.full_url, + "method": "POST", + "body": input.body, + }); + if capture_output { + return Ok(Some(dry_run_info)); + } + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &dry_run_info, false, true) + .context("Failed to write output")?; + return Ok(None); + } + + let mut pages_fetched: u32 = 0; + let mut captured_values = Vec::new(); + + // Build the client once outside the pagination loop. Client construction + // reads env vars and (with TLS) builds a connection pool; rebuilding per + // page would defeat connection reuse and emit any one-time warnings + // (e.g. insecure-mode) once per page. + let client = http_config.build_client()?; + + loop { + let request = build_http_request(&client, &input, auth_provider)?; + + let method_id = method.id.as_deref().unwrap_or("unknown"); + let start = std::time::Instant::now(); + let response = match request.send().await { + Ok(resp) => resp, + Err(e) => { + // Surface a human-readable hint to stderr if this looks like + // a TLS failure — the most common debugging hump for users + // behind corporate proxies / interception tools. The hint is + // a side effect; the error then propagates up like any other. + crate::http::maybe_emit_tls_hint(http_config, &e); + return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + } + }; + let latency_ms = start.elapsed().as_millis() as u64; + + let status = response.status(); + + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + tracing::warn!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + latency_ms = latency_ms, + "API error" + ); + return handle_error_response( + status, + &error_body, + auth_provider.as_ref(), + &EndpointAuthMetadata::unspecified(), + ); + } + + tracing::debug!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + latency_ms = latency_ms, + page = pages_fetched, + "API request" + ); + + let body_text = response + .text() + .await + .context("Failed to read response body")?; + let response_body = parse_graphql_response(&body_text)?; + + handle_json_response( + &response_body, + pipeline, + &mut pages_fetched, + pagination.page_all, + capture_output, + &mut captured_values, + ) + .await?; + + // GraphQL cursor-based pagination: rebuild the body with the next + // cursor and POST again until we run out of pages or hit the limit. + if pagination.page_all { + if let Some(cursor) = extract_graphql_cursor(&response_body) { + if pages_fetched < pagination.page_limit { + if let Some(ref gql_info) = method.graphql { + let params_clone = input.params.clone(); + input.body = build_graphql_body( + gql_info, + ¶ms_clone, + body_json, + &method.parameters, + Some(&cursor), + )?; + } + if pagination.page_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + pagination.page_delay_ms, + )) + .await; + } + continue; + } + } + } + + break; + } + + if capture_output && !captured_values.is_empty() { + if captured_values.len() == 1 { + return Ok(Some(captured_values.pop().unwrap())); + } else { + return Ok(Some(Value::Array(captured_values))); + } + } + + Ok(None) +} + +/// Build a GraphQL request body using the variables mechanism. +/// +/// User-supplied values are placed in the `variables` JSON object and referenced +/// via `$name: Type` declarations in the query — they never appear in the query +/// string itself, preventing GraphQL injection. +/// +/// `cursor` injects an `after` variable for cursor-based pagination when +/// `page-all` is in effect; it is only applied when the method declares an +/// `after` argument. +fn build_graphql_body( + gql: &GraphQLMethodInfo, + params: &Map, + body_json: Option<&str>, + method_params: &HashMap, + cursor: Option<&str>, +) -> Result { + let mut variables: Map = Map::new(); + + // Parse --json once; it targets the first input arg only. + let body_obj: Option> = if let Some(json_str) = body_json { + let json_val: Value = serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --json body: {e}")))?; + match json_val { + Value::Object(obj) => Some(obj), + _ => None, + } + } else { + None + }; + let mut json_applied = false; + + for arg_def in &gql.args { + if arg_def.is_input { + // Reconstruct the input object from flattened CLI flags. Each flag + // tagged with this arg_name carries a graphql_field_path (dotted + // camelCase path within the input) for nested field placement. + let mut input_obj: Map = Map::new(); + for (flag_key, value) in params { + if let Some(mp) = method_params.get(flag_key) { + if mp.graphql_input_arg.as_deref() == Some(arg_def.name.as_str()) { + let coerced = coerce_graphql_value(value, Some(mp)); + if let Some(path) = mp.graphql_field_path.as_deref() { + set_nested_value(&mut input_obj, path, coerced); + } else { + let camel = kebab_to_camel(flag_key); + set_nested_value(&mut input_obj, &camel, coerced); + } + } + } + } + // --json targets the first input arg only (deep-merges at the top level). + if !json_applied { + if let Some(ref obj) = body_obj { + for (k, v) in obj { + input_obj.insert(k.clone(), v.clone()); + } + json_applied = true; + } + } + if !input_obj.is_empty() { + // For list-typed input arguments (e.g. `arg: [SomeInput!]`), the + // variable must be serialized as a JSON array. The GraphQL spec + // defines input coercion that lifts a single value into a singleton + // list, but coercion of typed *variables* is not uniformly enforced + // across server implementations — emitting an explicit array is the + // portable, spec-conformant shape. We currently flatten one element's + // worth of fields, so wrap the reconstructed object accordingly. + let value = if arg_def.is_list { + Value::Array(vec![Value::Object(input_obj)]) + } else { + Value::Object(input_obj) + }; + variables.insert(arg_def.name.clone(), value); + } + } else { + // Direct scalar/enum arg: look it up by its CLI flag key. + if let Some(value) = params.get(&arg_def.flag_key) { + let coerced = coerce_graphql_value(value, method_params.get(&arg_def.flag_key)); + variables.insert(arg_def.name.clone(), coerced); + } + } + } + + // Inject pagination cursor when the method declares an `after` argument. + if let Some(cursor_val) = cursor { + if gql.args.iter().any(|a| a.name == "after") { + variables.insert("after".to_string(), Value::String(cursor_val.to_string())); + } + } + + let op_type = &gql.operation_type; + let field_name = &gql.field_name; + let selection = &gql.default_selection; + + let query = if variables.is_empty() { + format!("{op_type} {{ {field_name} {selection} }}") + } else { + let present_args: Vec<&GraphQLArgDef> = gql + .args + .iter() + .filter(|a| variables.contains_key(&a.name)) + .collect(); + let decls = present_args + .iter() + .map(|a| format!("${}: {}", a.name, a.gql_type)) + .collect::>() + .join(", "); + let refs = present_args + .iter() + .map(|a| format!("{}: ${}", a.name, a.name)) + .collect::>() + .join(", "); + format!("{op_type}({decls}) {{ {field_name}({refs}) {selection} }}") + }; + + Ok(json!({ + "query": query, + "variables": variables, + })) +} + +/// Set a value at a dotted camelCase path within a JSON object, creating +/// intermediate objects as needed. E.g., path `"dateRange.start"` sets +/// `obj["dateRange"]["start"] = value`. +fn set_nested_value(obj: &mut Map, path: &str, value: Value) { + match path.split_once('.') { + None => { + obj.insert(path.to_string(), value); + } + Some((head, tail)) => { + let nested = obj + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(nested_map) = nested { + set_nested_value(nested_map, tail, value); + } + } + } +} + +/// Extract `endCursor` from an unwrapped GraphQL response when `hasNextPage` is true. +fn extract_graphql_cursor(response_body: &str) -> Option { + let val: Value = serde_json::from_str(response_body).ok()?; + let page_info = val.get("pageInfo")?; + let has_next = page_info.get("hasNextPage")?.as_bool()?; + if !has_next { + return None; + } + page_info + .get("endCursor")? + .as_str() + .map(|s| s.to_string()) +} + +/// Coerce a JSON value to the correct type based on the parameter definition. +/// CLI flags always come in as strings; this converts "3" → 3 for integers, etc. +fn coerce_graphql_value(value: &Value, param_def: Option<&MethodParameter>) -> Value { + if let Value::String(s) = value { + if let Some(def) = param_def { + match def.param_type.as_deref() { + Some("integer") => { + if let Ok(n) = s.parse::() { + return Value::Number(n.into()); + } + } + Some("number") => { + if let Ok(n) = s.parse::() { + if let Some(num) = serde_json::Number::from_f64(n) { + return Value::Number(num); + } + } + } + Some("boolean") => match s.as_str() { + "true" => return Value::Bool(true), + "false" => return Value::Bool(false), + _ => {} + }, + _ => {} + } + } + } + value.clone() +} + +/// Convert kebab-case to camelCase. +fn kebab_to_camel(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut capitalize_next = false; + for ch in s.chars() { + if ch == '-' { + capitalize_next = true; + } else if capitalize_next { + result.push(ch.to_uppercase().next().unwrap()); + capitalize_next = false; + } else { + result.push(ch); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_pagination_config_default() { + let config = PaginationConfig::default(); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + } + + // ----------------------------------------------------------------------- + // handle_json_response + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_handle_json_response_capture_output() { + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut captured = Vec::new(); + + handle_json_response( + r#"{"items":["a"]}"#, + &pipeline, + &mut pages_fetched, + false, + true, + &mut captured, + ) + .await + .unwrap(); + + assert_eq!(captured.len(), 1); + assert_eq!(pages_fetched, 1); + } + + #[tokio::test] + async fn test_handle_json_response_non_json_body() { + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut captured = Vec::new(); + + handle_json_response( + "not json at all", + &pipeline, + &mut pages_fetched, + false, + false, + &mut captured, + ) + .await + .unwrap(); + + assert_eq!(pages_fetched, 0); + } + + // ----------------------------------------------------------------------- + // build_http_request + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_build_http_request_posts_json_body() { + let client = reqwest::Client::new(); + let input = ExecutionInput { + full_url: "https://example.com/graphql".to_string(), + body: json!({"query": "{ ping }", "variables": {}}), + params: Map::new(), + }; + + let request = build_http_request(&client, &input, &crate::auth::no_auth_provider()).unwrap(); + let built = request.build().unwrap(); + + assert_eq!(built.method(), "POST"); + assert_eq!( + built + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + ); + } + + // ----------------------------------------------------------------------- + // execute_method + // ----------------------------------------------------------------------- + + fn minimal_ping_doc_and_method() -> (GraphQLSchema, GraphQLOperation) { + let doc = GraphQLSchema { + name: "test".to_string(), + version: "v1".to_string(), + root_url: "https://example.com/graphql".to_string(), + ..Default::default() + }; + let method = GraphQLOperation { + id: Some("ping".to_string()), + graphql: Some(crate::graphql::discovery::GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "ping".to_string(), + default_selection: String::new(), + args: Vec::new(), + }), + ..Default::default() + }; + (doc, method) + } + + #[tokio::test] + async fn test_execute_method_dry_run_with_http_config() { + // dry_run skips network I/O entirely, but still exercises the new + // http_config parameter path — proving that the caller's + // HttpConfig is plumbed all the way to execute_method. + let (doc, method) = minimal_ping_doc_and_method(); + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let http_config = crate::http::HttpConfig::new("test").unwrap(); + + let result = execute_method( + &doc, + &method, + None, + None, + &crate::auth::no_auth_provider(), + true, // dry_run + &pagination, + &pipeline, + true, // capture_output + None, + &http_config, + ) + .await + .expect("dry-run should succeed"); + + let value = result.expect("dry-run with capture_output should return Some"); + assert_eq!(value["dry_run"], json!(true)); + assert_eq!(value["url"], json!("https://example.com/graphql")); + assert_eq!(value["method"], json!("POST")); + } + + // ----------------------------------------------------------------------- + // parse_graphql_response + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_graphql_response_errors_only_is_fatal() { + let body = json!({ + "errors": [{"message": "Not found"}] + }) + .to_string(); + let result = parse_graphql_response(&body); + assert!(result.is_err(), "errors-only should be fatal"); + } + + #[test] + fn test_parse_graphql_response_errors_and_data_returns_data() { + let body = json!({ + "data": {"node": {"id": "n1", "name": "test"}}, + "errors": [{"message": "partial failure"}] + }) + .to_string(); + let result = parse_graphql_response(&body).expect("errors+data should succeed"); + let val: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(val["id"], "n1", "partial data should be returned"); + } + + #[test] + fn test_parse_graphql_response_null_data_with_errors_is_fatal() { + let body = json!({ + "data": null, + "errors": [{"message": "fatal"}] + }) + .to_string(); + let result = parse_graphql_response(&body); + assert!(result.is_err(), "null data + errors should be fatal"); + } + + #[test] + fn test_parse_graphql_response_unwraps_single_field() { + let body = json!({ + "data": {"issues": {"nodes": [{"id": "i1"}]}} + }) + .to_string(); + let result = parse_graphql_response(&body).unwrap(); + let val: Value = serde_json::from_str(&result).unwrap(); + assert!(val.get("nodes").is_some(), "should unwrap single-field data envelope"); + } + + // ----------------------------------------------------------------------- + // extract_graphql_cursor + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_graphql_cursor_returns_cursor_when_has_next() { + let body = json!({ + "nodes": [], + "pageInfo": {"hasNextPage": true, "endCursor": "cursor-abc"} + }) + .to_string(); + let cursor = extract_graphql_cursor(&body); + assert_eq!(cursor, Some("cursor-abc".to_string())); + } + + #[test] + fn test_extract_graphql_cursor_returns_none_when_no_next() { + let body = json!({ + "nodes": [], + "pageInfo": {"hasNextPage": false, "endCursor": "cursor-abc"} + }) + .to_string(); + assert_eq!(extract_graphql_cursor(&body), None); + } + + #[test] + fn test_extract_graphql_cursor_returns_none_when_no_page_info() { + let body = json!({"nodes": []}).to_string(); + assert_eq!(extract_graphql_cursor(&body), None); + } + + // ----------------------------------------------------------------------- + // set_nested_value + // ----------------------------------------------------------------------- + + #[test] + fn test_set_nested_value_flat() { + let mut obj = Map::new(); + set_nested_value(&mut obj, "name", Value::String("alice".to_string())); + assert_eq!(obj["name"], "alice"); + } + + #[test] + fn test_set_nested_value_two_levels() { + let mut obj = Map::new(); + set_nested_value( + &mut obj, + "dateRange.start", + Value::String("2024-01-01".to_string()), + ); + set_nested_value( + &mut obj, + "dateRange.end", + Value::String("2024-12-31".to_string()), + ); + let date_range = obj["dateRange"].as_object().unwrap(); + assert_eq!(date_range["start"], "2024-01-01"); + assert_eq!(date_range["end"], "2024-12-31"); + } + + #[test] + fn test_set_nested_value_three_levels() { + let mut obj = Map::new(); + set_nested_value(&mut obj, "a.b.c", Value::String("deep".to_string())); + assert_eq!(obj["a"]["b"]["c"], "deep"); + } + + // ----------------------------------------------------------------------- + // build_graphql_body + // ----------------------------------------------------------------------- + + #[test] + fn test_build_graphql_body_injects_cursor_when_after_arg_present() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "nodes".to_string(), + default_selection: "{ nodes { id } pageInfo { hasNextPage endCursor } }".to_string(), + args: vec![ + GraphQLArgDef { + name: "first".to_string(), + flag_key: "first".to_string(), + gql_type: "Int".to_string(), + is_input: false, + is_list: false, + }, + GraphQLArgDef { + name: "after".to_string(), + flag_key: "after".to_string(), + gql_type: "String".to_string(), + is_input: false, + is_list: false, + }, + ], + }; + let params = Map::new(); + let method_params: HashMap = HashMap::new(); + + let body = + build_graphql_body(&gql, ¶ms, None, &method_params, Some("cursor-xyz")).unwrap(); + let vars = body["variables"].as_object().unwrap(); + assert_eq!(vars.get("after").and_then(|v| v.as_str()), Some("cursor-xyz")); + assert!(body["query"].as_str().unwrap().contains("$after: String")); + } + + #[test] + fn test_build_graphql_body_no_cursor_when_no_after_arg() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "node".to_string(), + default_selection: "{ id name }".to_string(), + args: vec![GraphQLArgDef { + name: "id".to_string(), + flag_key: "id".to_string(), + gql_type: "String!".to_string(), + is_input: false, + is_list: false, + }], + }; + let mut params = Map::new(); + params.insert("id".to_string(), Value::String("n1".to_string())); + let method_params: HashMap = HashMap::new(); + + let body = + build_graphql_body(&gql, ¶ms, None, &method_params, Some("cursor-xyz")).unwrap(); + let vars = body["variables"].as_object().unwrap(); + assert!(vars.get("after").is_none(), "no after arg = cursor not injected"); + } + + #[test] + fn test_build_graphql_body_reconstructs_nested_input() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "filteredNodes".to_string(), + default_selection: "{ nodes { id } }".to_string(), + args: vec![GraphQLArgDef { + name: "filter".to_string(), + flag_key: "filter".to_string(), + gql_type: "NodeFilter".to_string(), + is_input: true, + is_list: false, + }], + }; + + let mut params = Map::new(); + params.insert( + "date-range-start".to_string(), + Value::String("2024-01-01".to_string()), + ); + params.insert( + "date-range-end".to_string(), + Value::String("2024-12-31".to_string()), + ); + + let mut method_params: HashMap = HashMap::new(); + method_params.insert( + "date-range-start".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("dateRange.start".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "date-range-end".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("dateRange.end".to_string()), + ..Default::default() + }, + ); + + let body = build_graphql_body(&gql, ¶ms, None, &method_params, None).unwrap(); + let filter = body["variables"]["filter"].as_object().unwrap(); + let date_range = filter["dateRange"].as_object().unwrap(); + assert_eq!(date_range["start"], "2024-01-01"); + assert_eq!(date_range["end"], "2024-12-31"); + } +} diff --git a/cli/src/graphql/help.rs b/cli/src/graphql/help.rs new file mode 100644 index 00000000..944ab887 --- /dev/null +++ b/cli/src/graphql/help.rs @@ -0,0 +1,361 @@ +//! JSON help output — renders `--help --format json` as a machine-readable +//! schema. When an agent passes both `--help` (or `-h`) and `--format json`, +//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. + +use serde_json::{json, Map, Value}; + +use crate::error::CliError; +use crate::graphql::discovery::{GraphQLOperation, GraphQLResource, GraphQLSchema}; + +/// Renders JSON help for the given subcommand path and prints it to stdout. +pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliError> { + let output = match path.len() { + 0 => list_all_operations(doc), + 1 => list_resource_operations(doc, &path[0])?, + _ => { + // Try treating last element as a method name first. + // If that fails, the full path may resolve to a nested sub-resource — list its ops. + let resource_path: Vec<&str> = path[..path.len() - 1].iter().map(|s| s.as_str()).collect(); + let method_name = path[path.len() - 1].as_str(); + match operation_schema(doc, &resource_path, method_name) { + Ok(schema) => schema, + Err(_) => { + let full_path: Vec<&str> = path.iter().map(|s| s.as_str()).collect(); + list_nested_resource_operations(doc, &full_path)? + } + } + } + }; + + println!( + "{}", + serde_json::to_string_pretty(&output) + .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? + ); + Ok(()) +} + +fn list_all_operations(doc: &GraphQLSchema) -> Value { + let mut ops: Vec = Vec::new(); + let mut names: Vec<_> = doc.resources.keys().collect(); + names.sort(); + for name in names { + collect_resource_ops(&doc.resources[name], &[name], &mut ops); + } + json!(ops) +} + +fn list_resource_operations(doc: &GraphQLSchema, resource: &str) -> Result { + let res = doc + .resources + .get(resource) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {resource}")))?; + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, &[resource], &mut ops); + Ok(json!(ops)) +} + +fn list_nested_resource_operations(doc: &GraphQLSchema, path: &[&str]) -> Result { + let first = path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + for segment in &path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, path, &mut ops); + Ok(json!(ops)) +} + +fn operation_schema(doc: &GraphQLSchema, resource_path: &[&str], method_name: &str) -> Result { + let first = resource_path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + + for segment in &resource_path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + + let method = res.methods.get(method_name).ok_or_else(|| { + CliError::Validation(format!( + "Operation not found: {} {method_name}", + resource_path.join(" ") + )) + })?; + + Ok(build_schema(resource_path, method_name, method)) +} + +fn build_schema(resource_path: &[&str], method_name: &str, method: &GraphQLOperation) -> Value { + let mut properties: Map = Map::new(); + let mut required: Vec = Vec::new(); + + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for name in param_names { + let param = &method.parameters[name]; + let mut prop = json!({ + "type": param.param_type.as_deref().unwrap_or("string"), + "description": param.description.as_deref().unwrap_or(""), + }); + if let Some(enums) = ¶m.enum_values { + prop["enum"] = json!(enums); + } + if param.required { + required.push(name.clone()); + } + properties.insert(name.clone(), prop); + } + required.sort(); + + let (operation_type, field) = method + .graphql + .as_ref() + .map(|g| (g.operation_type.as_str(), g.field_name.as_str())) + .unwrap_or(("query", "")); + + json!({ + "operation": format!("{}.{}", resource_path.join("."), method_name), + "operationType": operation_type, + "field": field, + "description": method.description.as_deref().unwrap_or(""), + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }) +} + +fn collect_resource_ops(res: &GraphQLResource, path: &[&str], ops: &mut Vec) { + let mut method_names: Vec<_> = res.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let m = &res.methods[method_name]; + let (operation_type, field) = m + .graphql + .as_ref() + .map(|g| (g.operation_type.as_str(), g.field_name.as_str())) + .unwrap_or(("query", "")); + ops.push(json!({ + "operation": format!("{}.{}", path.join("."), method_name), + "operationType": operation_type, + "field": field, + "description": m.description.as_deref().unwrap_or(""), + })); + } + let mut sub_names: Vec<_> = res.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let mut sub_path = path.to_vec(); + sub_path.push(sub_name); + collect_resource_ops(&res.resources[sub_name], &sub_path, ops); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graphql::discovery::{MethodParameter, GraphQLOperation, GraphQLResource}; + use std::collections::HashMap; + + fn make_doc() -> GraphQLSchema { + use crate::graphql::discovery::GraphQLMethodInfo; + + let mut params = HashMap::new(); + params.insert( + "user_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user ID".to_string()), + required: true, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + GraphQLOperation { + description: Some("Get a user".to_string()), + parameters: params, + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "user".to_string(), + default_selection: "{ id name }".to_string(), + args: Vec::new(), + }), + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + GraphQLResource { + methods, + resources: HashMap::new(), + }, + ); + GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_render_root_lists_all() { + let doc = make_doc(); + let output = list_all_operations(&doc); + let arr = output.as_array().unwrap(); + assert!(!arr.is_empty()); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_resource() { + let doc = make_doc(); + let output = list_resource_operations(&doc, "users").unwrap(); + let arr = output.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_operation_schema() { + let doc = make_doc(); + let schema = operation_schema(&doc, &["users"], "get").unwrap(); + assert_eq!(schema["operationType"], "query"); + assert_eq!(schema["field"], "user"); + let required = schema["parameters"]["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "user_id")); + } + + #[test] + fn test_render_json_help_nested_sub_resource_listing() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation::default(), + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into()]; + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "sub-resource path should list operations, not error"); + } + + #[test] + fn test_render_nested_operation_schema() { + use crate::graphql::discovery::GraphQLMethodInfo; + + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation { + description: Some("Get a membership".to_string()), + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "membership".to_string(), + default_selection: "{ id }".to_string(), + args: Vec::new(), + }), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let schema = operation_schema(&doc, &["organizations", "memberships"], "get-membership").unwrap(); + assert_eq!(schema["operation"], "organizations.memberships.get-membership"); + assert_eq!(schema["operationType"], "query"); + assert_eq!(schema["field"], "membership"); + } + + #[test] + fn test_render_json_help_dispatches_nested_path() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation::default(), + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into(), "get-membership".into()]; + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "nested path should resolve correctly"); + } +} diff --git a/cli/src/graphql/mod.rs b/cli/src/graphql/mod.rs new file mode 100644 index 00000000..765c987a --- /dev/null +++ b/cli/src/graphql/mod.rs @@ -0,0 +1,9 @@ +mod app; +pub mod commands; +mod help; +pub mod executor; +mod parser; +pub mod discovery; + +pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +pub use self::parser::load_graphql_schema; diff --git a/cli/src/graphql/parser.rs b/cli/src/graphql/parser.rs new file mode 100644 index 00000000..2b956c4b --- /dev/null +++ b/cli/src/graphql/parser.rs @@ -0,0 +1,974 @@ +//! GraphQL Introspection JSON Parser +//! +//! Converts a GraphQL introspection JSON schema into the internal `GraphQLSchema` +//! used by the CLI command builder and executor. +//! +//! Input format: `{"data": {"__schema": {...}}}` (standard introspection response) +//! or `{"__schema": {...}}` (bare schema). +//! +//! Use `src/bin/strip_schema.rs` to remove descriptions and built-in meta-types +//! before checking in a schema file. + +use serde_json::Value; +use std::collections::HashMap; + +use crate::graphql::discovery::{ + GraphQLArgDef, GraphQLMethodInfo, MethodParameter, GraphQLSchema, GraphQLOperation, GraphQLResource, +}; +use crate::error::CliError; + +/// GraphQL built-in scalar type names. +const BUILTIN_SCALARS: &[&str] = &["String", "Int", "Float", "Boolean", "ID"]; + +/// Known suffixes for mutations, used to split into resource + method name. +const MUTATION_SUFFIXES: &[&str] = &[ + "Unarchive", + "Archive", + "Create", + "Update", + "Delete", + "Remove", + "Connect", + "Disconnect", + "Import", + "Rotate", + "Accept", + "Decline", + "Leave", + "Join", + "Resume", + "Pause", + "Suspend", + "Unsuspend", + "Mark", +]; + +/// Load a GraphQL introspection JSON schema and convert it into a `GraphQLSchema`. +/// +/// Accepts either the full introspection response (`{"data": {"__schema": ...}}`) +/// or the bare schema object (`{"__schema": ...}`). +pub fn load_graphql_schema( + introspection_json: &str, + cli_name: &str, + endpoint: &str, +) -> Result { + let data: Value = serde_json::from_str(introspection_json) + .map_err(|e| CliError::Discovery(format!("Failed to parse introspection JSON: {e}")))?; + + // Support both wrapped and bare introspection responses. + let schema = if data.get("data").is_some() { + &data["data"]["__schema"] + } else { + &data["__schema"] + }; + + let types = schema["types"] + .as_array() + .ok_or_else(|| CliError::Discovery("Missing 'types' array in schema".to_string()))?; + + let mut object_types: HashMap<&str, &Value> = HashMap::new(); + let mut input_types: HashMap<&str, &Value> = HashMap::new(); + let mut enum_types: HashMap<&str, &Value> = HashMap::new(); + let mut scalar_names: Vec = BUILTIN_SCALARS.iter().map(|s| s.to_string()).collect(); + + for ty in types { + let kind = ty["kind"].as_str().unwrap_or(""); + let name = match ty["name"].as_str() { + Some(n) if !n.starts_with("__") => n, + _ => continue, + }; + match kind { + "OBJECT" => { + object_types.insert(name, ty); + } + "INPUT_OBJECT" => { + input_types.insert(name, ty); + } + "ENUM" => { + enum_types.insert(name, ty); + } + "SCALAR" => { + scalar_names.push(name.to_string()); + } + _ => {} + } + } + + let query_type_name = schema["queryType"]["name"].as_str().unwrap_or("Query"); + let mutation_type_name = schema["mutationType"]["name"].as_str(); + + let mut resources: HashMap = HashMap::new(); + let empty_args: Vec = Vec::new(); + + // Process Query fields + if let Some(query_type) = object_types.get(query_type_name) { + let fields = query_type["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let field_name = match field["name"].as_str() { + Some(n) if !n.starts_with('_') => n, + _ => continue, + }; + let return_type_name = unwrap_type_name(&field["type"]); + let (resource_name, method_name) = split_query_name(field_name, &return_type_name); + let args = field["args"].as_array().unwrap_or(&empty_args); + let (parameters, arg_defs) = + build_parameters_from_args(args, &input_types, &enum_types, &scalar_names); + let default_selection = + build_default_selection(&return_type_name, &object_types, &scalar_names); + + let method = GraphQLOperation { + id: Some(format!("{resource_name}.{method_name}")), + description: desc(field), + parameters, + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: field_name.to_string(), + default_selection, + args: arg_defs, + }), + ..Default::default() + }; + resources.entry(resource_name).or_default().methods.insert(method_name, method); + } + } + + // Process Mutation fields + if let Some(mt_name) = mutation_type_name { + if let Some(mutation_type) = object_types.get(mt_name) { + let fields = mutation_type["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let field_name = match field["name"].as_str() { + Some(n) if !n.starts_with('_') => n, + _ => continue, + }; + let return_type_name = unwrap_type_name(&field["type"]); + let (resource_name, method_name) = split_mutation_name(field_name); + let args = field["args"].as_array().unwrap_or(&empty_args); + let (parameters, arg_defs) = + build_parameters_from_args(args, &input_types, &enum_types, &scalar_names); + let default_selection = + build_default_selection(&return_type_name, &object_types, &scalar_names); + + let method = GraphQLOperation { + id: Some(format!("{resource_name}.{method_name}")), + description: desc(field), + parameters, + graphql: Some(GraphQLMethodInfo { + operation_type: "mutation".to_string(), + field_name: field_name.to_string(), + default_selection, + args: arg_defs, + }), + ..Default::default() + }; + resources.entry(resource_name).or_default().methods.insert(method_name, method); + } + } + } + + Ok(GraphQLSchema { + name: cli_name.to_string(), + version: "1".to_string(), + root_url: endpoint.to_string(), + resources, + ..Default::default() + }) +} + +/// Extract an optional description string from a JSON node. +fn desc(val: &Value) -> Option { + val.get("description") + .and_then(|d| d.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn default_val(val: &Value) -> Option { + val.get("defaultValue") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn enum_values(enum_def: &Value) -> Vec { + enum_def["enumValues"] + .as_array() + .map(|ev| { + ev.iter() + .filter_map(|v| v["name"].as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Follow NON_NULL/LIST wrappers to find the named type. +fn unwrap_type_name(ty: &Value) -> String { + match ty["kind"].as_str().unwrap_or("") { + "NON_NULL" | "LIST" => unwrap_type_name(&ty["ofType"]), + _ => ty["name"].as_str().unwrap_or("String").to_string(), + } +} + +/// True when the outermost wrapper is NON_NULL. +fn is_non_null(ty: &Value) -> bool { + ty["kind"].as_str() == Some("NON_NULL") +} + +/// True when the type wraps (at any outer level) a LIST. We descend through +/// NON_NULL wrappers — `[T!]`, `[T!]!`, `[T]` all count as list types. +fn is_list_type(ty: &Value) -> bool { + match ty["kind"].as_str().unwrap_or("") { + "LIST" => true, + "NON_NULL" => is_list_type(&ty["ofType"]), + _ => false, + } +} + +/// Reconstruct the type string including nullability, e.g. `"String!"`, `"[ID!]!"`. +fn gql_type_string(ty: &Value) -> String { + match ty["kind"].as_str().unwrap_or("") { + "NON_NULL" => format!("{}!", gql_type_string(&ty["ofType"])), + "LIST" => format!("[{}]", gql_type_string(&ty["ofType"])), + _ => ty["name"].as_str().unwrap_or("String").to_string(), + } +} + +/// Check if a type name is a known scalar. +fn is_scalar(name: &str, scalar_names: &[String]) -> bool { + scalar_names.iter().any(|s| s == name) +} + +/// Split a query field name into (resource_name, method_name). +fn split_query_name(field_name: &str, return_type: &str) -> (String, String) { + let kebab = camel_to_kebab(field_name); + + // "For" pattern: attachmentsForURL → (attachment, list-for-url) + if let Some(pos) = field_name.find("For") { + if pos > 0 { + let prefix = &field_name[..pos]; + let suffix = &field_name[pos..]; + let resource = camel_to_kebab(&singular_camel(prefix)); + let method = format!("list-{}", camel_to_kebab(suffix).to_lowercase()); + return (resource, method); + } + } + + // Connection return type is authoritative — always a list + if return_type.ends_with("Connection") { + return (singular_kebab(&kebab), "list".to_string()); + } + + // Plural field name heuristic + if field_name.ends_with('s') + && !field_name.ends_with("ss") + && !field_name.ends_with("us") + && !field_name.ends_with("Status") + && field_name.len() > 2 + { + return (singular_kebab(&kebab), "list".to_string()); + } + + (kebab, "get".to_string()) +} + +/// Split a mutation field name into (resource_name, method_name). +fn split_mutation_name(field_name: &str) -> (String, String) { + for suffix in MUTATION_SUFFIXES { + if field_name.ends_with(suffix) && field_name.len() > suffix.len() { + let prefix = &field_name[..field_name.len() - suffix.len()]; + return (camel_to_kebab(prefix), suffix.to_lowercase()); + } + } + if let Some((resource, action)) = split_at_second_word(field_name) { + return (camel_to_kebab(&resource), camel_to_kebab(&action)); + } + (camel_to_kebab(field_name), "execute".to_string()) +} + +fn split_at_second_word(name: &str) -> Option<(String, String)> { + let chars: Vec = name.chars().collect(); + for i in 1..chars.len() { + if chars[i].is_uppercase() { + let prefix = &name[..i]; + let suffix = &name[i..]; + if prefix.len() > 2 { + return Some((prefix.to_string(), suffix.to_string())); + } + } + } + None +} + +fn camel_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + let chars: Vec = s.chars().collect(); + for (i, &ch) in chars.iter().enumerate() { + if ch.is_uppercase() { + if i > 0 + && (chars[i - 1].is_lowercase() + || (i + 1 < chars.len() && chars[i + 1].is_lowercase())) + { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + result +} + +fn singular_kebab(kebab: &str) -> String { + let (prefix, last) = match kebab.rfind('-') { + Some(pos) => (&kebab[..pos + 1], &kebab[pos + 1..]), + None => ("", kebab), + }; + format!("{prefix}{}", singular_word(last)) +} + +fn singular_word(word: &str) -> String { + if let Some(stem) = word.strip_suffix("ies") { + if stem.len() >= 2 { + return format!("{stem}y"); + } + } + if word.ends_with("xes") + || word.ends_with("ches") + || word.ends_with("shes") + || word.ends_with("sses") + || word.ends_with("zzes") + { + if let Some(stem) = word.strip_suffix("es") { + if stem.len() >= 2 { + return stem.to_string(); + } + } + } + if let Some(stem) = word.strip_suffix('s') { + // Block Latin/Greek singulars that end in -us and must not be stripped. + const LATIN_SINGULARS: &[&str] = &[ + "status", "bonus", "campus", "census", "focus", + "nexus", "radius", "virus", "alias", + ]; + if stem.len() >= 3 && !stem.ends_with('s') && !LATIN_SINGULARS.contains(&word) { + return stem.to_string(); + } + } + word.to_string() +} + +fn singular_camel(name: &str) -> String { + singular_kebab(&camel_to_kebab(name)) +} + +fn param_to_flag_name(name: &str) -> String { + camel_to_kebab(name) +} + +/// Map GraphQL scalar type names to CLI param type strings. +fn graphql_type_to_param_type(type_name: &str) -> String { + match type_name { + "Int" => "integer".to_string(), + "Float" => "number".to_string(), + "Boolean" => "boolean".to_string(), + _ => "string".to_string(), + } +} + +/// Build CLI parameters and `GraphQLArgDef` list from introspection field arguments. +fn build_parameters_from_args( + args: &[Value], + input_types: &HashMap<&str, &Value>, + enum_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> (HashMap, Vec) { + let mut params = HashMap::new(); + let mut arg_defs = Vec::new(); + + let is_known_complex = + |name: &str| input_types.contains_key(name) || enum_types.contains_key(name); + + for arg in args { + let arg_name = match arg["name"].as_str() { + Some(n) => n, + None => continue, + }; + let type_name = unwrap_type_name(&arg["type"]); + let is_required = is_non_null(&arg["type"]); + let flag_key = param_to_flag_name(arg_name); + + if is_scalar(&type_name, scalar_names) || !is_known_complex(&type_name) { + params.insert( + flag_key.clone(), + MethodParameter { + param_type: Some(graphql_type_to_param_type(&type_name)), + description: desc(arg), + required: is_required, + default: default_val(arg), + ..Default::default() + }, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: false, + is_list: is_list_type(&arg["type"]), + }); + } else if let Some(enum_def) = enum_types.get(type_name.as_str()) { + let values = enum_values(enum_def); + params.insert( + flag_key.clone(), + MethodParameter { + param_type: Some("string".to_string()), + description: desc(arg), + required: is_required, + default: default_val(arg), + enum_values: Some(values), + ..Default::default() + }, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: false, + is_list: is_list_type(&arg["type"]), + }); + } else if input_types.contains_key(type_name.as_str()) { + flatten_input_type( + &type_name, + arg_name, + "", + "", + is_required, + input_types, + enum_types, + scalar_names, + &mut params, + 0, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: true, + is_list: is_list_type(&arg["type"]), + }); + } + } + + (params, arg_defs) +} + +const MAX_INPUT_DEPTH: u8 = 3; + +#[allow(clippy::too_many_arguments)] +fn flatten_input_type( + type_name: &str, + arg_name: &str, + field_path: &str, + flag_prefix: &str, + parent_required: bool, + input_types: &HashMap<&str, &Value>, + enum_types: &HashMap<&str, &Value>, + scalar_names: &[String], + params: &mut HashMap, + depth: u8, +) { + if depth >= MAX_INPUT_DEPTH { + return; + } + let input_def = match input_types.get(type_name) { + Some(d) => d, + None => return, + }; + let input_fields = match input_def["inputFields"].as_array() { + Some(f) => f, + None => return, + }; + + for input_field in input_fields { + let field_name = match input_field["name"].as_str() { + Some(n) => n, + None => continue, + }; + let field_type_name = unwrap_type_name(&input_field["type"]); + let field_required = parent_required && is_non_null(&input_field["type"]); + + let field_flag = param_to_flag_name(field_name); + let full_flag = if flag_prefix.is_empty() { + field_flag + } else { + format!("{flag_prefix}.{}", param_to_flag_name(field_name)) + }; + let full_path = if field_path.is_empty() { + field_name.to_string() + } else { + format!("{field_path}.{field_name}") + }; + + if is_scalar(&field_type_name, scalar_names) { + params.insert( + full_flag, + MethodParameter { + param_type: Some(graphql_type_to_param_type(&field_type_name)), + description: desc(input_field), + required: field_required, + default: default_val(input_field), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + ..Default::default() + }, + ); + } else if let Some(enum_def) = enum_types.get(field_type_name.as_str()) { + let values = enum_values(enum_def); + params.insert( + full_flag, + MethodParameter { + param_type: Some("string".to_string()), + description: desc(input_field), + required: field_required, + default: default_val(input_field), + enum_values: Some(values), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + }, + ); + } else if input_types.contains_key(field_type_name.as_str()) { + flatten_input_type( + &field_type_name, + arg_name, + &full_path, + &full_flag, + field_required, + input_types, + enum_types, + scalar_names, + params, + depth + 1, + ); + } else { + // Undeclared custom scalar — treat as string + params.insert( + full_flag, + MethodParameter { + param_type: Some("string".to_string()), + description: desc(input_field), + required: field_required, + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + ..Default::default() + }, + ); + } + } +} + +/// Build a default selection set for a GraphQL return type. +fn build_default_selection( + type_name: &str, + object_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> String { + if type_name.ends_with("Connection") { + let node_type = type_name.strip_suffix("Connection").unwrap(); + let node_selection = build_scalar_fields(node_type, object_types, scalar_names); + let nodes_part = if node_selection.is_empty() { + "nodes { id }".to_string() + } else { + format!("nodes {{ {node_selection} }}") + }; + return format!("{{ {nodes_part} pageInfo {{ hasNextPage endCursor }} }}"); + } + + if type_name.ends_with("Payload") { + if let Some(obj) = object_types.get(type_name) { + let mut parts = Vec::new(); + let fields = obj["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let args = field["args"].as_array().map(|a| a.len()).unwrap_or(0); + if args > 0 { + continue; + } + let field_name = field["name"].as_str().unwrap_or(""); + let ft = unwrap_type_name(&field["type"]); + if is_scalar(&ft, scalar_names) { + parts.push(field_name.to_string()); + } else if object_types.contains_key(ft.as_str()) { + let inner = build_scalar_fields(&ft, object_types, scalar_names); + if !inner.is_empty() { + parts.push(format!("{field_name} {{ {inner} }}")); + } + } + } + if parts.is_empty() { + return "{ success }".to_string(); + } + return format!("{{ {} }}", parts.join(" ")); + } + } + + let fields = build_scalar_fields(type_name, object_types, scalar_names); + if fields.is_empty() { + return "{ id }".to_string(); + } + format!("{{ {fields} }}") +} + +/// Build a space-separated scalar field selection for a type. +fn build_scalar_fields( + type_name: &str, + object_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> String { + let obj = match object_types.get(type_name) { + Some(o) => o, + None => return String::new(), + }; + let fields = match obj["fields"].as_array() { + Some(f) => f, + None => return String::new(), + }; + + let mut parts: Vec = Vec::new(); + for f in fields { + let args_len = f["args"].as_array().map(|a| a.len()).unwrap_or(0); + if args_len > 0 { + continue; + } + let field_name = f["name"].as_str().unwrap_or(""); + let ft = unwrap_type_name(&f["type"]); + if is_scalar(&ft, scalar_names) { + parts.push(field_name.to_string()); + } else if !ft.ends_with("Connection") { + if let Some(inner_obj) = object_types.get(ft.as_str()) { + let inner_fields = inner_obj["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + let inner_scalars: Vec<&str> = inner_fields + .iter() + .filter(|if_| { + if_["args"].as_array().map(|a| a.len()).unwrap_or(0) == 0 + && is_scalar(&unwrap_type_name(&if_["type"]), scalar_names) + }) + .filter_map(|if_| if_["name"].as_str()) + .collect(); + if !inner_scalars.is_empty() { + parts.push(format!("{field_name} {{ {} }}", inner_scalars.join(" "))); + } + } + } + } + + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // --------------------------------------------------------------------------- + // Naming utility tests (no schema needed) + // --------------------------------------------------------------------------- + + #[test] + fn test_camel_to_kebab() { + assert_eq!(camel_to_kebab("issueCreate"), "issue-create"); + assert_eq!(camel_to_kebab("issue"), "issue"); + assert_eq!(camel_to_kebab("customView"), "custom-view"); + assert_eq!(camel_to_kebab("attachmentsForURL"), "attachments-for-url"); + assert_eq!(camel_to_kebab("teamMembershipCreate"), "team-membership-create"); + } + + #[test] + fn test_singular_kebab() { + assert_eq!(singular_kebab("issues"), "issue"); + assert_eq!(singular_kebab("activities"), "activity"); + assert_eq!(singular_kebab("gift-card-activities"), "gift-card-activity"); + assert_eq!(singular_kebab("boxes"), "box"); + assert_eq!(singular_kebab("watches"), "watch"); + assert_eq!(singular_kebab("programs"), "program"); + assert_eq!(singular_kebab("loyalty-programs"), "loyalty-program"); + assert_eq!(singular_kebab("sms"), "sms"); + assert_eq!(singular_kebab("status"), "status"); + assert_eq!(singular_kebab("menus"), "menu"); + assert_eq!(singular_kebab("gurus"), "guru"); + } + + #[test] + fn test_split_query_name() { + assert_eq!( + split_query_name("issues", "IssueConnection"), + ("issue".to_string(), "list".to_string()) + ); + assert_eq!( + split_query_name("giftCardActivities", "GiftCardActivityConnection"), + ("gift-card-activity".to_string(), "list".to_string()) + ); + assert_eq!( + split_query_name("issue", "Issue"), + ("issue".to_string(), "get".to_string()) + ); + assert_eq!( + split_query_name("attachmentsForURL", "AttachmentConnection"), + ("attachment".to_string(), "list-for-url".to_string()) + ); + } + + #[test] + fn test_split_mutation_name() { + assert_eq!( + split_mutation_name("issueCreate"), + ("issue".to_string(), "create".to_string()) + ); + assert_eq!( + split_mutation_name("issueDelete"), + ("issue".to_string(), "delete".to_string()) + ); + assert_eq!( + split_mutation_name("attachmentLinkSlack"), + ("attachment".to_string(), "link-slack".to_string()) + ); + } + + // --------------------------------------------------------------------------- + // Schema loading helpers + // --------------------------------------------------------------------------- + + /// Shorthand type-ref builders for inline test schemas. + fn nn(inner: Value) -> Value { + json!({"kind": "NON_NULL", "name": null, "ofType": inner}) + } + fn scalar(name: &str) -> Value { + json!({"kind": "SCALAR", "name": name, "ofType": null}) + } + fn obj(name: &str) -> Value { + json!({"kind": "OBJECT", "name": name, "ofType": null}) + } + fn input_obj(name: &str) -> Value { + json!({"kind": "INPUT_OBJECT", "name": name, "ofType": null}) + } + fn list_of(inner: Value) -> Value { + json!({"kind": "LIST", "name": null, "ofType": inner}) + } + + fn make_schema(types: Value) -> String { + json!({ + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "types": types + } + } + }) + .to_string() + } + + // --------------------------------------------------------------------------- + // Schema loading tests + // --------------------------------------------------------------------------- + + #[test] + fn test_load_minimal_schema() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [ + { + "name": "issue", + "args": [{"name": "id", "type": nn(scalar("String"))}], + "type": nn(obj("Issue")), "isDeprecated": false + }, + { + "name": "issues", + "args": [ + {"name": "first", "type": scalar("Int")}, + {"name": "after", "type": scalar("String")} + ], + "type": nn(obj("IssueConnection")), "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", "name": "Mutation", + "fields": [ + { + "name": "issueCreate", + "args": [{"name": "input", "type": nn(input_obj("IssueCreateInput"))}], + "type": nn(obj("IssuePayload")), "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", "name": "Issue", + "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false}, + {"name": "title", "args": [], "type": nn(scalar("String")), "isDeprecated": false}, + {"name": "description", "args": [], "type": scalar("String"), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "IssueConnection", + "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("Issue")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "PageInfo", + "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "IssuePayload", + "fields": [ + {"name": "success", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "issue", "args": [], "type": obj("Issue"), "isDeprecated": false} + ] + }, + { + "kind": "INPUT_OBJECT", "name": "IssueCreateInput", + "inputFields": [ + {"name": "title", "type": nn(scalar("String"))}, + {"name": "description", "type": scalar("String")}, + {"name": "teamId", "type": nn(scalar("String"))} + ] + } + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + assert_eq!(doc.name, "test"); + + let issue = doc.resources.get("issue").expect("issue resource missing"); + assert!(issue.methods.contains_key("get"), "missing get"); + assert!(issue.methods.contains_key("list"), "missing list"); + assert!(issue.methods.contains_key("create"), "missing create"); + + let get = issue.methods.get("get").unwrap(); + assert!(get.parameters.contains_key("id")); + assert!(get.parameters.get("id").unwrap().required); + + let list = issue.methods.get("list").unwrap(); + let list_sel = &list.graphql.as_ref().unwrap().default_selection; + assert!(list_sel.contains("pageInfo"), "list selection missing pageInfo: {list_sel}"); + assert!(list_sel.contains("hasNextPage"), "list selection missing hasNextPage: {list_sel}"); + assert!(list_sel.contains("endCursor"), "list selection missing endCursor: {list_sel}"); + assert!(list.parameters.contains_key("after"), "missing --after flag"); + + let create = issue.methods.get("create").unwrap(); + assert!(create.parameters.contains_key("title")); + assert!(create.parameters.contains_key("description")); + assert!(create.parameters.contains_key("team-id")); + + let gql = get.graphql.as_ref().unwrap(); + assert_eq!(gql.operation_type, "query"); + assert_eq!(gql.field_name, "issue"); + + let gql_create = create.graphql.as_ref().unwrap(); + assert_eq!(gql_create.operation_type, "mutation"); + assert_eq!(gql_create.field_name, "issueCreate"); + } + + #[test] + fn test_nested_input_flattening() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [{ + "name": "search", + "args": [{"name": "filter", "type": nn(input_obj("SearchFilter"))}], + "type": nn(obj("SearchConnection")), "isDeprecated": false + }] + }, + { + "kind": "OBJECT", "name": "Mutation", + "fields": [] + }, + { + "kind": "OBJECT", "name": "SearchConnection", + "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("SearchResult")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ] + }, + {"kind": "OBJECT", "name": "SearchResult", "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "PageInfo", "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ]}, + { + "kind": "INPUT_OBJECT", "name": "SearchFilter", + "inputFields": [ + {"name": "query", "type": scalar("String")}, + {"name": "dateRange", "type": input_obj("DateRangeInput")}, + {"name": "minAmount", "type": scalar("Int")} + ] + }, + { + "kind": "INPUT_OBJECT", "name": "DateRangeInput", + "inputFields": [ + {"name": "start", "type": nn(scalar("String"))}, + {"name": "end", "type": nn(scalar("String"))} + ] + } + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + let all_params: Vec = doc + .resources.values() + .flat_map(|r| r.methods.values()) + .flat_map(|m| m.parameters.keys().cloned()) + .collect(); + + assert!(all_params.iter().any(|k| k == "query"), "missing top-level query param: {all_params:?}"); + assert!(all_params.iter().any(|k| k.contains("start")), "missing dateRange.start: {all_params:?}"); + assert!(all_params.iter().any(|k| k.contains("end")), "missing dateRange.end: {all_params:?}"); + } + + #[test] + fn test_undeclared_scalar_as_arg() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [{ + "name": "orders", + "args": [ + {"name": "after", "type": json!({"kind": "SCALAR", "name": "Cursor", "ofType": null})}, + {"name": "first", "type": scalar("Int")} + ], + "type": nn(obj("OrderConnection")), "isDeprecated": false + }] + }, + {"kind": "OBJECT", "name": "Mutation", "fields": []}, + {"kind": "OBJECT", "name": "OrderConnection", "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("Order")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "Order", "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "PageInfo", "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ]} + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + let all_params: Vec = doc + .resources.values() + .flat_map(|r| r.methods.values()) + .flat_map(|m| m.parameters.keys().cloned()) + .collect(); + assert!( + all_params.iter().any(|k| k == "after"), + "undeclared Cursor scalar should produce --after flag: {all_params:?}" + ); + } + +} diff --git a/cli/src/http.rs b/cli/src/http.rs new file mode 100644 index 00000000..4d7ee2f1 --- /dev/null +++ b/cli/src/http.rs @@ -0,0 +1,845 @@ +//! HTTP client construction and TLS-error diagnostics. +//! +//! [`HttpConfig`] holds the inputs that go into building a [`reqwest::Client`] +//! for a CLI: the binary name (used to scope env vars) and any compile-time +//! trust roots a binary author baked in via `CliApp::extra_root_cert`. +//! +//! [`HttpConfig::build_client`] honors a small set of environment variables +//! so users can adapt TLS / proxy behavior without rebuilding the CLI. +//! Variables are prefixed with `_` (the CLI's name uppercased with `-` +//! mapped to `_`). +//! +//! | Variable | Effect | +//! | --------------------------------- | --------------------------------------------------- | +//! | `_CA_BUNDLE` | Path to PEM file appended to the default trust roots. Generic fallback: `SSL_CERT_FILE`. | +//! | `_INSECURE` = `1`/`true`/`yes` | Disable TLS verification (with a one-time stderr warning). | +//! | `_PROXY` | HTTP(S) proxy URL — replaces `HTTPS_PROXY`/`HTTP_PROXY` for this CLI. Pair with `_NO_PROXY` for a scoped bypass list, or rely on the global `NO_PROXY` (used as a fallback when `_NO_PROXY` is unset). | +//! | `_TIMEOUT_SECS` | Total request timeout in seconds (default: no timeout). | +//! | `_CONNECT_TIMEOUT_SECS` | Connection-establishment timeout in seconds. | +//! +//! Aliases: `_EXTRA_CA_CERTS` (= `_CA_BUNDLE`), +//! `_INSECURE_SKIP_VERIFY` (= `_INSECURE`). +//! +//! `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` are honored by reqwest's defaults +//! when the scoped overrides are absent. +//! +//! ## Configuration timing +//! +//! Compile-time roots passed via [`HttpConfig::with_extra_root_cert`] are +//! captured once when the config is built. Env vars are re-read on every +//! [`HttpConfig::build_client`] call, so a long-running consumer that +//! rebuilds the client picks up env changes for `_INSECURE`, `_PROXY`, etc. +//! For one-shot CLI use this distinction doesn't matter. + +use std::collections::HashSet; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; + +use crate::error::CliError; + +// ---------------------------------------------------------------------------- +// HttpConfig — the SDK's HTTP layer configuration +// ---------------------------------------------------------------------------- + +/// Configuration for building HTTP clients on behalf of a named CLI. +/// +/// Holds the binary name (which scopes env-var lookups) and any compile-time +/// trust roots the binary author registered. `CliApp::run` builds one once +/// and threads it through to the executor. +#[derive(Clone, Debug)] +pub struct HttpConfig { + /// CLI binary name (e.g. `"myapi"`). Cheap to clone via `Arc`. + name: Arc, + /// Env-var prefix derived once from `name`: uppercase + `-` → `_`. Cached + /// so the transform isn't recomputed on every `build_client` call (and + /// so external callers can't forget the `-` substitution). + prefix: Arc, + /// Trust roots baked in at compile time. We store parsed `Certificate`s + /// (not raw PEM bytes) so `build_client` doesn't re-parse — and so a + /// later `build_client` failure can only come from runtime input. + extra_root_certs: Vec, + /// Raw PEM bytes for each compile-time trust root, kept alongside the + /// parsed `reqwest::Certificate` above. Required by transport-neutral + /// consumers (`resolve()`) that build their own TLS connectors (e.g. + /// `tokio-tungstenite` for WebSockets) and need to feed PEM in rather + /// than reqwest-typed certs. Each `Vec` is the raw bytes supplied + /// to [`HttpConfig::with_extra_root_cert`]. + extra_root_certs_pem: Vec>, +} + +/// Transport-neutral view of the resolved HTTP/TLS configuration. +/// +/// Returned by [`HttpConfig::resolve`]. Holds compile-time roots, the +/// env-var-resolved CA bundle (if any), insecure-skip-verify flag, proxy +/// settings, and timeouts. Lets non-reqwest transports (e.g. WebSocket via +/// `tokio-tungstenite`, future SSE / gRPC) build their own clients while +/// honouring the same `_*` env vars users already configure for the +/// reqwest path. +/// +/// The reqwest path in [`HttpConfig::build_client`] reads env vars +/// independently for historical reasons — keep both readers in sync. The +/// `resolved_matches_build_client` test asserts agreement on the subset +/// that's representable in both shapes. +#[derive(Debug, Clone)] +pub struct ResolvedTlsConfig { + /// Raw PEM bytes of all trust roots — compile-time first, then the + /// env-resolved bundle (if any). Order matches the order they would be + /// added to a `reqwest::ClientBuilder` via `add_root_certificate`. + pub extra_root_certs_pem: Vec>, + /// `_INSECURE=1` / `_INSECURE_SKIP_VERIFY=1` was set. + /// Transports honoring this should disable cert+hostname verification. + pub insecure_skip_verify: bool, + /// `_PROXY=` was set. Transports that support HTTP proxying + /// should route through this URL. The `no_proxy` field carries either + /// `_NO_PROXY` or the fallback `NO_PROXY`, matching the reqwest + /// path's bypass-list resolution. + pub proxy: Option, + /// `_CONNECT_TIMEOUT_SECS` if set. Bound on socket establishment. + pub connect_timeout: Option, + /// `_TIMEOUT_SECS` if set. Bound on total request lifetime + /// (reqwest semantics); for streaming transports (WebSocket), use as a + /// handshake-only deadline since the connection lifetime is unbounded. + pub request_timeout: Option, +} + +/// Resolved proxy override, as parsed from `_PROXY` / `_NO_PROXY`. +#[derive(Debug, Clone)] +pub struct ResolvedProxy { + /// Proxy URL (`http://...` or `https://...`). + pub url: String, + /// Bypass list — either `_NO_PROXY` (if set) or the fallback + /// `NO_PROXY` env var. `None` means honor the standard reqwest defaults. + pub no_proxy: Option, +} + +impl HttpConfig { + /// Create a config for the given CLI name. Empty names are rejected — + /// they would silently disable the entire env-var scoping system. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() { + return Err(CliError::Other(anyhow::anyhow!( + "HttpConfig::new called with empty name — \ + env-var scoping requires a non-empty CLI name" + ))); + } + let prefix: Arc = Arc::from(name.to_uppercase().replace('-', "_")); + Ok(Self { + name: Arc::from(name), + prefix, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + }) + } + + /// Append a PEM-encoded trust root that this CLI will accept on top of + /// the system's default roots. Typically called via `CliApp::extra_root_cert`. + /// Returns an error if the PEM is unparseable or contains zero certs. + pub fn with_extra_root_cert(mut self, pem: &[u8]) -> Result { + // Validate the PEM up front (`parse_extra_root_cert` rejects empty / + // unparseable bundles). Storing the raw bytes alongside lets + // non-reqwest transports build their own connectors without + // re-parsing through reqwest types. + self.extra_root_certs.extend(parse_extra_root_cert(pem)?); + self.extra_root_certs_pem.push(pem.to_vec()); + Ok(self) + } + + /// Append already-parsed trust roots. Used internally by `CliApp` to + /// thread compile-time roots from the builder into the runtime config + /// without re-parsing. The matching PEM bytes must be supplied so + /// `resolve()` can hand them to non-reqwest transports. + pub(crate) fn with_parsed_root_certs( + mut self, + certs: impl IntoIterator, + pem_bytes: impl IntoIterator>, + ) -> Self { + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.extend(pem_bytes); + self + } + + /// CLI binary name (e.g. `"myapi"`). + pub fn name(&self) -> &str { + &self.name + } + + /// Env-var prefix derived from the binary name (uppercase, `-` → `_`). + /// `MYAPI`, `OTHER_API`, etc. Use this when constructing scoped env vars + /// so the transform stays consistent across the codebase. + pub fn env_prefix(&self) -> &str { + &self.prefix + } + + /// Resolve the transport-neutral view of this config: compile-time + /// trust roots concatenated with the env-resolved `_CA_BUNDLE`, + /// the `_INSECURE` flag, the proxy override, and timeouts. + /// + /// Used by non-reqwest transports (`fern_cli_sdk::websocket`, future + /// SSE / raw-socket consumers) that need to build their own TLS + /// connector while honoring the same `_*` env vars users + /// already configure for the reqwest path. + /// + /// Reads env vars at call time. Reading the CA bundle file can fail + /// (missing / unparseable / no PEM certs) — those errors surface here + /// rather than getting swallowed by the transport's own connect path. + /// + /// Side effects mirror [`HttpConfig::build_client`]: emits the + /// `_INSECURE` warning at most once per (binary, process). No + /// network calls. + pub fn resolve(&self) -> Result { + let prefix = &self.prefix; + + let mut extra_root_certs_pem: Vec> = self.extra_root_certs_pem.clone(); + if let Some(path) = first_env([ + scoped(prefix, "_CA_BUNDLE"), + scoped(prefix, "_EXTRA_CA_CERTS"), + "SSL_CERT_FILE".to_string(), + ]) { + let pem = std::fs::read(&path).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to read CA bundle from {path}: {e}" + )) + })?; + // Validate the bundle here so transport callers don't have to + // re-implement the "empty / non-PEM" diagnostic. We parse but + // discard the certs — the raw bytes are what we hand back. + let source = format!("CA bundle at {path}"); + let _ = parse_pem_bundle(&pem, &source)?; + extra_root_certs_pem.push(pem); + } + + let insecure_skip_verify = if let Some(active_key) = first_env_truthy([ + scoped(prefix, "_INSECURE"), + scoped(prefix, "_INSECURE_SKIP_VERIFY"), + ]) { + warn_insecure_once(&self.name, &active_key); + true + } else { + false + }; + + let proxy = first_env([scoped(prefix, "_PROXY")]).map(|url| { + // Mirror the reqwest path's bypass-list resolution: _NO_PROXY + // wins when set, otherwise fall back to the standard NO_PROXY env. + let no_proxy = first_env([scoped(prefix, "_NO_PROXY")]) + .or_else(|| first_env(["NO_PROXY".to_string()])); + ResolvedProxy { url, no_proxy } + }); + + let connect_timeout = parse_secs(&scoped(prefix, "_CONNECT_TIMEOUT_SECS")) + .map(Duration::from_secs); + let request_timeout = parse_secs(&scoped(prefix, "_TIMEOUT_SECS")) + .map(Duration::from_secs); + + Ok(ResolvedTlsConfig { + extra_root_certs_pem, + insecure_skip_verify, + proxy, + connect_timeout, + request_timeout, + }) + } + + /// Build an HTTP client, applying compile-time roots, env-var overrides, + /// proxy settings, and timeouts. Reads `_*` env vars at call time; + /// compile-time roots were captured when this config was built. + pub fn build_client(&self) -> Result { + let prefix = &self.prefix; + + let mut builder = reqwest::Client::builder(); + let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + if let Ok(header_value) = HeaderValue::from_str(&user_agent) { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, header_value); + builder = builder.default_headers(headers); + } + + // --- Compile-time trust roots (from CliApp::extra_root_cert) --- + for cert in &self.extra_root_certs { + builder = builder.add_root_certificate(cert.clone()); + } + + // --- Runtime trust roots from env --- + if let Some(path) = first_env([ + scoped(prefix, "_CA_BUNDLE"), + scoped(prefix, "_EXTRA_CA_CERTS"), + "SSL_CERT_FILE".to_string(), + ]) { + let pem = std::fs::read(&path).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to read CA bundle from {path}: {e}" + )) + })?; + let source = format!("CA bundle at {path}"); + for cert in parse_pem_bundle(&pem, &source)? { + builder = builder.add_root_certificate(cert); + } + } + + // --- Insecure mode (opt-in, loud) --- + if let Some(active_key) = first_env_truthy([ + scoped(prefix, "_INSECURE"), + scoped(prefix, "_INSECURE_SKIP_VERIFY"), + ]) { + warn_insecure_once(&self.name, &active_key); + builder = builder + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + // --- Proxy override --- + // + // Reqwest's default behavior reads `HTTPS_PROXY` / `HTTP_PROXY` and + // adds them automatically. Adding our explicit `.proxy(...)` on top + // would result in *both* being tried in order — the env-detected one + // first. So when `_PROXY` is set, we clear reqwest's + // auto-detection with `.no_proxy()` first, then add ours. + // + // Bypass-list semantics: `_PROXY` *replaces* the global + // `HTTPS_PROXY`/`HTTP_PROXY`, but the bypass list is *augmenting*: + // - if `_NO_PROXY` is set, it's used (global NO_PROXY ignored); + // - otherwise, the standard `NO_PROXY` is honored as a fallback so + // a user who only set the shell-wide bypass list doesn't lose it. + // Standalone `_NO_PROXY` (without `_PROXY`) is *not* + // honored — it would have ambiguous semantics (override which proxy?). + let proxy_key = scoped(prefix, "_PROXY"); + if let Some(url) = first_env([proxy_key.clone()]) { + let mut proxy = reqwest::Proxy::all(&url).map_err(|e| { + CliError::Other(anyhow::anyhow!("invalid {proxy_key}={url}: {e}")) + })?; + if let Some(list) = first_env([scoped(prefix, "_NO_PROXY")]) { + if let Some(np) = reqwest::NoProxy::from_string(&list) { + proxy = proxy.no_proxy(Some(np)); + } + } else if let Some(np) = reqwest::NoProxy::from_env() { + proxy = proxy.no_proxy(Some(np)); + } + builder = builder.no_proxy().proxy(proxy); + } + + // --- Timeouts --- + if let Some(secs) = parse_secs(&scoped(prefix, "_TIMEOUT_SECS")) { + builder = builder.timeout(std::time::Duration::from_secs(secs)); + } + if let Some(secs) = parse_secs(&scoped(prefix, "_CONNECT_TIMEOUT_SECS")) { + builder = builder.connect_timeout(std::time::Duration::from_secs(secs)); + } + + builder.build().map_err(|e| { + CliError::Other(anyhow::anyhow!("failed to build HTTP client: {e}")) + }) + } +} + +/// Parse a PEM bundle into trust-root certs, with the SDK's standard +/// validation: empty bytes / no PEM headers / unparseable bytes all surface +/// as errors. `source` is woven into error messages so users can tell where +/// the bad PEM came from (`"extra root cert"`, `"CA bundle at /path/..."`). +fn parse_pem_bundle(pem: &[u8], source: &str) -> Result, CliError> { + let certs = reqwest::Certificate::from_pem_bundle(pem).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to parse {source}: {e} — check the bytes are valid PEM-encoded certificates" + )) + })?; + if certs.is_empty() { + return Err(CliError::Other(anyhow::anyhow!( + "{source} contains no PEM certificates — check the bytes are PEM-encoded" + ))); + } + Ok(certs) +} + +/// Convenience wrapper for the compile-time path. Used by +/// [`HttpConfig::with_extra_root_cert`] and `CliApp::extra_root_cert`. +pub(crate) fn parse_extra_root_cert(pem: &[u8]) -> Result, CliError> { + parse_pem_bundle(pem, "extra root cert") +} + +// ---------------------------------------------------------------------------- +// TLS error diagnostics +// ---------------------------------------------------------------------------- + +/// If the given reqwest error looks like a TLS chain failure, print a hint +/// to stderr telling the user how to fix it (export `_CA_BUNDLE`, +/// unset `HTTPS_PROXY`, or use `_INSECURE=1` for debugging). +/// +/// Emits at most once per (binary, process) so paginated callers don't spam. +pub(crate) fn maybe_emit_tls_hint(cfg: &HttpConfig, err: &reqwest::Error) { + if !looks_like_tls_failure(err) { + return; + } + if !is_first_emission(&cfg.name, "tls") { + return; + } + let prefix = cfg.env_prefix(); + eprintln!( + "hint: TLS chain validation failed. If you're behind a corporate proxy or \ + interception tool (Proxyman, Charles, mitmproxy):\n \ + export {prefix}_CA_BUNDLE=/path/to/ca.pem # trust an extra root\n \ + export SSL_CERT_FILE=/path/to/ca.pem # generic fallback\n \ + {prefix}_INSECURE=1 # skip verification (debugging only)" + ); +} + +/// Detect whether a reqwest error is plausibly a TLS chain failure. Uses the +/// typed `is_connect()` predicate plus a deliberately-broad substring match +/// against `"certificate"` in the rendered error chain. We accept some false +/// positives (the hint is benign when wrong) in exchange for not missing real +/// TLS failures when reqwest's error wording shifts between versions. +fn looks_like_tls_failure(err: &reqwest::Error) -> bool { + if !err.is_connect() { + return false; + } + // `{:#}` prints the full source chain — TLS errors are usually wrapped + // several layers deep, with the actual word appearing near the bottom. + format!("{err:#}").to_lowercase().contains("certificate") +} + +/// Print the insecure-mode warning at most once per (binary, process). +fn warn_insecure_once(name: &str, active_key: &str) { + if !is_first_emission(name, "insecure") { + return; + } + eprintln!( + "warning: TLS verification disabled via {active_key} — \ + requests are vulnerable to MITM. Unset for production use." + ); +} + +/// Returns true the *first* time a (binary, kind) pair is seen in this +/// process, false thereafter. Lets us print one-shot warnings/hints without +/// silencing them across multiple binaries built on the SDK in the same +/// process (e.g. test harnesses, library consumers wiring up two CLIs). +fn is_first_emission(name: &str, kind: &str) -> bool { + static EMITTED: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + let mut guard = EMITTED.lock().unwrap_or_else(|e| e.into_inner()); + guard.insert(format!("{name}::{kind}")) +} + +// ---------------------------------------------------------------------------- +// Env-var helpers +// ---------------------------------------------------------------------------- + +/// Format a scoped env-var name. `scoped("MYAPI", "_CA_BUNDLE")` → +/// `"MYAPI_CA_BUNDLE"`. +fn scoped(prefix: &str, suffix: &str) -> String { + format!("{prefix}{suffix}") +} + +/// Return the first non-empty env var value among the given keys, in order. +fn first_env>(keys: impl IntoIterator) -> Option { + keys.into_iter().find_map(|k| { + let k = k.as_ref(); + if k.is_empty() { + return None; + } + std::env::var(k).ok().filter(|v| !v.is_empty()) + }) +} + +/// Like `first_env`, but checks for truthy values and returns the *name* of +/// the env var that fired so warnings can name the actual variable the user +/// set. +fn first_env_truthy>(keys: impl IntoIterator) -> Option { + keys.into_iter().find_map(|k| { + let k = k.as_ref(); + if k.is_empty() { + return None; + } + match std::env::var(k) { + Ok(v) if is_truthy(&v) => Some(k.to_string()), + _ => None, + } + }) +} + +fn is_truthy(v: &str) -> bool { + v.eq_ignore_ascii_case("1") + || v.eq_ignore_ascii_case("true") + || v.eq_ignore_ascii_case("yes") + || v.eq_ignore_ascii_case("on") +} + +fn parse_secs(key: &str) -> Option { + std::env::var(key).ok().and_then(|v| v.parse().ok()) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// RAII guard that sets env vars on construction and unsets them on + /// drop, so a panic mid-test doesn't leak mutations into other tests. + /// The `unset` helper additionally restores any pre-existing value + /// on drop so the guard works for both setting and clearing. + #[derive(Default)] + struct EnvGuard { + set_keys: Vec, + unset_keys: Vec<(String, Option)>, + } + + impl EnvGuard { + fn set(&mut self, k: &str, v: impl AsRef) { + std::env::set_var(k, v); + self.set_keys.push(k.to_string()); + } + /// Temporarily clear `k` for the duration of the guard, restoring + /// any previously-set value on drop. Used to isolate tests from + /// ambient CI/local env (e.g. Linux runners that set + /// `SSL_CERT_FILE` globally — that var would otherwise leak into + /// `HttpConfig::resolve`'s CA-bundle fallback). + fn unset(&mut self, k: &str) { + let prior = std::env::var_os(k); + std::env::remove_var(k); + self.unset_keys.push((k.to_string(), prior)); + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for k in &self.set_keys { + std::env::remove_var(k); + } + for (k, prior) in &self.unset_keys { + if let Some(v) = prior { + std::env::set_var(k, v); + } else { + std::env::remove_var(k); + } + } + } + } + + /// Standard env-isolation for `resolve()` tests — clears the + /// CA-bundle fallback chain that CI may have pre-populated. Use at + /// the top of any test that asserts on a clean / minimal resolved + /// config. + fn isolated_env_guard() -> EnvGuard { + let mut g = EnvGuard::default(); + g.unset("SSL_CERT_FILE"); + g + } + + #[test] + #[serial_test::serial] + fn build_client_succeeds_with_clean_env() { + let cfg = HttpConfig::new("myapi").unwrap(); + assert!(cfg.build_client().is_ok()); + } + + #[test] + fn http_config_rejects_empty_name() { + let err = HttpConfig::new("").expect_err("empty name should error"); + assert!(err.to_string().contains("empty name")); + } + + #[test] + fn env_prefix_uppercases_and_translates_dashes() { + let cfg = HttpConfig::new("openapi-fixture").unwrap(); + assert_eq!(cfg.env_prefix(), "OPENAPI_FIXTURE"); + assert_eq!(cfg.name(), "openapi-fixture"); + } + + #[test] + fn with_extra_root_cert_rejects_non_pem() { + let cfg = HttpConfig::new("regtest").unwrap(); + let err = cfg + .with_extra_root_cert(b"not a pem") + .expect_err("non-PEM should error"); + assert!(err.to_string().contains("extra root cert")); + } + + #[test] + fn with_extra_root_cert_rejects_empty_bundle() { + let cfg = HttpConfig::new("regtest").unwrap(); + let err = cfg + .with_extra_root_cert(b"") + .expect_err("empty bytes should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("empty") || msg.contains("no pem")); + } + + #[test] + #[serial_test::serial] + fn first_env_truthy_returns_active_key_name() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_ACTIVE_PRIMARY", "1"); + env.set("CLI_TEST_ACTIVE_ALIAS", "true"); + let primary = "CLI_TEST_ACTIVE_PRIMARY".to_string(); + let alias = "CLI_TEST_ACTIVE_ALIAS".to_string(); + assert_eq!( + first_env_truthy([&primary, &alias]).as_deref(), + Some("CLI_TEST_ACTIVE_PRIMARY"), + ); + std::env::remove_var("CLI_TEST_ACTIVE_PRIMARY"); + // Alias wins now that primary is unset. + assert_eq!( + first_env_truthy([&primary, &alias]).as_deref(), + Some("CLI_TEST_ACTIVE_ALIAS"), + ); + } + + #[test] + #[serial_test::serial] + fn first_env_truthy_rejects_falsy() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_FALSY", "0"); + let key = "CLI_TEST_FALSY".to_string(); + assert!(first_env_truthy([&key]).is_none()); + env.set("CLI_TEST_FALSY", "false"); + assert!(first_env_truthy([&key]).is_none()); + env.set("CLI_TEST_FALSY", ""); + assert!(first_env_truthy([&key]).is_none()); + } + + #[test] + fn is_truthy_is_case_insensitive() { + assert!(is_truthy("1")); + assert!(is_truthy("TRUE")); + assert!(is_truthy("True")); + assert!(is_truthy("yes")); + assert!(is_truthy("ON")); + assert!(!is_truthy("0")); + assert!(!is_truthy("")); + assert!(!is_truthy("anything-else")); + } + + #[test] + #[serial_test::serial] + fn parse_secs_handles_numeric_and_invalid() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_SECS", "42"); + assert_eq!(parse_secs("CLI_TEST_SECS"), Some(42)); + env.set("CLI_TEST_SECS", "not-a-number"); + assert_eq!(parse_secs("CLI_TEST_SECS"), None); + assert_eq!(parse_secs("CLI_TEST_NEVER_SET"), None); + } + + #[test] + #[serial_test::serial] + fn first_env_picks_first_set_value_and_skips_empty() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_FIRST_A", ""); + env.set("CLI_TEST_FIRST_B", "winner"); + env.set("CLI_TEST_FIRST_C", "loser"); + let a = "CLI_TEST_FIRST_A".to_string(); + let b = "CLI_TEST_FIRST_B".to_string(); + let c = "CLI_TEST_FIRST_C".to_string(); + assert_eq!(first_env([&a, &b, &c]), Some("winner".to_string())); + } + + #[test] + #[serial_test::serial] + fn ca_bundle_env_invalid_path_returns_error() { + let mut env = EnvGuard::default(); + env.set("CLI_E2E_TEST_CA_BUNDLE", "/no/such/file.pem"); + let cfg = HttpConfig::new("cli-e2e-test").unwrap(); + let err = cfg.build_client().expect_err("missing path should error"); + let msg = err.to_string(); + assert!(msg.contains("/no/such/file.pem"), "error: {msg}"); + } + + #[test] + #[serial_test::serial] + fn ca_bundle_env_empty_file_returns_error() { + let mut env = EnvGuard::default(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + env.set("CLI_EMPTY_BUNDLE_TEST_CA_BUNDLE", tmp.path()); + let cfg = HttpConfig::new("cli-empty-bundle-test").unwrap(); + let err = cfg.build_client().expect_err("empty bundle should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("no pem") || msg.contains("empty"), "error: {msg}"); + } + + #[test] + #[serial_test::serial] + fn is_first_emission_dedupes_by_binary_and_kind() { + // The emission tracker is a process-global LazyLock, so this test + // shares state with anything else that calls `is_first_emission`. + // Serializing keeps it deterministic; unique binary-name keys would + // be required if other tests called it. + assert!(is_first_emission("emit-dedupe-test", "marker-1")); + assert!(!is_first_emission("emit-dedupe-test", "marker-1")); + assert!(is_first_emission("emit-dedupe-test", "marker-2")); + assert!(is_first_emission("emit-dedupe-test-other", "marker-1")); + } + + #[test] + fn scoped_helper_concatenates_prefix_and_suffix() { + assert_eq!(scoped("MYAPI", "_CA_BUNDLE"), "MYAPI_CA_BUNDLE"); + assert_eq!(scoped("OTHER", "_INSECURE"), "OTHER_INSECURE"); + } + + // ----- resolve() — transport-neutral view --------------------------------- + + /// Minimal valid self-signed PEM. Used by both reqwest and rustls parsers + /// to verify the round-trip stays byte-identical after going through + /// [`HttpConfig::resolve`]. + const TEST_PEM: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\n\ +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\n\ +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d\n\ +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B\n\ +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\n\ +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\n\ +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l\n\ +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n\ +6MF9+Yw1Yy0t\n\ +-----END CERTIFICATE-----\n"; + + #[test] + #[serial_test::serial] + fn resolve_clean_env_yields_no_overrides() { + // CI runners (notably ubuntu-latest) set SSL_CERT_FILE globally; + // `resolve()` reads it as the CA-bundle fallback so we must clear + // it for the duration of this test to actually see "clean env". + let _g = isolated_env_guard(); + let cfg = HttpConfig::new("resolve-clean").unwrap(); + let resolved = cfg.resolve().expect("clean env should resolve"); + assert!(resolved.extra_root_certs_pem.is_empty()); + assert!(!resolved.insecure_skip_verify); + assert!(resolved.proxy.is_none()); + assert!(resolved.connect_timeout.is_none()); + assert!(resolved.request_timeout.is_none()); + } + + #[test] + #[serial_test::serial] + fn resolve_preserves_compile_time_pem_bytes_unchanged() { + let _g = isolated_env_guard(); + let cfg = HttpConfig::new("resolve-ct-pem") + .unwrap() + .with_extra_root_cert(TEST_PEM.as_bytes()) + .expect("test PEM should parse"); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 1); + // Round-trip must be byte-identical — non-reqwest transports parse + // these bytes with their own PEM reader and need them verbatim. + assert_eq!(resolved.extra_root_certs_pem[0], TEST_PEM.as_bytes()); + } + + #[test] + #[serial_test::serial] + fn resolve_appends_env_ca_bundle_after_compile_time_roots() { + let mut env = isolated_env_guard(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, TEST_PEM.as_bytes()).unwrap(); + env.set("RESOLVE_ENV_PEM_CA_BUNDLE", tmp.path()); + + let cfg = HttpConfig::new("resolve-env-pem") + .unwrap() + .with_extra_root_cert(TEST_PEM.as_bytes()) + .unwrap(); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 2, + "compile-time PEM first, env PEM appended"); + } + + #[test] + #[serial_test::serial] + fn resolve_invalid_ca_bundle_path_errors() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_BAD_PATH_CA_BUNDLE", "/no/such/file.pem"); + let cfg = HttpConfig::new("resolve-bad-path").unwrap(); + let err = cfg.resolve().expect_err("missing path should error"); + assert!(err.to_string().contains("/no/such/file.pem")); + } + + #[test] + #[serial_test::serial] + fn resolve_invalid_ca_bundle_contents_errors() { + let mut env = EnvGuard::default(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, b"not a pem").unwrap(); + env.set("RESOLVE_BAD_PEM_CA_BUNDLE", tmp.path()); + let cfg = HttpConfig::new("resolve-bad-pem").unwrap(); + let err = cfg.resolve().expect_err("unparseable PEM should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("ca bundle") || msg.contains("pem")); + } + + #[test] + #[serial_test::serial] + fn resolve_picks_up_insecure_flag() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_INSECURE_INSECURE", "1"); + let cfg = HttpConfig::new("resolve-insecure").unwrap(); + let resolved = cfg.resolve().unwrap(); + assert!(resolved.insecure_skip_verify); + } + + #[test] + #[serial_test::serial] + fn resolve_proxy_with_explicit_no_proxy_wins_over_env() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_PROXY_PROXY", "http://proxy.example:3128"); + env.set("RESOLVE_PROXY_NO_PROXY", "internal.example"); + env.set("NO_PROXY", "should-be-ignored"); + let cfg = HttpConfig::new("resolve-proxy").unwrap(); + let resolved = cfg.resolve().unwrap(); + let p = resolved.proxy.expect("proxy should be set"); + assert_eq!(p.url, "http://proxy.example:3128"); + assert_eq!(p.no_proxy.as_deref(), Some("internal.example")); + } + + #[test] + #[serial_test::serial] + fn resolve_proxy_falls_back_to_global_no_proxy() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_PROXY_FALLBACK_PROXY", "http://p.example:3128"); + env.set("NO_PROXY", "fallback.example"); + let cfg = HttpConfig::new("resolve-proxy-fallback").unwrap(); + let resolved = cfg.resolve().unwrap(); + let p = resolved.proxy.expect("proxy should be set"); + assert_eq!(p.no_proxy.as_deref(), Some("fallback.example")); + } + + #[test] + #[serial_test::serial] + fn resolve_and_build_client_agree_on_common_env_var_shape() { + // Cheap drift check: with the same env vars set, both readers + // succeed. This doesn't prove they map values identically into + // their respective output types (reqwest::Client vs + // ResolvedTlsConfig) — that would require introspecting reqwest + // internals — but it does catch the class of bug where one + // reader accepts an env-var combination the other rejects. + let mut env = isolated_env_guard(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, TEST_PEM.as_bytes()).unwrap(); + env.set("RESOLVE_AGREE_CA_BUNDLE", tmp.path()); + env.set("RESOLVE_AGREE_TIMEOUT_SECS", "42"); + env.set("RESOLVE_AGREE_CONNECT_TIMEOUT_SECS", "7"); + + let cfg = HttpConfig::new("resolve-agree").unwrap(); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 1); + assert_eq!(resolved.request_timeout, Some(Duration::from_secs(42))); + assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(7))); + + // build_client reads env vars independently. If it errors here + // with the same env set, the two readers have drifted on a + // value the spec says both accept. + cfg.build_client() + .expect("build_client should accept the same env vars as resolve()"); + } + + #[test] + #[serial_test::serial] + fn resolve_timeouts_parsed_as_seconds() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_TIMEOUTS_TIMEOUT_SECS", "30"); + env.set("RESOLVE_TIMEOUTS_CONNECT_TIMEOUT_SECS", "5"); + let cfg = HttpConfig::new("resolve-timeouts").unwrap(); + let resolved = cfg.resolve().unwrap(); + assert_eq!(resolved.request_timeout, Some(Duration::from_secs(30))); + assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(5))); + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 00000000..595a915f --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,63 @@ +//! Fern CLI SDK +//! +//! A library for building CLIs from OpenAPI or GraphQL SDL schemas. +//! Uses `x-fern-sdk-group-name` and `x-fern-sdk-method-name` extensions +//! to build the command hierarchy. + +// Public API — building blocks +pub mod auth; +pub mod cli_args; +pub mod completions; +pub mod custom_commands; +pub mod http; +pub mod error; +pub mod formatter; +pub mod graphql; +pub mod man; +pub mod openapi; +pub mod validate; +pub mod websocket; + +// Convenience re-exports for OAuth2 types +pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; + +// Internal modules +pub(crate) mod early_intercept; +pub(crate) mod logging; +pub(crate) mod output; +pub(crate) mod text; + +/// Initialize logging from environment variables. Call once at startup. +/// +/// `cli_name` is the binary name (e.g. `"my-cli"`). The function reads +/// `_LOG` and `_LOG_FILE` where `` is +/// `cli_name` uppercased with hyphens replaced by underscores. +pub fn init_logging(cli_name: &str) { + logging::init_logging(cli_name); +} + +/// Reset the `SIGPIPE` signal handler to its default disposition (`SIG_DFL`). +/// +/// Rust's runtime sets `SIGPIPE` to `SIG_IGN`, which causes writes to a +/// broken pipe (e.g. ` completion bash | head -5`) to return +/// `EPIPE` errors instead of terminating the process. For CLI tools that +/// produce large output this surfaces as a panic in `println!` or +/// `write_all`. Resetting to `SIG_DFL` lets the OS deliver the signal +/// and terminate the process cleanly — the standard behavior expected by +/// Unix pipelines. +/// +/// This is the idiomatic fix used by `bat`, `ripgrep`, `fd`, `eza`, and +/// most other Rust CLI tools. Called at the very top of each binary's +/// `run()` method before any I/O. +/// +/// On non-Unix platforms this is a no-op. +#[cfg(unix)] +pub fn reset_sigpipe() { + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } +} + +/// No-op on non-Unix platforms. +#[cfg(not(unix))] +pub fn reset_sigpipe() {} diff --git a/cli/src/logging.rs b/cli/src/logging.rs new file mode 100644 index 00000000..b9a951a4 --- /dev/null +++ b/cli/src/logging.rs @@ -0,0 +1,123 @@ +//! Structured Logging +//! +//! Provides opt-in, PII-free logging for HTTP requests and CLI operations. +//! All output goes to stderr or a log file — stdout remains clean for +//! machine-consumable JSON output. +//! +//! ## Environment Variables +//! +//! - `_LOG`: Filter directive for stderr logging +//! (e.g., `fern=debug`). `` is the CLI binary name uppercased +//! with hyphens replaced by underscores. If unset, no stderr logging. +//! +//! - `_LOG_FILE`: Directory path for JSON-line log +//! files with daily rotation. If unset, no file logging. + +use tracing_subscriber::prelude::*; + +/// Compute the env-var prefix from a CLI binary name: uppercase, hyphens → underscores. +fn env_prefix(cli_name: &str) -> String { + cli_name.to_uppercase().replace('-', "_") +} + +/// Initialize the tracing subscriber based on environment variables. +/// +/// `cli_name` is the binary name (e.g. `"my-cli"`). The function reads +/// `_LOG` and `_LOG_FILE` where `` is +/// `cli_name` uppercased with hyphens replaced by underscores. +/// +/// If neither variable is set, this is a no-op and logging adds zero +/// overhead. +pub fn init_logging(cli_name: &str) { + let prefix = env_prefix(cli_name); + let env_log = format!("{prefix}_LOG"); + let env_log_file = format!("{prefix}_LOG_FILE"); + + let stderr_filter = std::env::var(&env_log).ok(); + let log_file_dir = std::env::var(&env_log_file).ok(); + + if stderr_filter.is_none() && log_file_dir.is_none() { + return; + } + + let registry = tracing_subscriber::registry(); + + let stderr_layer = stderr_filter.map(|filter| { + let env_filter = tracing_subscriber::EnvFilter::new(filter); + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_target(false) + .compact() + .with_filter(env_filter) + }); + + let (file_layer, _guard) = if let Some(ref dir) = log_file_dir { + let log_filename = format!("{cli_name}.log"); + let file_appender = tracing_appender::rolling::daily(dir, log_filename); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let layer = tracing_subscriber::fmt::layer() + .json() + .with_writer(non_blocking) + .with_target(true) + .with_filter(tracing_subscriber::EnvFilter::new("debug")); + (Some(layer), Some(guard)) + } else { + (None, None) + }; + + let subscriber = registry.with(stderr_layer).with(file_layer); + if tracing::subscriber::set_global_default(subscriber).is_ok() { + if let Some(guard) = _guard { + std::mem::forget(guard); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn test_env_prefix() { + assert_eq!(env_prefix("test-cli"), "TEST_CLI"); + assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); + } + + #[test] + fn test_env_var_names_derived() { + let prefix = env_prefix("test-cli"); + assert_eq!(format!("{prefix}_LOG"), "TEST_CLI_LOG"); + assert_eq!(format!("{prefix}_LOG_FILE"), "TEST_CLI_LOG_FILE"); + } + + #[test] + #[serial] + fn test_init_logging_default_no_panic() { + std::env::remove_var("TEST_CLI_LOG"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + init_logging("test-cli"); + } + + #[test] + #[serial] + fn test_init_logging_with_stderr_filter_no_panic() { + // set_global_default may fail if another test already set it — that's fine, + // we still exercise the branch up to and including that call. + std::env::set_var("TEST_CLI_LOG", "fern=debug"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + init_logging("test-cli"); + std::env::remove_var("TEST_CLI_LOG"); + } + + #[test] + #[serial] + fn test_init_logging_with_file_dir_no_panic() { + let dir = tempfile::tempdir().unwrap(); + std::env::remove_var("TEST_CLI_LOG"); + std::env::set_var("TEST_CLI_LOG_FILE", dir.path().to_str().unwrap()); + init_logging("test-cli"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + } +} diff --git a/cli/src/man.rs b/cli/src/man.rs new file mode 100644 index 00000000..5a1d0638 --- /dev/null +++ b/cli/src/man.rs @@ -0,0 +1,101 @@ +//! Man page generation. +//! +//! Shared infrastructure for emitting roff-formatted man pages. Sits above +//! both protocol paths (`openapi/` and `graphql/`) and has no +//! protocol-specific dependencies. Mirrors the shape of `completions.rs`. + +use clap::Command; + +/// Returns `true` when `args` contains `"man"` as the first positional +/// token (i.e. the subcommand position). This allows early interception +/// before normal API dispatch — avoiding collision with an API resource +/// that might also be named `man`. +/// +/// Delegates to the shared [`crate::early_intercept::first_positional_is`] +/// helper which handles `--flag value` skip logic and boolean-flag awareness. +pub fn wants_man(args: &[String]) -> bool { + crate::early_intercept::first_positional_is(args, "man") +} + +/// Generate a roff-formatted man page for `cmd` and write it to `writer`. +/// +/// `bin_name` is the name the user types to invoke the CLI (e.g. `"myapi"`). +/// The caller is responsible for building a `Command` that mirrors the full +/// CLI surface (subcommands, flags, etc.) so the generated page is complete. +/// +/// Returns an IO error if writing fails. +pub fn generate_man_to(cmd: Command, bin_name: &str, writer: &mut dyn std::io::Write) -> std::io::Result<()> { + let cmd = cmd.name(bin_name.to_owned()); + let man = clap_mangen::Man::new(cmd); + let mut buf = Vec::new(); + man.render(&mut buf)?; + writer.write_all(&buf) +} + +/// Generate a roff-formatted man page for `cmd` and write it to stdout. +/// +/// Thin wrapper around [`generate_man_to`] that targets `stdout`. +pub fn generate_man(cmd: Command, bin_name: &str) -> std::io::Result<()> { + generate_man_to(cmd, bin_name, &mut std::io::stdout()) +} + +/// Build the `man` subcommand definition. Registered at the root of the +/// command tree so ` man` works. +pub fn man_command() -> Command { + Command::new("man") + .about("Generate a man page (roff format)") + .after_help( + "EXAMPLES:\n \ + # macOS / Linux (user-local)\n \ + man > ~/.local/share/man/man1/.1\n \ + # System-wide (Linux)\n \ + man | sudo tee /usr/local/share/man/man1/.1\n \ + # View directly without installing\n \ + man | groff -Tutf8 -man | less", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn wants_man_basic() { + assert!(wants_man(&args(&["myapi", "man"]))); + } + + #[test] + fn wants_man_false_when_flag_value() { + assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + } + + #[test] + fn wants_man_with_boolean_flag() { + assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + } + + #[test] + fn generate_man_produces_roff() { + let cmd = Command::new("myapi").about("test"); + let mut buf = Vec::new(); + generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + let output = String::from_utf8(buf).expect("man page should be valid UTF-8"); + assert!( + output.contains(".TH"), + "man page should contain a .TH title-header macro, got:\n{}", + &output[..output.len().min(200)] + ); + assert!( + output.contains("myapi"), + "man page should contain the binary name" + ); + assert!( + output.contains("test"), + "man page should contain the about text" + ); + } +} diff --git a/cli/src/openapi/__fixtures__/openapi.json b/cli/src/openapi/__fixtures__/openapi.json new file mode 100644 index 00000000..9b465f33 --- /dev/null +++ b/cli/src/openapi/__fixtures__/openapi.json @@ -0,0 +1,63 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test Fixture API", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "x-fern-sdk-group-name": ["users"], + "x-fern-sdk-method-name": "list", + "operationId": "users_list", + "summary": "List users", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { "description": "OK" } + } + } + }, + "/files/{file_id}": { + "get": { + "x-fern-sdk-group-name": ["files"], + "x-fern-sdk-method-name": "get", + "operationId": "files_get", + "parameters": [ + { + "name": "file_id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { "description": "OK" } + } + } + }, + "/files/{file_id}/thumbnail": { + "get": { + "x-fern-sdk-group-name": ["files"], + "x-fern-sdk-method-name": "thumbnail", + "operationId": "files_thumbnail", + "parameters": [ + { + "name": "file_id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { "description": "OK" } + } + } + } + } +} diff --git a/cli/src/openapi/app.rs b/cli/src/openapi/app.rs new file mode 100644 index 00000000..cceff271 --- /dev/null +++ b/cli/src/openapi/app.rs @@ -0,0 +1,3913 @@ +//! High-level API for building CLIs from OpenAPI specs. +//! +//! [`CliApp`] provides a builder-style API that lets consumers create a +//! fully-functional CLI in just a few lines. [`AppContext`] exposes the +//! loaded spec and executor so that custom command handlers can call the +//! API programmatically. + +use std::collections::HashMap; + +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; +use crate::cli_args; +use crate::custom_commands::CustomCommandRegistry; +use crate::error::{print_error_json, CliError}; +use crate::formatter; +use crate::openapi::commands; +use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; +use crate::openapi::executor; + +/// Split a slash-delimited prefix string into its path components, dropping +/// empty segments so accidental leading/trailing slashes are forgiving. +fn split_prefix(prefix: &str) -> Vec { + prefix + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Merge `incoming` resources into `target` at the given nested namespace +/// path. Empty path = flat top-level merge. Multi-segment path = walk/create +/// intermediate resources, merge at the leaf. +/// +/// **Stutter elision:** at the leaf, if `incoming` contains a top-level +/// resource whose name matches the leaf namespace, that resource's methods +/// and sub-resources are *hoisted* into the namespace itself — eliminating +/// the `myapi v3 customers customers get` repetition that would +/// otherwise occur when a spec's primary domain matches the namespace name. +/// Other top-level resources from the spec become children of the +/// namespace as usual. +fn merge_into_path( + target: &mut HashMap, + path: &[String], + mut incoming: HashMap, +) -> Result<(), CliError> { + if path.is_empty() { + for key in incoming.keys() { + if target.contains_key(key) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{key}' appears in multiple specs" + ))); + } + } + target.extend(incoming); + return Ok(()); + } + + if path.len() == 1 { + let leaf = path[0].clone(); + let entry = target.entry(leaf.clone()).or_insert_with(|| RestResource { + resources: HashMap::new(), + methods: HashMap::new(), + }); + + // Hoist a matching-name resource from the spec into the namespace. + if let Some(matching) = incoming.remove(&leaf) { + for (k, v) in matching.methods { + if entry.methods.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Method key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.methods.insert(k, v); + } + for (k, v) in matching.resources { + if entry.resources.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.resources.insert(k, v); + } + } + + for (k, v) in incoming { + if entry.resources.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.resources.insert(k, v); + } + return Ok(()); + } + + let head = path[0].clone(); + let entry = target.entry(head).or_insert_with(|| RestResource { + resources: HashMap::new(), + methods: HashMap::new(), + }); + merge_into_path(&mut entry.resources, &path[1..], incoming) +} + +/// Replace `{name}` substrings in `s` with values from `subs`. Variables not +/// in the map are left literal so dry-run output and downstream errors can +/// still pinpoint what's missing. +fn substitute_url_vars(s: &str, subs: &HashMap) -> String { + let mut out = s.to_string(); + for (name, value) in subs { + out = out.replace(&format!("{{{name}}}"), value); + } + out +} + +/// Walk the merged doc and substitute server variables in every `root_url` +/// (top-level + per-method, since per-operation server overrides each have +/// their own URL). +fn apply_server_var_substitutions( + doc: &mut crate::openapi::discovery::RestDescription, + subs: &HashMap, +) { + if subs.is_empty() { + return; + } + doc.root_url = substitute_url_vars(&doc.root_url, subs); + for server in &mut doc.servers { + server.url = substitute_url_vars(&server.url, subs); + } + fn walk(res: &mut crate::openapi::discovery::RestResource, subs: &HashMap) { + for method in res.methods.values_mut() { + method.root_url = substitute_url_vars(&method.root_url, subs); + for server in &mut method.servers { + server.url = substitute_url_vars(&server.url, subs); + } + } + for sub in res.resources.values_mut() { + walk(sub, subs); + } + } + for res in doc.resources.values_mut() { + walk(res, subs); + } +} + +/// Apply generator-supplied env-var overrides to every idempotent +/// operation's synthetic idempotency-header parameter. The parser +/// already populated `MethodParameter.env_var` from each +/// `IdempotencyHeader.env` declared in the spec; this pass overlays the +/// builder map so calls like `.idempotency_header_env("Idempotency-Key", +/// "API_IDEMPOTENCY_KEY")` win over a value baked into the spec. +/// +/// Keys in `envs` are matched against the entry's `name` first, then +/// its `header` value — letting generators register against whichever +/// identifier they emit at the call site. +fn apply_idempotency_header_envs( + doc: &mut crate::openapi::discovery::RestDescription, + envs: &HashMap, +) { + if envs.is_empty() || doc.idempotency_headers.is_empty() { + return; + } + + // Resolve each idempotency header's wire header name to an env var, + // checking the `name` field first and falling back to `header`. + // Collected up front so the per-method walk below is O(headers) per + // method instead of O(headers * builder_entries). + let mut header_to_env: HashMap = HashMap::new(); + for h in &doc.idempotency_headers { + let resolved = h + .name + .as_deref() + .and_then(|n| envs.get(n)) + .or_else(|| envs.get(&h.header)); + if let Some(env_var) = resolved { + header_to_env.insert(h.header.clone(), env_var.clone()); + } + } + if header_to_env.is_empty() { + return; + } + + fn walk( + res: &mut crate::openapi::discovery::RestResource, + header_to_env: &HashMap, + ) { + for method in res.methods.values_mut() { + if !method.idempotent { + continue; + } + for (header, env_var) in header_to_env { + if let Some(param) = method.parameters.get_mut(header) { + if param.location.as_deref() == Some("header") { + param.env_var = Some(env_var.clone()); + } + } + } + } + for sub in res.resources.values_mut() { + walk(sub, header_to_env); + } + } + for res in doc.resources.values_mut() { + walk(res, &header_to_env); + } +} + +fn merge_schemas( + acc: &mut HashMap, + incoming: HashMap, +) -> Result<(), CliError> { + // Multi-spec setups commonly share schema names (`ErrorResponse`, + // `Pagination`, `Meta`) across many specs authored from the same + // template — collisions are the norm, not a bug. + // First write wins; schemas are only used for best-effort request-body + // validation, so a worst-case mismatch surfaces as a client-side + // validation warning, not silent corruption. A future structural-equality + // check could promote real differences back to an error. + for (key, schema) in incoming { + acc.entry(key).or_insert(schema); + } + Ok(()) +} + +/// Merge security-scheme declarations from another spec into the accumulator. +/// First write wins on collisions — multi-spec setups frequently re-declare a +/// shared `bearerAuth` from a common template, and a structural-equality check +/// would surface noise rather than help. Each operation's +/// `security_requirements` are denormalized into the operation itself at parse +/// time, so schemes only need to be merged at the top level for the eventual +/// `RoutingAuthProvider` registry. +fn merge_security_schemes( + acc: &mut HashMap, + incoming: HashMap, +) { + for (key, scheme) in incoming { + acc.entry(key).or_insert(scheme); + } +} + +/// Merge `x-fern-sdk-variables` declarations across specs. First write +/// wins on name collisions, mirroring [`merge_schemas`] and +/// [`merge_security_schemes`]. Multi-spec setups that share a common +/// variable across two OpenAPI files should only register the flag once +/// at the root, and a single source of truth is what makes resolution +/// deterministic. +fn merge_sdk_variables( + acc: &mut Vec, + incoming: Vec, +) { + use std::collections::HashSet; + let existing: HashSet = acc.iter().map(|v| v.name.clone()).collect(); + for var in incoming { + if !existing.contains(&var.name) { + acc.push(var); + } + } +} + +/// Returns true when the kebab-cased flag derived from an +/// `x-fern-sdk-variables` declaration collides with a built-in CLI flag +/// (`--params`, `--format`, `--dry-run`, …). Registering a global with +/// the same long name would panic clap's debug_assert at command tree +/// construction; the caller skips the offending entry and emits a +/// `tracing::warn!` so the spec author can rename the variable. +pub(crate) fn sdk_variable_collides_with_builtin(kebab: &str) -> bool { + crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab) +} + +/// Merge `x-fern-global-headers` declarations across specs. First write +/// wins on header-name collisions, mirroring [`merge_sdk_variables`]. +/// Multi-spec setups that share a common header across two OpenAPI files +/// should only register the flag once at the root. +fn merge_global_headers( + acc: &mut Vec, + incoming: Vec, +) { + use std::collections::HashSet; + let existing: HashSet = acc.iter().map(|h| h.header.clone()).collect(); + for h in incoming { + if !existing.contains(&h.header) { + acc.push(h); + } + } +} + +/// Derive the kebab-cased CLI flag (`--`) for a global header. +/// Prefers `name` (the SDK display identifier) when present; otherwise +/// falls back to kebab-casing the wire header name. Mirrors the +/// `flag_name_override` pathway used by `x-fern-idempotency-headers`. +pub(crate) fn global_header_flag_name(h: &crate::openapi::discovery::GlobalHeader) -> String { + let source = h.name.as_deref().unwrap_or(h.header.as_str()); + crate::text::to_kebab_flag(source) +} + +/// Stable clap arg ID for a global header. Anchored to the wire header +/// name so per-op parameter lookups (which key off the same string) +/// remain consistent with what clap returns. +pub(crate) fn global_header_arg_id(h: &crate::openapi::discovery::GlobalHeader) -> String { + format!("__global_header::{}", h.header) +} + +/// Returns true when the kebab-cased flag derived from an +/// `x-fern-global-headers` entry collides with a built-in CLI flag +/// (`--params`, `--format`, …) or an already-registered global. clap +/// would panic in debug builds on collision; we skip the offending entry +/// with a `tracing::warn!` so the spec still loads. +fn global_header_flag_collides_with_builtin(kebab: &str) -> bool { + crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab) +} + +/// Resolve a global header value from `matched_args`, the env, and the +/// configured default — in that order. Returns `None` when none of the +/// three sources produced a value, OR when the resolved value is empty +/// or whitespace-only (callers shouldn't stamp a header like `X-API-Stage:` +/// on the wire — that's almost always a user mistake worth surfacing as a +/// required-header error, and matches the env-var-handling convention). +/// +/// `matched_args.get_one::` already incorporates clap's +/// `.env()` and `.default_value()` bindings, so the lookup is a single +/// read; the explicit env/default fields on [`GlobalHeader`] are what +/// feed those clap bindings at registration time. +pub(crate) fn resolve_global_header_value( + matched_args: &clap::ArgMatches, + h: &crate::openapi::discovery::GlobalHeader, +) -> Option { + matched_args + .get_one::(&global_header_arg_id(h)) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +/// True when an operation declares a `header`-located parameter with +/// the same wire-name as a global header AND the user supplied a value +/// for it in `params`. HTTP header names are case-insensitive per RFC +/// 7230 §3.2, so the lookup is `eq_ignore_ascii_case` rather than +/// `HashMap::contains_key` / `HashMap::get`. +pub(crate) fn per_op_header_param_overrides_global( + params: &serde_json::Map, + method: &RestMethod, + wire_name: &str, +) -> bool { + let supplied = params + .keys() + .any(|k| k.eq_ignore_ascii_case(wire_name)); + if !supplied { + return false; + } + method + .parameters + .iter() + .any(|(k, p)| k.eq_ignore_ascii_case(wire_name) && p.location.as_deref() == Some("header")) +} + +/// Build the structured validation error used when a required global +/// header has neither a CLI/env/default value nor a per-op override. +/// Shared by both the built-in command path +/// ([`build_global_header_overrides`]) and the custom-command path +/// ([`AppContext::extra_headers_for`]) so users get the same message +/// regardless of which dispatcher they hit. +fn missing_required_global_header_error(h: &crate::openapi::discovery::GlobalHeader) -> CliError { + let flag = global_header_flag_name(h); + let env_hint = match &h.env { + Some(e) => format!(" or set ${e}"), + None => String::new(), + }; + CliError::Validation(format!( + "Missing required global header '{}': provide --{}{}", + h.header, flag, env_hint + )) +} + +/// Shared implementation of the per-op-aware required-header walk. +/// Both [`build_global_header_overrides`] (built-in path) and +/// [`AppContext::extra_headers_for`] (custom-command path) call this +/// helper, differing only in how a header's value is resolved — the +/// built-in path reads directly from clap's `ArgMatches`, the +/// custom-command path looks up the pre-resolved map. +/// +/// Walks `doc_global_headers` and for each entry: +/// * skips if the operation declares a same-named header param that +/// the user supplied (per-op wins); +/// * emits `(wire-name, value)` if the resolver returns a non-empty +/// value; +/// * errors if the header is required (`optional: false`) and neither +/// a resolved value nor a per-op override is present. +/// +/// The resolver closure is responsible for any trimming / empty-string +/// filtering — see [`resolve_global_header_value`] for the canonical +/// implementation. +fn finalize_global_header_overrides( + doc_global_headers: &[crate::openapi::discovery::GlobalHeader], + method: &RestMethod, + per_op_params: &serde_json::Map, + mut resolver: R, +) -> Result, CliError> +where + R: FnMut(&crate::openapi::discovery::GlobalHeader) -> Option, +{ + let mut out = Vec::new(); + for h in doc_global_headers { + let overridden_by_per_op = + per_op_header_param_overrides_global(per_op_params, method, &h.header); + let resolved = resolver(h); + match (resolved, overridden_by_per_op) { + (Some(value), false) => out.push((h.header.clone(), value)), + (Some(_), true) => { /* per-op wins, do not stamp */ } + (None, true) => { /* per-op satisfies the required check */ } + (None, false) => { + if !h.optional { + return Err(missing_required_global_header_error(h)); + } + } + } + } + Ok(out) +} + +/// Build the resolved `(wire-name, value)` list of `x-fern-global-headers` +/// to stamp on every outgoing request for this invocation. +/// +/// The resolution chain per header is `CLI flag > env var > default`, +/// implemented by clap's `.env()` + `.default_value()` bindings — see +/// the registration loop in `run_async`. +/// +/// Per-operation overrides: if the operation declares a `header`-located +/// parameter with the same (case-insensitive) wire-name AND the user +/// supplied a value for it (present in `params`), the global header is +/// suppressed; the per-op value wins both on the wire and in the +/// required-header satisfiability check. This mirrors Fern's importer +/// behavior where a header parameter declared on the operation replaces +/// the global. +/// +/// Errors when a `required` (i.e. `optional: false`) global header has +/// neither a CLI/env/default value nor a per-op override. +pub(crate) fn build_global_header_overrides( + matched_args: &clap::ArgMatches, + doc: &RestDescription, + method: &RestMethod, + params: &serde_json::Map, +) -> Result, CliError> { + finalize_global_header_overrides(&doc.global_headers, method, params, |h| { + resolve_global_header_value(matched_args, h) + }) +} + +/// Compose the root `--help` footer from the optional global-headers +/// section, the optional auth section, and the always-present runtime +/// footer. Sections are joined with a single newline; absent sections +/// are skipped entirely (no stray blank dividers). +/// +/// Extracted so the section-skipping logic is unit-testable in +/// isolation — the clap `Command` it eventually feeds into is opaque +/// and harder to introspect from tests. +pub(crate) fn compose_root_after_help_sections( + global_headers_section: Option<&str>, + auth_section: Option<&str>, + footer: &str, +) -> String { + let mut sections: Vec<&str> = Vec::with_capacity(3); + if let Some(s) = global_headers_section { + sections.push(s); + } + if let Some(s) = auth_section { + sections.push(s); + } + sections.push(footer); + sections.join("\n") +} + +/// Result of [`register_global_flags_with_help`] — carries both the +/// augmented command and the optional `Global headers:` help section +/// so callers can compose the root after-help footer. +struct RegisterGlobalFlagsResult { + cmd: clap::Command, + global_headers_section: Option, +} + +/// Register all global flags (server variables, SDK variables, global +/// headers, auth CLI args) onto `cmd`. Returns the augmented command. +/// Used by the completion path where the help-section text is not needed. +fn register_global_flags( + cmd: clap::Command, + server_vars: &[ServerVar], + doc: &RestDescription, + auth_bindings: &[(String, crate::auth::SchemeBinding)], +) -> clap::Command { + register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd +} + +/// Register all global flags and return both the command and the +/// optional `Global headers:` section for the root help footer. The +/// normal path uses this variant to compose the after-help text. +fn register_global_flags_with_help( + mut cmd: clap::Command, + server_vars: &[ServerVar], + doc: &RestDescription, + auth_bindings: &[(String, crate::auth::SchemeBinding)], +) -> RegisterGlobalFlagsResult { + for var in server_vars { + let kebab = var.name.replace('_', "-"); + let help_text = var + .description + .clone() + .unwrap_or_else(|| { + format!("Value for the {{{}}} URL template variable", var.name) + }); + let mut arg = clap::Arg::new(var.name.clone()) + .long(kebab) + .global(true) + .value_name(var.name.to_uppercase()) + .help(help_text); + if let Some(env) = &var.env_var { + arg = arg.env(env.clone()); + } + if let Some(default) = &var.default { + arg = arg.default_value(default.clone()); + } + cmd = cmd.arg(arg); + } + + for var in &doc.sdk_variables { + let kebab = crate::text::to_kebab_flag(&var.name); + if sdk_variable_collides_with_builtin(&kebab) { + tracing::warn!( + "x-fern-sdk-variables entry '{}' would register --{} which \ + collides with a built-in flag; skipping. Rename the \ + variable in the spec to avoid the collision.", + var.name, + kebab, + ); + continue; + } + let env_name = crate::text::to_screaming_snake(&var.name); + let help_text = var.description.clone().unwrap_or_else(|| { + format!( + "Value for the SDK variable '{}' (substituted into path templates)", + var.name + ) + }); + let arg = clap::Arg::new(var.name.clone()) + .long(kebab) + .global(true) + .value_name(env_name.clone()) + .help(help_text) + .env(env_name); + cmd = cmd.arg(arg); + } + + use std::collections::HashSet; + let mut registered_kebabs: HashSet = HashSet::new(); + let mut global_header_help_pairs: Vec<(String, String)> = Vec::new(); + for h in &doc.global_headers { + let kebab = global_header_flag_name(h); + if global_header_flag_collides_with_builtin(&kebab) { + tracing::warn!( + "x-fern-global-headers entry '{}' would register --{} which \ + collides with a built-in flag; skipping. Rename via \ + `name:` in the spec to avoid the collision.", + h.header, + kebab, + ); + continue; + } + if !registered_kebabs.insert(kebab.clone()) { + tracing::warn!( + "x-fern-global-headers entry '{}' would register --{} which \ + duplicates an earlier global-header flag; skipping.", + h.header, + kebab, + ); + continue; + } + let value_name = crate::text::to_screaming_snake(&kebab); + let mut help_lines: Vec = + vec![format!("Global header `{}` (sent on every request).", h.header)]; + if let Some(env) = &h.env { + help_lines.push(format!("Env: {env}.")); + } + if let Some(def) = &h.default { + help_lines.push(format!("Default: {def}.")); + } else if !h.optional { + help_lines.push("Required.".to_string()); + } + let help_text = help_lines.join(" "); + let prefix = format!("--{kebab} <{value_name}>"); + global_header_help_pairs.push((prefix, help_text.clone())); + let mut arg = clap::Arg::new(global_header_arg_id(h)) + .long(kebab) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); + } + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cmd = cmd.arg(arg); + } + let global_headers_section: Option = if global_header_help_pairs.is_empty() { + None + } else { + let prefix_width = global_header_help_pairs + .iter() + .map(|(p, _)| p.chars().count()) + .max() + .unwrap_or(0); + let rows: Vec = global_header_help_pairs + .iter() + .map(|(prefix, help)| { + let pad = prefix_width.saturating_sub(prefix.chars().count()); + format!(" {prefix}{:pad$} {help}", "", pad = pad) + }) + .collect(); + Some(format!("Global headers:\n{}", rows.join("\n"))) + }; + + for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { + cmd = cmd.arg( + clap::Arg::new(arg_name.clone()) + .long(arg_name.clone()) + .global(true) + .value_name(arg_name.to_uppercase().replace('-', "_")) + .help(format!("Credential value for auth source `{arg_name}`")), + ); + } + + RegisterGlobalFlagsResult { cmd, global_headers_section } +} + +/// A custom command handler function. +/// +/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an +/// [`AppContext`] that provides access to the spec, auth token, and +/// executor. +pub type HandlerFn = crate::custom_commands::HandlerFn; + +/// Internal entry describing one OpenAPI spec to be merged. +pub(crate) struct SpecEntry { + yaml: String, + /// Empty = flat at the top level. One entry = wrap under that prefix. + /// Multiple = wrap under nested resources (`["v3", "customers"]` → + /// `v3.customers.*`). Path is constructed from slash-delimited input on + /// the public API. + prefix_path: Vec, + /// Overlay documents to apply before parsing. + overlays: Vec, + /// Optional overrides YAML strings that are deep-merged onto the base spec + /// before parsing. Applied sequentially — later overrides take precedence. + /// Matches the Fern CLI `generators.yml` `overrides:` key behavior: + /// maps merge key-by-key, arrays replace wholesale, `null` deletes keys. + overrides: Vec, +} + +/// A server-URL template variable like `{store_hash}` in +/// `https://api.example.com/stores/{store_hash}/v3`. Resolved at runtime +/// from a CLI flag (`--`), an env var, or a built-in default — first +/// match wins. +#[derive(Clone)] +pub(crate) struct ServerVar { + /// OpenAPI variable name as it appears in the URL template (`store_hash`). + name: String, + /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). + env_var: Option, + /// Fallback default (for variables that have one — tenant/store + /// identifiers typically don't). + default: Option, + /// One-line `--help` string. + description: Option, +} + +/// Builder for a schema-driven CLI application (OpenAPI). +pub struct CliApp { + pub(crate) name: String, + pub(crate) specs: Vec, + title_override: Option, + description_override: Option, + /// Auth bindings registered via [`auth_scheme`](Self::auth_scheme), + /// [`auth_basic_scheme`](Self::auth_basic_scheme), and + /// [`auth_provider`](Self::auth_provider). The constructed provider is + /// built from these (lowered against the spec's + /// `components.securitySchemes`). + auth_bindings: Vec<(String, SchemeBinding)>, + /// Override for how bindings compose. Defaults to [`AuthStrategy::Auto`] + /// — the spec drives the choice. Generators that already know the + /// API's auth model can pin a specific strategy. + auth_strategy: AuthStrategy, + /// Trust roots parsed at builder-call time. Storing parsed certs (not + /// raw bytes) means the validation error message lives in one place + /// — at the call site of `extra_root_cert`, where it's most useful. + extra_root_certs: Vec, + /// Raw PEM bytes for each trust root added via `extra_root_cert`, kept + /// alongside the parsed `extra_root_certs` above. Threaded through to + /// `HttpConfig::with_parsed_root_certs` so transport-neutral callers + /// (`HttpConfig::resolve`) can hand PEM to non-reqwest TLS connectors + /// (e.g. `tokio-tungstenite`). + extra_root_certs_pem: Vec>, + pub(crate) custom_commands: CustomCommandRegistry, + pub(crate) server_vars: Vec, + /// Generator-supplied environment-variable overrides for spec-root + /// idempotency headers (parsed from `x-fern-idempotency-headers`). + /// Keyed by the entry's `name` (preferred) or `header` value; + /// `CliApp::build_doc` applies these to every idempotent operation's + /// synthetic header parameter so the `--` accepts the value + /// from the env var as a fallback. + idempotency_header_envs: HashMap, + /// Compile-time preset audiences. Operations whose + /// `x-fern-audiences` doesn't intersect this set are dropped from + /// the command tree before clap ever sees them. Empty (the default) + /// = no filter — every operation is included. + /// + /// Configured by the binary's `main.rs` via [`Self::audiences`]; not + /// exposed as a CLI flag, mirroring fern's intent that audience + /// selection is a build-time decision baked into the generated SDK + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). + audiences: Vec, +} + +impl CliApp { + /// Create a new CLI application with the given binary name. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + specs: Vec::new(), + title_override: None, + description_override: None, + auth_bindings: Vec::new(), + auth_strategy: AuthStrategy::Auto, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + custom_commands: CustomCommandRegistry::new(), + server_vars: Vec::new(), + idempotency_header_envs: HashMap::new(), + audiences: Vec::new(), + } + } + + /// Pin the CLI surface to operations tagged with one of the given + /// `x-fern-audiences` values. Operations without an + /// `x-fern-audiences` tag, or whose tags don't intersect this set, + /// are dropped from the command tree at build time — they don't + /// appear in `--help`, JSON help, completions, or anywhere else. + /// + /// Multiple audiences union (OR): an operation tagged with *any* of + /// the listed audiences survives. Calling `.audiences([])` (or not + /// calling this at all) is a no-op — every operation is included. + /// + /// Audience selection is a compile-time decision baked into each + /// binary's `main.rs`, not a runtime flag. This mirrors fern's + /// importer semantics + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`), + /// where the audience filter physically removes operations from the + /// IR rather than hiding them at execution time. + /// + /// ```ignore + /// CliApp::new("my-public-api") + /// .spec(include_str!("openapi.json")) + /// .audiences(["public"]) + /// .run(); + /// ``` + pub fn audiences(mut self, audiences: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.audiences = audiences + .into_iter() + .map(Into::into) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + self + } + + /// Register an environment-variable fallback for a spec-root + /// idempotency header (declared via `x-fern-idempotency-headers`). + /// + /// `name` matches against the entry's `name` field first, then its + /// `header` field — whichever the generator finds most convenient at + /// the call site. When the user invokes an idempotent operation + /// without the corresponding `--`, the value is taken from the + /// named environment variable. + /// + /// ```ignore + /// CliApp::new("api") + /// .spec(include_str!("openapi.json")) + /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") + /// .run(); + /// ``` + /// + /// This is the cli-sdk entry point referenced by FER-9852, where the + /// generator emits one call per parsed idempotency header. The + /// header itself is only sent on operations marked + /// `x-fern-idempotent: true`; non-idempotent operations are + /// unaffected. + pub fn idempotency_header_env(mut self, name: &str, env_var: &str) -> Self { + self.idempotency_header_envs.insert(name.to_string(), env_var.to_string()); + self + } + + /// Register a server-URL template variable (e.g. `{store_hash}`). + /// + /// Auto-generates a global `--` flag (with kebab-cased name) and + /// resolves the value at request time from, in order: + /// 1. The CLI flag + /// 2. The given env var (if any) + /// 3. The built-in default (if any) + /// 4. Otherwise, errors with a helpful message + /// + /// Used for multi-tenant APIs where every URL is parameterized + /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// referenced in `servers[].url` but not registered here remain literal + /// in the URL (and the request will fail at send time), so registering + /// them is effectively required. + pub fn server_var( + mut self, + name: &str, + env_var: Option<&str>, + default: Option<&str>, + description: Option<&str>, + ) -> Self { + self.server_vars.push(ServerVar { + name: name.to_string(), + env_var: env_var.map(str::to_string), + default: default.map(str::to_string), + description: description.map(str::to_string), + }); + self + } + + /// Add an OpenAPI spec YAML string. May be called multiple times; specs are flat-merged. + /// Typically used with `include_str!`. + pub fn spec(mut self, yaml: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: Vec::new(), + overlays: Vec::new(), + overrides: Vec::new(), + }); + self + } + + /// Add an OpenAPI spec with a Fern-style overrides file applied before parsing. + /// + /// The override YAML is deep-merged onto the spec: maps merge key-by-key + /// (override wins on leaf collisions), arrays replace wholesale, and + /// `null` values delete the corresponding key. This matches the Fern CLI's + /// `generators.yml` `overrides:` behavior. + /// + /// Use this to add `x-fern-sdk-group-name`, `x-fern-sdk-method-name`, or + /// any other spec-level patches without modifying the upstream spec. + pub fn spec_with_overrides(mut self, yaml: &str, overrides: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: Vec::new(), + overlays: Vec::new(), + overrides: vec![overrides.to_string()], + }); + self + } + + /// Add an OpenAPI spec whose resources are wrapped under `prefix`. Use + /// slashes to nest: `"v3/customers"` puts the spec's resources under + /// `v3.customers.*`. Multiple `spec_under` calls with the same path + /// merge into a shared namespace; inner-resource collisions error. + pub fn spec_under(mut self, prefix: &str, yaml: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: split_prefix(prefix), + overlays: Vec::new(), + overrides: Vec::new(), + }); + self + } + + /// Like [`spec_under`](Self::spec_under), but with a Fern-style overrides + /// file deep-merged onto the spec before parsing. + pub fn spec_under_with_overrides( + mut self, + prefix: &str, + yaml: &str, + overrides: &str, + ) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: split_prefix(prefix), + overlays: Vec::new(), + overrides: vec![overrides.to_string()], + }); + self + } + + /// Add multiple specs that all merge under the same `prefix` (flat). + /// Equivalent to repeated `spec_under` calls; inner-resource collisions + /// across the specs error at startup. + pub fn specs_under(mut self, prefix: &str, yamls: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let path = split_prefix(prefix); + for yaml in yamls { + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path.clone(), + overlays: Vec::new(), + overrides: Vec::new(), + }); + } + self + } + + /// Add multiple specs under `prefix`, each given its own sub-namespace. + /// `specs_under_named("v3", [("customers", yaml1), ("orders", yaml2)])` + /// produces `v3.customers.*` and `v3.orders.*` — what `specs_under` + /// would flatten, this preserves per-spec scoping. Useful when specs + /// share cross-cutting tags (`Metafields`) that would otherwise collide + /// once flattened. + pub fn specs_under_named(mut self, prefix: &str, named: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + let parent = split_prefix(prefix); + for (sub, yaml) in named { + let mut path = parent.clone(); + path.extend(split_prefix(sub.as_ref())); + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path, + overlays: Vec::new(), + overrides: Vec::new(), + }); + } + self + } + + /// Like [`specs_under_named`](Self::specs_under_named), but each entry is + /// a `(name, yaml, overrides_yaml)` triple. The overrides file is + /// deep-merged onto the spec before parsing. + /// + /// ```ignore + /// CliApp::new("myapi") + /// .specs_under_named_with_overrides("v3", [ + /// ("customers", + /// include_str!("specs/management/customers.v3.yml"), + /// include_str!("overrides/management/customers.v3.yml")), + /// ]) + /// ``` + pub fn specs_under_named_with_overrides( + mut self, + prefix: &str, + named: I, + ) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + O: AsRef, + { + let parent = split_prefix(prefix); + for (sub, yaml, overrides) in named { + let mut path = parent.clone(); + path.extend(split_prefix(sub.as_ref())); + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path, + overlays: Vec::new(), + overrides: vec![overrides.as_ref().to_string()], + }); + } + self + } + + /// Add an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) + /// to the most recently added spec. Overlays are applied in order before + /// the spec is parsed into the internal representation. + /// + /// # Panics + /// + /// Panics if called before `.spec()` or `.spec_under()`. + /// + /// # Example + /// + /// ```rust,ignore + /// use fern_cli_sdk::openapi::CliApp; + /// + /// CliApp::new("my-api") + /// .spec(include_str!("openapi.json")) + /// .overlay(include_str!("overlay.yaml")) + /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") + /// .run() + /// ``` + pub fn overlay(mut self, overlay_yaml: &str) -> Self { + let entry = self + .specs + .last_mut() + .expect("overlay() called before spec(); add a spec first"); + entry.overlays.push(overlay_yaml.to_string()); + self + } + + /// Override the top-level --help title, regardless of what the spec(s) declare. + pub fn title(mut self, t: &str) -> Self { + self.title_override = Some(t.to_string()); + self + } + + /// Override the top-level --help description, regardless of what the spec(s) declare. + pub fn description(mut self, d: &str) -> Self { + self.description_override = Some(d.to_string()); + self + } + + /// Build the merged `RestDescription` from all registered specs. + pub(crate) fn build_doc(&self) -> Result { + if self.specs.is_empty() { + return Err(CliError::Discovery( + "No spec provided. Call .spec() on CliApp.".to_string(), + )); + } + + let mut merged: Option = None; + + for entry in &self.specs { + // 1. Apply overlays (RFC 7396 style) first. + let effective_yaml = crate::openapi::overlay::apply_overlays_to_spec( + &entry.yaml, + &entry.overlays, + )?; + + // 2. Apply Fern-style overrides (deep-merge) on top. + let spec_doc = if entry.overrides.is_empty() { + crate::openapi::load_openapi_spec(&effective_yaml, &self.name)? + } else { + let mut value: serde_yaml::Value = serde_yaml::from_str(&effective_yaml) + .map_err(|e| CliError::Discovery( + format!("Failed to parse OpenAPI spec: {e}"), + ))?; + for ovr in &entry.overrides { + let override_value: serde_yaml::Value = serde_yaml::from_str(ovr) + .map_err(|e| CliError::Discovery( + format!("Failed to parse overrides YAML: {e}"), + ))?; + value = crate::openapi::deep_merge_yaml(value, override_value); + } + crate::openapi::load_openapi_spec_from_value(value, &self.name)? + }; + + match merged { + None => { + let mut base = spec_doc; + let resources = std::mem::take(&mut base.resources); + base.resources = HashMap::new(); + merge_into_path(&mut base.resources, &entry.prefix_path, resources)?; + merged = Some(base); + } + Some(ref mut acc) => { + merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; + merge_schemas(&mut acc.schemas, spec_doc.schemas)?; + merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); + merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); + } + } + } + + let mut doc = merged.expect("at least one spec was processed"); + if let Some(ref t) = self.title_override { + doc.title = Some(t.clone()); + } + if let Some(ref d) = self.description_override { + doc.description = Some(d.clone()); + } + + // Apply generator-supplied idempotency-header env overrides. + // The parser populates each idempotent operation's synthetic + // header MethodParameter with `env_var = entry.env` from the + // spec; this pass lets the generator override or supply that + // mapping post-hoc (FER-9852 builder API) so end users don't + // need to edit the spec to wire a new env var. + if !self.idempotency_header_envs.is_empty() { + apply_idempotency_header_envs(&mut doc, &self.idempotency_header_envs); + } + + Ok(doc) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::from_env(env))`. + /// Covers the 80% case — most callers bind a scheme to one env var. + /// + /// ```ignore + /// CliApp::new("api") + /// .spec(include_str!("openapi.json")) + /// .auth_scheme_env("bearerAuth", "API_TOKEN") + /// .run(); + /// ``` + pub fn auth_scheme_env(self, scheme_name: &str, env_var: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::from_env(env_var)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::cli(arg_name))`. + /// Auto-registers a global `--` flag at run time. Accepts + /// either `"api-token"` or `"--api-token"`. + pub fn auth_scheme_cli(self, scheme_name: &str, arg_name: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::cli(arg_name)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::file(path))`. + /// `~` and `~/` are expanded against `$HOME`. + pub fn auth_scheme_file(self, scheme_name: &str, path: impl AsRef) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::file(path)) + } + + /// Bind a credential source to a single-value auth scheme declared in the + /// spec's `components.securitySchemes` (bearer / apiKey / oauth2). + /// + /// `scheme_name` should match the spec key. The credential's resolved + /// value is sent according to the scheme's declared shape: + /// + /// | Scheme | Outgoing | + /// | -------------------- | ------------------------------------- | + /// | `http: bearer` | `Authorization: Bearer ` | + /// | `apiKey, in: header` | `: ` | + /// | `oauth2` | `Authorization: Bearer ` | + /// + /// When any operation in the spec declares per-endpoint `security:`, + /// the constructed provider is a [`RoutingAuthProvider`][rap] that picks + /// the right scheme per request. Otherwise it's a plain + /// [`AnyAuthProvider`][aap] that tries each binding in order. + /// + /// [rap]: crate::auth::RoutingAuthProvider + /// [aap]: crate::auth::AnyAuthProvider + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Token(source))); + self + } + + /// Bind separate username and password sources to an `http: basic` scheme. + /// Both must resolve for the provider to attach `Authorization: Basic + /// base64(user:pass)`; if either is missing the binding contributes no + /// credentials. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Basic { username, password }, + )); + self + } + + /// Bind a single source to the *username* half of an `http: basic` + /// scheme; password goes out as the empty string. Use for APIs that + /// expect the credential in the basic-auth username slot (Close, + /// Stripe-with-key-as-username, etc.). + /// + /// Equivalent to [`auth_basic_scheme`] with the password set to an + /// always-resolving zero-length source, but distinct because the + /// SDK's `has_credentials()` check only looks at the username here — + /// callers don't need to invent a sentinel for the unused half. + /// + /// [`auth_basic_scheme`]: Self::auth_basic_scheme + pub fn auth_basic_scheme_username_only( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::BasicUsernameOnly(username), + )); + self + } + + /// Symmetric counterpart to [`auth_basic_scheme_username_only`] — bind + /// a single source to the basic-auth password while the username goes + /// out empty. Used by APIs that put the token in the password slot. + /// + /// [`auth_basic_scheme_username_only`]: Self::auth_basic_scheme_username_only + pub fn auth_basic_scheme_password_only( + mut self, + scheme_name: &str, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::BasicPasswordOnly(password), + )); + self + } + + /// Plug in a fully-custom [`AuthProvider`][crate::auth::AuthProvider] for + /// a scheme name. Useful when the spec uses a scheme the SDK doesn't + /// model out-of-the-box (mTLS-derived headers, request signing, OAuth2 + /// client-credentials with token refresh, etc.). + /// + /// Accepts any concrete `AuthProvider` by value and wraps it in [`Arc`] + /// internally. For pre-built `Arc` values (sharing a + /// provider across multiple binders), use [`auth_provider_shared`]. + /// + /// [`auth_provider_shared`]: Self::auth_provider_shared + pub fn auth_provider

(self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_provider_shared(scheme_name, std::sync::Arc::new(provider)) + } + + /// Same as [`auth_provider`] but takes an already-built + /// [`DynAuthProvider`]. Use this when sharing one provider across + /// multiple bindings or storing custom providers in a registry. + /// + /// [`auth_provider`]: Self::auth_provider + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: DynAuthProvider, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Custom(provider), + )); + self + } + + /// Pin how the bound auth schemes compose into a single provider. + /// Defaults to [`AuthStrategy::Auto`], which derives the strategy from + /// the spec (Routing if any operation declares per-endpoint security, + /// otherwise Any). + /// + /// Generators that know their API's auth model statically can override + /// this — most importantly to express the [`All`][a] case (every + /// scheme on every request) which the spec doesn't always model. + /// + /// [a]: AuthStrategy::All + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.auth_strategy = strategy; + self + } + + /// Register a custom top-level subcommand with its handler function. + /// + /// Equivalent to [`command_under`](Self::command_under) with an empty path. + pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { + self.custom_commands.register(cmd, handler); + self + } + + /// Register a custom subcommand under an existing path in the spec-derived + /// command tree. Useful for adding a new leaf alongside spec-generated + /// commands (e.g. grafting `webhooks verify` next to a spec-generated + /// `webhooks list` and `webhooks create`). + /// + /// - `path` — the parent path the command should be grafted under. An + /// empty path registers the command at the top level. Intermediate + /// parents that do not yet exist are auto-created. + /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final + /// segment of the path. + /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and + /// the [`AppContext`]. + /// + /// If a subcommand with the same leaf name already exists at the target + /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — + /// custom commands take precedence on leaf collisions. + pub fn command_under>( + mut self, + path: &[S], + cmd: clap::Command, + handler: HandlerFn, + ) -> Self { + self.custom_commands.register_under(path, cmd, handler); + self + } + + /// Register an extra trust root that this CLI will accept on top of the + /// system's default roots. `pem` must be a PEM-encoded certificate (or + /// concatenated PEM bundle), typically loaded with `include_bytes!`. + /// + /// Useful for distributing a CLI inside an organization where every + /// machine should trust the company's internal CA out of the box, without + /// asking each user to set `_CA_BUNDLE`. + /// + /// ```ignore + /// # // ignored: needs a real PEM file at the include path. + /// CliApp::new("internal-tool") + /// .spec(include_str!("openapi.json")) + /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) + /// .run() + /// ``` + /// + /// Panics if the bytes don't parse as PEM, or if the PEM contains no + /// certificates. Failing fast at startup is preferable to silently + /// shipping a CLI that ignores its bundled cert. + pub fn extra_root_cert(mut self, pem: &[u8]) -> Self { + // Share the validation path with `HttpConfig::with_extra_root_cert` + // so error wording stays in sync between the panicking builder API + // and the Result-returning lower-level API. + let certs = crate::http::parse_extra_root_cert(pem) + .unwrap_or_else(|e| panic!("CliApp::extra_root_cert: {e}")); + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.push(pem.to_vec()); + self + } + + /// Run the CLI application. This is the main entry point. + /// + /// Builds a tokio runtime internally so the caller's `main()` does not + /// need to be async. + pub fn run(self) { + // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't + // panic. Must happen before any I/O. + crate::reset_sigpipe(); + + // Load .env file if present (silently ignored if missing) + let _ = dotenvy::dotenv(); + + // Initialize structured logging (no-op if env vars are unset) + crate::init_logging(&self.name); + + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + if let Err(err) = rt.block_on(self.run_async()) { + print_error_json(&err); + std::process::exit(err.exit_code()); + } + } + + /// The async implementation of the CLI run loop. + async fn run_async(mut self) -> Result<(), CliError> { + let args: Vec = std::env::args().collect(); + + // Handle --version early (before loading spec) + if args.iter().any(|a| cli_args::is_version_flag(a)) { + println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + // Build the HTTP config once per run. Holds the binary name (used to + // scope env-var lookups) and any compile-time trust roots. The roots + // were already validated at builder time; we just thread the parsed + // certs through. + let http_config = crate::http::HttpConfig::new(&self.name)? + .with_parsed_root_certs( + self.extra_root_certs.iter().cloned(), + self.extra_root_certs_pem.iter().cloned(), + ); + + // Load and merge all API specs + let mut doc = self.build_doc()?; + + // Apply the audience filter *before* anything else inspects + // `doc`. The filter physically removes operations whose + // `x-fern-audiences` doesn't intersect the binary's preset + // audience set, so excluded operations never appear in: + // - the JSON help output below (`render_json_help`), + // - the clap command tree (`build_cli`), + // - `--help` for any subcommand, + // - completions / introspection. + // + // Mirrors fern-api/fern's "drop from IR" semantics + // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). + // The audience list is configured by the binary's `main.rs` via + // [`Self::audiences`] — a compile-time preset, not a runtime + // flag. An empty preset is a no-op (every operation included). + commands::filter_doc_by_audiences(&mut doc, &self.audiences); + + // Intercept --help --format json before clap parses, to emit machine-readable output + if cli_args::wants_json_help(&args) { + let path = cli_args::extract_subcommand_path(&args); + return crate::openapi::help::render_json_help(&doc, &path); + } + + // Intercept ` completion ` early — before normal API + // dispatch — so a spec resource named "completion" doesn't collide. + // Builds the full command tree (including global flags) so the + // generated script covers the entire CLI surface. + if crate::completions::wants_completion(&args) { + // Extract the shell name: positional #1 (since `completion` + // is positional #0), applying the same BOOLEAN_FLAGS-aware + // skip logic so `--base-url ` doesn't leak as the shell. + let raw_shell_arg: Option<&str> = + crate::early_intercept::nth_positional(&args, 1); + + let base = self + .custom_commands + .graft_into(commands::build_cli(&doc)) + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_command()); + + match raw_shell_arg { + Some(s) => match crate::completions::parse_shell(s) { + Some(shell) => { + let mut full_cmd = register_global_flags( + base, + &self.server_vars, + &doc, + &self.auth_bindings, + ); + crate::completions::generate_completion( + shell, + &mut full_cmd, + &self.name, + ) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(()); + } + None => { + return Err(CliError::Validation(format!( + "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" + ))); + } + }, + None => { + // No shell argument — print friendly help and exit 0. + let mut full_cmd = register_global_flags( + base, + &self.server_vars, + &doc, + &self.auth_bindings, + ); + if let Some(sub) = full_cmd.find_subcommand_mut("completion") { + sub.print_help().ok(); + } + return Ok(()); + } + } + } + + // Intercept ` man` early — same pattern as completion above. + // If `--help` / `-h` appears after `man`, fall through to normal + // clap dispatch so the subcommand help (with EXAMPLES) is shown + // instead of generating the man page. + if crate::man::wants_man(&args) { + let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) + .any(|a| a == "--help" || a == "-h"); + let base = self + .custom_commands + .graft_into(commands::build_cli(&doc)) + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_command()); + let mut full_cmd = + register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); + if has_help { + if let Some(sub) = full_cmd.find_subcommand_mut("man") { + sub.print_help().ok(); + } + return Ok(()); + } + crate::man::generate_man(full_cmd, &self.name) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(()); + } + + // Build the dynamic command tree, then graft custom commands into + // it. Empty path → top-level. On leaf-name collision with a + // spec-generated command, custom wins. The `completion` and `man` + // subcommands are also registered here so they appear in `--help`. + let base = self + .custom_commands + .graft_into(commands::build_cli(&doc)) + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_command()); + + let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = + register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); + + let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + cli = cli.after_help(compose_root_after_help_sections( + global_headers_section.as_deref(), + auth_section.as_deref(), + &commands::after_help_footer(&doc.name), + )); + + // Parse args. clap raises a special `DisplayHelp*` "error" both for + // explicit `--help` and for the implicit help from + // `arg_required_else_help` — neither is a real failure, so print to + // stdout and exit 0 instead of wrapping in a validation error JSON. + let matches = cli.try_get_matches_from(&args).map_err(|e| { + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + || e.kind() == clap::error::ErrorKind::DisplayVersion + { + print!("{e}"); + std::process::exit(0); + } + CliError::Validation(e.to_string()) + })?; + + // Finalize auth bindings against the parsed matches. After this, + // any `AuthCredentialSource::Cli(name)` in the bindings is replaced + // with a closure reading from the matches — so `build_auth_provider` + // (called below for both custom-command dispatch and regular + // execution) sees a fully resolvable provider. + if !self.auth_bindings.is_empty() { + let matches_arc = std::sync::Arc::new(matches.clone()); + self.auth_bindings = crate::auth::finalize_bindings( + std::mem::take(&mut self.auth_bindings), + &matches_arc, + ); + } + + // Substitute server variables in root_urls. Clap pulls from --flag + // first, then the registered env var (via .env()), then the default, + // so a single get_one lookup covers the full priority chain. + if !self.server_vars.is_empty() { + let mut substitutions: std::collections::HashMap = + std::collections::HashMap::new(); + for var in &self.server_vars { + if let Some(value) = matches.get_one::(&var.name) { + substitutions.insert(var.name.clone(), value.clone()); + } + } + apply_server_var_substitutions(&mut doc, &substitutions); + } + + // Dispatch to a custom command if one was invoked. + if !self.custom_commands.is_empty() { + let auth_provider = self.build_auth_provider(&doc); + // Resolve global headers once for custom-command handlers. + // Required-header validation is deferred until execute/invoke + // is called, because the per-op override check needs to know + // the operation. Here we only collect CLI/env/default values. + let resolved_global_headers: Vec<(String, String)> = doc + .global_headers + .iter() + .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) + .collect(); + let ctx = AppContext { + doc: doc.clone(), + auth_provider, + http_config: http_config.clone(), + global_headers: resolved_global_headers, + }; + if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { + return result; + } + } + + // Build the output pipeline (format + color + later: --fields/--jq/--template). + let pipeline = formatter::OutputPipeline::from_matches(&matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + + // Walk the subcommand tree to find the target method + let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; + + let params_override = matched_args + .get_one::("params") + .map(|s| s.as_str()); + let params = collect_params_from_flags(matched_args, method, params_override)?; + let params_json_string = serde_json::to_string(¶ms) + .map_err(|e| CliError::Validation(format!("Failed to serialize params: {e}")))?; + let params_json: Option<&str> = if params.is_empty() { + None + } else { + Some(¶ms_json_string) + }; + // Resolve the configured `x-fern-global-headers` (CLI > env > + // default) and check that required ones have a value, deferring + // to per-op overrides where the operation declares a header + // parameter with the same wire-name. Built once per invocation + // and stamped on every outgoing request inside the executor. + let global_header_overrides = + build_global_header_overrides(matched_args, &doc, method, ¶ms)?; + let body_json = matched_args + .try_get_one::("json") + .ok() + .flatten() + .map(|s| s.as_str()); + // The binary-body flag name is per-operation (driven by + // `x-fern-parameter-name` or the schema's `format: binary` default). + // Look it up only for methods that declare one. The raw value is + // parsed by the executor into one of three forms — plain path, + // `@`, or `-` for stdin — so we only reject control characters + // here (and only on the path-bearing forms). + let binary_body_path = method + .binary_request_body + .as_ref() + .and_then(|b| { + matched_args + .try_get_one::(&b.flag_name) + .ok() + .flatten() + .map(|s| (b.flag_name.clone(), s.as_str())) + }); + if let Some((ref flag, p)) = binary_body_path { + let stripped = p.strip_prefix('@').unwrap_or(p); + if stripped != "-" { + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); + let output_path = matched_args + .get_one::("output") + .map(|s| s.as_str()); + + // Validate file paths against traversal + let output_path_buf = if let Some(p) = output_path { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + let dry_run = matched_args.get_flag("dry-run"); + + // Build pagination config with API-specific token names + let pagination = build_pagination_config(matched_args, &doc); + + // Build the auth provider once, from the registered bindings + // lowered against the spec's `components.securitySchemes`. + let auth_provider = self.build_auth_provider(&doc); + + // --base-url flag wins; otherwise {NAME}_BASE_URL env var. + let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; + let base_url_override = base_url_override_owned.as_deref(); + + // Honor `x-fern-sdk-return-value` extraction unless the caller + // passes `--no-extract`. The flag is a debugging escape hatch + // that prints the full response body; matches the upstream + // behavior of falling back to the raw response when the SDK + // can't (or shouldn't) project to the named property. + let no_extract = matched_args.get_flag("no-extract"); + + // Honor `--no-retry` as a debug-only opt-out. When set, the + // executor skips the retry wrapper regardless of the operation's + // `x-fern-retries` policy — including transient network errors — + // so failures surface immediately. Aligns with the open design + // question called out in the FER-9864 PR description. + let no_retry = matched_args.get_flag("no-retry"); + + // `--no-stream` is only registered on operations with + // `x-fern-streaming` (see `build_method_command`). Use + // `try_get_one` so the flag-absent case is a clean false + // rather than a panic on unknown-arg lookup. + let no_stream = matched_args + .try_get_one::("no-stream") + .ok() + .flatten() + .copied() + .unwrap_or(false); + + // Execute + executor::execute_method( + &doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // no upload + binary_body_path, + dry_run, + &pagination, + &pipeline, + false, + base_url_override, + &http_config, + no_extract, + no_retry, + no_stream, + &global_header_overrides, + ) + .await + .map(|_| ()) + } + + /// Construct the [`DynAuthProvider`] used for this run from the + /// registered bindings. With no bindings, returns a `NoAuthProvider` + /// — the CLI runs unauthenticated. + fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); + crate::auth::build_provider_with_strategy( + &self.auth_bindings, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// Runtime context passed to custom command handlers. +/// +/// Provides access to the loaded API spec, the constructed auth provider, +/// and a convenience method for executing API methods. +pub struct AppContext { + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + /// Resolved `x-fern-global-headers` for this CLI invocation + /// (CLI flag > env var > default, computed up front in `run_async`). + /// Per-op overrides are applied at the call site of `execute_method` + /// — see [`AppContext::extra_headers_for`]. + global_headers: Vec<(String, String)>, +} + +impl AppContext { + /// Compute the per-op `extra_headers` slice from the pre-resolved + /// global headers, suppressing entries whose wire-name is also + /// supplied as a per-op `header` parameter via `params_json` + /// (per-op wins, mirroring the built-in command path). + /// + /// Required-header validation lives here rather than at + /// `AppContext` construction time because per-op overrides depend + /// on the specific operation being invoked: a required global + /// header with no resolved value is allowed when the operation + /// itself declares the same header as a per-op parameter (the + /// per-op value takes its place on the wire). This mirrors + /// `build_global_header_overrides` on the built-in command path so + /// custom-command handlers get the same validation error shape. + fn extra_headers_for( + &self, + method: &RestMethod, + params_json: Option<&str>, + ) -> Result, CliError> { + let params: serde_json::Map = match params_json { + Some(s) if !s.trim().is_empty() => serde_json::from_str(s) + .map_err(|e| CliError::Validation(format!("Invalid params JSON: {e}")))?, + _ => serde_json::Map::new(), + }; + // HTTP header names are case-insensitive per RFC 7230 §3.2 — key + // the lookup table by lowercased wire-name so a custom-command + // handler that resolved `x-api-stage` still satisfies the spec's + // declared `X-API-Stage` global. + let resolved_by_wire: std::collections::HashMap = self + .global_headers + .iter() + .map(|(n, v)| (n.to_ascii_lowercase(), v.as_str())) + .collect(); + finalize_global_header_overrides(&self.doc.global_headers, method, ¶ms, |h| { + resolved_by_wire + .get(&h.header.to_ascii_lowercase()) + .map(|v| (*v).to_string()) + }) + } + + /// Execute an API method by name, using the same executor as built-in + /// commands. + pub fn execute( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + output_format: &formatter::OutputFormat, + ) -> Result<(), CliError> { + let pagination = executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: self + .doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: self + .doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + }; + + let pipeline = formatter::OutputPipeline { + format: output_format.clone(), + color_mode: formatter::ColorMode::default(), + }; + let extra_headers = self.extra_headers_for(method, params_json)?; + + // Custom commands dispatch from inside `run_async`, which is itself + // driven by a tokio runtime. Naively calling `block_on` from a sync + // handler panics ("Cannot start a runtime from within a runtime"). + // `block_in_place` parks the current worker so `block_on` is legal. + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(executor::execute_method( + &self.doc, + method, + params_json, + body_json, + &self.auth_provider, + None, + None, + None, + false, + &pagination, + &pipeline, + false, + None, + &self.http_config, + // TODO(mcp/programmatic): programmatic callers always + // honor `x-fern-sdk-return-value` (matches typed-SDK + // semantics). If/when an MCP-tool surface wraps this + // path and needs to expose `--no-extract` to its + // clients, plumb a flag through `AppContext::execute` + // rather than flipping this constant. + false, + // Programmatic callers always honor `x-fern-retries` + // — the debug-only `--no-retry` flag is intentionally + // a CLI-only surface. If/when an MCP-tool path needs + // to disable retries for stability/debugging, plumb + // a flag through `AppContext::execute` rather than + // flipping this constant. + false, + // Same trade-off for `--no-stream`: programmatic callers + // chaining streaming endpoints almost always want the + // events emitted as they arrive (stdout printing path); + // forcing buffered mode here would block the entire + // response in memory. The CLI surface keeps the + // streaming default; only the CLI front-end exposes the + // opt-in buffered toggle. + false, + &extra_headers, + )) + }) + .map(|_| ()) + } + + /// Invoke an API method and return the parsed JSON response. + /// + /// Like [`execute`](Self::execute) but captures the response instead of + /// printing it, and accepts a `binary_body_path` for operations with a + /// binary request body (e.g. a multipart file upload). Designed for + /// custom commands that chain multiple API calls. + pub fn invoke( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + binary_body_path: Option<&str>, + ) -> Result { + let pagination = executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: self + .doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: self + .doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + }; + + let extra_headers = self.extra_headers_for(method, params_json)?; + // See note in `execute` — `block_in_place` is required because the + // handler runs inside the outer tokio runtime. + let value = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(executor::execute_method( + &self.doc, + method, + params_json, + body_json, + &self.auth_provider, + None, + None, + binary_body_path, + false, + &pagination, + &formatter::OutputPipeline::default(), + true, // capture_output + None, + &self.http_config, + // See TODO in `execute` above — same trade-off applies + // here: chained custom commands expect the + // spec-promised subvalue, not the raw envelope. + false, + // Programmatic callers always honor `x-fern-retries` + // (see note in `execute`). + false, + // `invoke` captures the response into a `serde_json::Value` + // for callers that chain multiple API calls. Stream-mode + // makes no sense here — the executor would have to invent + // an ordering decision (last event? array of events?) + // when the caller just wants a typed value back. Force + // buffered semantics so the captured value mirrors the + // unary-response shape callers already handle. + true, + &extra_headers, + )) + })?; + + value.ok_or_else(|| { + CliError::Other(anyhow::anyhow!( + "API method returned no value (non-JSON or empty body)" + )) + }) + } + + /// Returns a reference to the loaded API spec. + pub fn spec(&self) -> &RestDescription { + &self.doc + } + + /// Returns a reference to the HTTP/TLS configuration for this CLI run. + /// + /// Holds the binary name (used to scope `_*` env vars) and any + /// compile-time trust roots. Non-reqwest transports — e.g. the + /// [`websocket`](crate::websocket) module — call + /// [`HttpConfig::resolve`](crate::http::HttpConfig::resolve) on this to + /// build their own TLS connectors while honoring the same env vars + /// users already configure for the HTTP path. + /// + /// Auth credentials are intentionally *not* exposed via `AppContext`: + /// transports needing a credential value take an + /// [`AuthCredentialSource`](crate::auth::AuthCredentialSource) directly + /// at the call site. See `docs/adr/0001-auth-provider-no-cred-extraction.md`. + pub fn http_config(&self) -> &crate::http::HttpConfig { + &self.http_config + } + +} + +/// Walk a resource (and its sub-resources) for any method that declares +/// `security_requirements`. Used by `build_auth_provider` to feed the +/// per-endpoint flag into `build_provider_with_strategy`. +fn resource_has_per_endpoint_security(resource: &RestResource) -> bool { + if resource + .methods + .values() + .any(|m| m.security_requirements.is_some()) + { + return true; + } + resource.resources.values().any(resource_has_per_endpoint_security) +} + +/// Recursively walks clap ArgMatches to find the leaf method and its matches. +pub fn resolve_method_from_matches<'a>( + doc: &'a RestDescription, + matches: &'a clap::ArgMatches, +) -> Result<(&'a RestMethod, &'a clap::ArgMatches), CliError> { + let mut path: Vec<&str> = Vec::new(); + let mut current_matches = matches; + + while let Some((sub_name, sub_matches)) = current_matches.subcommand() { + path.push(sub_name); + current_matches = sub_matches; + } + + if path.is_empty() { + return Err(CliError::Validation( + "No resource or method specified".to_string(), + )); + } + + let resource_name = path[0]; + let resource = doc + .resources + .get(resource_name) + .ok_or_else(|| CliError::Validation(format!("Resource '{resource_name}' not found")))?; + + let mut current_resource = resource; + + for &name in &path[1..path.len() - 1] { + if let Some(sub) = current_resource.resources.get(name) { + current_resource = sub; + } else { + return Err(CliError::Validation(format!( + "Sub-resource '{name}' not found" + ))); + } + } + + let method_name = path[path.len() - 1]; + + if let Some(method) = current_resource.methods.get(method_name) { + return Ok((method, current_matches)); + } + + Err(CliError::Validation(format!( + "Method '{method_name}' not found on resource. Available methods: {:?}", + current_resource.methods.keys().collect::>() + ))) +} + +/// Collect individual flag values into a params map. +/// Values from --params JSON override individual flags. +/// +/// When a parameter has a `default_value` from `x-fern-default` and +/// the user did not supply the flag, clap surfaces the default as a +/// stringified value. We detect this via `ArgMatches::value_source` +/// and substitute the originally-typed JSON so numbers and booleans +/// keep their wire type — strings pass through unchanged. +/// +/// Parameters whose only default comes from the OpenAPI standard +/// `default:` keyword (stored on `documentation_default_value`) do +/// **not** get a clap default, so `get_one` returns `None` and the +/// `let-else continue` below correctly omits them from the outgoing +/// request — the API server applies its own default. +pub(crate) fn collect_params_from_flags( + matched_args: &clap::ArgMatches, + method: &crate::openapi::discovery::RestMethod, + params_override: Option<&str>, +) -> Result, CliError> { + let mut params = serde_json::Map::new(); + + // Collect values from individual flags. Three extensions interact here: + // + // 1. `x-fern-sdk-variable`: variable-bound path params are NOT + // registered as per-op flags (see `commands::build_resource_command`); + // their value comes from the root-level global flag registered in + // `run_async` from `doc.sdk_variables`. clap propagates global args + // down to subcommand matches so we look them up by the variable + // name on the same `matched_args`. If the global is unset, defer + // the validation error until AFTER the `--params` JSON override is + // applied below — `--params` is documented as "overrides individual + // flags" and must be allowed to act as a fallback here too, + // mirroring how plain path params behave when their per-op flag is + // absent. + // + // 2. `x-fern-default`: when clap surfaced an `x-fern-default` value + // (i.e. the user omitted the flag and the parameter had a + // `default_value` populated by `x-fern-default`), use the + // originally-typed JSON value so numbers/booleans keep their + // wire type instead of arriving as strings. + // + // 3. `x-fern-enum`: for user-supplied values on (non-variable-bound) + // parameters that declare enum aliases, resolve the display + // alias back to the wire value so the executor only ever sees + // what the server expects. + let mut missing_variable_bound: Vec<(String, String)> = Vec::new(); + for (param_name, param_def) in &method.parameters { + if let Some(var_name) = param_def.variable_reference.as_deref() { + // Global flag ids match the variable name (see `run_async`). + // clap's `.env(...)` on the global arg already covers the + // env-var fallback before we get here, so a missing value + // means neither CLI flag nor env var was provided. + match matched_args.get_one::(var_name) { + Some(value) => { + params.insert( + param_name.clone(), + serde_json::Value::String(value.clone()), + ); + } + None => { + missing_variable_bound.push((param_name.clone(), var_name.to_string())); + } + } + continue; + } + if param_def.repeated { + if let Some(values) = matched_args.get_many::(param_name) { + let arr: Vec = values + .map(|v| serde_json::Value::String(v.clone())) + .collect(); + params.insert(param_name.clone(), serde_json::Value::Array(arr)); + } + continue; + } + + let Some(value) = matched_args.get_one::(param_name) else { + continue; + }; + let from_default = matched_args.value_source(param_name) + == Some(clap::parser::ValueSource::DefaultValue); + let json_value = match (from_default, ¶m_def.default_value) { + (true, Some(typed)) => typed.clone(), + _ => { + // For object-typed params (e.g. deepObject query parameters), + // attempt JSON parsing so deepObject serialization receives a + // Value::Object rather than a string. + if param_def.param_type.as_deref() == Some("object") { + serde_json::from_str(value.as_str()) + .unwrap_or_else(|_| serde_json::Value::String(value.clone())) + } else { + let wire = param_def + .resolve_enum_display_to_wire(value.as_str()) + .into_owned(); + serde_json::Value::String(wire) + } + } + }; + params.insert(param_name.clone(), json_value); + } + + // Override with --params JSON if provided (--params wins). + if let Some(json_str) = params_override { + let overrides: serde_json::Map = + serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))?; + for (key, value) in overrides { + params.insert(key, value); + } + } + + // Now that --params has had its say, check whether any variable-bound + // parameter is still unsupplied. Only then emit the validation error + // naming both the global CLI flag and its env-var fallback. + for (param_name, var_name) in missing_variable_bound { + if !params.contains_key(¶m_name) { + let kebab = crate::text::to_kebab_flag(&var_name); + let env = crate::text::to_screaming_snake(&var_name); + return Err(CliError::Validation(format!( + "Missing required SDK variable '{var_name}': provide --{kebab}, \ + set ${env}, or include it in --params" + ))); + } + } + + Ok(params) +} + +pub(crate) fn build_pagination_config( + matches: &clap::ArgMatches, + doc: &RestDescription, +) -> executor::PaginationConfig { + executor::PaginationConfig { + page_all: matches.get_flag("page-all"), + page_limit: matches + .get_one::("page-limit") + .copied() + .unwrap_or(10), + page_delay_ms: matches + .get_one::("page-delay") + .copied() + .unwrap_or(100), + token_query_param: doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ------------------------------------------------------------------ + // x-fern-global-headers (FER-9864 P2) — registration helpers. + // ------------------------------------------------------------------ + + /// `global_header_flag_name` honors `name:` (kebab-cased) when set, + /// otherwise falls back to kebab-casing the wire header. This is + /// the same precedence the upstream Fern importer uses. + #[test] + fn test_global_header_flag_name_respects_name_field_then_header() { + let h_with_name = crate::openapi::discovery::GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_flag_name(&h_with_name), "api-stage"); + + let h_no_name = crate::openapi::discovery::GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_flag_name(&h_no_name), "x-tenant-id"); + } + + /// The clap arg ID for a global header must be namespaced so it + /// can't collide with any per-op parameter HashMap key. The wire + /// header is preserved verbatim so the executor's lookup against + /// `RestMethod.parameters` stays straightforward. + #[test] + fn test_global_header_arg_id_is_namespaced_by_wire_name() { + let h = crate::openapi::discovery::GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_arg_id(&h), "__global_header::X-API-Stage"); + } + + /// `build_global_header_overrides` errors with a message naming the + /// flag, env var, and wire-header name when a required header has + /// no value source. Pins the human-facing error shape required by + /// the FER-9864 acceptance criteria ("required-without-value + /// fails"), at the level where the validation actually lives. + #[test] + fn test_build_global_header_overrides_errors_when_required_missing() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + use clap::Command; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIXTURE_API_STAGE".into()), + default: None, + }], + ..Default::default() + }; + let method = RestMethod::default(); + + // Use a clap Command with NO defaults bound for the arg — + // simulating "user passed nothing, env unset, no default". + let cmd = Command::new("test").arg( + clap::Arg::new(global_header_arg_id(&doc.global_headers[0])) + .long(global_header_flag_name(&doc.global_headers[0])) + .global(true), + ); + let matches = cmd.try_get_matches_from(["test"]).unwrap(); + let params = serde_json::Map::new(); + + let err = build_global_header_overrides(&matches, &doc, &method, ¶ms).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("--api-stage"), + "error should name the CLI flag: {msg}" + ); + assert!( + msg.contains("FIXTURE_API_STAGE"), + "error should name the env var: {msg}" + ); + assert!( + msg.contains("X-API-Stage"), + "error should name the wire header: {msg}" + ); + } + + /// When the user supplies a per-op header parameter with the same + /// wire-name as a global header, the per-op value wins and the + /// global is dropped from the override list. Mirrors the upstream + /// Fern importer's per-op-wins behavior so operators get a single + /// override surface for collision cases. + #[test] + fn test_build_global_header_overrides_per_op_param_wins() { + use crate::openapi::discovery::{ + GlobalHeader, MethodParameter, RestDescription, RestMethod, + }; + use clap::Command; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: Some("production".into()), + }], + ..Default::default() + }; + + // Per-op method declares `X-API-Stage` as a header parameter. + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "X-API-Stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + + // Simulate clap matches with the global default applied. + let cmd = Command::new("test").arg( + clap::Arg::new(global_header_arg_id(&doc.global_headers[0])) + .long(global_header_flag_name(&doc.global_headers[0])) + .default_value("production") + .global(true), + ); + let matches = cmd.try_get_matches_from(["test"]).unwrap(); + + // The per-op `params` map contains a value for the same wire-name. + let mut params = serde_json::Map::new(); + params.insert("X-API-Stage".into(), serde_json::json!("canary")); + + let overrides = + build_global_header_overrides(&matches, &doc, &method, ¶ms).unwrap(); + assert!( + overrides.is_empty(), + "per-op param suppresses the global override, got: {overrides:?}", + ); + } + + #[test] + fn test_sdk_variable_collides_with_builtin_flags() { + // Variables whose kebab form matches any built-in per-op flag + // must be flagged as colliding so the global registration site + // can skip them with a warning instead of letting clap panic. + // Cover the names that are most likely to be picked accidentally. + for builtin in ["params", "format", "dry-run", "base-url", "page-all", "output", "json"] { + assert!( + sdk_variable_collides_with_builtin(builtin), + "expected '{builtin}' to collide with a built-in flag", + ); + } + // Plain identifiers and innocuous variable names must NOT collide. + for ok in ["garden-id", "tenant-id", "page-token", "uuid", "client-id"] { + assert!( + !sdk_variable_collides_with_builtin(ok), + "expected '{ok}' NOT to collide with a built-in flag", + ); + } + } + + #[test] + fn test_cli_app_builder() { + let app = CliApp::new("test-cli") + .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") + .auth_scheme_env("bearer", "TEST_TOKEN"); + + assert_eq!(app.name, "test-cli"); + assert_eq!(app.specs.len(), 1); + assert_eq!(app.auth_bindings.len(), 1); + assert_eq!(app.auth_bindings[0].0, "bearer"); + } + + #[test] + fn test_auth_scheme_records_token_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_scheme("bearerAuth", AuthCredentialSource::from_env("API_TOKEN")); + assert_eq!(app.auth_bindings.len(), 1); + assert_eq!(app.auth_bindings[0].0, "bearerAuth"); + match &app.auth_bindings[0].1 { + SchemeBinding::Token(_) => {} + other => panic!("expected Token, got {other:?}"), + } + } + + #[test] + fn test_auth_basic_scheme_records_basic_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_basic_scheme( + "basic", + AuthCredentialSource::from_env("U"), + AuthCredentialSource::from_env("P"), + ); + assert!(matches!( + app.auth_bindings[0].1, + SchemeBinding::Basic { .. }, + )); + } + + #[test] + fn test_auth_basic_scheme_username_only_records_specialized_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_basic_scheme_username_only("basic", AuthCredentialSource::from_env("KEY")); + assert!(matches!( + app.auth_bindings[0].1, + SchemeBinding::BasicUsernameOnly(_), + )); + } + + #[test] + fn test_auth_basic_scheme_password_only_records_specialized_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_basic_scheme_password_only("basic", AuthCredentialSource::from_env("KEY")); + assert!(matches!( + app.auth_bindings[0].1, + SchemeBinding::BasicPasswordOnly(_), + )); + } + + #[test] + fn test_cli_app_custom_command() { + fn handler( + _matches: &clap::ArgMatches, + _ctx: &AppContext, + ) -> Result<(), CliError> { + Ok(()) + } + + let app = CliApp::new("test") + .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") + .command(clap::Command::new("custom"), handler); + + assert_eq!(app.custom_commands.len(), 1); + assert!(app.custom_commands.entries()[0].0.is_empty()); + assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); + } + + #[test] + fn test_cli_app_command_under_records_path() { + fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } + let app = CliApp::new("test") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .command_under(&["webhooks"], clap::Command::new("verify"), handler); + assert_eq!(app.custom_commands.len(), 1); + assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); + assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); + } + + #[test] + fn test_resolve_method_from_matches_basic() { + let mut resources = std::collections::HashMap::new(); + let mut files_res = crate::openapi::discovery::RestResource::default(); + files_res.methods.insert( + "list".to_string(), + crate::openapi::discovery::RestMethod { + id: Some("files.list".to_string()), + http_method: "GET".to_string(), + ..Default::default() + }, + ); + resources.insert("files".to_string(), files_res); + + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let cmd = clap::Command::new("cli") + .subcommand(clap::Command::new("files").subcommand(clap::Command::new("list"))); + + let matches = cmd.get_matches_from(vec!["cli", "files", "list"]); + let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + assert_eq!(method.id.as_deref(), Some("files.list")); + } + + #[test] + fn test_resolve_method_from_matches_nested() { + let mut resources = std::collections::HashMap::new(); + let mut files_res = crate::openapi::discovery::RestResource::default(); + let mut permissions_res = crate::openapi::discovery::RestResource::default(); + permissions_res.methods.insert( + "get".to_string(), + crate::openapi::discovery::RestMethod { + id: Some("files.permissions.get".to_string()), + ..Default::default() + }, + ); + files_res + .resources + .insert("permissions".to_string(), permissions_res); + resources.insert("files".to_string(), files_res); + + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let cmd = + clap::Command::new("cli").subcommand(clap::Command::new("files").subcommand( + clap::Command::new("permissions").subcommand(clap::Command::new("get")), + )); + + let matches = cmd.get_matches_from(vec!["cli", "files", "permissions", "get"]); + let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + assert_eq!(method.id.as_deref(), Some("files.permissions.get")); + } + + #[test] + fn test_resolve_method_empty_path() { + let doc = RestDescription { + name: "test".to_string(), + ..Default::default() + }; + + let cmd = clap::Command::new("cli"); + let matches = cmd.get_matches_from(vec!["cli"]); + let result = resolve_method_from_matches(&doc, &matches); + assert!(result.is_err()); + } + + /// `AppContext::extra_headers_for` mirrors the built-in command + /// path: a required global header with no resolved value and no + /// per-op override fails with a validation error that names both + /// the CLI flag and the env var. This is the regression test for + /// the custom-command-handler path that previously dropped the + /// header silently. + #[test] + fn test_app_context_extra_headers_required_missing_errors() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIXTURE_API_STAGE".into()), + default: None, + }], + ..Default::default() + }; + let ctx = AppContext { + doc, + auth_provider: crate::auth::no_auth_provider(), + http_config: crate::http::HttpConfig::new("test").unwrap(), + // Note: the custom-command path's filter_map silently + // dropped this required header. With the fix, + // extra_headers_for surfaces a validation error. + global_headers: Vec::new(), + }; + let method = RestMethod::default(); + let err = ctx.extra_headers_for(&method, None).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("--api-stage"), "should name flag: {msg}"); + assert!(msg.contains("FIXTURE_API_STAGE"), "should name env: {msg}"); + assert!(msg.contains("X-API-Stage"), "should name wire header: {msg}"); + } + + /// A required global header with no resolved value is permitted + /// when the operation itself declares a same-named header + /// parameter that the user supplied — the per-op value will be + /// sent on the wire in place of the global. Mirrors the built-in + /// command path's per-op-wins behavior. + #[test] + fn test_app_context_extra_headers_per_op_param_satisfies_required() { + use crate::openapi::discovery::{ + GlobalHeader, MethodParameter, RestDescription, RestMethod, + }; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }], + ..Default::default() + }; + let ctx = AppContext { + doc, + auth_provider: crate::auth::no_auth_provider(), + http_config: crate::http::HttpConfig::new("test").unwrap(), + global_headers: Vec::new(), + }; + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "X-API-Stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + let params_json = r#"{"X-API-Stage":"canary"}"#; + let headers = ctx + .extra_headers_for(&method, Some(params_json)) + .expect("per-op override should satisfy the required global header"); + assert!(headers.is_empty(), "per-op wins: globals dropped: {headers:?}"); + } + + /// An optional global header with no resolved value is silently + /// omitted (no error). Pins the negative case so a future + /// over-strict change to the required-header guard doesn't start + /// failing optional headers too. + #[test] + fn test_app_context_extra_headers_optional_missing_is_ok() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: true, + env: None, + default: None, + }], + ..Default::default() + }; + let ctx = AppContext { + doc, + auth_provider: crate::auth::no_auth_provider(), + http_config: crate::http::HttpConfig::new("test").unwrap(), + global_headers: Vec::new(), + }; + let method = RestMethod::default(); + let headers = ctx.extra_headers_for(&method, None).expect("optional ok"); + assert!(headers.is_empty(), "optional with no value: {headers:?}"); + } + + /// Multi-spec merge: when two specs declare the same wire-name in + /// `x-fern-global-headers`, the first write wins and the second is + /// silently dropped. Mirrors `merge_sdk_variables` and keeps the + /// resolved flag registry deterministic across spec ordering. + #[test] + fn test_merge_global_headers_first_write_wins() { + use crate::openapi::discovery::GlobalHeader; + + let mut acc = vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIRST_STAGE".into()), + default: Some("production".into()), + }]; + let incoming = vec![ + // Same wire-name → must be dropped, preserving the first env / default. + GlobalHeader { + header: "X-API-Stage".into(), + name: Some("stage".into()), + optional: true, + env: Some("SECOND_STAGE".into()), + default: Some("staging".into()), + }, + // Distinct wire-name → must be appended. + GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: true, + env: None, + default: None, + }, + ]; + merge_global_headers(&mut acc, incoming); + assert_eq!(acc.len(), 2, "got: {acc:?}"); + assert_eq!(acc[0].header, "X-API-Stage"); + assert_eq!(acc[0].env.as_deref(), Some("FIRST_STAGE")); + assert_eq!(acc[0].default.as_deref(), Some("production")); + assert!(!acc[0].optional); + assert_eq!(acc[1].header, "X-Tenant-Id"); + } + + /// Per-op-override match must be case-insensitive per RFC 7230 §3.2. + /// A spec that declares `X-API-Stage` globally and `x-api-stage` as a + /// header param on a single op should treat them as the same header + /// — the per-op value wins and the global is suppressed (rather than + /// both landing on the wire). + #[test] + fn test_per_op_header_param_override_is_case_insensitive() { + use crate::openapi::discovery::{GlobalHeader, MethodParameter, RestDescription, RestMethod}; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }], + ..Default::default() + }; + // Per-op param uses lowercase wire-name; case-insensitive lookup + // must still treat this as an override of the global. + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "x-api-stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + let ctx = AppContext { + doc, + auth_provider: crate::auth::no_auth_provider(), + http_config: crate::http::HttpConfig::new("test").unwrap(), + global_headers: Vec::new(), + }; + // User supplied the per-op param under a third casing — the + // override should still kick in, satisfying the required check + // without a CLI flag / env value. + let headers = ctx + .extra_headers_for(&method, Some(r#"{"X-Api-Stage": "canary"}"#)) + .expect( + "per-op override should satisfy required-header check regardless of casing", + ); + assert!( + headers.is_empty(), + "global header must be suppressed when per-op param overrides it: {headers:?}", + ); + } + + /// `--api-stage ""` (or trimming-only whitespace) must NOT resolve + /// to `Some("")`. `resolve_global_header_value` trims and treats + /// empties as "no value supplied", so the required-header guard + /// fires instead of silently sending an empty `X-API-Stage:` header. + /// Pins the fix for the self-review finding noted in PR #45. + #[test] + fn test_resolve_global_header_value_filters_empty_and_whitespace() { + use crate::openapi::discovery::GlobalHeader; + + let h = GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + let cmd = clap::Command::new("t").arg( + clap::Arg::new(global_header_arg_id(&h)) + .long(global_header_flag_name(&h)), + ); + // Empty string flag value → None. + let m = cmd.clone().get_matches_from(["t", "--api-stage", ""]); + assert!( + resolve_global_header_value(&m, &h).is_none(), + "empty flag value must resolve to None", + ); + // Whitespace-only flag value → None. + let m = cmd.clone().get_matches_from(["t", "--api-stage", " "]); + assert!( + resolve_global_header_value(&m, &h).is_none(), + "whitespace-only flag value must resolve to None", + ); + // Normal value → Some(trimmed). + let m = cmd.get_matches_from(["t", "--api-stage", " canary "]); + assert_eq!( + resolve_global_header_value(&m, &h).as_deref(), + Some("canary"), + ); + } + + /// `compose_root_after_help_sections` joins present sections with + /// the footer and skips any `None` sections cleanly. Pins the + /// neither-auth-nor-global-headers regression target raised in PR + /// #45's self-review. + #[test] + fn test_compose_root_after_help_sections_skips_absent() { + let footer = "Standard env vars: …"; + let g = "Global headers:\n --api-stage …"; + let a = "Authentication:\n bearer …"; + + // Both absent: only the footer. + assert_eq!( + compose_root_after_help_sections(None, None, footer), + footer, + "no global headers, no auth → only the footer is rendered", + ); + // Auth only: same as the pre-FER-9864 baseline. + assert_eq!( + compose_root_after_help_sections(None, Some(a), footer), + format!("{a}\n{footer}"), + ); + // Globals only: no auth section. + assert_eq!( + compose_root_after_help_sections(Some(g), None, footer), + format!("{g}\n{footer}"), + ); + // Both present: globals first, then auth, then footer. + assert_eq!( + compose_root_after_help_sections(Some(g), Some(a), footer), + format!("{g}\n{a}\n{footer}"), + ); + } + + #[test] + fn test_app_context_spec_accessor() { + let doc = RestDescription { + name: "test".to_string(), + ..Default::default() + }; + let ctx = AppContext { + doc, + auth_provider: crate::auth::no_auth_provider(), + http_config: crate::http::HttpConfig::new("test").unwrap(), + global_headers: Vec::new(), + }; + assert_eq!(ctx.spec().name, "test"); + } + + #[test] + fn test_collect_params_individual_flags() { + let mut params = std::collections::HashMap::new(); + params.insert( + "uuid".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("uuid").long("uuid")) + .arg(clap::Arg::new("params").long("params")); + + let matches = cmd.get_matches_from(vec!["test", "--uuid", "abc-123"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("uuid").unwrap().as_str().unwrap(), "abc-123"); + } + + #[test] + fn test_collect_params_override_wins() { + let mut params = std::collections::HashMap::new(); + params.insert( + "uuid".to_string(), + crate::openapi::discovery::MethodParameter::default(), + ); + + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("uuid").long("uuid")) + .arg(clap::Arg::new("params").long("params")); + + let matches = cmd.get_matches_from(vec![ + "test", + "--uuid", + "from-flag", + "--params", + r#"{"uuid":"from-json"}"#, + ]); + let override_str = matches.get_one::("params").map(|s| s.as_str()); + let result = collect_params_from_flags(&matches, &method, override_str).unwrap(); + assert_eq!(result.get("uuid").unwrap().as_str().unwrap(), "from-json"); + } + + #[test] + fn test_collect_params_empty_when_no_flags() { + let method = crate::openapi::discovery::RestMethod::default(); + let cmd = clap::Command::new("test").arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert!(result.is_empty()); + } + + // ------------------------------------------------------------------ + // CliApp::idempotency_header_env — generator-side env-var wiring for + // FER-9852, implemented in cli-sdk for FER-9864 P1. Verifies the + // builder overlays env vars on every idempotent operation's + // synthetic header MethodParameter (and skips non-idempotent + // siblings). + // ------------------------------------------------------------------ + + const IDEMPOTENCY_SPEC: &str = r#" +openapi: 3.0.2 +info: + title: Idempotency Builder Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key +paths: + /payments: + get: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: list + operationId: payments_list + responses: + "200": + description: ok + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + + #[test] + fn test_idempotency_header_env_matches_by_name() { + // Generator wires env var by `name` field (kebab/snake form, + // not the wire header). Should land on the idempotent op's + // synthetic param. + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("idempotency_key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create.parameters.get("Idempotency-Key").unwrap(); + assert_eq!(p.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + } + + #[test] + fn test_idempotency_header_env_matches_by_header() { + // Falls back to the wire header name when `name` isn't matched. + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create.parameters.get("Idempotency-Key").unwrap(); + assert_eq!(p.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + } + + #[test] + fn test_idempotency_header_env_skips_non_idempotent_ops() { + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("idempotency_key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let list = &doc.resources["payments"].methods["list"]; + assert!(!list.idempotent); + assert!( + !list.parameters.contains_key("Idempotency-Key"), + "non-idempotent op must have no idempotency-header param at all", + ); + } + + fn pagination_cmd() -> clap::Command { + clap::Command::new("test") + .arg( + clap::Arg::new("page-all") + .long("page-all") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("page-limit") + .long("page-limit") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + clap::Arg::new("page-delay") + .long("page-delay") + .value_parser(clap::value_parser!(u64)), + ) + } + + #[test] + fn test_build_pagination_config_defaults() { + let doc = RestDescription::default(); + let matches = pagination_cmd().get_matches_from(vec!["test"]); + let config = build_pagination_config(&matches, &doc); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + assert_eq!(config.token_query_param, "pageToken"); + assert_eq!(config.token_response_path, "nextPageToken"); + } + + #[test] + fn test_build_pagination_config_uses_doc_token_names() { + let doc = RestDescription { + pagination_token_query_param: Some("cursor".to_string()), + pagination_token_response_path: Some("meta.next_cursor".to_string()), + ..Default::default() + }; + let matches = pagination_cmd().get_matches_from(vec!["test"]); + let config = build_pagination_config(&matches, &doc); + assert_eq!(config.token_query_param, "cursor"); + assert_eq!(config.token_response_path, "meta.next_cursor"); + } + + #[test] + fn test_resolve_method_resource_not_found() { + let doc = RestDescription::default(); + let cmd = + clap::Command::new("cli").subcommand(clap::Command::new("unknown")); + let matches = cmd.get_matches_from(vec!["cli", "unknown"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Resource 'unknown' not found")); + } + + #[test] + fn test_resolve_method_method_not_found() { + let mut resources = std::collections::HashMap::new(); + resources.insert("files".to_string(), crate::openapi::discovery::RestResource::default()); + let doc = RestDescription { resources, ..Default::default() }; + + let cmd = clap::Command::new("cli") + .subcommand(clap::Command::new("files").subcommand(clap::Command::new("delete"))); + let matches = cmd.get_matches_from(vec!["cli", "files", "delete"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Method 'delete' not found")); + } + + #[test] + fn test_resolve_method_sub_resource_not_found() { + let mut resources = std::collections::HashMap::new(); + resources.insert("files".to_string(), crate::openapi::discovery::RestResource::default()); + let doc = RestDescription { resources, ..Default::default() }; + + let cmd = clap::Command::new("cli").subcommand( + clap::Command::new("files").subcommand( + clap::Command::new("permissions").subcommand(clap::Command::new("list")), + ), + ); + let matches = cmd.get_matches_from(vec!["cli", "files", "permissions", "list"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Sub-resource 'permissions' not found")); + } + + #[test] + fn test_collect_params_invalid_json_override() { + let method = crate::openapi::discovery::RestMethod::default(); + let cmd = clap::Command::new("test").arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test"]); + let err = + collect_params_from_flags(&matches, &method, Some("{not valid json}")).unwrap_err(); + assert!(err.to_string().contains("Invalid --params JSON")); + } + + #[test] + fn test_multi_spec_flat_merge() { + // Two specs with non-overlapping resources should merge + let spec_a = r#" +openapi: "3.0.0" +info: + title: "API A" + version: "1.0" +servers: + - url: "https://api-a.example.com" +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let spec_b = r#" +openapi: "3.0.0" +info: + title: "API B" + version: "1.0" +servers: + - url: "https://api-b.example.com" +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec(spec_a).spec(spec_b); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("users")); + assert!(doc.resources.contains_key("orders")); + } + + #[test] + fn test_multi_spec_collision_error() { + let spec = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec(spec).spec(spec); + let result = app.build_doc(); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("users"), "error should name the colliding key"); + } + + #[test] + fn test_title_description_override() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Original Title" + description: "Original description" + version: "1.0" +paths: {} +"#; + let app = CliApp::new("test") + .spec(spec) + .title("My Custom Title") + .description("My custom description"); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.title.as_deref(), Some("My Custom Title")); + assert_eq!(doc.description.as_deref(), Some("My custom description")); + } + + #[test] + fn test_spec_under_namespaces_resources() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Billing API" + version: "1.0" +servers: + - url: "https://billing.example.com" +paths: + /invoices: + get: + x-fern-sdk-group-name: ["invoices"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec_under("billing", spec); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("billing")); + let billing = doc.resources.get("billing").unwrap(); + assert!(billing.resources.contains_key("invoices")); + } + + #[test] + fn test_security_schemes_merge_across_multi_spec() { + // When two specs each declare `components.securitySchemes`, the + // merged doc should contain the union. Without merging, the second + // spec's schemes would silently disappear and the eventual + // RoutingAuthProvider registry would be missing entries — operations + // referencing those schemes would fall through to passthrough. + let spec_a = r#" +openapi: "3.0.0" +info: { title: A, version: "1.0" } +servers: [{ url: "https://a.example.com" }] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: B, version: "1.0" } +servers: [{ url: "https://b.example.com" }] +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Api-Key } +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("multi").spec(spec_a).spec(spec_b).build_doc().unwrap(); + // Both schemes from both specs survive the merge. + assert!( + doc.security_schemes.contains_key("bearerAuth"), + "spec A's scheme missing: {:?}", + doc.security_schemes, + ); + assert!( + doc.security_schemes.contains_key("apiKey"), + "spec B's scheme missing: {:?}", + doc.security_schemes, + ); + } + + #[test] + fn test_merge_security_schemes_first_write_wins() { + use crate::openapi::discovery::SecurityScheme; + let mut acc = HashMap::new(); + acc.insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); + let mut incoming = HashMap::new(); + // Same name, different shape — first write wins, like merge_schemas. + incoming.insert( + "bearerAuth".to_string(), + SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + ); + merge_security_schemes(&mut acc, incoming); + assert_eq!(acc["bearerAuth"], SecurityScheme::HttpBearer); + } + + #[test] + fn test_merge_schemas_first_write_wins_on_duplicate() { + // Multi-spec setups commonly share schema names (`ErrorResponse`, + // `Pagination`). A strict-error policy makes such setups + // unworkable; first-write-wins lets specs share without manual + // de-duplication. + let mut acc = HashMap::new(); + acc.insert( + "ErrorResponse".to_string(), + crate::openapi::discovery::JsonSchema { + description: Some("first".to_string()), + ..Default::default() + }, + ); + let mut incoming = HashMap::new(); + incoming.insert( + "ErrorResponse".to_string(), + crate::openapi::discovery::JsonSchema { + description: Some("second".to_string()), + ..Default::default() + }, + ); + merge_schemas(&mut acc, incoming).expect("collision should not error"); + assert_eq!( + acc["ErrorResponse"].description.as_deref(), + Some("first"), + "first write should win" + ); + } + + #[test] + fn test_specs_under_batch_helper() { + // specs_under accepts an iterator of yamls and registers each under + // the same prefix. Sanity check it actually wires through. + let s1 = r#" +openapi: "3.0.0" +info: { title: A, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let s2 = r#" +openapi: "3.0.0" +info: { title: B, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").specs_under("ns", [s1, s2]); + let doc = app.build_doc().unwrap(); + let ns = &doc.resources["ns"]; + assert!(ns.resources.contains_key("alpha")); + assert!(ns.resources.contains_key("beta")); + } + + #[test] + fn test_spec_under_accepts_slash_delimited_path() { + // Slash splits into nested namespaces equivalent to specs_under_named. + let spec = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /widgets: + get: + x-fern-sdk-group-name: ["widgets"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("t") + .spec_under("v3/products", spec) + .build_doc() + .unwrap(); + let v3 = doc.resources.get("v3").expect("v3 namespace"); + let products = v3.resources.get("products").expect("nested products"); + assert!(products.resources.contains_key("widgets")); + } + + #[test] + fn test_spec_under_merges_multiple_specs_into_same_prefix() { + // Two specs sharing a prefix should merge under it (not error). + // Supports use cases where many specs all need to live under a + // single namespace (e.g. a versioned `v2` group). + let spec_a = r#" +openapi: "3.0.0" +info: { title: "A", version: "1.0" } +servers: [{ url: "https://a.example.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "B", version: "1.0" } +servers: [{ url: "https://b.example.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("test") + .spec_under("v2", spec_a) + .spec_under("v2", spec_b); + let doc = app.build_doc().unwrap(); + let v2 = doc.resources.get("v2").expect("v2 prefix should exist"); + assert!(v2.resources.contains_key("alpha")); + assert!(v2.resources.contains_key("beta")); + } + + #[test] + fn test_spec_under_collides_on_inner_resource() { + // Two specs with the same inner resource under the same prefix collide. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://x.example.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let err = CliApp::new("test") + .spec_under("v2", spec) + .spec_under("v2", spec) + .build_doc() + .expect_err("inner-key collision should error"); + assert!(err.to_string().contains("things"), "error: {err}"); + } + + #[test] + fn test_spec_under_hoists_matching_top_level_resource() { + // When the namespace name matches a top-level resource in the spec, + // hoist that resource's methods into the namespace itself — so users + // type `customers get` instead of `customers customers get`. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } + /customers/{id}/addresses: + get: + tags: [Addresses] + operationId: getAddresses + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").spec_under("customers", spec); + let doc = app.build_doc().unwrap(); + let customers = doc.resources.get("customers").expect("namespace exists"); + // Methods from the spec's `customers` resource hoisted into namespace. + assert!(customers.methods.contains_key("get-customers")); + // Sibling top-level resources (`addresses`) become children of the namespace. + assert!(customers.resources.contains_key("addresses")); + // No double-nested `customers.customers` from the hoist. + assert!(!customers.resources.contains_key("customers")); + } + + #[test] + fn test_specs_under_named_creates_nested_namespaces() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "A", version: "1.0" } +servers: [{ url: "https://a.example.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "B", version: "1.0" } +servers: [{ url: "https://b.example.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").specs_under_named( + "v3", + [("alpha", spec_a), ("beta", spec_b)], + ); + let doc = app.build_doc().unwrap(); + let v3 = doc.resources.get("v3").expect("v3 namespace"); + // Both specs nested under their own sub-namespace inside v3 (with hoist). + assert!(v3.resources.contains_key("alpha")); + assert!(v3.resources.contains_key("beta")); + let alpha = &v3.resources["alpha"]; + assert!(alpha.methods.contains_key("list")); + } + + #[test] + fn test_substitute_url_vars_replaces_known_and_leaves_unknown() { + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "abc123".to_string()); + let url = "https://api.example.com/stores/{store_hash}/v3/customers/{customer_id}"; + let out = substitute_url_vars(url, &subs); + // Known var substituted, unknown left literal so the failure mode is + // visible in dry-run output and downstream error messages. + assert_eq!( + out, + "https://api.example.com/stores/abc123/v3/customers/{customer_id}" + ); + } + + #[test] + fn test_apply_server_var_substitutions_walks_nested_resources() { + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://api.example.com/stores/{store_hash}/v3" }] +paths: + /a/{id}/b: + get: + x-fern-sdk-group-name: ["a", "b"] + x-fern-sdk-method-name: get + responses: { "200": { description: ok } } +"#; + let mut doc = CliApp::new("t").spec(spec).build_doc().unwrap(); + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "xyz".to_string()); + apply_server_var_substitutions(&mut doc, &subs); + + let nested_method = doc.resources["a"].resources["b"].methods.get("get").unwrap(); + assert_eq!(nested_method.root_url, "https://api.example.com/stores/xyz/v3"); + } + + #[test] + fn test_apply_server_var_substitutions_walks_named_servers() { + // Spec combines `{store_hash}` URL template variables with + // `x-fern-server-name` named servers. The substitution pass + // must rewrite the named-server URLs too — otherwise + // `resolve_named_server_url` reads back an unsubstituted URL + // and the executor sends the request to a literal + // `{store_hash}` host. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: + - url: "https://api.example.com/stores/{store_hash}/v3" + x-fern-server-name: Production + - url: "https://staging.example.com/stores/{store_hash}/v3" + x-fern-server-name: Staging +paths: + /uploads: + post: + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + servers: + - url: "https://upload.example.com/stores/{store_hash}/v3" + x-fern-server-name: Upload + responses: { "200": { description: ok } } +"#; + let mut doc = CliApp::new("t").spec(spec).build_doc().unwrap(); + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "abc123".to_string()); + apply_server_var_substitutions(&mut doc, &subs); + + // Top-level named servers are substituted. + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[0].url, "https://api.example.com/stores/abc123/v3"); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + assert_eq!(doc.servers[1].url, "https://staging.example.com/stores/abc123/v3"); + + // Per-operation `servers:` overrides are substituted too. + let create = doc.resources["uploads"].methods.get("create").unwrap(); + assert_eq!(create.servers.len(), 1); + assert_eq!(create.servers[0].name.as_deref(), Some("Upload")); + assert_eq!( + create.servers[0].url, + "https://upload.example.com/stores/abc123/v3", + ); + } + + #[test] + fn test_spec_under_root_url_on_methods() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Billing API" + version: "1.0" +servers: + - url: "https://billing.example.com" +paths: + /invoices: + get: + x-fern-sdk-group-name: ["invoices"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec_under("billing", spec); + let doc = app.build_doc().unwrap(); + let billing = doc.resources.get("billing").unwrap(); + let invoices = billing.resources.get("invoices").unwrap(); + let list = invoices.methods.get("list").unwrap(); + assert_eq!(list.root_url, "https://billing.example.com"); + } + + #[test] + fn test_per_method_root_url_set_by_openapi_parser() { + let spec = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +servers: + - url: "https://myapi.example.com" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let doc = crate::openapi::load_openapi_spec(spec, "myapi").unwrap(); + let method = doc.resources["things"].methods["list"].clone(); + assert_eq!(method.root_url, "https://myapi.example.com"); + } + + #[test] + fn test_overlay_applied_before_parsing() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: Add description + version: "1.0" +actions: + - target: "$.info" + update: + description: "A plant management API" +"#; + let app = CliApp::new("plant-api").spec(spec).overlay(overlay); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.description, Some("A plant management API".to_string())); + assert!(doc.resources.contains_key("plants")); + } + + #[test] + fn test_overlay_adds_fern_extensions() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + responses: + "200": + description: ok +"#; + // Overlay adds the fern extensions that were missing + let overlay = r#" +overlay: "1.0.0" +info: + title: Add fern extensions + version: "1.0" +actions: + - target: "$.paths['/plants'].get" + update: + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("plant-api").spec(spec).overlay(overlay); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("plants")); + assert!(doc.resources["plants"].methods.contains_key("list")); + } + + #[test] + fn test_multiple_overlays_on_same_spec() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let overlay1 = r#" +overlay: "1.0.0" +info: + title: Overlay 1 + version: "1.0" +actions: + - target: "$.info" + update: + description: "Plant API v1" +"#; + let overlay2 = r#" +overlay: "1.0.0" +info: + title: Overlay 2 + version: "1.0" +actions: + - target: "$.info" + update: + contact: + name: "Plant Store" +"#; + let app = CliApp::new("plant-api") + .spec(spec) + .overlay(overlay1) + .overlay(overlay2); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.description, Some("Plant API v1".to_string())); + assert!(doc.resources.contains_key("plants")); + } + + // ----------------------------------------------------------------------- + // Overrides integration tests + // ----------------------------------------------------------------------- + + #[test] + fn test_spec_with_overrides_applies_fern_extensions() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let customers = &doc.resources["customers"]; + assert!( + customers.methods.contains_key("list"), + "overrides should rename method to 'list', got: {:?}", + customers.methods.keys().collect::>() + ); + } + + #[test] + fn test_spec_under_with_overrides() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [Items] + operationId: getItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_under_with_overrides("v3", base, overrides); + let doc = app.build_doc().unwrap(); + let v3 = &doc.resources["v3"]; + let items = &v3.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides under prefix should rename method to 'list'" + ); + } + + #[test] + fn test_specs_under_named_with_overrides() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /orders: + get: + tags: [Orders] + operationId: getOrders + responses: { "200": { description: ok } } +"#; + let overrides_a = r#" +paths: + /orders: + get: + x-fern-sdk-group-name: [orders] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test") + .specs_under_named_with_overrides("v3", [("orders", spec_a, overrides_a)]); + let doc = app.build_doc().unwrap(); + let v3 = &doc.resources["v3"]; + // merge_into_path hoists: prefix "v3/orders" + group-name "orders" → v3 > orders > list + let orders = &v3.resources["orders"]; + assert!( + orders.methods.contains_key("list"), + "named overrides should rename method to 'list', got: {:?}", + orders.methods.keys().collect::>() + ); + } + + #[test] + fn test_spec_without_overrides_unchanged() { + // Verify that `.spec()` (no overrides) still works identically. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /pets: + get: + tags: [Pets] + operationId: getPets + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("test").spec(yaml); + let doc = app.build_doc().unwrap(); + let pets = &doc.resources["pets"]; + assert!( + pets.methods.contains_key("get-pets"), + "without overrides, method name should come from operationId" + ); + } + + #[test] + fn test_overrides_null_removes_field() { + let base = r#" +openapi: "3.0.0" +info: + title: T + version: "1.0" + description: "Remove me" +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [Items] + operationId: listItems + summary: "Original summary" + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + summary: null + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let items = &doc.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides should rename method even when combined with null deletions" + ); + } + + /// Array-of-objects merge via overrides: servers array elements are merged + /// by index (Fern parity), so the override can patch just one field. + #[test] + fn test_overrides_array_of_objects_merged_by_index() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + description: Production + - url: "https://staging.example.com" + description: Staging +paths: + /items: + get: + tags: [Items] + operationId: listItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +servers: + - url: "https://api-patched.example.com" +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + // Server[0] should be merged (url patched, description preserved) + assert_eq!(doc.root_url, "https://api-patched.example.com"); + } + + /// Primitive array replacement via overrides: tags are primitives so the + /// override replaces rather than merging by index. + #[test] + fn test_overrides_primitive_array_replaced() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [OldTag] + operationId: listItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + tags: [NewTag] + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let items = &doc.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides with replaced tags should still apply fern extensions" + ); + } + + /// Sequential overrides: two overrides applied in order. + #[test] + fn test_sequential_overrides_chain() { + use crate::openapi::parser::deep_merge_yaml; + + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /a: + get: + tags: [A] + operationId: getA + responses: { "200": { description: ok } } + /b: + get: + tags: [B] + operationId: getB + responses: { "200": { description: ok } } +"#; + let ovr1 = r#" +paths: + /a: + get: + x-fern-sdk-group-name: [alpha] + x-fern-sdk-method-name: list +"#; + let ovr2 = r#" +paths: + /b: + get: + x-fern-sdk-group-name: [beta] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base).unwrap(); + let ovr1_val: serde_yaml::Value = serde_yaml::from_str(ovr1).unwrap(); + let ovr2_val: serde_yaml::Value = serde_yaml::from_str(ovr2).unwrap(); + let merged = deep_merge_yaml(deep_merge_yaml(base_val, ovr1_val), ovr2_val); + let doc = crate::openapi::parser::load_openapi_spec_from_value(merged, "t").unwrap(); + assert!(doc.resources["alpha"].methods.contains_key("list")); + assert!(doc.resources["beta"].methods.contains_key("list")); + } +} diff --git a/cli/src/openapi/commands.rs b/cli/src/openapi/commands.rs new file mode 100644 index 00000000..296ffd35 --- /dev/null +++ b/cli/src/openapi/commands.rs @@ -0,0 +1,1554 @@ +//! CLI Command Builder +//! +//! Builds a dynamic `clap::Command` tree from the internal API representation. + +use clap::builder::{PossibleValue, PossibleValuesParser}; +use clap::{Arg, Command}; + +use std::collections::HashMap; + +use crate::openapi::discovery::{ + Availability, FernEnumValue, MethodParameter, RestDescription, RestResource, SdkGroupInfo, +}; +use crate::text::to_kebab_flag; + +/// Filter the document in-place so only operations matching at least +/// one of `active_audiences` survive into the command tree. Mirrors +/// fern-api/fern's OpenAPI importer behavior in +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`: +/// +/// ```text +/// if (audiences.length > 0 +/// && !audiences.some(a => operationAudiences.includes(a))) { +/// continue; +/// } +/// ``` +/// +/// Semantics, mirroring fern verbatim: +/// +/// 1. **No active audience** (`active_audiences` is empty) — every +/// operation survives regardless of its tags. +/// 2. **One or more active audiences** — an operation is kept only if +/// its `audiences` set intersects `active_audiences` (set OR, not +/// AND). Operations with empty `audiences` are dropped, since +/// `[].some(...)` is always false. +/// 3. **Untagged operations are NOT included** when a filter is active — +/// deliberate fern parity (a "no audience" tag is treated as "belongs +/// to no audience", not "belongs to all audiences"). +/// +/// After the per-operation prune, empty resource groups (no methods, +/// no non-empty children) are collapsed so they don't surface as bare +/// subcommands with no leaves — same approach used by the +/// `x-fern-ignore` pass in `parser.rs::prune_empty_resources`. +pub fn filter_doc_by_audiences(doc: &mut RestDescription, active_audiences: &[String]) { + if active_audiences.is_empty() { + return; + } + filter_resources_by_audiences(&mut doc.resources, active_audiences); +} + +/// Recursive worker for [`filter_doc_by_audiences`]. Drops methods that +/// don't intersect `active`, then recurses into nested resources, then +/// finally prunes resources that ended up empty. +fn filter_resources_by_audiences( + resources: &mut std::collections::HashMap, + active: &[String], +) { + resources.retain(|_, resource| { + resource + .methods + .retain(|_, method| method_matches_audiences(&method.audiences, active)); + filter_resources_by_audiences(&mut resource.resources, active); + !resource.methods.is_empty() || !resource.resources.is_empty() + }); +} + +/// Membership check mirroring fern's +/// `audiences.some(a => operationAudiences.includes(a))`. The names are +/// compared as opaque strings (case-sensitive, no normalization) so a +/// preset `audiences(["Public"])` and an operation tagged +/// `x-fern-audiences: [public]` deliberately do NOT match — matching +/// how the upstream importer treats audience names as identifiers. +fn method_matches_audiences(method_audiences: &[String], active: &[String]) -> bool { + active.iter().any(|a| method_audiences.iter().any(|m| m == a)) +} + +/// Prepends the availability badge (e.g. `[BETA] `) to `text` when one is +/// present. Falls back to `text` unchanged for generally-available items +/// and items with no availability marker. +fn with_availability_badge(text: &str, availability: Option) -> String { + match availability.and_then(Availability::badge) { + Some(badge) if text.is_empty() => badge.to_string(), + Some(badge) => format!("{badge} {text}"), + None => text.to_string(), + } +} + +/// Names of built-in flags that must not be duplicated by parameter-derived flags. +pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ + "params", + "output", + "json", + "format", + "dry-run", + "base-url", + "page-all", + "page-limit", + "page-delay", + "no-extract", + "no-retry", + "no-stream", + "help", +]; + +/// The non-auth portion of the `--help` footer. Auth env vars are +/// computed dynamically from bindings by `CliApp::run_async` and +/// prepended via `Command::after_help` — keeping them out of this string +/// avoids stale `{NAME}_API_KEY` boilerplate. +pub fn after_help_footer(binary_name: &str) -> String { + let prefix = binary_name.to_uppercase().replace('-', "_"); + format!( + "Environment variables:\n \ + {prefix}_BASE_URL Override the API base URL\n \ + {prefix}_CA_BUNDLE Path to PEM file with extra trust roots (or SSL_CERT_FILE)\n \ + {prefix}_INSECURE=1 Skip TLS verification (debugging only)\n \ + {prefix}_PROXY HTTP(S) proxy URL\n \ + {prefix}_TIMEOUT_SECS Total request timeout\n\n\ + Standard env vars (HTTPS_PROXY / HTTP_PROXY / NO_PROXY / SSL_CERT_FILE) are also honored." + ) +} + +/// Builds the full CLI command tree from an API description. +pub fn build_cli(doc: &RestDescription) -> Command { + let about_text = doc + .title + .clone() + .unwrap_or_else(|| format!("{} CLI", doc.name)); + let after_help = after_help_footer(&doc.name); + let mut root = Command::new(doc.name.clone()) + .about(about_text) + .after_help(after_help) + .term_width(200) + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + clap::Arg::new("dry-run") + .long("dry-run") + .help("Validate the request locally without sending it to the API") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json (default), table, yaml, csv") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ); + + // Add resource subcommands + let mut resource_names: Vec<_> = doc.resources.keys().collect(); + resource_names.sort(); + for name in resource_names { + let resource = &doc.resources[name]; + if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + root = root.subcommand(cmd); + } + } + + root +} + +/// Resolve the `about()` line for a group's clap subcommand. Returns +/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from +/// the document-root `x-fern-groups` extension) when present; falls +/// back to the legacy `Operations on ''` label otherwise. The +/// fallback preserves the current default behavior unchanged for any +/// group identifier that doesn't appear in `x-fern-groups`. +pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { + groups + .get(name) + .and_then(|info| info.summary.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("Operations on '{name}'")) +} + +/// Resolve the `long_about()` line for a group's clap subcommand from +/// the document-root `x-fern-groups` extension's `description` field. +/// `None` when the group has no entry or the entry omits `description` +/// — clap then falls back to the `about()` text for `--help`. +pub(crate) fn group_long_about_text( + name: &str, + groups: &HashMap, +) -> Option { + groups + .get(name) + .and_then(|info| info.description.clone()) + .filter(|s| !s.is_empty()) +} + +/// Stringify a parameter's resolved client-side default value for +/// clap's `Arg::default_value`. Strings pass through verbatim; numbers +/// and booleans use their natural lexical form (e.g. `100`, `true`); +/// other JSON shapes (arrays, objects) fall through to compact JSON — +/// but in practice `x-fern-default` only carries scalar literals so the +/// scalar branch is the load-bearing case. +/// +/// Returns `None` for `Value::Null` and the `None` input so the caller +/// can skip setting any clap default. +pub(crate) fn default_value_for_clap(value: &Option) -> Option { + match value.as_ref()? { + serde_json::Value::Null => None, + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Number(n) => Some(n.to_string()), + other => Some(other.to_string()), + } +} + +/// Format an OpenAPI standard `default:` value as a trailing +/// documentation suffix to append to a flag's help text. Renders as +/// `[default: ]` so the user sees the same shape as clap's +/// auto-generated `[default: ...]` for `x-fern-default` — the help +/// surface intentionally does not distinguish client-side defaults +/// (sent on the wire) from server-side defaults (doc-only). The split +/// stays a wire-behavior concern, not a documentation concern. +/// +/// Returns the bare scalar rendering with a leading space so callers +/// can concatenate it directly onto `Arg::help`. +pub(crate) fn documentation_default_help_suffix( + value: &Option, +) -> Option { + let rendered = match value.as_ref()? { + serde_json::Value::Null => return None, + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + other => other.to_string(), + }; + Some(format!(" [default: {rendered}]")) +} + +/// Recursively builds a Command for a resource. +/// Returns None if the resource has no methods or sub-resources. +/// +/// `groups` carries the parsed document-root `x-fern-groups` block; when +/// a matching entry exists for `name` it overrides the `about()`/ +/// `long_about()` text rendered in `--help`. Unmatched resources retain +/// the legacy `Operations on ''` label and alphabetical placement +/// so adding `x-fern-groups` is strictly additive. +fn build_resource_command( + name: &str, + resource: &RestResource, + groups: &HashMap, +) -> Option { + let mut cmd = Command::new(name.to_string()) + .about(group_about_text(name, groups)) + .subcommand_required(true) + .arg_required_else_help(true); + + if let Some(long_about) = group_long_about_text(name, groups) { + cmd = cmd.long_about(long_about); + } + + let mut has_children = false; + + // Add method subcommands + let mut method_names: Vec<_> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + + has_children = true; + + let about = crate::text::truncate_description( + method.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + let about = with_availability_badge(&about, method.availability); + + let mut method_cmd = Command::new(method_name.to_string()) + .about(about) + .arg( + Arg::new("params") + .long("params") + .help("Additional parameters as JSON (overrides individual flags)") + .value_name("JSON"), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .help("Output file path for binary responses") + .value_name("PATH"), + ); + + // Add --json flag for REST request bodies + if method.request.is_some() { + method_cmd = method_cmd.arg( + Arg::new("json") + .long("json") + .help("JSON request body") + .value_name("JSON"), + ); + } + + // Add a typed flag for operations with a binary request body + // (e.g. application/octet-stream). The file is streamed as the body + // with the content type declared in the spec. The flag name comes from + // `x-fern-parameter-name` on the requestBody, or defaults to `file` + // for `format: binary` schemas (else `body`). + // + // Accepts three forms: , @ (curl-style), or `-` for stdin. + if let Some(ref binary) = method.binary_request_body { + method_cmd = method_cmd.arg( + Arg::new(binary.flag_name.clone()) + .long(binary.flag_name.clone()) + .value_name("PATH|@PATH|-") + .help(format!( + "Body for the request (Content-Type: {}). Accepts:\n \ + plain filesystem path\n \ + @ same path (curl-style prefix)\n \ + - read from stdin (sent chunked)", + binary.content_type, + )), + ); + } + + // Pagination flags + method_cmd = method_cmd + .arg( + Arg::new("page-all") + .long("page-all") + .help("Auto-paginate through all results (NDJSON)") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("page-limit") + .long("page-limit") + .help("Maximum number of pages to fetch (default: 10)") + .value_name("N") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("page-delay") + .long("page-delay") + .help("Delay in milliseconds between page fetches (default: 100)") + .value_name("MS") + .value_parser(clap::value_parser!(u64)), + ) + .arg( + Arg::new("no-extract") + .long("no-extract") + .help( + "Disable x-fern-sdk-return-value extraction and print the full response body", + ) + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("no-retry") + .long("no-retry") + .help( + "Disable retries declared by x-fern-retries on this operation, \ + including network errors. Useful for debugging.", + ) + .action(clap::ArgAction::SetTrue), + ); + + // `--no-stream` is only meaningful on operations with + // `x-fern-streaming`. Registering it unconditionally would let + // clap accept it on unrelated ops and silently no-op, which + // hides spec/runtime mismatches; instead, expose it only where + // it does something so non-streaming siblings reject the flag + // up-front. + if method.streaming.is_some() { + method_cmd = method_cmd.arg( + Arg::new("no-stream") + .long("no-stream") + .help( + "Buffer the streaming response and print it as a single value once \ + complete (handy for piping into another JSON tool)", + ) + .action(clap::ArgAction::SetTrue), + ); + } + + // Generate individual flags from method parameters + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for param_name in param_names { + let param = &method.parameters[param_name]; + + // Flag name resolution: + // 1. `flag_name_override` (set verbatim, no kebab pass) — + // populated only by synthetic Fern-extension injections + // (currently `inject_idempotency_header_params`). See + // `MethodParameter::flag_name_override`. + // 2. `display_name` from `x-fern-parameter-name` — kebabed. + // Renames the CLI flag while keeping `param_name` (the + // wire name) as the clap arg ID. Downstream + // `collect_params_from_flags` looks values up by arg ID, + // and the executor uses the params map key (= wire name) + // when serializing the request, so the alias never leaks + // onto the wire — only the user-facing flag changes. + // Mirrors fern's openapi-ir-parser, which renames the + // SDK parameter via `parameterNameOverride` while + // preserving the OpenAPI parameter's `name` on the HTTP + // request. + // 3. Fallback: kebab the HashMap key. + let kebab_name = if let Some(override_flag) = param.flag_name_override.as_deref() { + override_flag.to_string() + } else { + let flag_source = param.display_name.as_deref().unwrap_or(param_name.as_str()); + to_kebab_flag(flag_source) + }; + if BUILTIN_FLAG_NAMES.contains(&kebab_name.as_str()) { + continue; + } + + // Variable-bound path parameters get their value from a + // root-level global flag (registered in `app::run_async` from + // `doc.sdk_variables`) plus its env-var fallback. Skipping + // here keeps the per-operation flag surface clean and matches + // Fern's openapi-ir-parser, which lowers these into + // constructor-style globals rather than method arguments. + if param.variable_reference.is_some() { + continue; + } + + let value_name = match param.param_type.as_deref() { + Some("string") => "STRING", + Some("integer") => "NUMBER", + Some("number") => "NUMBER", + Some("boolean") => "BOOLEAN", + Some("array") => "JSON_ARRAY", + Some("object") => "JSON_OBJECT", + _ => "VALUE", + }; + + let help_text = crate::text::truncate_description( + param.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + let help_text = with_availability_badge(&help_text, param.availability); + // When the flag has been renamed via `x-fern-parameter-name`, + // surface the original wire name in `--help` so users can + // still correlate the flag with the API doc / `--params` JSON. + // (Synthetic `flag_name_override` injections already encode + // the wire name in their description, so they skip this.) + let help_text = match param.display_name.as_deref() { + Some(alias) if param.flag_name_override.is_none() && alias != param_name => { + if help_text.is_empty() { + format!("(wire name: {param_name})") + } else { + format!("{help_text} (wire name: {param_name})") + } + } + _ => help_text, + }; + // Append the OpenAPI standard `default:` value as a + // `[default: ...]` suffix when it is the only default + // source. Same visual shape as clap's auto-rendered + // `[default: ...]` for `x-fern-default` — the user sees + // "there is a default" without being told whether the CLI + // or the server applies it. The CLI itself does not send + // this value on the wire (only `x-fern-default` populates + // `default_value` below). + let help_text = match documentation_default_help_suffix( + ¶m.documentation_default_value, + ) { + Some(suffix) => format!("{help_text}{suffix}"), + None => help_text, + }; + + let mut arg = Arg::new(param_name.clone()) + .long(kebab_name) + .value_name(value_name) + .help(help_text); + + // Only `x-fern-default` (lowered into `default_value`) + // becomes a clap default. The standard `default:` keyword + // is doc-only and handled above via the help-text suffix. + if let Some(default_str) = default_value_for_clap(¶m.default_value) { + arg = arg.default_value(default_str); + } + + // Environment-variable fallback (currently populated by the + // OpenAPI parser for synthetic idempotency-header params from + // `x-fern-idempotency-headers`, with overrides applied by + // `CliApp::idempotency_header_env`). Clap reads `.env(...)` + // when the flag is absent on the command line, giving us the + // same priority order — flag → env → default — used for auth + // sources. + if let Some(ref env_var) = param.env_var { + arg = arg.env(env_var.clone()); + } + + if let Some(ref enum_values) = param.enum_values { + arg = arg.value_parser(build_enum_value_parser(enum_values, param)); + } + + if param.repeated { + arg = arg.action(clap::ArgAction::Append); + } + + method_cmd = method_cmd.arg(arg); + } + + cmd = cmd.subcommand(method_cmd); + } + + // Add sub-resource subcommands (recursive) + let mut sub_names: Vec<_> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub_resource = &resource.resources[sub_name]; + if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + has_children = true; + cmd = cmd.subcommand(sub_cmd); + } + } + + if has_children { + Some(cmd) + } else { + None + } +} + +/// Build a `PossibleValuesParser` that respects an optional `x-fern-enum` +/// override. When the parameter has no `fern_enum` map, this is a plain +/// `PossibleValuesParser::new(wire_values)`. When it does, each wire value +/// gets an alias + per-value help string so `--help` renders the display +/// name and description while either the display name or wire value parses +/// successfully on the command line. +fn build_enum_value_parser( + wire_values: &[String], + param: &MethodParameter, +) -> PossibleValuesParser { + let possible: Vec = wire_values + .iter() + .map(|wire| { + let cfg = param + .fern_enum + .as_ref() + .and_then(|m| m.get(wire)); + build_possible_value(wire, cfg) + }) + .collect(); + PossibleValuesParser::from(possible) +} + +/// Construct a single `PossibleValue` from a wire value and its optional +/// `x-fern-enum` config. The display name (if set and different from the +/// wire value) becomes the canonical rendered name, with the wire value +/// as a parse-time alias. Descriptions surface as long-help text. +fn build_possible_value(wire: &str, cfg: Option<&FernEnumValue>) -> PossibleValue { + let display = cfg.and_then(|c| c.display_name.as_deref()); + let mut pv = match display { + Some(name) if name != wire => PossibleValue::new(name.to_string()).alias(wire.to_string()), + _ => PossibleValue::new(wire.to_string()), + }; + if let Some(desc) = cfg.and_then(|c| c.description.as_deref()) { + pv = pv.help(desc.to_string()); + } + pv +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{FernEnumValue, MethodParameter, RestMethod, RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "list".to_string(), + ..Default::default() + }, + ); + methods.insert( + "delete".to_string(), + RestMethod { + http_method: "DELETE".to_string(), + path: "delete".to_string(), + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "files".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_all_commands_always_shown() { + let doc = make_doc(); + let cmd = build_cli(&doc); + + let files_cmd = cmd + .find_subcommand("files") + .expect("files resource missing"); + + assert!(files_cmd.find_subcommand("list").is_some()); + assert!(files_cmd.find_subcommand("delete").is_some()); + } + + #[test] + fn test_root_uses_doc_name() { + let doc = make_doc(); + let cmd = build_cli(&doc); + assert_eq!(cmd.get_name(), "test-cli"); + } + + #[test] + fn test_method_params_become_flags() { + let mut params = HashMap::new(); + params.insert( + "uuid".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user UUID".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + params.insert( + "status".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by status".to_string()), + location: Some("query".to_string()), + enum_values: Some(vec!["active".to_string(), "inactive".to_string()]), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get-user".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/users/{uuid}".to_string(), + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let users_cmd = cmd.find_subcommand("users").expect("users resource missing"); + let get_user_cmd = users_cmd + .find_subcommand("get-user") + .expect("get-user method missing"); + + // Verify individual flags exist + let args: Vec = get_user_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!(args.contains(&"uuid".to_string()), "uuid flag missing"); + assert!(args.contains(&"status".to_string()), "status flag missing"); + assert!(args.contains(&"params".to_string()), "params flag missing"); + } + + #[test] + fn test_variable_bound_param_skipped_from_per_op_flags() { + // Path parameters that carry `x-fern-sdk-variable` must NOT appear + // as per-operation flags. Their value comes from a root-level + // global flag registered in `app::run_async` from + // `doc.sdk_variables` (with env-var fallback). Mirrors Fern's + // openapi-ir-parser semantics: variables are constructor-style + // globals, not per-method arguments. + let mut params = HashMap::new(); + params.insert( + "gardenId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Garden tenant".to_string()), + location: Some("path".to_string()), + required: true, + variable_reference: Some("gardenId".to_string()), + ..Default::default() + }, + ); + // A plain (non-variable-bound) path param on the same op still + // surfaces as a per-op flag. + params.insert( + "zoneId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Zone id".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/gardens/{gardenId}/zones/{zoneId}".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "zones".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "garden-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let zones_cmd = cmd + .find_subcommand("zones") + .expect("zones resource missing"); + let get_cmd = zones_cmd + .find_subcommand("get") + .expect("zones.get missing"); + let arg_ids: Vec = get_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!( + !arg_ids.contains(&"gardenId".to_string()), + "variable-bound path param should NOT be a per-op flag, got: {arg_ids:?}", + ); + assert!( + arg_ids.contains(&"zoneId".to_string()), + "plain path param should still surface as a per-op flag, got: {arg_ids:?}", + ); + } + + #[test] + fn test_builtin_flag_names_not_duplicated() { + let mut params = HashMap::new(); + params.insert( + "format".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Response format".to_string()), + ..Default::default() + }, + ); + params.insert( + "output".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Output type".to_string()), + ..Default::default() + }, + ); + params.insert( + "real_param".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("A real param".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "test-method".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/test".to_string(), + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + // This should not panic from duplicate arg names + let cmd = build_cli(&doc); + let things_cmd = cmd + .find_subcommand("things") + .expect("things resource missing"); + let test_cmd = things_cmd + .find_subcommand("test-method") + .expect("test-method missing"); + + let args: Vec = test_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // "format" and "output" should NOT appear as duplicated param flags + // but "real_param" should be present + assert!( + args.contains(&"real_param".to_string()), + "real_param flag missing" + ); + + // Count occurrences of "format" — should be at most 1 (from the global flag) + let format_count = args.iter().filter(|a| *a == "format").count(); + assert!( + format_count <= 1, + "format flag duplicated: found {format_count}" + ); + } + // ------------------------------------------------------------------ + // x-fern-enum → clap PossibleValue wiring + // + // These tests target `build_enum_value_parser` directly so the + // mapping between the `MethodParameter.fern_enum` map and clap's + // `PossibleValue` (canonical name + alias + help) can't drift. + // ------------------------------------------------------------------ + fn param_with_fern_enum( + wire_values: &[&str], + entries: &[(&str, Option<&str>, Option<&str>)], + ) -> MethodParameter { + let mut map = HashMap::new(); + for (wire, name, desc) in entries { + map.insert( + (*wire).to_string(), + FernEnumValue { + display_name: name.map(|s| s.to_string()), + description: desc.map(|s| s.to_string()), + }, + ); + } + MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(wire_values.iter().map(|s| s.to_string()).collect()), + fern_enum: Some(map), + ..Default::default() + } + } + + /// Drive `build_enum_value_parser` through a real `clap::Command` + /// `--help` render so the assertions cover what the user sees, not + /// just internals. Returns the lower-cased help text so substring + /// matches are case-insensitive. + fn render_arg_long_help(param: &MethodParameter) -> String { + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), param); + let cmd = Command::new("test").arg( + Arg::new("status") + .long("status") + .value_parser(parser) + .help("Filter by status"), + ); + let mut buf = Vec::new(); + cmd.clone() + .write_long_help(&mut buf) + .expect("clap should render long help"); + String::from_utf8(buf).expect("clap long help is utf-8") + } + + #[test] + fn test_build_enum_value_parser_no_fern_enum_uses_wire_values() { + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["a".to_string(), "b".to_string()]), + ..Default::default() + }; + let help = render_arg_long_help(¶m); + assert!( + help.contains("possible values") && help.contains("a") && help.contains("b"), + "wire values must be listed in long help when no fern_enum is set; got:\n{help}", + ); + } + + #[test] + fn test_build_enum_value_parser_renders_display_name_and_per_value_help() { + let param = param_with_fern_enum( + &["all", "managed", "external"], + &[ + ("all", Some("All"), Some("Every user.")), + ( + "managed", + Some("Managed"), + Some("Enterprise-managed users."), + ), + ("external", None, Some("External collaborators only.")), + ], + ); + let help = render_arg_long_help(¶m); + + // Display names are the rendered option labels in long help. + assert!( + help.contains("All") && help.contains("Managed"), + "display names must appear in long help, got:\n{help}", + ); + // The un-overridden entry still surfaces its wire value. + assert!( + help.contains("external"), + "wire value must appear when no display override is set, got:\n{help}", + ); + // Per-value descriptions land in long help. + assert!( + help.contains("Every user."), + "missing first description in:\n{help}" + ); + assert!( + help.contains("Enterprise-managed users."), + "missing second description in:\n{help}", + ); + assert!( + help.contains("External collaborators only."), + "missing third description in:\n{help}", + ); + } + + /// Both the display alias and the wire value must successfully parse + /// when `display_name` is set — this is the key affordance promised + /// by `x-fern-enum` for CLI users who only know one of the names. + #[test] + fn test_build_enum_value_parser_accepts_both_display_and_wire() { + let param = param_with_fern_enum( + &["all", "managed", "external"], + &[ + ("all", Some("All"), None), + ("managed", Some("Managed"), None), + ], + ); + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("status").long("status").value_parser(parser)); + + for input in ["All", "all", "Managed", "managed", "external"] { + let matches = cmd + .clone() + .try_get_matches_from(vec!["test", "--status", input]) + .unwrap_or_else(|e| panic!("input `{input}` should parse; got error: {e}")); + let parsed = matches.get_one::("status").unwrap(); + assert_eq!( + parsed, input, + "clap returns the user-typed value verbatim; display->wire mapping happens later", + ); + } + + // A bogus value still fails — guards against accidentally + // accepting arbitrary strings when fern_enum is set. + assert!( + cmd.clone() + .try_get_matches_from(vec!["test", "--status", "Bogus"]) + .is_err(), + "values not in the enum must still be rejected", + ); + } + + /// `name == wire` is a no-op: clap rejects an alias equal to the + /// canonical name, so the builder must dedupe instead of crashing. + /// Build the parser into a `Command` to confirm clap accepts it. + #[test] + fn test_build_enum_value_parser_skips_alias_when_display_equals_wire() { + let param = param_with_fern_enum( + &["managed"], + &[("managed", Some("managed"), Some("Same wire & display."))], + ); + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("status").long("status").value_parser(parser)); + let matches = cmd + .try_get_matches_from(vec!["test", "--status", "managed"]) + .expect("clap should accept a PossibleValue without a self-alias"); + assert_eq!(matches.get_one::("status").unwrap(), "managed"); + } + + #[test] + fn test_json_help_text_rest_method() { + use crate::openapi::discovery::SchemaRef; + + // REST method with a request body → --json should describe the request body. + let mut rest_methods = HashMap::new(); + rest_methods.insert( + "create".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Thing".to_string()), + parameter_name: None, + }), + ..Default::default() + }, + ); + let mut rest_resources = HashMap::new(); + rest_resources.insert( + "things".to_string(), + RestResource { + methods: rest_methods, + resources: HashMap::new(), + }, + ); + let rest_doc = RestDescription { + name: "rest-cli".to_string(), + resources: rest_resources, + ..Default::default() + }; + let rest_cmd = build_cli(&rest_doc); + let rest_json = rest_cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("create")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "json")) + .expect("REST --json arg missing"); + let rest_help = rest_json + .get_help() + .map(|s| s.to_string()) + .unwrap_or_default(); + assert!( + rest_help.contains("request body"), + "REST --json help should describe the request body, got: {rest_help}", + ); + } + + // ------------------------------------------------------------------ + // filter_doc_by_audiences — fern parity + // ------------------------------------------------------------------ + + /// Build a doc with four operations covering every audience-tag + /// shape used by the fixture spec: one tagged, one with a + /// distinct tag, one untagged, and one multi-tagged. Used by all + /// `filter_doc_by_audiences` tests below. + fn doc_with_audiences() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "public-only".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/p".to_string(), + audiences: vec!["public".to_string()], + ..Default::default() + }, + ); + methods.insert( + "internal-only".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/i".to_string(), + audiences: vec!["internal".to_string()], + ..Default::default() + }, + ); + methods.insert( + "untagged".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/u".to_string(), + audiences: vec![], + ..Default::default() + }, + ); + methods.insert( + "multi-tagged".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/m".to_string(), + audiences: vec!["public".to_string(), "internal".to_string()], + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "audiences".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "fixture".to_string(), + resources, + ..Default::default() + } + } + + fn method_names(doc: &RestDescription, group: &str) -> Vec { + let mut names: Vec = doc + .resources + .get(group) + .map(|r| r.methods.keys().cloned().collect()) + .unwrap_or_default(); + names.sort(); + names + } + + /// Empty audience filter is a no-op. Mirrors fern's + /// `audiences.length > 0 && ...` guard in + /// `openapi-ir-parser/generateIr.ts:141` — when no audiences are + /// active, every operation passes through. + #[test] + fn test_filter_doc_by_audiences_empty_is_noop() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &[]); + assert_eq!( + method_names(&doc, "audiences"), + vec!["internal-only", "multi-tagged", "public-only", "untagged"], + ); + } + + /// Single audience keeps only operations whose `x-fern-audiences` + /// contains that tag. Untagged operations are dropped — matches + /// fern's `operationAudiences.includes(...)` over an empty array + /// always evaluating false. + #[test] + fn test_filter_doc_by_audiences_single_keeps_matching_only() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["public".to_string()]); + assert_eq!( + method_names(&doc, "audiences"), + vec!["multi-tagged", "public-only"], + ); + } + + /// Multiple active audiences union the matches (OR, not AND). + /// Mirrors fern's `audiences.some(a => operationAudiences.includes(a))`: + /// any one match keeps the operation. + #[test] + fn test_filter_doc_by_audiences_multiple_unions_matches() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences( + &mut doc, + &["public".to_string(), "internal".to_string()], + ); + assert_eq!( + method_names(&doc, "audiences"), + vec!["internal-only", "multi-tagged", "public-only"], + ); + } + + /// An audience that no operation declares prunes every operation + /// and then collapses the now-empty resource group. Matches fern's + /// behavior: a preset audience with no matches in the spec yields + /// an empty IR. + #[test] + fn test_filter_doc_by_audiences_nonexistent_prunes_empty_resources() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["nonexistent".to_string()]); + assert!( + doc.resources.is_empty(), + "filtering all ops out of a group should also prune the empty group itself: \ + {:?}", + doc.resources + ); + } + + /// Audience names are compared as opaque strings — case sensitive, + /// no normalization — to match fern's treatment of audience tags + /// as identifiers. `Public` and `public` do NOT match. + #[test] + fn test_filter_doc_by_audiences_is_case_sensitive() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["Public".to_string()]); + assert!( + doc.resources.is_empty(), + "case-mismatched audience should match nothing" + ); + } + + /// Nested resources are walked recursively, and an outer resource + /// with only an empty child is itself collapsed. Guards against the + /// recursive prune accidentally leaving orphan parent groups in the + /// command tree. + #[test] + fn test_filter_doc_by_audiences_recurses_into_nested_resources() { + let mut inner_methods = HashMap::new(); + inner_methods.insert( + "ping".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/p".to_string(), + audiences: vec!["public".to_string()], + ..Default::default() + }, + ); + let mut inner_resources = HashMap::new(); + inner_resources.insert( + "v2".to_string(), + RestResource { + methods: inner_methods, + resources: HashMap::new(), + }, + ); + let outer = RestResource { + methods: HashMap::new(), + resources: inner_resources, + }; + let mut resources = HashMap::new(); + resources.insert("audiences".to_string(), outer); + let mut doc = RestDescription { + name: "fixture".to_string(), + resources, + ..Default::default() + }; + + filter_doc_by_audiences(&mut doc, &["public".to_string()]); + let nested = &doc.resources["audiences"].resources["v2"]; + assert!(nested.methods.contains_key("ping")); + + filter_doc_by_audiences(&mut doc, &["nonexistent".to_string()]); + assert!( + doc.resources.is_empty(), + "nested empty groups should propagate up and prune the outer" + ); + } + + // ------------------------------------------------------------------ + // x-fern-groups (FER-9864 P3): document-root metadata that + // re-labels `x-fern-sdk-group-name` group subcommands for the + // help surface. No tree restructuring; the `--help` `about` / + // `long_about` lines for the group's clap Command change when a + // matching entry exists, otherwise the legacy `Operations on + // ''` fallback applies (preserving prior behavior). + // ------------------------------------------------------------------ + + fn make_doc_with_things_resource() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + /// Precondition for the rest of the suite: without `x-fern-groups` + /// metadata, the group's clap Command uses the legacy + /// `Operations on ''` about text. This is the fallback the + /// "missing metadata" integration case verifies end-to-end. + #[test] + fn test_group_about_falls_back_to_legacy_label_when_no_metadata() { + let doc = make_doc_with_things_resource(); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'things'", + ); + assert!(things.get_long_about().is_none()); + } + + /// A matching `x-fern-groups` entry with `summary` overrides the + /// fallback `Operations on ''` label on the clap `about()` + /// line. The `summary` text surfaces in both the parent's command + /// table (next to the subcommand name) and in `--help` for the + /// group itself. + #[test] + fn test_group_summary_overrides_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things Operations".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things Operations", + ); + // No `description` → no long_about override; clap will fall + // back to `about` for `--help`. + assert!(things.get_long_about().is_none()); + } + + /// `description` populates `long_about()` so `--help` shows the + /// detailed prose for the group. Setting `description` alone + /// (without `summary`) keeps the legacy short label — fern's IR + /// allows either field to be present without the other and we + /// preserve that asymmetry. + #[test] + fn test_group_description_sets_long_about_only() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Long-form prose about things.".to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'things'", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Long-form prose about things.", + ); + } + + /// Both fields set: `summary` becomes `about`, `description` + /// becomes `long_about`. This is the canonical case the + /// integration test exercises against `--help`. + #[test] + fn test_group_summary_and_description_populate_both_about_fields() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things Operations".to_string()), + description: Some("Long-form prose about things.".to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things Operations", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Long-form prose about things.", + ); + } + + /// Integration case (a) — matched group: spec carries + /// `x-fern-groups: { foo: { summary: "Foo Operations" } }`, two + /// operations are tagged `x-fern-sdk-group-name: foo`, and one is + /// untagged. Verifies the `foo` subcommand's `about()` line is + /// `Foo Operations` (driven by `summary`) and that the untagged + /// op lands on its tag-derived sibling group with the legacy + /// fallback label. + /// + /// Drives the parser end-to-end so the full path + /// (YAML → `OpenApiSpec` → `RestDescription` → `Command`) is + /// covered. + #[test] + fn test_x_fern_groups_summary_drives_about_for_matched_group() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + foo: + summary: Foo Operations + description: Operations on foo resources. +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } + /foo/create: + post: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: create + operationId: foo_create + responses: + "200": { description: ok } + /misc: + get: + tags: [Misc] + x-fern-sdk-method-name: list + operationId: misc_list + responses: + "200": { description: ok } +"#; + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + let cmd = build_cli(&doc); + + // Matched group: `summary` wins over the legacy fallback. + let foo = cmd + .find_subcommand("foo") + .expect("foo subcommand should exist"); + assert_eq!( + foo.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Foo Operations", + ); + assert_eq!( + foo.get_long_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on foo resources.", + ); + // Group children are still present — `x-fern-groups` does not + // restructure the clap tree. + assert!(foo.find_subcommand("list").is_some()); + assert!(foo.find_subcommand("create").is_some()); + } + + /// Integration case (b) — missing-metadata fallback: an operation + /// carries `x-fern-sdk-group-name: [bar]` but the spec has no + /// matching `x-fern-groups: bar` entry. The CLI must build + /// without error and the `bar` subcommand keeps the legacy + /// `Operations on 'bar'` about line. Verifies no crash and clean + /// fallback when only one side of the pair is present. + #[test] + fn test_x_fern_groups_missing_entry_falls_back_to_default_label() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /bar/list: + get: + x-fern-sdk-group-name: [bar] + x-fern-sdk-method-name: list + operationId: bar_list + responses: + "200": { description: ok } +"#; + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + assert!( + doc.groups.is_empty(), + "no x-fern-groups → groups map should be empty", + ); + let cmd = build_cli(&doc); + let bar = cmd + .find_subcommand("bar") + .expect("bar subcommand should exist"); + assert_eq!( + bar.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'bar'", + ); + assert!(bar.get_long_about().is_none()); + assert!(bar.find_subcommand("list").is_some()); + } + + /// `x-fern-groups` is purely metadata for rendering — adding it + /// must NOT change which subcommands exist, their nesting, or + /// their leaf method commands. This guards the hard constraint + /// that the clap tree structure stays untouched. + #[test] + fn test_x_fern_groups_does_not_restructure_clap_tree() { + let yaml_without = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } +"#; + let yaml_with = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + foo: + summary: Foo Operations +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } +"#; + let collect_tree = |yaml: &str| -> Vec { + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + let cmd = build_cli(&doc); + let mut out = Vec::new(); + for sub in cmd.get_subcommands() { + for leaf in sub.get_subcommands() { + out.push(format!("{}.{}", sub.get_name(), leaf.get_name())); + } + } + out.sort(); + out + }; + assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); + } +} diff --git a/cli/src/openapi/discovery.rs b/cli/src/openapi/discovery.rs new file mode 100644 index 00000000..f50cd56a --- /dev/null +++ b/cli/src/openapi/discovery.rs @@ -0,0 +1,1031 @@ +//! Internal OpenAPI Representation +//! +//! Data structures representing an OpenAPI API's resources, methods, parameters, and schemas. +//! These structs serve as the internal representation that the command builder and +//! executor consume. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// Top-level API description. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestDescription { + pub name: String, + pub version: String, + pub title: Option, + pub description: Option, + pub root_url: String, + /// All top-level `servers:` entries from the spec, in declaration order. + /// `root_url` is the URL of the first entry (kept for backwards + /// compatibility with existing call sites). Servers with a resolved + /// `name` (from `x-name`, falling back to `x-fern-server-name`) drive + /// the global `--server ` flag — when the spec has at least one + /// named server, the flag is exposed and its allowed values are the + /// top-level named server names. Unnamed entries are still preserved + /// here so the order matches the spec; helpers like + /// [`RestDescription::named_servers`] filter them out for the help + /// surface. + #[serde(default, skip)] + pub servers: Vec, + #[serde(default)] + pub service_path: String, + pub base_url: Option, + /// Common prefix prepended to every operation path at request time — + /// sourced from the spec-level `x-fern-base-path` extension. Used to + /// declare a shared base like `/v1` or `/api/public` once on the spec + /// instead of duplicating it on every path. Mirrors the upstream + /// Fern OpenAPI importer: + /// + /// + /// At request time the executor inserts this between the server URL + /// and the operation path with exactly one slash between segments. + /// An empty string is treated the same as `None`. + pub base_path: Option, + #[serde(default)] + pub schemas: HashMap, + #[serde(default)] + pub resources: HashMap, + #[serde(default)] + pub parameters: HashMap, + pub auth: Option, + /// Auth schemes declared in `components.securitySchemes`. The key is the + /// scheme name as it appears in the spec — that name is what + /// per-operation `security` requirements reference, and what + /// `CliApp::auth_scheme(name, source)` binds a credential source to. + #[serde(default)] + pub security_schemes: HashMap, + /// Query parameter name for pagination tokens (default: "pageToken"). + #[serde(default)] + pub pagination_token_query_param: Option, + /// Dotted path to next page token in response JSON (default: "nextPageToken"). + /// Supports nested paths like "pagination.next_page_token". + #[serde(default)] + pub pagination_token_response_path: Option, + /// Idempotency header definitions parsed from the spec-root + /// [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) + /// extension. Empty when the extension is absent. + /// + /// Each entry describes a header that operations marked with + /// `x-fern-idempotent: true` accept. The parser materializes one CLI + /// flag per header on every idempotent operation; non-idempotent + /// operations are unaffected and never send these headers. + #[serde(default, skip)] + pub idempotency_headers: Vec, + /// Constructor-style globals declared by the spec's top-level + /// `x-fern-sdk-variables` extension. Each entry surfaces as a global + /// CLI flag (kebab-cased) with an env-var fallback + /// (SCREAMING_SNAKE_CASE of the variable name) and replaces the + /// corresponding `{varName}` placeholder in path templates of + /// operations whose path parameter carries `x-fern-sdk-variable`. + /// + /// See . + #[serde(default, skip)] + pub sdk_variables: Vec, + /// Spec-root [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) + /// extension. Inherited by every operation that omits its own + /// `x-fern-retries` block, or that sets `x-fern-retries: true` to + /// opt in to the spec-root defaults. A per-op object merges over + /// this baseline; a per-op `false` (or `{ disabled: true }`) + /// disables retries on that operation regardless of root. + #[serde(default, skip)] + pub retries: Option, + /// Global header definitions parsed from the spec-root + /// [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) + /// extension. Empty when the extension is absent. + /// + /// Each entry surfaces as a global CLI flag at the root of the + /// command tree with an env-var fallback and (when configured) a + /// baked-in default value. The resolved value is stamped on every + /// outgoing request as the named HTTP header — per-operation + /// parameters with the same wire-name win. + #[serde(default, skip)] + pub global_headers: Vec, + /// Top-level group metadata sourced from the document-root + /// [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) + /// extension. Mirrors the upstream Fern OpenAPI importer's + /// [`SdkGroupInfo`](https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernGroups.ts) + /// record (`{ summary?, description? }`). + /// + /// Keys are kebab-cased to match the resource keys built from + /// `x-fern-sdk-group-name`, so a `foo` entry binds to the `foo` + /// subcommand resource and a `myGroup` entry binds to the + /// `my-group` resource. Values are purely metadata for `--help` + /// rendering — `x-fern-groups` does NOT restructure the clap tree, + /// matching fern's semantics where the extension only annotates + /// existing groups for documentation. + #[serde(default, skip)] + pub groups: HashMap, +} + +/// Metadata for a single group declared via the spec-root +/// [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) +/// extension. +/// +/// Mirrors fern's `SdkGroupInfo` IR type (both fields optional): +/// +/// (`SdkGroupInfo { summary: optional, description: optional }`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SdkGroupInfo { + /// Short human-friendly label for the group. When present, replaces + /// the default `Operations on ''` text used as the clap + /// subcommand's `about()` line. + pub summary: Option, + /// Longer prose description of the group. When present, used as the + /// clap subcommand's `long_about()` so `--help` shows the full text + /// for the group. + pub description: Option, +} + +/// A single global header definition from the spec-root +/// [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) +/// extension. Mirrors the [`GlobalHeader`] IR type emitted by the +/// upstream Fern OpenAPI importer. +/// +/// The CLI uses `name` (if set) to derive the kebab-cased flag name and +/// `header` as the on-the-wire HTTP header name. When `env` is set the +/// flag accepts the value from that environment variable as a fallback, +/// and `default` is used when neither the flag nor the env var is +/// supplied. Operations may opt out of sending the header by declaring +/// a same-named per-operation parameter, which takes precedence. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GlobalHeader { + /// HTTP header name sent on the wire (e.g. `X-API-Version`). + pub header: String, + /// Optional SDK/CLI parameter name. When set, used as the basis for + /// the kebab-cased CLI flag name; otherwise the flag derives from + /// `header`. + pub name: Option, + /// When `false` (the default), the CLI flag is required — every + /// outgoing request must carry a value. When `true`, the header is + /// omitted from requests where no value resolved. + pub optional: bool, + /// Optional environment variable that provides a fallback value for + /// the generated flag. + pub env: Option, + /// Optional baked-in default value applied when neither the flag + /// nor the environment variable is supplied. Mirrors the upstream + /// `x-fern-default` shape — only the value is preserved; the + /// schema type is informational. + pub default: Option, +} + +/// A single idempotency-header definition from the spec-root +/// [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) +/// extension. Mirrors the [`IdempotencyHeader`] IR type emitted by the +/// upstream Fern OpenAPI importer. +/// +/// The CLI uses `name` (if set) to derive the kebab-cased flag name and +/// `header` as the on-the-wire HTTP header name. When `env` is set the +/// flag accepts the value from that environment variable as a fallback. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct IdempotencyHeader { + /// HTTP header name sent on the wire (e.g. `Idempotency-Key`). + pub header: String, + /// Optional SDK/CLI parameter name. When set, used as the basis for + /// the kebab-cased CLI flag name; otherwise the flag derives from + /// `header`. + pub name: Option, + /// Optional environment variable that provides a default value for + /// the generated flag. Generators can override this at build time via + /// [`crate::openapi::app::CliApp::idempotency_header_env`]. + pub env: Option, +} + +/// A spec-level `x-fern-sdk-variables` entry. Modeled as a constructor-style +/// global that operations can bind path parameters to via +/// `x-fern-sdk-variable: `. +/// +/// Fern's TS/Python/Java SDKs only support `type: string` here today, so the +/// parser warns and skips non-string entries (mirroring the upstream +/// importer's `Variable has unsupported schema` rejection but without +/// failing the whole spec load — the CLI is intentionally permissive). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SdkVariable { + /// Variable name as it appears in path templates (e.g. `gardenId`). + pub name: String, + /// Lowered OpenAPI primitive type. Always `string` today; carried so a + /// future generator change can specialize the global flag's `value_name`. + pub ty: String, + /// One-line `--help` description (from the variable schema's + /// `description:` field). + pub description: Option, +} + +/// Lifecycle/availability of an operation or parameter, sourced from the +/// `x-fern-availability` extension on the OpenAPI element. Mirrors the +/// canonical Fern values documented at +/// . +/// +/// `Deprecated` is also reached when an operation has no +/// `x-fern-availability` extension but does carry the OpenAPI +/// `deprecated: true` flag — in that case the parser surfaces +/// `Deprecated` (see `parser.rs`). +/// +/// NOTE: deliberate divergence from the Fern OpenAPI IR importer +/// (`packages/cli/api-importers/openapi/openapi-ir-parser`): the importer +/// collapses `pre-release` into [`Availability::Beta`] in the IR, since +/// downstream SDK generators only need to know "is this stable" / +/// "is this pre-stable" / "is this gone". The cli-sdk parser keeps +/// `PreRelease` as its own variant so the help-output badge can +/// differentiate `[PRE-RELEASE]` from `[BETA]` — both are documented +/// values in the [Fern reference], and treating them as the same loses +/// signal at the CLI surface where the user is reading help text. +/// +/// [Fern reference]: https://buildwithfern.com/learn/api-definitions/openapi/extensions/availability +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Availability { + /// Pre-stable, in active development. Tagged `[ALPHA]` in help output. + Alpha, + /// Pre-release / preview API. Tagged `[PRE-RELEASE]` in help output. + /// Distinct from [`Availability::Beta`] in cli-sdk; see enum docs. + PreRelease, + /// Public beta. Tagged `[BETA]` in help output. + Beta, + /// Public preview. Tagged `[PREVIEW]` in help output. + Preview, + /// Generally available. No badge — this is the implicit default when + /// `x-fern-availability` is absent. Accepts `ga` as an alias (matches + /// the Fern OpenAPI importer). + #[serde(alias = "ga")] + GenerallyAvailable, + /// Deprecated; still callable but discouraged. Tagged `[DEPRECATED]` + /// in help output. Also inferred from OpenAPI `deprecated: true`. + Deprecated, + /// Legacy / sunset API. Tagged `[LEGACY]` in help output. + Legacy, +} + +impl Availability { + /// Returns the badge label used in CLI help output for this + /// availability, or `None` for [`Availability::GenerallyAvailable`] + /// (the implicit default — no badge). + pub fn badge(self) -> Option<&'static str> { + match self { + Availability::Alpha => Some("[ALPHA]"), + Availability::PreRelease => Some("[PRE-RELEASE]"), + Availability::Beta => Some("[BETA]"), + Availability::Preview => Some("[PREVIEW]"), + Availability::GenerallyAvailable => None, + Availability::Deprecated => Some("[DEPRECATED]"), + Availability::Legacy => Some("[LEGACY]"), + } + } + + /// Lowercase wire identifier matching the canonical Fern spelling + /// (`alpha`, `beta`, `pre-release`, `preview`, `generally-available`, + /// `deprecated`, `legacy`). Used for the `availability` field + /// surfaced in JSON help output. + pub fn as_str(self) -> &'static str { + match self { + Availability::Alpha => "alpha", + Availability::PreRelease => "pre-release", + Availability::Beta => "beta", + Availability::Preview => "preview", + Availability::GenerallyAvailable => "generally-available", + Availability::Deprecated => "deprecated", + Availability::Legacy => "legacy", + } + } +} + +/// A single auth scheme declared in `components.securitySchemes`. Mirrors +/// the OpenAPI 3 Security Scheme Object, lowered to just the bits we +/// dispatch on at runtime. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub enum SecurityScheme { + /// `type: http, scheme: bearer` → `Authorization: Bearer `. + HttpBearer, + /// `type: http, scheme: basic` → `Authorization: Basic `. + HttpBasic, + /// `type: apiKey, in: header, name: X-Api-Key` → `: `. + ApiKeyHeader { name: String }, + /// `type: apiKey, in: query, name: api_key` — represented for parsing + /// fidelity. The CLI doesn't attach query-key auth itself today; + /// `RoutingAuthProvider` will skip a requirement that names this scheme. + ApiKeyQuery { name: String }, + /// `type: oauth2`. The CLI treats these the same as `HttpBearer` at + /// request time — the user supplies an already-issued access token via + /// env var. Token refresh is out of scope. + OAuth2, + /// Anything we don't model (mTLS, openIdConnect, etc.). Recorded so the + /// scheme name is still routable if a separate provider is bound to it + /// programmatically. + Other(String), +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct AuthDescription { + pub oauth2: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct OAuth2Description { + pub scopes: Option>, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ScopeDescription { + pub description: Option, +} + +/// A resource which can contain methods and nested sub-resources. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct RestResource { + #[serde(default)] + pub methods: HashMap, + #[serde(default)] + pub resources: HashMap, +} + +/// One entry from an OpenAPI `servers:` array (top-level or per-operation), +/// lowered into the internal representation. +/// +/// `name` is populated from the Fern extensions `x-name` (v1, the +/// legacy alias) or `x-fern-server-name` (v2, the canonical Fern +/// spelling). When both are present on the same server entry, v1 wins +/// to mirror fern's `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` +/// first-match-wins semantics in +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. +/// Unnamed servers (no `x-fern-server-name` and no `x-name`) carry +/// `None`; they still participate in the default-URL chain (first +/// server wins) but are not selectable via the global `--server ` +/// flag. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Server { + /// Server URL as it appears in the spec (may contain `{variable}` + /// placeholders that are substituted later by [`CliApp::server_var`]). + pub url: String, + /// Resolved server name from `x-name` (v1 legacy alias, preferred to + /// mirror fern) or `x-fern-server-name` (v2 canonical Fern spelling). + /// `None` for unnamed entries. + pub name: Option, + /// Optional human-readable description from the spec — surfaced in + /// `--help` next to the server URL. + pub description: Option, +} + +impl RestDescription { + /// Returns the top-level servers that have a resolved name, paired + /// with the resolved name itself, in declaration order. Drives the + /// global `--server ` flag's allowed values and the + /// help-section listing. + /// + /// Yielding `(name, server)` tuples lets callers avoid re-checking + /// `server.name.is_some()` after the filter — the name is right + /// there, statically guaranteed to be non-empty (see + /// [`OpenApiServer::resolved_name`] in the parser, which trims and + /// drops empty strings at the source). + pub fn named_servers(&self) -> impl Iterator { + self.servers + .iter() + .filter_map(|s| s.name.as_deref().map(|n| (n, s))) + } +} + +/// Default total attempts (initial + retries) when retries are enabled. +/// +/// CLI users typically don't expect retries by default — they want fast, +/// observable failures — so we ship a *conservative* default that retries +/// at most once. The spec author can override this with +/// `x-fern-retries: { max_attempts: N }`. +/// +/// This is deliberately lower than the fern Python/TypeScript runtime SDKs +/// (which default to 3 total attempts) because those SDKs are embedded in +/// long-running applications where the latency of an extra retry is +/// acceptable. The CLI is interactive — a 3-second backoff before the +/// final failure feels broken. +pub const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 2; + +/// Default exponential-backoff base delay in milliseconds. The wait before +/// retry N is `base * factor^N` (plus jitter). With +/// [`DEFAULT_RETRY_MAX_ATTEMPTS`] = 2 the single retry happens after +/// `base * factor^0` = 250ms. +pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 250; + +/// Default exponential-backoff growth factor. +pub const DEFAULT_RETRY_FACTOR: f64 = 2.0; + +/// Default jitter fraction (`0.1` = ±10% of the computed delay). +pub const DEFAULT_RETRY_JITTER: f64 = 0.1; + +/// Resolved retry policy for an endpoint (or the spec-root default), +/// lowered from the [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) +/// extension. +/// +/// Mirrors the upstream Fern OpenAPI importer's tagged shape — the +/// canonical lever is `disabled: bool`, which the importer surfaces as +/// `RetriesConfiguration::Disabled(value)`. cli-sdk extends the same +/// extension with optional knobs that the runtime retry loop honors at +/// request time: `max_attempts`, `base_delay_ms`, `factor`, `jitter`. The +/// extra knobs are forward-compatible with the upstream importer — they +/// are simply ignored on the fern side until the IR carries them. +/// +/// Resolution precedence (handled by the parser): +/// - per-op block absent → inherit the spec-root block (or `None` if also absent) +/// - per-op `true` → spec-root config, or all-defaults when root is absent +/// - per-op `false` (or `{ disabled: true }`) → disabled regardless of root +/// - per-op object → root values, overridden field-by-field by the op block +#[derive(Debug, Clone, PartialEq)] +pub struct RetriesConfig { + /// `true` (the default) means the executor's retry loop is active; + /// `false` disables retries for the operation. Maps to upstream + /// fern's `RetriesConfiguration::Disabled(value)` — the importer's + /// `disabled: true` lowers here as `enabled: false`. + pub enabled: bool, + /// Maximum total attempts (the initial request counts as attempt 1). + /// `max_attempts: 2` performs the request once and retries up to one + /// additional time. Validated as `>= 0` at parse time; a value of + /// `0` is treated identically to `disabled: true`. + pub max_attempts: u32, + /// Base delay between retries in milliseconds. The actual wait before + /// retry `n` (1-indexed) is `base_delay_ms * factor^(n-1)`, plus + /// optional jitter, capped by any server-supplied `Retry-After`. + pub base_delay_ms: u64, + /// Growth factor for exponential backoff (e.g. `2.0` doubles the + /// delay each retry). + pub factor: f64, + /// Jitter fraction in `[0.0, 1.0]`. A value of `0.1` adds a uniform + /// random offset in `±10%` of the computed delay so a stampede of + /// clients does not synchronize retries. + pub jitter: f64, +} + +impl Default for RetriesConfig { + fn default() -> Self { + Self { + enabled: true, + max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS, + base_delay_ms: DEFAULT_RETRY_BASE_DELAY_MS, + factor: DEFAULT_RETRY_FACTOR, + jitter: DEFAULT_RETRY_JITTER, + } + } +} + +impl RetriesConfig { + /// Explicitly-disabled retry policy. Returned by the parser when the + /// spec sets `x-fern-retries: false` or `{ disabled: true }`. The + /// executor short-circuits on this variant — no retry loop, no + /// backoff, no Retry-After honor. + pub fn disabled() -> Self { + Self { + enabled: false, + max_attempts: 0, + base_delay_ms: 0, + factor: DEFAULT_RETRY_FACTOR, + jitter: 0.0, + } + } +} + +/// A single API method. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestMethod { + pub id: Option, + pub description: Option, + pub http_method: String, + pub path: String, + #[serde(default)] + pub parameters: HashMap, + #[serde(default)] + pub parameter_order: Vec, + pub request: Option, + pub response: Option, + #[serde(default)] + pub scopes: Vec, + pub flat_path: Option, + #[serde(default)] + pub supports_media_download: bool, + #[serde(default)] + pub supports_media_upload: bool, + pub media_upload: Option, + /// Per-operation base URL (populated from the spec's servers block during parsing). + /// When non-empty, takes priority over RestDescription.root_url in URL construction. + #[serde(default)] + pub root_url: String, + /// Per-operation `servers:` overrides (named or unnamed), in declaration + /// order. Empty when the operation has no `servers:` block (the + /// top-level [`RestDescription::servers`] applies instead). + /// + /// When non-empty, this list is the authoritative server set for the + /// operation — per-op `servers:` *replaces* the global default, it does + /// not augment it. The global `--server ` flag resolves against + /// this list first for operations that have it; if the flag value + /// doesn't match any per-op name, the executor falls back to + /// [`RestMethod::root_url`] (the first per-op server) so per-op routing + /// overrides are preserved. + #[serde(default, skip)] + pub servers: Vec, + /// Metadata for operations whose request body is raw binary (e.g. + /// `application/octet-stream`, `audio/mpeg`). When `Some`, the CLI exposes + /// a typed flag that streams a file as the body with the declared content + /// type. + #[serde(default)] + pub binary_request_body: Option, + /// Lowered OpenAPI security requirements: OR of ANDs. + /// + /// - `None` — operation didn't declare `security` and there was no + /// spec-level default to inherit. + /// - `Some(vec![])` — operation explicitly opts out (`security: []` in + /// the spec, or inherited explicit empty). + /// - `Some(vec![req1, req2, ...])` — satisfy any one requirement; each + /// requirement is an AND of scheme names with their requested scopes. + #[serde(default)] + pub security_requirements: Option>>>, + /// Resolved `x-fern-pagination` extension for this operation, after + /// applying root-level inheritance (per-op `x-fern-pagination: true` + /// inherits from the spec-root `x-fern-pagination` block). + /// + /// `None` means the operation has no explicit pagination config — the + /// executor falls back to the document-wide heuristic + /// (`pagination_token_query_param` + `pagination_token_response_path`). + #[serde(default, skip)] + pub pagination: Option, + /// Lowered `x-fern-availability` for the operation. `None` is the + /// implicit default (no badge). When the extension is absent but the + /// operation carries `deprecated: true`, the parser sets this to + /// `Some(Availability::Deprecated)` so the standard OpenAPI flag is + /// honored. + #[serde(default)] + pub availability: Option, + /// `true` when the operation is marked with + /// [`x-fern-idempotent: true`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotent). + /// Idempotent operations surface the spec-root idempotency-header + /// definitions as CLI flags; non-idempotent operations do not, and + /// never send idempotency headers on the wire. + #[serde(default)] + pub idempotent: bool, + /// Resolved `x-fern-sdk-return-value` extension — a dot-separated key + /// path through the JSON response body identifying the subvalue the + /// SDK / CLI should return to the caller. `None` (the implicit + /// default) means the executor prints the full response. + /// + /// Mirrors fern-api/fern's OpenAPI importer + /// (`FernOpenAPIExtension.RESPONSE_PROPERTY = "x-fern-sdk-return-value"`): + /// the value is consumed as a property path on the response body, + /// surfacing only the named subvalue. cli-sdk extends this to + /// support nested paths (e.g. `result.items`) at runtime — the + /// upstream Fern Definition path resolves a single object property, + /// but the CLI executor walks dotted paths the same way it does for + /// `x-fern-pagination`'s `next_*` / `results` paths. + #[serde(default)] + pub return_value: Option, + /// Resolved `x-fern-streaming` extension. `None` means the operation + /// returns a unary response and the executor reads/buffers the body + /// normally. `Some(_)` opts the executor into incremental + /// line-by-line response handling: each event/value is decoded as it + /// arrives and emitted to stdout (or buffered when `--no-stream` is + /// set). Mirrors the upstream Fern OpenAPI importer's + /// `getFernStreamingExtension` + /// (`fern-api/fern/.../extensions/getFernStreamingExtension.ts`). + /// + /// The runtime variant carries only what the executor needs at + /// request time: the wire format (SSE vs newline-delimited JSON) and + /// an optional terminator line. Upstream's `stream-condition` form + /// (which generates a streaming-and-unary endpoint pair in typed + /// SDKs) is parsed for parity but is not surfaced at the CLI + /// runtime — the CLI exposes one command per OpenAPI operation, so + /// the boolean stream-condition is treated as an unconditional + /// stream. + #[serde(default, skip)] + pub streaming: Option, + /// Resolved `x-fern-retries` extension for this operation, after + /// applying root-level inheritance (per-op `true` adopts the spec-root + /// baseline; per-op object merges field-by-field over root). `None` + /// means the operation has no retry policy at all — the executor + /// runs the request exactly once. See [`RetriesConfig`] for the + /// precedence rules. + #[serde(default, skip)] + pub retries: Option, + /// Resolved [`x-fern-audiences`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/audiences) + /// tags for this operation, in declaration order with duplicates + /// preserved. Empty when the operation has no `x-fern-audiences` + /// extension. + /// + /// Used by the audience-filter pass at command-tree build time + /// (`commands::filter_doc_by_audiences`) to decide whether the + /// operation appears as a CLI subcommand. Untouched at request + /// time — the executor never inspects this field, matching fern's + /// "drop from IR" semantics rather than "skip at runtime". + /// + /// Mirrors fern-api/fern's OpenAPI importer + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/operation/convertHttpOperation.ts:330`): + /// `audiences: getExtension(operation, FernOpenAPIExtension.AUDIENCES) ?? []`. + /// + /// `skip` mirrors the convention used by peer internal-only + /// fields parsed from `x-fern-*` extensions (`retries`, + /// `streaming`, `pagination`) — set programmatically by the + /// parser, never round-tripped through `RestMethod` serialization. + #[serde(default, skip)] + pub audiences: Vec, +} + +/// Per-operation pagination configuration, resolved from the +/// [`x-fern-pagination`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/pagination) +/// OpenAPI extension. +/// +/// The five forms mirror `fern-api/fern`'s OpenAPI importer (see +/// `getPaginationExtension.ts`): +/// +/// - [`PaginationConfig::Cursor`] — token-based forward pagination +/// - [`PaginationConfig::Offset`] — numeric offset pagination +/// - [`PaginationConfig::Uri`] — server returns a fully-formed next URL +/// - [`PaginationConfig::Path`] — server returns a relative next path +/// - [`PaginationConfig::Custom`] — caller-driven; the executor stops after +/// one request (no automatic continuation) and exposes only the +/// `results` extraction +/// +/// `$request.` / `$response.` JSONPath prefixes are stripped during +/// parsing so values can be consumed directly: `cursor` / `offset` are the +/// request parameter name to populate on the next page, and `next_*`, +/// `results`, `has_next_page` are dotted JSON paths into the response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaginationConfig { + /// Cursor-style pagination — send the previous response's + /// `next_cursor` value as the request's `cursor` parameter on the next + /// page. Pagination stops when `next_cursor` is absent, null, or empty. + Cursor { + /// Request parameter name receiving the cursor token. + cursor: String, + /// Dotted JSON path in the response to the next cursor token. + next_cursor: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Offset-style pagination — send the running offset as the request's + /// `offset` parameter on each page. Pagination stops when + /// `has_next_page` is `false`, when the results array is empty, or + /// when the configured page limit is reached. + Offset { + /// Request parameter name receiving the offset value. + offset: String, + /// Dotted JSON path in the response to the results array. + results: String, + /// Optional request parameter name holding the page-size step. When + /// present, the offset advances by the step value the caller + /// supplied (e.g. `--params '{"limit": 50}'`). When absent, the + /// offset advances by the response page's results length. + step: Option, + /// Optional dotted JSON path in the response to a boolean + /// "more pages?" flag. + has_next_page: Option, + }, + /// URI pagination — the server returns a fully-formed URL for the + /// next page (e.g. `https://api.example.com/v1/things?cursor=abc`). + /// The executor uses that URL verbatim for the next request. + /// Pagination stops when the URL is absent, null, or empty. + Uri { + /// Dotted JSON path in the response to the next-page URL. + next_uri: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Path pagination — like [`PaginationConfig::Uri`] but the response + /// contains a relative path (e.g. `/v1/things?cursor=abc`) that the + /// executor resolves against the original request's base URL. + /// Pagination stops when the path is absent, null, or empty. + Path { + /// Dotted JSON path in the response to the next-page path. + next_path: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Custom pagination — caller-driven. The CLI does not attempt + /// automatic continuation; it issues exactly one request and only + /// uses the `results` path for result extraction. + Custom { + /// Dotted JSON path in the response to the results array. + results: String, + }, +} + +/// Per-operation streaming configuration, resolved from the +/// [`x-fern-streaming`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/streaming) +/// OpenAPI extension. Mirrors the upstream Fern OpenAPI importer's +/// `getFernStreamingExtension` tagged union — the three wire formats +/// the runtime distinguishes (`sse`, `json`, `text`) line up with +/// Fern IR's `StreamingResponse` union (see +/// `packages/ir-sdk/fern/apis/ir-types-latest/definition/http.yml`). +/// +/// Recognized YAML shapes (parser side): +/// - `x-fern-streaming: true` → [`StreamingConfig::Json`] with no terminator +/// (matches upstream's boolean shorthand: `format: "json"`). +/// - `x-fern-streaming: false` → `None` (explicit opt-out). +/// - `x-fern-streaming: { format: sse }` → [`StreamingConfig::Sse`]. +/// - `x-fern-streaming: { format: json }` → [`StreamingConfig::Json`]. +/// - `x-fern-streaming: { format: text }` → [`StreamingConfig::Text`]. +/// - `{ format: sse, terminator: "[DONE]" }` → SSE with explicit terminator. +/// +/// The optional `terminator` is the literal line that ends the stream +/// — for SSE, the event payload after the `data:` prefix; for JSON, +/// the full line. When unset, the executor reads until the server +/// closes the connection (matches the TS / C# typed-SDK runtimes, +/// which also skip the terminator check when the spec didn't declare +/// one). Text streams have no terminator concept. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamingConfig { + /// Server-Sent Events stream (`format: sse`). Body is parsed line + /// by line; lines beginning with `data: ` have the prefix stripped + /// and the remainder is emitted as one event. Other SSE field + /// lines (`event:`, `id:`, `retry:`, comment lines starting with + /// `:`) are ignored at runtime. + Sse { + /// Optional sentinel line that terminates the stream + /// (compared against the post-`data: ` event payload using + /// exact equality, matching the C# generator). When `None`, + /// the stream reads to EOF; mirrors the TS/C# typed-SDK + /// behavior of only checking the terminator when the spec + /// declared one. + terminator: Option, + }, + /// Newline-delimited JSON stream (`format: json`, aka NDJSON / + /// JSONL). Each non-empty line is a complete JSON value; the + /// executor parses one value per line and emits it as it arrives. + Json { + /// Optional sentinel line that terminates the stream (compared + /// against the raw line, before JSON parsing). When `None`, + /// the stream ends when the server closes the connection. + terminator: Option, + }, + /// Plain-text line stream (`format: text`). Each non-empty line is + /// emitted verbatim as a raw string event — no JSON parsing, no + /// SSE framing strip, no terminator check. Mirrors the C# SDK + /// generator (`HttpEndpointGenerator.ts:815-825`), which reads + /// the response line-by-line and `yield return line` for any + /// non-empty line. + /// + /// `x-fern-sdk-return-value` is a no-op for text streams — the + /// event payload is already a JSON string after escaping. + Text, +} + +/// Metadata describing a binary request body. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BinaryRequestBody { + /// Content type to send with the request (e.g. `application/octet-stream`). + pub content_type: String, + /// CLI flag name (kebab-cased). Resolved from `x-fern-parameter-name` on + /// the requestBody when present; falls back to `file` for `format: binary` + /// schemas, otherwise `body`. + pub flag_name: String, +} + +/// Media upload metadata. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUpload { + pub protocols: Option, + pub accept: Option>, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUploadProtocols { + pub simple: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUploadProtocol { + pub path: String, + pub multipart: Option, +} + +/// A reference to a schema (e.g., `{ "$ref": "File" }`). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct SchemaRef { + #[serde(rename = "$ref")] + pub schema_ref: Option, + #[serde(rename = "parameterName")] + pub parameter_name: Option, +} + +/// A parameter definition for a method. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MethodParameter { + #[serde(rename = "type")] + pub param_type: Option, + pub description: Option, + pub location: Option, + #[serde(default)] + pub required: bool, + pub format: Option, + /// Client-side default sourced only from the Fern `x-fern-default` + /// extension. When set, the generated CLI plumbs this into clap's + /// `.default_value(...)` (so the value shows up in `--help` and the + /// flag becomes optional) AND substitutes the original JSON value + /// into the outgoing request when the caller omits the flag. Stored + /// as a typed `serde_json::Value` so numbers/booleans keep their + /// wire type. + /// + /// Precedence within this field, **first match wins**: + /// 1. `x-fern-default` placed at the ref-site (next to `$ref`) + /// 2. `x-fern-default` on the resolved component parameter + /// + /// The OpenAPI standard `default:` keyword does **not** populate + /// this field — it lives separately on + /// [`documentation_default_value`]. See ticket FER-9864. + pub default_value: Option, + /// Documentation hint sourced from the OpenAPI standard `default:` + /// keyword on the parameter's `schema`. The OpenAPI spec defines + /// `default:` as describing **server** behavior when the parameter + /// is omitted — it is not a directive to clients to send the value. + /// + /// We surface this in `--help` (so users know what the API will do + /// if they leave the flag off) but we do **not** wire it into + /// clap's `.default_value(...)` and we do **not** send it on the + /// wire. Only `x-fern-default` (stored on [`default_value`]) + /// produces a client-side default. + /// + /// Ignored when `default_value` is set — the extension supersedes + /// the documentation hint for display purposes too. + pub documentation_default_value: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + pub enum_descriptions: Option>, + #[serde(default)] + pub repeated: bool, + pub minimum: Option, + pub maximum: Option, + #[serde(default)] + pub deprecated: bool, + /// OpenAPI serialization style (form, deepObject, etc.) + #[serde(default)] + pub style: Option, + /// Whether arrays/objects should be exploded into separate params. + #[serde(default)] + pub explode: Option, + /// Lowered `x-fern-availability` for the parameter. `None` is the + /// implicit default (no badge). + #[serde(default)] + pub availability: Option, + /// Optional environment variable that supplies a default value when + /// the corresponding CLI flag is not passed. Populated for synthetic + /// parameters injected by Fern extensions (e.g. idempotency headers); + /// not currently set for spec-declared parameters. + #[serde(default)] + pub env_var: Option, + /// Override the kebab-cased long-flag derived from the parameter's + /// HashMap key. When `Some(_)`, `commands.rs` uses this value + /// verbatim as the `--` instead of running the key through + /// `to_kebab_flag`. The clap arg ID — and the on-the-wire wire-key + /// (e.g. HTTP header name) — still derives from the HashMap key, so + /// the executor's lookup pathway is unchanged. + /// + /// Populated by `inject_idempotency_header_params` so an entry like + /// `{ header: X-Trace-Id, name: trace_id }` surfaces as `--trace-id` + /// (matching the SDK parameter naming the upstream Fern OpenAPI + /// importer produces) while still sending the `X-Trace-Id` header. + #[serde(default)] + pub flag_name_override: Option, + /// Lowered `x-fern-parameter-name` for the parameter. When `Some`, + /// the command builder renames the CLI flag (kebab-cased), while the + /// executor keeps using the original wire name (the map key) for the + /// outgoing HTTP request. Mirrors fern's OpenAPI importer, which uses + /// the alias on the SDK surface but the wire name in the request. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/parameter-name + #[serde(default)] + pub display_name: Option, + /// Lowered `x-fern-enum` per-value overrides. Keyed by the wire + /// value. Entries are only present when the spec opted into the + /// extension; absent → fall back to the raw wire value with no + /// description. + #[serde(default, skip)] + pub fern_enum: Option>, + /// Name of the spec-level `x-fern-sdk-variables` entry that supplies + /// this parameter's value. Set when the parameter carries an + /// `x-fern-sdk-variable: ` extension. Variable-bound path + /// parameters are excluded from the per-operation flag surface; their + /// value is read from the global root flag (or its env-var fallback) + /// and substituted into the path template at request time. + #[serde(default, skip)] + pub variable_reference: Option, +} + +impl MethodParameter { + /// Map a user-supplied value (which may be either the wire value or + /// the `x-fern-enum` display alias) back to the **wire** value the + /// HTTP layer must send. When no override matches, returns the input + /// unchanged so non-enum params and absent extensions are pure + /// identity. + pub fn resolve_enum_display_to_wire<'a>( + &self, + input: &'a str, + ) -> std::borrow::Cow<'a, str> { + let Some(map) = self.fern_enum.as_ref() else { + return std::borrow::Cow::Borrowed(input); + }; + for (wire, entry) in map { + if entry + .display_name + .as_deref() + .is_some_and(|name| name == input) + { + return std::borrow::Cow::Owned(wire.clone()); + } + } + std::borrow::Cow::Borrowed(input) + } +} + +/// Per-value override for `x-fern-enum`. Mirrors the Fern OpenAPI IR +/// importer's `FernEnumConfig` entry — `description` and `name` are the +/// only fields cli-sdk consumes; `casing` is reserved for SDK codegen. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FernEnumValue { + /// User-facing rendered name. When set, surfaces as the canonical + /// option in `--help` while the wire value remains accepted as an + /// alias. + pub display_name: Option, + /// Per-value description rendered in long `--help` output. + pub description: Option, +} + +/// JSON Schema definition for request/response bodies. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JsonSchema { + pub id: Option, + #[serde(rename = "type")] + pub schema_type: Option, + pub description: Option, + #[serde(default)] + pub properties: HashMap, + #[serde(rename = "$ref")] + pub schema_ref: Option, + pub items: Option>, + #[serde(default)] + pub required: Vec, + pub additional_properties: Option>, +} + +/// A property within a JSON Schema. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JsonSchemaProperty { + #[serde(rename = "type")] + pub prop_type: Option, + pub description: Option, + #[serde(rename = "$ref")] + pub schema_ref: Option, + pub format: Option, + pub items: Option>, + #[serde(default)] + pub properties: HashMap, + #[serde(default)] + pub read_only: bool, + pub default: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + pub additional_properties: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_rest_description() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/", + "servicePath": "", + "resources": { + "users": { + "methods": { + "list": { + "httpMethod": "GET", + "path": "/users" + } + } + } + } + }"#; + + let doc: RestDescription = serde_json::from_str(json).unwrap(); + assert_eq!(doc.name, "test"); + assert_eq!(doc.version, "v1"); + + let users = doc.resources.get("users").expect("users resource missing"); + let list = users.methods.get("list").expect("list method missing"); + assert_eq!(list.http_method, "GET"); + } + + #[test] + fn test_deserialize_defaults() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/" + }"#; + + let doc: RestDescription = serde_json::from_str(json).unwrap(); + assert_eq!(doc.service_path, ""); + assert!(doc.resources.is_empty()); + assert!(doc.schemas.is_empty()); + } +} diff --git a/cli/src/openapi/executor.rs b/cli/src/openapi/executor.rs new file mode 100644 index 00000000..2dcbd249 --- /dev/null +++ b/cli/src/openapi/executor.rs @@ -0,0 +1,6771 @@ +//! API Request Execution +//! +//! Handles building and dispatching HTTP requests to APIs. +//! Responsibilities include multipart file uploads, response pagination, +//! and error mapping. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use anyhow::Context; +use futures_util::stream::TryStreamExt; +use futures_util::StreamExt; +use serde_json::{json, Map, Value}; +use tokio::io::AsyncWriteExt; + +use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; +use crate::openapi::discovery::{ + MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, + RetriesConfig, StreamingConfig, +}; + +/// Resolved source for a binary request body (octet-stream uploads etc.). +/// +/// Driven by the value passed on the CLI's binary-body flag (`--file`, `--body`, +/// or whatever name the spec dictates). Accepts three forms: +/// +/// - `` — plain filesystem path. Sent with `Content-Length`. +/// - `@` — same path, curl-style prefix. Sent with `Content-Length`. +/// - `-` — read from stdin. Sent with `Transfer-Encoding: chunked` (no length). +pub enum BinaryBodySource<'a> { + /// Stream from a file on disk. Content-Length comes from file metadata. + File(&'a str), + /// Read from stdin. Body is streamed with chunked transfer encoding. + Stdin, +} + +impl<'a> BinaryBodySource<'a> { + /// Parse a raw flag value into one of the three accepted forms. Stripping + /// the optional `@` prefix happens here so the rest of the pipeline only + /// sees a clean path or `Stdin`. + pub fn parse(raw: &'a str) -> Self { + let stripped = raw.strip_prefix('@').unwrap_or(raw); + if stripped == "-" { + Self::Stdin + } else { + Self::File(stripped) + } + } +} + +/// Source for media upload content. +/// +/// Two mutually exclusive strategies: upload from a file on disk (for Drive, +/// Chat, etc.) or from in-memory bytes (for Gmail's constructed RFC 5322 +/// messages). Using an enum makes illegal states (both set, or mismatched +/// content types) unrepresentable. +pub enum UploadSource<'a> { + /// Stream from a file on disk. Content type is inferred from the file + /// extension, overridden by metadata mimeType, or explicitly set. + File { + path: &'a str, + content_type: Option<&'a str>, + }, + /// Upload from in-memory bytes with an explicit content type. + Bytes { + data: &'a [u8], + content_type: &'a str, + }, +} + +/// Configuration for auto-pagination. +#[derive(Debug, Clone)] +pub struct PaginationConfig { + /// Whether to auto-paginate through all pages. + pub page_all: bool, + /// Maximum number of pages to fetch (default: 10). + pub page_limit: u32, + /// Delay between page fetches in milliseconds (default: 100). + pub page_delay_ms: u64, + /// Query parameter name for the page token (default: "pageToken"). + pub token_query_param: String, + /// Dotted path in JSON response to find the next page token (default: "nextPageToken"). + /// Supports nested paths like "pagination.next_page_token". + pub token_response_path: String, +} + +impl Default for PaginationConfig { + fn default() -> Self { + Self { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: "pageToken".to_string(), + token_response_path: "nextPageToken".to_string(), + } + } +} + +/// Outcome of a single retry-loop iteration. +/// +/// Captures everything the retry policy needs to make its next decision: +/// the HTTP status (or `None` for transport-layer failures), the +/// `Retry-After` header value if any, and the wall-clock timestamp the +/// header should be interpreted against. Keeping the timestamp on the +/// outcome lets unit tests pin `SystemTime::now` to a known instant +/// without monkey-patching the global clock. +#[derive(Debug)] +pub(crate) struct RetryOutcome<'a> { + pub status: Option, + pub retry_after: Option<&'a str>, +} + +/// Returns the default set of retryable HTTP status codes. +/// +/// Matches fern's TypeScript SDK `retryStatusCodes: recommended` mode +/// ([fern PR](https://github.com/fern-api/fern/blob/main/generators/typescript/sdk/changes/3.67.0/feat-retry-status-codes.yml)): +/// +/// - 408 Request Timeout — server gave up before reading; safe. +/// - 429 Too Many Requests — backoff signal; safe. +/// - 502 Bad Gateway — upstream layer failed; transient. +/// - 503 Service Unavailable — explicitly transient by spec. +/// - 504 Gateway Timeout — upstream timeout; transient. +/// +/// Deliberately *excludes* 500 Internal Server Error — a 500 often +/// indicates a non-transient bug on the server (bad input shape, app +/// crash) where retrying just masks the underlying issue. Servers that +/// genuinely want us to retry a 500 can still surface a `Retry-After` +/// header and the executor will honor it. +/// +/// Also excludes 425 Too Early (TLS 1.3 0-RTT replay protection) — +/// never seen in practice from reqwest's HTTP/1.1 client. +pub(crate) fn is_retryable_status(status: u16) -> bool { + matches!(status, 408 | 429 | 502 | 503 | 504) +} + +/// Whether the per-method retry policy allows retrying *non-idempotent* +/// HTTP responses (e.g. a 503 on a POST). GET / HEAD / OPTIONS / DELETE +/// / PUT are idempotent by the HTTP spec; `x-fern-idempotent` on the +/// operation marks an otherwise-unsafe method (POST / PATCH) as +/// safe-to-retry, which mirrors fern's per-method retry policy. +pub(crate) fn method_allows_retry(http_method: &str, marked_idempotent: bool) -> bool { + if marked_idempotent { + return true; + } + matches!( + http_method.to_ascii_uppercase().as_str(), + "GET" | "HEAD" | "OPTIONS" | "DELETE" | "PUT" + ) +} + +/// Whether the given `binary_body_path` raw string designates stdin +/// (`-` or `@-`). Stdin-sourced bodies cannot be replayed on retry — +/// the pipe is consumed by the first send — so callers must disable +/// retries when this returns `true`. Mirrors `BinaryBodySource::parse` +/// without the lifetime gymnastics needed at the call site. +pub(crate) fn binary_body_is_stdin(binary_body_path: Option<&str>) -> bool { + match binary_body_path { + Some(raw) => matches!(BinaryBodySource::parse(raw), BinaryBodySource::Stdin), + None => false, + } +} + +/// Parse a `Retry-After` header value into a `Duration`. +/// +/// HTTP/1.1 allows two forms (RFC 7231 §7.1.3): a non-negative integer +/// number of seconds, or an HTTP-date. We accept either. Past dates +/// (the server's clock is ahead, or `Retry-After: 0`) collapse to +/// zero so callers don't underflow. +pub(crate) fn parse_retry_after(value: &str, now: std::time::SystemTime) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + // Numeric seconds first — cheaper and far more common in practice. + if let Ok(secs) = trimmed.parse::() { + return Some(std::time::Duration::from_secs(secs)); + } + // HTTP-date (IMF-fixdate / RFC 850 / asctime). `httpdate::parse_http_date` + // accepts all three formats per RFC 7231. + if let Ok(target) = httpdate::parse_http_date(trimmed) { + return Some(target.duration_since(now).unwrap_or(std::time::Duration::ZERO)); + } + None +} + +/// Compute the delay for the *next* retry attempt (i.e. the wait +/// between attempt `attempt` and attempt `attempt + 1`). +/// +/// Math: `base * factor^attempt`, with deterministic jitter in +/// `[1 - jitter/2, 1 + jitter/2]` applied to the result. The jitter +/// factor is sampled from a fast LCG so test runs are deterministic +/// when seeded — see [`compute_backoff_delay_with_rand`] below. +pub(crate) fn compute_backoff_delay( + attempt: u32, + config: &RetriesConfig, +) -> std::time::Duration { + // Use system entropy for the random sample. Unit tests use + // `compute_backoff_delay_with_rand` to pin the sample. + let jitter_sample = if config.jitter > 0.0 { + // Sub-second component of wall-clock time as cheap entropy. + // We don't care about cryptographic quality here — just enough + // variance to de-correlate retries from competing clients + // (i.e. avoid the thundering-herd problem during an outage). + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u64; + ((nanos.wrapping_mul(2654435761)) & 0xFFFF) as f64 / 65535.0 + } else { + 0.5 + }; + compute_backoff_delay_with_rand(attempt, config, jitter_sample) +} + +/// Test-friendly variant of [`compute_backoff_delay`]. `rand_unit` is +/// any value in `[0.0, 1.0]`; pass `0.5` for the "exact" backoff +/// (no jitter offset). +pub(crate) fn compute_backoff_delay_with_rand( + attempt: u32, + config: &RetriesConfig, + rand_unit: f64, +) -> std::time::Duration { + if !config.enabled { + return std::time::Duration::ZERO; + } + let exponent = attempt as i32; + let raw_ms = (config.base_delay_ms as f64) * config.factor.powi(exponent); + + // Jitter spreads the delay symmetrically around `raw_ms` to + // de-correlate clients all retrying off the same server outage. + let jitter_span = raw_ms * config.jitter; + let offset = (rand_unit.clamp(0.0, 1.0) - 0.5) * jitter_span; + let ms = (raw_ms + offset).max(0.0); + + // Cap at u64 to avoid panics on absurd configs (e.g. factor=1e9). + let capped = if ms > u64::MAX as f64 { + u64::MAX + } else { + ms as u64 + }; + std::time::Duration::from_millis(capped) +} + +/// Decide whether to retry after an HTTP outcome. +/// +/// Returns `Some(delay)` to schedule a retry, or `None` to surface the +/// outcome to the caller. Encapsulates the precedence rules in one +/// place so the wire executor stays a thin loop body. +pub(crate) fn decide_retry( + attempt: u32, + outcome: &RetryOutcome<'_>, + config: &RetriesConfig, + http_method: &str, + marked_idempotent: bool, + no_retry: bool, +) -> Option { + // Hard opt-outs first. + if no_retry || !config.enabled || config.max_attempts == 0 { + return None; + } + // attempt is 0-indexed (the request just completed was attempt + // `attempt`); we retry while we still have room before + // `max_attempts` total sends. + if attempt + 1 >= config.max_attempts { + return None; + } + + match outcome.status { + // Network / transport failure (no response at all). + None => { + // Network errors are always treated as transient. GET-like + // methods retry per default; POST/PATCH only when the + // operation is explicitly marked idempotent. + if !method_allows_retry(http_method, marked_idempotent) { + return None; + } + Some(compute_backoff_delay(attempt, config)) + } + Some(status) => { + if !is_retryable_status(status) { + return None; + } + // 408/429 are safe to retry on any method (the request + // didn't reach business logic). 5xx on non-idempotent + // methods *could* have been processed — respect per-method + // policy unless the op is marked idempotent. + let always_safe = matches!(status, 408 | 429); + if !always_safe && !method_allows_retry(http_method, marked_idempotent) { + return None; + } + // Honor `Retry-After` when present, fall back to backoff. + if let Some(raw) = outcome.retry_after { + if let Some(d) = parse_retry_after(raw, std::time::SystemTime::now()) { + return Some(d); + } + } + Some(compute_backoff_delay(attempt, config)) + } + } +} + +/// Parsed and validated inputs ready for request execution. +#[derive(Debug)] +struct ExecutionInput { + body: Option, + full_url: String, + query_params: Vec<(String, String)>, + header_params: Vec<(String, String)>, + is_upload: bool, +} + +/// Parse parameters and body JSON, validate against schema, check required params, and build the URL. +fn parse_and_validate_inputs( + doc: &RestDescription, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + is_media_upload: bool, + base_url_override: Option<&str>, + extra_headers: &[(String, String)], +) -> Result { + let params: Map = if let Some(p) = params_json { + serde_json::from_str(p) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))? + } else { + Map::new() + }; + + // Helper: build the `Provide it via …` hint listing every channel a + // user can satisfy this parameter through. Mirrors `commands.rs`'s + // flag-name resolution so the suggested `--` is the actual flag + // the user can pass: `flag_name_override` wins verbatim (synthetic + // injections that already encode the wire name); otherwise kebab the + // `display_name` from `x-fern-parameter-name`, falling back to the + // wire name. Body fields also accept `--json`; every other location + // only accepts the per-field flag or `--params`. + let missing_param_hint = |param_def: &MethodParameter, param_name: &str| -> String { + let flag = if let Some(override_flag) = param_def.flag_name_override.as_deref() { + override_flag.to_string() + } else { + crate::text::to_kebab_flag( + param_def.display_name.as_deref().unwrap_or(param_name), + ) + }; + if param_def.location.as_deref() == Some("body") { + format!("Provide it via --{flag}, --json, or --params") + } else { + format!("Provide it via --{flag} or --params") + } + }; + + for param_name in &method.parameter_order { + if let Some(param_def) = method.parameters.get(param_name) { + if param_def.required + && param_def.location.as_deref() == Some("path") + && !params.contains_key(param_name) + { + let hint = missing_param_hint(param_def, param_name); + return Err(CliError::Validation(format!( + "Required path parameter '{param_name}' is missing. {hint}" + ))); + } + } + } + + for (param_name, param_def) in &method.parameters { + if param_def.required && !params.contains_key(param_name) { + let hint = missing_param_hint(param_def, param_name); + return Err(CliError::Validation(format!( + "Required parameter '{param_name}' is missing. {hint}" + ))); + } + } + + // Split params by `location` into header / body / non-header buckets. + // Body-located params are coerced by type and merged into the JSON body + // (with --json overriding any individual flag values). + let mut header_params: Vec<(String, String)> = Vec::new(); + let mut body_from_flags = Map::new(); + let mut non_header_params = Map::new(); + + for (key, value) in ¶ms { + let location = method.parameters.get(key).and_then(|p| p.location.as_deref()); + match location { + Some("header") => { + let str_value = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + header_params.push((key.clone(), str_value)); + } + Some("body") => { + let coerced = coerce_body_param_value( + value, + method.parameters.get(key).and_then(|p| p.param_type.as_deref()), + )?; + set_nested_value(&mut body_from_flags, key, coerced); + } + _ => { + non_header_params.insert(key.clone(), value.clone()); + } + } + } + + // Append spec-root `x-fern-global-headers` last so per-operation + // headers (already populated above from `params`) override globals + // with the same wire-name. Resolution of CLI flag / env / default + // happens upstream in `run_async`; the executor's job here is just + // to stamp the resolved value on the request when no per-op + // parameter already supplied it. + for (name, value) in extra_headers { + if !header_params.iter().any(|(k, _)| k == name) { + header_params.push((name.clone(), value.clone())); + } + } + + let body: Option = match (body_json, body_from_flags.is_empty()) { + (None, true) => None, + (None, false) => Some(Value::Object(body_from_flags)), + (Some(b), flags_empty) => { + let json_val: Value = serde_json::from_str(b) + .map_err(|e| CliError::Validation(format!("Invalid --json body: {e}")))?; + // Object `--json` merges per-field flag values, with `--json` + // winning on overlapping keys (documented "--json > flag" + // precedence). Non-object `--json` (top-level array or scalar) + // replaces the body wholesale — there is no sensible way to + // merge per-field fields into it. The full precedence chain is + // `--json` > `--params` > per-field flag. + let merged = match json_val { + Value::Object(json_map) if !flags_empty => { + let mut merged = body_from_flags; + for (k, v) in json_map { + merged.insert(k, v); + } + Value::Object(merged) + } + other => other, + }; + Some(merged) + } + }; + + // Validate the assembled body against the request schema regardless of + // how it was built (per-field flags, `--json`, or both). The previous + // version only validated on the `--json` path, which let per-field-flag + // bodies skip schema checks even though those values arrive as + // CLI-typed strings and are more likely to violate the schema. + if let Some(ref body_val) = body { + if let Some(ref req_ref) = method.request { + if let Some(ref schema_name) = req_ref.schema_ref { + validate_body_against_schema(body_val, schema_name, doc)?; + } + } + } + + let (full_url, query_params) = build_url(doc, method, &non_header_params, is_media_upload, base_url_override)?; + let is_upload = is_media_upload && method.supports_media_upload; + + Ok(ExecutionInput { + body, + full_url, + query_params, + header_params, + is_upload, + }) +} + +/// Build the per-operation auth metadata from the lowered security +/// requirements. Computed once per execute_method call and reused across +/// pagination iterations — the requirements don't change page to page. +fn endpoint_metadata_for(method: &RestMethod) -> EndpointAuthMetadata { + EndpointAuthMetadata { + security_requirements: method.security_requirements.clone(), + } +} + +/// Pagination loop state tracked across page fetches. +/// +/// Each variant matches one of the five `x-fern-pagination` forms, plus +/// the document-level heuristic (which uses [`PageState::Cursor`]): +/// +/// - [`PageState::Cursor`] — token threaded through a request query param +/// - [`PageState::Offset`] — running offset counter sent as a query param +/// - [`PageState::NextUrl`] — server-returned absolute URL (uri form) or +/// resolved relative path (path form) used verbatim for the next page +/// - [`PageState::Custom`] — single-shot; the executor never continues +/// +/// Encoded as a discriminated union rather than several `Option`s so that +/// callers can't accidentally mix semantics from different forms. +#[derive(Debug)] +enum PageState { + Cursor(Option), + Offset(u64), + /// `None` on the first page, `Some(url)` once the previous response + /// supplied a next URL/path. The string is always a fully-qualified + /// URL — relative `next_path` values are resolved against the + /// previous request's URL before being stored here. + NextUrl(Option), + Custom, +} + +impl PageState { + /// Pick the initial state from the resolved per-operation pagination + /// config. Operations without explicit `x-fern-pagination` (or with + /// cursor-style config) start with no token; offset-style starts at + /// 0; uri/path/custom forms start in their respective first-page + /// states. + fn initial(endpoint: Option<&EndpointPagination>) -> Self { + match endpoint { + Some(EndpointPagination::Offset { .. }) => PageState::Offset(0), + Some(EndpointPagination::Uri { .. } | EndpointPagination::Path { .. }) => { + PageState::NextUrl(None) + } + Some(EndpointPagination::Custom { .. }) => PageState::Custom, + // Cursor + heuristic + None all use the cursor-style state. + _ => PageState::Cursor(None), + } + } + + /// Override the outgoing URL when the pagination form does so (uri / + /// path). `None` means leave the request's URL untouched. + fn url_override(&self) -> Option<&str> { + match self { + PageState::NextUrl(Some(url)) => Some(url.as_str()), + _ => None, + } + } + + /// Convert the state into the (query-param name, value) pair to inject + /// on the next outgoing request, or `None` when the state represents + /// "first page, no extra param yet" or "URL is fully self-contained". + fn injection( + &self, + endpoint: Option<&EndpointPagination>, + heuristic_param: &str, + ) -> Option<(String, String)> { + match self { + PageState::Cursor(None) => None, + PageState::Cursor(Some(token)) => { + let name = match endpoint { + Some(EndpointPagination::Cursor { cursor, .. }) => cursor.clone(), + _ => heuristic_param.to_string(), + }; + Some((name, token.clone())) + } + PageState::Offset(0) => None, + PageState::Offset(n) => { + let name = match endpoint { + Some(EndpointPagination::Offset { offset, .. }) => offset.clone(), + _ => heuristic_param.to_string(), + }; + Some((name, n.to_string())) + } + // Uri / Path embed the cursor in the URL itself. + PageState::NextUrl(_) | PageState::Custom => None, + } + } +} + +/// Build an HTTP request with auth, query params, page token, and body/multipart attachment. +#[allow(clippy::too_many_arguments)] +async fn build_http_request( + client: &reqwest::Client, + method: &RestMethod, + input: &ExecutionInput, + auth_provider: &DynAuthProvider, + auth_metadata: &EndpointAuthMetadata, + page_state: &PageState, + pages_fetched: u32, + upload: &Option>, + binary_body_path: Option<&str>, + pagination: &PaginationConfig, +) -> Result { + // Uri / Path pagination supplies a fully-resolved next URL in the + // page state; use it verbatim so that the server's cursor / query + // params travel as-is. + let target_url = page_state.url_override().unwrap_or(&input.full_url); + + let mut request = match method.http_method.as_str() { + "GET" => client.get(target_url), + "POST" => client.post(target_url), + "PUT" => client.put(target_url), + "PATCH" => client.patch(target_url), + "DELETE" => client.delete(target_url), + other => { + return Err(CliError::Other(anyhow::anyhow!( + "Unsupported HTTP method: {other}" + ))) + } + }; + + // `security: []` in the spec means the operation opts out of auth. + // Short-circuit before involving the provider so leaf providers + // (Bearer/Basic/Header) and composition wrappers that don't inspect + // the endpoint (AnyAuthProvider, AllAuthProvider, user-built custom + // providers) can't leak credentials onto an explicitly anonymous + // endpoint. RoutingAuthProvider already honors this internally; the + // executor-side check makes it universal. + if !auth_metadata.is_explicit_anonymous() { + request = auth_provider.apply(request, auth_metadata)?; + } + + // Prefer JSON when the API supports content negotiation (some providers + // return XML otherwise). Only inject when the operation doesn't already + // set an Accept header. + if !input + .header_params + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("accept")) + { + request = request.header("Accept", "application/json"); + } + + // Send header parameters as HTTP headers + for (name, value) in &input.header_params { + if let Ok(header_value) = reqwest::header::HeaderValue::from_str(value) { + request = request.header(name.as_str(), header_value); + } + } + + // When the URL is supplied by the server (uri / path pagination) + // the URL already carries every query param the server cares about + // — re-appending the user's initial filters would either double them + // up or fight the server's own cursor. Honor the server's URL as-is. + if page_state.url_override().is_none() { + let mut all_query_params = input.query_params.clone(); + if let Some((name, value)) = + page_state.injection(method.pagination.as_ref(), &pagination.token_query_param) + { + all_query_params.push((name, value)); + } + if !all_query_params.is_empty() { + request = request.query(&all_query_params); + } + } + + if pages_fetched == 0 { + if let Some(upload_source) = upload { + request = request.query(&[("uploadType", "multipart")]); + let (body, content_type, content_length) = match upload_source { + UploadSource::Bytes { data, content_type } => { + if content_type.contains('\r') || content_type.contains('\n') { + return Err(CliError::Validation( + "Upload content type must not contain CR or LF".to_string(), + )); + } + build_multipart_bytes(&input.body, data, content_type)? + } + UploadSource::File { path, content_type } => { + let file_meta = tokio::fs::metadata(path).await.map_err(|e| { + CliError::Validation(format!( + "Failed to get metadata for upload file '{path}': {e}" + )) + })?; + let file_size = file_meta.len(); + let media_mime = resolve_upload_mime(*content_type, Some(path), &input.body); + build_multipart_stream(&input.body, path, file_size, &media_mime)? + } + }; + request = request.header("Content-Type", content_type); + request = request.header("Content-Length", content_length); + request = request.body(body); + } else if let Some(raw) = binary_body_path { + let binary = method.binary_request_body.as_ref().ok_or_else(|| { + CliError::Validation( + "binary body path was provided but the operation has no binary request body declared" + .to_string(), + ) + })?; + request = request.header("Content-Type", &binary.content_type); + match BinaryBodySource::parse(raw) { + BinaryBodySource::File(path) => { + let file_meta = tokio::fs::metadata(path).await.map_err(|e| { + CliError::Validation(format!( + "Failed to read --{} '{path}': {e}", + binary.flag_name + )) + })?; + let (body, content_length) = + build_binary_file_stream(path, file_meta.len(), &binary.flag_name); + request = request.header("Content-Length", content_length); + request = request.body(body); + } + BinaryBodySource::Stdin => { + // No Content-Length — reqwest emits Transfer-Encoding: chunked. + // Memory stays at O(64 KB) regardless of input size. + request = request.body(build_stdin_body_stream()); + } + } + } else if let Some(ref body_val) = input.body { + request = request.header("Content-Type", "application/json"); + request = request.json(body_val); + } else if matches!(method.http_method.as_str(), "POST" | "PUT" | "PATCH") { + request = request.header("Content-Length", "0"); + } + } else if let Some(ref body_val) = input.body { + request = request.header("Content-Type", "application/json"); + request = request.json(body_val); + } + + Ok(request) +} + +/// Walk a dotted path like "pagination.next_page_token" through nested JSON objects. +fn get_nested_str<'a>(val: &'a Value, dotted_path: &str) -> Option<&'a str> { + let mut current = val; + for segment in dotted_path.split('.') { + current = current.get(segment)?; + } + current.as_str() +} + +/// Resolve a dot-separated path (`data`, `result.items`, `users.0.name`) +/// against a JSON value, returning a reference to the addressed subvalue. +/// +/// Empty / pure-dot paths are treated as "no path" and return `None` so the +/// caller can decide between "use the whole value" and "this is an error". +/// A non-empty path that doesn't resolve also returns `None` — callers +/// that need to surface a user-facing error (like +/// `x-fern-sdk-return-value` extraction) check for that case and emit a +/// `CliError::Validation` explaining which path missed. +/// +/// Segments are matched against object keys (`Value::get(&str)`); a +/// segment that parses as a non-negative integer additionally indexes +/// into arrays at the corresponding position (`Value::get(usize)`). +/// Object-key lookup wins when the same segment is ambiguous — JSON +/// object keys can be the literal string `"0"`, and surfacing the +/// matching key is what a user reading the spec expects. Falling back +/// to array indexing only when the value is actually an array keeps +/// the dot-path grammar a strict superset of upstream's +/// `RESPONSE_PROPERTY` (object-only) behavior. +fn get_nested_value<'a>(val: &'a Value, dotted_path: &str) -> Option<&'a Value> { + let trimmed = dotted_path.trim(); + if trimmed.is_empty() { + return None; + } + let mut current = val; + for segment in trimmed.split('.') { + if segment.is_empty() { + return None; + } + // Object-key lookup first, then numeric-array-index fallback so + // an object with a literal `"0"` key still resolves there. The + // array path only triggers when the current value is actually + // a JSON array — otherwise the segment was meant as an object + // key and was simply missing, which the `?` propagates. + if let Some(next) = current.get(segment) { + current = next; + continue; + } + if current.is_array() { + if let Ok(idx) = segment.parse::() { + current = current.get(idx)?; + continue; + } + } + return None; + } + Some(current) +} + +/// Apply `x-fern-sdk-return-value` extraction to a single response value. +/// +/// `return_path` is the dot-separated key path declared by the spec (e.g. +/// `data`, `result.items`). When the path resolves, the addressed subvalue +/// is returned for downstream printing / capture. A non-empty path that +/// resolves to JSON `null` is preserved as `Value::Null` (the field was +/// in the response, just null — typed SDKs surface this identically). +/// A path that fails to resolve *at all* (missing key, intermediate +/// non-object, out-of-range index) is a hard error — the spec promised +/// that subvalue and the server didn't deliver it. `no_extract = true` +/// bypasses the extraction entirely so callers (typically via +/// `--no-extract`) can see the full response for debugging. +/// +/// TODO(error-variant): `CliError::Validation` is the closest existing +/// variant but conceptually this is *response-contract* validation, not +/// input validation. Worth introducing a `CliError::ResponseContract` +/// variant once another response-side validation error needs the same +/// classification. +fn extract_return_value( + body: &Value, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + match return_path { + Some(path) if !no_extract && !path.trim().is_empty() => { + match get_nested_value(body, path) { + Some(v) => Ok(v.clone()), + None => Err(CliError::Validation(format!( + "x-fern-sdk-return-value path '{path}' did not resolve in response for \ + operation {method_descriptor}. Pass --no-extract to see the full response." + ))), + } + } + _ => Ok(body.clone()), + } +} + +/// Resolve the offset-pagination `step` value used for the +/// "did we get a full page?" check that gates pagination on short pages. +/// +/// `step_field` is the post-prefix-stripped field name from the spec (e.g. +/// `step: $request.limit` becomes `"limit"`). Resolution order: +/// +/// 1. Look up the field name in the request's outgoing query params and +/// parse the value as an integer — the canonical `$request.` +/// interpretation, matching fern-api/fern's SDK generators. +/// 2. If the field is itself a parseable integer literal (e.g. `step: "50"`), +/// use that. +/// 3. Otherwise return `None` — the caller falls back to the legacy +/// `items.len() > 0` check. +/// +/// Mirrors upstream `fern-api/fern`'s SDK generators: the step value is +/// used **only** for the `hasNextPage` full-page comparison +/// (`items.length >= step`) — never as the increment amount. The increment +/// is always `len(items)` in item-index semantics, which is what the +/// executor's offset loop already does. See: +/// - `generators/python/.../client_generator/pagination/offset.py` +/// - `generators/typescript/.../GeneratedThrowingEndpointResponse.ts` +fn resolve_step_target( + step_field: Option<&str>, + request_query_params: &[(String, String)], +) -> Option { + let name = step_field?; + if let Some((_, value)) = request_query_params.iter().find(|(k, _)| k == name) { + if let Ok(parsed) = value.parse::() { + return Some(parsed); + } + } + name.parse::().ok() +} + +/// Resolve a `next_path` value from `x-fern-pagination` against the URL of +/// the request that produced it. Mirrors browser-style URL resolution: +/// absolute URLs (`https://…`) replace the base; absolute paths (`/foo`) +/// keep the scheme + host; relative paths inherit the base's directory. +fn resolve_next_path(base_url: &str, next_path: &str) -> Result { + let base = reqwest::Url::parse(base_url) + .map_err(|e| format!("base URL `{base_url}` is not a valid URL: {e}"))?; + let resolved = base + .join(next_path) + .map_err(|e| format!("could not join next_path `{next_path}` to `{base_url}`: {e}"))?; + Ok(resolved.to_string()) +} + +/// Handle a JSON response: parse, output, and check pagination. +/// Returns `Ok(true)` if the pagination loop should continue. +/// +/// `return_path` is the operation's resolved `x-fern-sdk-return-value` +/// extension (a dot-separated key path into the JSON body). When set and +/// `no_extract` is false, only the addressed subvalue is printed / +/// captured — but the full response is still used for pagination +/// continuation checks, since pagination paths (`next_cursor`, +/// `results`, …) are declared relative to the whole body and would +/// silently break if extracted away. +#[allow(clippy::too_many_arguments)] +async fn handle_json_response( + body_text: &str, + pagination: &PaginationConfig, + endpoint_pag: Option<&EndpointPagination>, + pipeline: &crate::formatter::OutputPipeline, + pages_fetched: &mut u32, + page_state: &mut PageState, + capture_output: bool, + captured: &mut Vec, + request_url: &str, + request_query_params: &[(String, String)], + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + if let Ok(json_val) = serde_json::from_str::(body_text) { + let output_val = + extract_return_value(&json_val, return_path, no_extract, method_descriptor)?; + + *pages_fetched += 1; + + // The three branches below are mutually exclusive (one consumes + // `output_val`), so the unconditional move into `captured.push` + // is safe. If a future change adds a side-effect that also + // needs `output_val` outside this if/else chain, the compiler + // will flag it — clone there rather than reintroducing a + // speculative `.clone()` here. + if capture_output { + captured.push(output_val); + } else if pagination.page_all { + let is_first_page = *pages_fetched == 1; + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &output_val, true, is_first_page) + .context("Failed to write output")?; + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &output_val, false, true) + .context("Failed to write output")?; + } + + // Check whether to fetch a next page. Per-op `x-fern-pagination` + // overrides the document heuristic when present. + if pagination.page_all && *pages_fetched < pagination.page_limit { + let should_continue = match endpoint_pag { + Some(EndpointPagination::Cursor { next_cursor, .. }) => { + match get_nested_str(&json_val, next_cursor) { + Some(token) if !token.is_empty() => { + *page_state = PageState::Cursor(Some(token.to_string())); + true + } + _ => false, + } + } + Some(EndpointPagination::Offset { + results, + has_next_page, + step, + .. + }) => { + let still_more = match has_next_page { + Some(path) => json_val + .pointer(&format!("/{}", path.replace('.', "/"))) + .and_then(Value::as_bool) + .unwrap_or(true), + None => true, + }; + let page_size = json_val + .pointer(&format!("/{}", results.replace('.', "/"))) + .and_then(Value::as_array) + .map(|a| a.len() as u64) + .unwrap_or(0); + // When `step` is wired, gate the next page on whether + // the server returned a *full* page. Matches upstream + // fern-api/fern's `items.length >= step` check — a + // server returning a short page signals end-of-data + // even if `has_next_page` was omitted, preventing the + // executor from over-advancing past the last record. + let got_full_page = + match resolve_step_target(step.as_deref(), request_query_params) { + Some(target) => page_size >= target, + None => page_size > 0, + }; + if still_more && got_full_page { + let current = match page_state { + PageState::Offset(n) => *n, + _ => 0, + }; + // Advance by the number of items actually returned + // — item-index semantics, matching upstream's + // default `offsetSemantics`. The `step` field + // controls only the full-page gate above, not the + // increment amount. + *page_state = PageState::Offset(current + page_size); + true + } else { + false + } + } + Some(EndpointPagination::Uri { next_uri, .. }) => { + match get_nested_str(&json_val, next_uri) { + Some(url) if !url.is_empty() => { + *page_state = PageState::NextUrl(Some(url.to_string())); + true + } + _ => false, + } + } + Some(EndpointPagination::Path { next_path, .. }) => { + match get_nested_str(&json_val, next_path) { + Some(path) if !path.is_empty() => { + // Resolve relative paths (e.g. `/v1/things?cursor=…`) + // against the previous request's URL so the host + // + scheme are preserved across pages. + let base = page_state + .url_override() + .unwrap_or(request_url) + .to_string(); + match resolve_next_path(&base, path) { + Ok(resolved) => { + *page_state = PageState::NextUrl(Some(resolved)); + true + } + Err(e) => { + tracing::warn!( + next_path = %path, + base_url = %base, + error = %e, + "failed to resolve x-fern-pagination next_path; halting pagination" + ); + false + } + } + } + _ => false, + } + } + // Custom: caller-driven. The executor never auto-continues; + // it issues exactly one request, surfaces the `results` + // selection like the others, and stops. + Some(EndpointPagination::Custom { .. }) => false, + None => match get_nested_str(&json_val, &pagination.token_response_path) { + Some(token) if !token.is_empty() => { + *page_state = PageState::Cursor(Some(token.to_string())); + true + } + _ => false, + }, + }; + + if should_continue { + if pagination.page_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + pagination.page_delay_ms, + )) + .await; + } + return Ok(true); + } + } + } else { + // Not valid JSON, output as-is + if !capture_output && !body_text.is_empty() { + println!("{body_text}"); + } + } + + Ok(false) +} + +/// Handle a binary response by streaming it to a file. +async fn handle_binary_response( + response: reqwest::Response, + content_type: &str, + output_path: Option<&str>, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, +) -> Result, CliError> { + let file_path = if let Some(p) = output_path { + PathBuf::from(p) + } else { + let ext = mime_to_extension(content_type); + PathBuf::from(format!("download.{ext}")) + }; + + let mut file = tokio::fs::File::create(&file_path) + .await + .context("Failed to create output file")?; + + let mut stream = response.bytes_stream(); + let mut total_bytes: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read response chunk")?; + file.write_all(&chunk) + .await + .context("Failed to write to file")?; + total_bytes += chunk.len() as u64; + } + + file.flush().await.context("Failed to flush file")?; + + let result = json!({ + "status": "success", + "saved_file": file_path.display().to_string(), + "mimeType": content_type, + "bytes": total_bytes, + }); + + if capture_output { + return Ok(Some(result)); + } + + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &result, false, true) + .context("Failed to write output")?; + + Ok(None) +} + +// --------------------------------------------------------------------------- +// x-fern-streaming response handling. +// +// Two entry points: +// - `stream_response` — consume the response body line-by-line and emit +// each event to stdout as it arrives. Used by the default CLI path +// (no `--no-stream`, not `capture_output`). +// - `buffer_streaming_response` — collect every event into one JSON value +// (single object when only one event arrived, array otherwise) and +// return it for downstream printing / capture. Used when the caller +// passed `--no-stream` (pretty-print to stdout) or is a programmatic +// `AppContext::invoke` caller that needs a typed value back. +// +// Line decoding is delegated to `decode_stream_event`, which is a pure +// function over (config, raw_line) — exercised directly by unit tests +// without spinning up a wiremock server. +// --------------------------------------------------------------------------- + +/// Outcome of decoding a single raw stream line. +#[derive(Debug, PartialEq, Eq)] +enum StreamEvent { + /// A complete event payload was decoded (post-`data:` strip for + /// SSE; the line verbatim for NDJSON). The caller emits this. + Event(String), + /// The line was framing-only and carries no payload (blank lines, + /// `event:`/`id:`/`retry:` SSE field lines, SSE comments starting + /// with `:`, or an empty JSON line). Skip and keep reading. + Skip, + /// The terminator sentinel was reached. The caller stops reading. + Terminate, +} + +/// Decode a single raw line of streaming response body against the +/// configured wire format. Pure / synchronous so unit tests can hit +/// every decoding branch (with and without `data:` prefix, comment +/// lines, terminator handling) without setting up a mock HTTP server. +/// +/// The `line` is expected to already have its trailing newline / CR +/// stripped — the caller (the line-reading loop) handles framing. +/// +/// Only the line-at-a-time formats (NDJSON, text) flow through here. +/// SSE framing is stateful (multi-line `data:` payloads are joined +/// with `\n` and dispatched on a blank-line separator per the WHATWG +/// spec), so the SSE path uses [`SseLineDecoder`] instead. +fn decode_stream_event(config: &StreamingConfig, line: &str) -> StreamEvent { + match config { + StreamingConfig::Sse { .. } => { + // SSE is decoded statefully via `SseLineDecoder`; reaching + // this arm is a bug in the caller. + debug_assert!(false, "SSE lines must flow through SseLineDecoder"); + StreamEvent::Skip + } + StreamingConfig::Json { terminator } => { + // NDJSON / JSONL framing: empty lines are skipped (some + // servers emit blank keepalive lines between records). + if line.is_empty() { + return StreamEvent::Skip; + } + + if let Some(sentinel) = terminator.as_deref() { + if line == sentinel { + return StreamEvent::Terminate; + } + } + + StreamEvent::Event(line.to_string()) + } + StreamingConfig::Text => { + // Plain-text line stream: empty lines are dropped per the + // C# generator (`if(!string.IsNullOrEmpty(line)) yield + // return line` — see `HttpEndpointGenerator.ts:815-825`). + // No JSON parse, no SSE prefix strip, no terminator. + if line.is_empty() { + return StreamEvent::Skip; + } + StreamEvent::Event(line.to_string()) + } + } +} + +/// Stateful SSE event accumulator. Buffers `data:` payloads across +/// multiple lines (joined with `\n` per the WHATWG SSE spec +/// ) +/// and dispatches the joined payload as one event on a blank-line +/// separator or at stream EOF. Mirrors the TS runtime's +/// `iterSseEvents` loop in +/// `generators/typescript/utils/core-utilities/src/core/stream/Stream.template.ts:123-165`. +/// +/// Unknown SSE field lines (`id:`, `retry:`, or anything else) are +/// ignored per spec; `event:` is tracked across the same event +/// boundary for parity even though the CLI surface does not yet +/// route on it (no `eventDiscriminator` support — a deliberate +/// non-feature, left out of this parity sweep). +#[derive(Default)] +struct SseLineDecoder { + data_buf: Option, + event_type: Option, +} + +impl SseLineDecoder { + /// Process one raw line. Returns `Some(payload)` when a blank + /// line dispatches a buffered event (the joined `data:` + /// payload); `None` otherwise. + fn push_line(&mut self, line: &str) -> Option { + if line.is_empty() { + // Blank line: dispatch the buffered event if any, then + // reset event_type either way (matches TS, which clears + // both fields on dispatch regardless of whether one was + // actually emitted). + let dispatched = self.data_buf.take(); + self.event_type = None; + return dispatched; + } + if line.starts_with(':') { + // SSE comment / heartbeat — framing only, no payload. + return None; + } + if let Some(rest) = line.strip_prefix("event:") { + self.event_type = Some(rest.trim().to_string()); + return None; + } + if let Some(rest) = line.strip_prefix("data:") { + // Strip exactly one optional leading space per the SSE + // spec ("If value starts with a U+0020 SPACE, remove it"). + let val = rest.strip_prefix(' ').unwrap_or(rest); + match &mut self.data_buf { + Some(buf) => { + buf.push('\n'); + buf.push_str(val); + } + None => { + self.data_buf = Some(val.to_string()); + } + } + return None; + } + // Unknown SSE fields (`id:`, `retry:`, anything else) are + // ignored per spec. + None + } + + /// Flush the final partial event at stream EOF. Mirrors the TS + /// runtime's post-loop `if (dataValue != null) yield ...` block + /// — servers commonly close the connection without a trailing + /// blank line on the last event. + fn flush(&mut self) -> Option { + let dispatched = self.data_buf.take(); + self.event_type = None; + dispatched + } +} + +/// Apply `x-fern-sdk-return-value` to a decoded event payload. Each +/// event is parsed as JSON, the configured path is projected, and the +/// printable form (a JSON-encoded string) is returned. When the JSON +/// fails to parse (servers occasionally emit a non-JSON keepalive +/// frame), the raw event string is emitted verbatim so the caller can +/// still see what came over the wire. +/// +/// Text streams ([`StreamingConfig::Text`]) bypass this projection +/// entirely — their event payload is a raw line, not a JSON value, +/// so `x-fern-sdk-return-value` and `--no-extract` are both no-ops +/// (mirrors the C# generator, which `yield return line` directly). +fn project_stream_event( + streaming: &StreamingConfig, + event_payload: &str, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + if matches!(streaming, StreamingConfig::Text) { + return Ok(Value::String(event_payload.to_string())); + } + match serde_json::from_str::(event_payload) { + Ok(parsed) => extract_return_value(&parsed, return_path, no_extract, method_descriptor), + // Bare strings, numbers, or partial frames flow through as + // strings so the caller's output stream isn't blocked by + // upstream noise. The user can `--no-extract` to inspect the + // raw frames when debugging unexpected shapes. + Err(_) => Ok(Value::String(event_payload.to_string())), + } +} + +/// Stream the response body line-by-line, emitting one formatted event +/// per dispatched payload to stdout. Stops at the configured +/// terminator (when the spec declared one) or at end-of-body. +async fn stream_response( + response: reqwest::Response, + streaming: &StreamingConfig, + return_path: Option<&str>, + no_extract: bool, + pipeline: &crate::formatter::OutputPipeline, + method_descriptor: &str, +) -> Result<(), CliError> { + read_stream_events(response, streaming, |payload| { + let value = project_stream_event( + streaming, + &payload, + return_path, + no_extract, + method_descriptor, + )?; + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &value, false, true) + .context("Failed to write output")?; + Ok(()) + }) + .await +} + +/// Buffer the streaming response into a single JSON value: a lone event +/// is returned as-is so downstream consumers see the unary shape; two +/// or more events are collected into a JSON array. An empty stream +/// returns `Value::Null` — the body finished without emitting any +/// payload, which is what the typed SDKs surface back to callers. +async fn buffer_streaming_response( + response: reqwest::Response, + streaming: &StreamingConfig, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + let mut events: Vec = Vec::new(); + read_stream_events(response, streaming, |payload| { + events.push(project_stream_event( + streaming, + &payload, + return_path, + no_extract, + method_descriptor, + )?); + Ok(()) + }) + .await?; + Ok(match events.len() { + 0 => Value::Null, + 1 => events.into_iter().next().unwrap(), + _ => Value::Array(events), + }) +} + +/// Drive the response body through the format-appropriate line +/// decoder, invoking `emit` for each dispatched event payload. SSE +/// uses [`SseLineDecoder`] (stateful multi-line `data:` buffering); +/// NDJSON and text use [`decode_stream_event`] line-by-line. The +/// configured terminator (if any) is checked here, before `emit`, so +/// callers don't need to know about format-specific framing rules. +async fn read_stream_events( + response: reqwest::Response, + streaming: &StreamingConfig, + mut emit: F, +) -> Result<(), CliError> +where + F: FnMut(String) -> Result<(), CliError>, +{ + let mut line_stream = ResponseLineStream::new(response); + match streaming { + StreamingConfig::Sse { terminator } => { + let mut decoder = SseLineDecoder::default(); + while let Some(line) = line_stream.next_line().await? { + if let Some(payload) = decoder.push_line(&line) { + if let Some(sentinel) = terminator.as_deref() { + if payload == sentinel { + return Ok(()); + } + } + emit(payload)?; + } + } + // EOF: flush any final unterminated event — matches the + // TS runtime's post-loop dispatch (see Stream.template.ts). + if let Some(payload) = decoder.flush() { + if let Some(sentinel) = terminator.as_deref() { + if payload == sentinel { + return Ok(()); + } + } + emit(payload)?; + } + Ok(()) + } + StreamingConfig::Json { .. } | StreamingConfig::Text => { + while let Some(line) = line_stream.next_line().await? { + match decode_stream_event(streaming, &line) { + StreamEvent::Skip => continue, + StreamEvent::Terminate => return Ok(()), + StreamEvent::Event(payload) => emit(payload)?, + } + } + Ok(()) + } + } +} + +/// Adapt a `reqwest::Response`'s byte stream into a line iterator. Keeps +/// a small in-memory buffer of bytes received but not yet terminated +/// by a newline; reads stop at LF and emit the preceding bytes (CR is +/// also stripped) as a UTF-8 string. The terminating line of a +/// response that doesn't end with a newline is still emitted from +/// `next_line` before the stream returns `None`. +struct ResponseLineStream { + stream: futures_util::stream::BoxStream<'static, reqwest::Result>, + buf: Vec, + done: bool, +} + +impl ResponseLineStream { + fn new(response: reqwest::Response) -> Self { + Self { + stream: Box::pin(response.bytes_stream()), + buf: Vec::with_capacity(4096), + done: false, + } + } + + async fn next_line(&mut self) -> Result, CliError> { + loop { + // Emit a buffered line if a newline has already been received. + if let Some(idx) = self.buf.iter().position(|&b| b == b'\n') { + let mut line: Vec = self.buf.drain(..=idx).collect(); + line.pop(); // drop the trailing '\n' + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(Some(decode_line_lossy(line))); + } + + // If the stream is exhausted, flush any trailing bytes that + // didn't end with a newline (servers commonly omit the final + // newline on the last event of an NDJSON stream). + if self.done { + if self.buf.is_empty() { + return Ok(None); + } + let mut line: Vec = std::mem::take(&mut self.buf); + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(Some(decode_line_lossy(line))); + } + + // Pull the next chunk off the wire. + match self.stream.next().await { + Some(Ok(chunk)) => self.buf.extend_from_slice(&chunk), + Some(Err(err)) => { + return Err(anyhow::Error::from(err) + .context("Failed to read streaming response chunk") + .into()); + } + None => self.done = true, + } + } + } +} + +/// Decode a single line as UTF-8, replacing invalid sequences with +/// U+FFFD so a malformed byte (e.g. truncated multibyte from a flaky +/// proxy) doesn't crash the stream. +fn decode_line_lossy(bytes: Vec) -> String { + match String::from_utf8(bytes) { + Ok(s) => s, + Err(e) => String::from_utf8_lossy(&e.into_bytes()).into_owned(), + } +} + +/// Executes an API method call. +/// +/// This is the core function of the CLI that handles: +/// 1. Parameter validation and URL construction. +/// 2. Request body validation against the Discovery Document schema. +/// 3. Authentication (OAuth or none). +/// 4. Sending the HTTP request (GET/POST/etc). +/// 5. Handling various response types (JSON, binary). +/// 6. Auto-pagination for list endpoints. +#[allow(clippy::too_many_arguments)] +pub async fn execute_method( + doc: &RestDescription, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + auth_provider: &DynAuthProvider, + output_path: Option<&str>, + upload: Option>, + binary_body_path: Option<&str>, + dry_run: bool, + pagination: &PaginationConfig, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, + base_url_override: Option<&str>, + http_config: &crate::http::HttpConfig, + no_extract: bool, + no_retry: bool, + no_stream: bool, + extra_headers: &[(String, String)], +) -> Result, CliError> { + let binary_flag = method + .binary_request_body + .as_ref() + .map(|b| b.flag_name.as_str()); + if binary_body_path.is_some() && binary_flag.is_none() { + return Err(CliError::Validation( + "binary body path is only valid for operations with a binary request body" + .to_string(), + )); + } + if binary_body_path.is_some() && body_json.is_some() { + return Err(CliError::Validation(format!( + "--{} and --json are mutually exclusive", + binary_flag.unwrap_or("file"), + ))); + } + + let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some(), base_url_override, extra_headers)?; + + // Human-readable identifier for the operation, used in + // `x-fern-sdk-return-value` extraction errors so the user can find + // the offending op when the response shape disagrees with the + // spec. Prefer the `operationId` (matches the spec text) and fall + // back to `GET /things` when it's absent. + let method_descriptor = match method.id.as_deref() { + Some(id) => format!("'{id}'"), + None => format!( + "{} {}", + method.http_method.to_ascii_uppercase(), + method.path + ), + }; + + if dry_run { + let mut dry_run_info = json!({ + "dry_run": true, + "url": input.full_url, + "method": method.http_method, + "query_params": input.query_params, + "headers": input.header_params, + "body": input.body, + "is_multipart_upload": input.is_upload, + }); + if let Some(raw) = binary_body_path { + let (content_type, flag_name) = method + .binary_request_body + .as_ref() + .map(|b| (b.content_type.as_str(), b.flag_name.as_str())) + .unwrap_or(("", "")); + let (source, transfer) = match BinaryBodySource::parse(raw) { + BinaryBodySource::File(p) => (json!({ "file": p }), "content-length"), + BinaryBodySource::Stdin => (json!({ "stdin": true }), "chunked"), + }; + dry_run_info["binary_body"] = json!({ + "source": source, + "content_type": content_type, + "transfer_encoding": transfer, + "flag": flag_name, + }); + } + if capture_output { + return Ok(Some(dry_run_info)); + } + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &dry_run_info, false, true) + .context("Failed to write output")?; + return Ok(None); + } + + let endpoint_pag = method.pagination.as_ref(); + let mut page_state: PageState = PageState::initial(endpoint_pag); + let mut pages_fetched: u32 = 0; + let mut captured_values = Vec::new(); + let auth_metadata = endpoint_metadata_for(method); + + // Build the client once outside the pagination loop. Client construction + // reads env vars and (with TLS) builds a connection pool; rebuilding per + // page would defeat connection reuse and emit any one-time warnings + // (e.g. insecure-mode) once per page. + let client = http_config.build_client()?; + + loop { + // Snapshot the URL we are about to hit so the response handler can + // resolve relative `next_path` values against it. Captured before + // `page_state` is borrowed mutably below. + let current_url = page_state + .url_override() + .unwrap_or(&input.full_url) + .to_string(); + + let method_id = method.id.as_deref().unwrap_or("unknown"); + let start = std::time::Instant::now(); + + // Retry loop. Each iteration rebuilds the request (so streaming + // bodies start fresh) and dispatches it. `retry_attempt` is + // 0-indexed and counts *prior* sends — we increment it after + // each retry, then re-check the policy before the next send. + // + // Stdin-sourced binary bodies are *not* replayable: the first + // attempt consumes the pipe and any retry would silently send + // an empty body. Disable retries for that case so we preserve + // the pre-retry behavior (a single attempt, surface whatever + // the server returns) rather than masking the original failure. + let retries_cfg = if binary_body_is_stdin(binary_body_path) { + None + } else { + method.retries.as_ref() + }; + let mut retry_attempt: u32 = 0; + let response = loop { + let request = build_http_request( + &client, + method, + &input, + auth_provider, + &auth_metadata, + &page_state, + pages_fetched, + &upload, + binary_body_path, + pagination, + ) + .await?; + + match request.send().await { + Ok(resp) => { + let status = resp.status(); + let retry_after_header = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + if let Some(cfg) = retries_cfg { + let outcome = RetryOutcome { + status: Some(status.as_u16()), + retry_after: retry_after_header.as_deref(), + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + cfg, + &method.http_method, + method.idempotent, + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + "retrying after retryable HTTP status", + ); + // Drain the body so the connection can be + // returned to the pool. We don't surface + // the body on retried responses; the final + // response (success or terminal failure) + // is what the user sees. + let _ = resp.bytes().await; + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + } + break resp; + } + Err(e) => { + if let Some(cfg) = retries_cfg { + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + cfg, + &method.http_method, + method.idempotent, + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + error = %e, + "retrying after network/transport failure", + ); + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + } + // Surface a human-readable hint to stderr if this looks like + // a TLS failure — the most common debugging hump for users + // behind corporate proxies / interception tools. The hint is + // a side effect; the error then propagates up like any other. + crate::http::maybe_emit_tls_hint(http_config, &e); + return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + } + } + }; + let latency_ms = start.elapsed().as_millis() as u64; + + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + latency_ms = latency_ms, + "API error" + ); + return handle_error_response( + status, + &error_body, + auth_provider.as_ref(), + &auth_metadata, + ); + } + + tracing::debug!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + latency_ms = latency_ms, + content_type = %content_type, + is_upload = input.is_upload, + page = pages_fetched, + "API request" + ); + + // Streaming response branch. Selected when: + // - the operation declares `x-fern-streaming`, AND + // - the caller hasn't explicitly opted out via `--no-stream`, + // AND + // - we aren't capturing into a single `Value` for a + // programmatic caller (those need a unary shape and treat + // `--no-stream` as implicit). + // + // `--no-stream` and `capture_output` both fall through to the + // existing buffered path below: the body is read once and + // either pretty-printed (no_stream from the CLI) or decoded + // into a `Value` (capture_output from `AppContext::invoke`). + if let Some(streaming) = method.streaming.as_ref() { + if !no_stream && !capture_output { + // Note: `pages_fetched` is intentionally left untouched + // here. Streaming endpoints are single-request by + // construction (see the parse-time mutual exclusion + // with `x-fern-pagination`), so the pagination loop + // never re-enters; bumping the counter would only + // confuse the unrelated request-tracing in `debug!`. + stream_response( + response, + streaming, + method.return_value.as_deref(), + no_extract, + pipeline, + &method_descriptor, + ) + .await?; + break; + } + // Buffered fallback: collect every event into a single + // JSON array (or unwrap the lone event when only one + // arrived) so the downstream printer / capture path sees + // the kind of value it expects from a unary endpoint. The + // server may legitimately send a non-streaming body, so we + // still parse it line-by-line and fall back to a + // single-value array when the body holds one JSON object. + let buffered = buffer_streaming_response( + response, + streaming, + method.return_value.as_deref(), + no_extract, + &method_descriptor, + ) + .await?; + if capture_output { + captured_values.push(buffered); + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &buffered, false, true) + .context("Failed to write output")?; + } + break; + } + + let is_json = + content_type.contains("application/json") || content_type.contains("text/json"); + + if is_json || content_type.is_empty() { + let body_text = response + .text() + .await + .context("Failed to read response body")?; + + let response_body = body_text; + let should_continue = handle_json_response( + &response_body, + pagination, + endpoint_pag, + pipeline, + &mut pages_fetched, + &mut page_state, + capture_output, + &mut captured_values, + ¤t_url, + &input.query_params, + method.return_value.as_deref(), + no_extract, + &method_descriptor, + ) + .await?; + + if should_continue { + continue; + } + } else if let Some(res) = handle_binary_response( + response, + &content_type, + output_path, + pipeline, + capture_output, + ) + .await? + { + captured_values.push(res); + } + + break; + } + + if capture_output && !captured_values.is_empty() { + if captured_values.len() == 1 { + return Ok(Some(captured_values.pop().unwrap())); + } else { + return Ok(Some(Value::Array(captured_values))); + } + } + + Ok(None) +} + +/// Serialize a query parameter value according to its OpenAPI style. +fn serialize_query_param( + key: &str, + value: &Value, + param_def: Option<&crate::openapi::discovery::MethodParameter>, +) -> Vec<(String, String)> { + let style = param_def + .and_then(|p| p.style.as_deref()) + .unwrap_or("form"); + let explode = param_def + .and_then(|p| p.explode) + .unwrap_or(style == "form"); + + match style { + "deepObject" => serialize_deep_object(key, value), + _ => serialize_form(key, value, explode), + } +} + +fn serialize_deep_object(key: &str, value: &Value) -> Vec<(String, String)> { + match value { + Value::Object(_) => { + // Wrap as {key: value} so serde-qs produces key[...]=... pairs. + // ArrayFormat::Unindexed gives filter[tags]=a&filter[tags]=b, + // consistent with the Fern Python and C# SDKs. + let wrapped = serde_json::json!({ key: value }); + let config = serde_qs::Config::new() + .array_format(serde_qs::ArrayFormat::Unindexed); + match config.serialize_string(&wrapped) { + Ok(qs) => { + // serde-qs URL-encodes the output; decode each pair + qs.split('&') + .filter(|s| !s.is_empty()) + .filter_map(|pair| { + let (k, v) = pair.split_once('=')?; + let decoded_k = percent_encoding::percent_decode_str(k) + .decode_utf8_lossy() + .into_owned(); + let decoded_v = percent_encoding::percent_decode_str(v) + .decode_utf8_lossy() + .into_owned(); + Some((decoded_k, decoded_v)) + }) + .collect() + } + Err(_) => vec![(key.to_string(), value_to_query_string(value))], + } + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + +fn serialize_form(key: &str, value: &Value, explode: bool) -> Vec<(String, String)> { + match value { + Value::Array(arr) if explode => arr + .iter() + .map(|v| (key.to_string(), value_to_query_string(v))) + .collect(), + Value::Array(arr) => { + let joined = arr + .iter() + .map(value_to_query_string) + .collect::>() + .join(","); + vec![(key.to_string(), joined)] + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + +fn value_to_query_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +fn effective_root_url(method: &RestMethod, doc: &RestDescription) -> String { + if !method.root_url.is_empty() { method.root_url.clone() } else { doc.root_url.clone() } +} + +/// Prepend `doc.base_path` (sourced from `x-fern-base-path`) to `base`, +/// inserting exactly one slash between the two segments regardless of +/// whether either side already has a slash on its boundary. Returns +/// `base` unchanged when `doc.base_path` is `None` or normalizes to +/// empty. +/// +/// Examples (server URL × base_path slash matrix): +/// - `"https://x/"` + `"/v1"` → `"https://x/v1"` +/// - `"https://x"` + `"/v1"` → `"https://x/v1"` +/// - `"https://x/"` + `"v1"` → `"https://x/v1"` +/// - `"https://x"` + `"v1"` → `"https://x/v1"` +/// - `"https://x"` + `"/v1/"` → `"https://x/v1"` (trailing slash on +/// base_path is stripped; `build_url` re-adds one before the path) +/// +/// `build_url` calls this helper uniformly across all three URL sources +/// — `--base-url` override, `doc.base_url`, and `effective_root_url + +/// service_path` — so the base path is applied *additively* on top of +/// any one of them. In particular, `--base-url https://staging/v2` on a +/// spec with `x-fern-base-path: /v1` produces `https://staging/v2/v1/...`, +/// not `https://staging/v2/...`: `x-fern-base-path` is part of the spec's +/// logical URL structure, not a property of any specific host. +/// +/// Mirrors fern-api/fern's openapi-ir-parser: +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernBasePath.ts`. +/// +/// The base path passed in is expected to already have any `{param}` +/// placeholders substituted — `build_url` calls `render_path_template` +/// on `doc.base_path` first so this helper only deals with the +/// post-substitution slash-edge logic. +fn apply_base_path(base: &str, base_path: Option<&str>) -> String { + let Some(bp) = base_path else { + return base.to_string(); + }; + let bp_trimmed = bp.trim_matches('/'); + if bp_trimmed.is_empty() { + return base.to_string(); + } + let base_trimmed = base.trim_end_matches('/'); + format!("{base_trimmed}/{bp_trimmed}") +} + +fn build_url( + doc: &RestDescription, + method: &RestMethod, + params: &Map, + is_upload: bool, + base_url_override: Option<&str>, +) -> Result<(String, Vec<(String, String)>), CliError> { + // Build URL base and path. The base_url here is just the server (or + // override) plus any Discovery `service_path`; x-fern-base-path is + // applied as a separate step below so the slash-edge logic stays in + // one place and applies to all three base sources (override, explicit + // `base_url`, and effective root_url + service_path). + let raw_base_url = if let Some(b) = base_url_override { + b.trim_end_matches('/').to_string() + } else if let Some(b) = &doc.base_url { + b.clone() + } else { + format!("{}{}", effective_root_url(method, doc), doc.service_path) + }; + // Render any `{param}` placeholders in `x-fern-base-path` (e.g. + // `/{tenant}/v1`) against the operation's parameters. The placeholder + // names are also collected so we can exclude them from the query + // string below — the param has been consumed by the URL path and + // must not leak as `?tenant=acme`. Mirrors upstream Fern where base + // path placeholders are baked into endpoint paths at Definition build + // time and then resolved by the SDK's path-parameter renderer at + // request time. + let rendered_base_path = doc + .base_path + .as_deref() + .map(|bp| render_path_template(bp, params)) + .transpose()?; + let base_path_parameters: HashSet<&str> = doc + .base_path + .as_deref() + .map(extract_template_path_parameters) + .unwrap_or_default(); + let base_url = apply_base_path(&raw_base_url, rendered_base_path.as_deref()); + + // Prefer flatPath when its placeholders match the method's path parameters. + // Some Discovery Documents (e.g., Slides presentations.get) have flatPath + // placeholders that don't match parameter names ({presentationsId} vs + // {presentationId}). In those cases, fall back to path which uses RFC 6570 + // operators ({+var}) that this function already handles. + let path_template = match method.flat_path.as_deref() { + Some(fp) => { + let all_match = method + .parameters + .iter() + .filter(|(_, p)| p.location.as_deref() == Some("path")) + .all(|(name, _)| { + let plain = format!("{{{name}}}"); + let plus = format!("{{+{name}}}"); + fp.contains(&plain) || fp.contains(&plus) + }); + if all_match { + fp + } else { + method.path.as_str() + } + } + None => method.path.as_str(), + }; + + // Substitute path parameters and separate query parameters + let path_parameters = extract_template_path_parameters(path_template); + let mut query_params: Vec<(String, String)> = Vec::new(); + + for (key, value) in params { + if path_parameters.contains(key.as_str()) { + continue; + } + // Params that backfill placeholders in `x-fern-base-path` have + // already been consumed by the URL path; they must not also + // appear as query string entries. + if base_path_parameters.contains(key.as_str()) { + continue; + } + + let is_path_param = method + .parameters + .get(key) + .and_then(|p| p.location.as_deref()) + == Some("path"); + + if is_path_param { + return Err(CliError::Validation(format!( + "Path parameter '{key}' was provided but is not present in URL template '{path_template}'" + ))); + } + + // Use style-aware serialization for query parameters. + // For backward compatibility, `repeated` params still use the legacy + // expansion (equivalent to form+explode). + let param_def = method.parameters.get(key); + let is_repeated = param_def.map(|p| p.repeated).unwrap_or(false); + + if is_repeated { + if let Value::Array(arr) = value { + for item in arr { + let val_str = match item { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + query_params.push((key.clone(), val_str)); + } + continue; + } + } + + let pairs = serialize_query_param(key, value, param_def); + query_params.extend(pairs); + } + + let url_path = render_path_template(path_template, params)?; + + let full_url = if is_upload { + // Use the upload endpoint from the Discovery Document + let upload_endpoint = method + .media_upload + .as_ref() + .and_then(|mu| mu.protocols.as_ref()) + .and_then(|p| p.simple.as_ref()) + .map(|s| s.path.as_str()) + .ok_or_else(|| { + CliError::Validation( + "Method supports media upload but no upload path found in Discovery Document" + .to_string(), + ) + })?; + let upload_path = render_path_template(upload_endpoint, params)?; + // Compose the upload host with the spec-level base_path the same + // way the non-upload branch does, so x-fern-base-path is applied + // uniformly. This branch is currently unreachable from OpenAPI + // specs (only Google Discovery sets `media_upload`, and Discovery + // specs don't carry `base_path`), but keeping the wiring + // symmetric prevents a silent gap if either side ever changes. + let root = base_url_override + .map(|b| b.trim_end_matches('/').to_string()) + .unwrap_or_else(|| effective_root_url(method, doc).trim_end_matches('/').to_string()); + let root = apply_base_path(&root, rendered_base_path.as_deref()); + format!("{root}{upload_path}") + } else { + match (base_url.ends_with('/'), url_path.starts_with('/')) { + (true, true) => format!("{}{}", base_url.trim_end_matches('/'), url_path), + (false, false) => format!("{base_url}/{url_path}"), + _ => format!("{base_url}{url_path}"), + } + }; + + Ok((full_url, query_params)) +} + +fn extract_template_path_parameters(path_template: &str) -> HashSet<&str> { + let mut found = HashSet::new(); + let mut cursor = 0; + + while let Some(open_idx) = path_template[cursor..].find('{') { + let token_start = cursor + open_idx; + let Some(close_idx) = path_template[token_start..].find('}') else { + break; + }; + + let token_end = token_start + close_idx; + let token = &path_template[token_start + 1..token_end]; + if let Some(key) = token.strip_prefix('+') { + found.insert(key); + } else { + found.insert(token); + } + cursor = token_end + 1; + } + + found +} + +fn render_path_template( + path_template: &str, + params: &Map, +) -> Result { + let mut rendered = String::with_capacity(path_template.len()); + let mut cursor = 0; + + while let Some(open_idx) = path_template[cursor..].find('{') { + let token_start = cursor + open_idx; + rendered.push_str(&path_template[cursor..token_start]); + + let Some(close_idx) = path_template[token_start..].find('}') else { + rendered.push_str(&path_template[token_start..]); + return Ok(rendered); + }; + + let token_end = token_start + close_idx; + let token = &path_template[token_start + 1..token_end]; + let (is_plus, key) = if let Some(key) = token.strip_prefix('+') { + (true, key) + } else { + (false, token) + }; + + if let Some(value) = params.get(key) { + let val_str = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + let encoded = if is_plus { + let validated = crate::validate::validate_resource_name(&val_str)?; + crate::validate::encode_path_preserving_slashes(validated) + } else { + crate::validate::encode_path_segment(&val_str) + }; + rendered.push_str(&encoded); + } else { + rendered.push_str(&path_template[token_start..=token_end]); + } + + cursor = token_end + 1; + } + + rendered.push_str(&path_template[cursor..]); + Ok(rendered) +} + +/// Resolves the MIME type for the uploaded media content. +/// +/// Priority: +/// 1. `--upload-content-type` flag (explicit override) +/// 2. File extension inference (common extensions mapped to MIME types) +/// 3. Metadata `mimeType` (fallback for backward compatibility) +/// 4. `application/octet-stream` +/// +/// All returned MIME types have control characters stripped to prevent +/// MIME header injection via user-controlled metadata. +fn resolve_upload_mime( + explicit: Option<&str>, + upload_path: Option<&str>, + metadata: &Option, +) -> String { + let raw = explicit + .map(|s| s.to_string()) + .or_else(|| upload_path.and_then(mime_from_extension)) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| m.get("mimeType")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + // Strip CR/LF and other control characters to prevent MIME header injection. + let sanitized: String = raw.chars().filter(|c| !c.is_control()).collect(); + if sanitized.is_empty() { + "application/octet-stream".to_string() + } else { + sanitized + } +} + +/// Simple MIME type inference from file extension. +/// Returns `None` for unrecognized extensions. +fn mime_from_extension(path: &str) -> Option { + let ext = path.rsplit('.').next()?.to_lowercase(); + let mime = match ext.as_str() { + "txt" => "text/plain", + "html" | "htm" => "text/html", + "css" => "text/css", + "csv" => "text/csv", + "xml" => "application/xml", + "json" => "application/json", + "js" => "application/javascript", + "pdf" => "application/pdf", + "zip" => "application/zip", + "gz" | "gzip" => "application/gzip", + "tar" => "application/x-tar", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "ico" => "image/x-icon", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "mp4" => "video/mp4", + "webm" => "video/webm", + "md" | "markdown" => "text/markdown", + "yaml" | "yml" => "application/yaml", + "toml" => "application/toml", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "wasm" => "application/wasm", + _ => return None, + }; + Some(mime.to_string()) +} + +/// Streams stdin as a raw request body via chunked transfer encoding. +/// Used when the user passes `-` to the binary-body flag. +fn build_stdin_body_stream() -> reqwest::Body { + let stream = tokio_util::io::ReaderStream::new(tokio::io::stdin()); + reqwest::Body::wrap_stream(stream) +} + +/// Streams a file as a raw request body. Used for operations whose request +/// body is declared as a binary content type (e.g. `application/octet-stream`). +/// Memory usage stays at O(64 KB) regardless of file size. +/// +/// `flag_name` is the spec-derived CLI flag (`file`, `body`, or whatever +/// `x-fern-parameter-name` set) — surfaced in the error message if the file +/// disappears between the upfront `metadata()` check and stream open (TOCTOU). +fn build_binary_file_stream( + file_path: &str, + file_size: u64, + flag_name: &str, +) -> (reqwest::Body, u64) { + let file_path_owned = file_path.to_owned(); + let flag_owned = flag_name.to_owned(); + let stream = futures_util::stream::once(async move { + tokio::fs::File::open(&file_path_owned).await.map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to open --{flag_owned} '{file_path_owned}': {e}"), + ) + }) + }) + .map_ok(tokio_util::io::ReaderStream::new) + .try_flatten(); + + (reqwest::Body::wrap_stream(stream), file_size) +} + +/// Builds a streaming multipart/related body for media upload requests. +/// +/// Instead of reading the entire file into memory, this streams the file in +/// chunks via `ReaderStream`, keeping memory usage at O(64 KB) regardless of +/// file size. The `Content-Length` is pre-computed from file metadata so APIs +/// Generate a unique boundary ID for multipart requests using timestamp. +fn generate_boundary_id() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64 +} + +/// still receive the correct header without buffering. +/// +/// Returns `(body, content_type, content_length)`. +fn build_multipart_stream( + metadata: &Option, + file_path: &str, + file_size: u64, + media_mime: &str, +) -> Result<(reqwest::Body, String, u64), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + let media_mime = media_mime.to_string(); + + let metadata_json = match metadata { + Some(m) => serde_json::to_string(m).map_err(|e| { + CliError::Validation(format!("Failed to serialize upload metadata: {e}")) + })?, + None => "{}".to_string(), + }; + + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: {media_mime}\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + + let content_length = preamble.len() as u64 + file_size + postamble.len() as u64; + let content_type = format!("multipart/related; boundary={boundary}"); + + let preamble_bytes: bytes::Bytes = preamble.into_bytes().into(); + let postamble_bytes: bytes::Bytes = postamble.into_bytes().into(); + + let file_path_owned = file_path.to_owned(); + let file_stream = futures_util::stream::once(async move { + tokio::fs::File::open(&file_path_owned).await.map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to open upload file '{file_path_owned}': {e}"), + ) + }) + }) + .map_ok(tokio_util::io::ReaderStream::new) + .try_flatten(); + + let stream = futures_util::stream::once(async { Ok::<_, std::io::Error>(preamble_bytes) }) + .chain(file_stream) + .chain(futures_util::stream::once(async { + Ok::<_, std::io::Error>(postamble_bytes) + })); + + Ok(( + reqwest::Body::wrap_stream(stream), + content_type, + content_length, + )) +} + +/// Builds a multipart/related body from in-memory bytes. +/// +/// Used when the upload content is constructed in memory (e.g., a Gmail RFC 5322 +/// message with attachments) rather than read from a file on disk. +fn build_multipart_bytes( + metadata: &Option, + data: &[u8], + media_mime: &str, +) -> Result<(reqwest::Body, String, u64), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + let metadata_json = match metadata { + Some(m) => serde_json::to_string(m).map_err(|e| { + CliError::Validation(format!("Failed to serialize upload metadata: {e}")) + })?, + None => "{}".to_string(), + }; + + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: {media_mime}\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + + let mut body = Vec::with_capacity(preamble.len() + data.len() + postamble.len()); + body.extend_from_slice(preamble.as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(postamble.as_bytes()); + + let content_length = body.len() as u64; + let content_type = format!("multipart/related; boundary={boundary}"); + + Ok((reqwest::Body::from(body), content_type, content_length)) +} + +/// Builds a buffered multipart/related body for media upload requests. +/// +/// This is the legacy implementation retained for unit tests that need +/// a fully materialized body to assert against. +/// +/// Returns the body bytes and the Content-Type header value (with boundary). +#[cfg(test)] +fn build_multipart_body( + metadata: &Option, + file_bytes: &[u8], + media_mime: &str, +) -> Result<(Vec, String), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + // Build multipart/related body + let metadata_json = metadata + .as_ref() + .map(|m| serde_json::to_string(m).unwrap_or_else(|_| "{}".to_string())) + .unwrap_or_else(|| "{}".to_string()); + + let mut body = Vec::new(); + // Part 1: JSON metadata + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Type: application/json; charset=UTF-8\r\n\r\n"); + body.extend_from_slice(metadata_json.as_bytes()); + body.extend_from_slice(b"\r\n"); + // Part 2: File content + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(format!("Content-Type: {media_mime}\r\n\r\n").as_bytes()); + body.extend_from_slice(file_bytes); + body.extend_from_slice(b"\r\n"); + // Closing boundary + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + + let content_type = format!("multipart/related; boundary={boundary}"); + Ok((body, content_type)) +} + +/// Intentional duplication from `graphql/executor.rs` — no shared module by design. +fn set_nested_value(obj: &mut Map, path: &str, value: Value) { + match path.split_once('.') { + None => { + obj.insert(path.to_string(), value); + } + Some((head, tail)) => { + let nested = obj + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(nested_map) = nested { + set_nested_value(nested_map, tail, value); + } + } + } +} + +/// +/// CLI flags arrive as `Value::String` (clap stores them as `String`), but a +/// body field declared `integer` / `number` / `boolean` should land in the +/// JSON body with the right runtime type, not as a quoted string. Values +/// supplied via `--params` are already typed by `serde_json` and pass through +/// unchanged. `object` and `array` types are JSON-decoded so callers can pass +/// nested structures via individual flags (e.g. `--addresses '[{"city":"SF"}]'`). +fn coerce_body_param_value(value: &Value, param_type: Option<&str>) -> Result { + let Value::String(raw) = value else { + return Ok(value.clone()); + }; + match param_type { + Some("integer") => raw + .parse::() + .map(|n| Value::Number(n.into())) + .map_err(|e| CliError::Validation(format!("Invalid integer body value '{raw}': {e}"))), + Some("number") => { + let n = raw.parse::().map_err(|e| { + CliError::Validation(format!("Invalid number body value '{raw}': {e}")) + })?; + serde_json::Number::from_f64(n) + .map(Value::Number) + .ok_or_else(|| CliError::Validation(format!("Non-finite number body value '{raw}'"))) + } + Some("boolean") => match raw.as_str() { + "true" | "1" => Ok(Value::Bool(true)), + "false" | "0" => Ok(Value::Bool(false)), + _ => Err(CliError::Validation(format!( + "Invalid boolean body value '{raw}' (expected true/false)" + ))), + }, + Some("object") | Some("array") => serde_json::from_str(raw).map_err(|e| { + CliError::Validation(format!("Invalid JSON body value for nested field: {e}")) + }), + _ => Ok(Value::String(raw.clone())), + } +} + +/// Validates a JSON body against a Discovery Document schema. +fn validate_body_against_schema( + body: &Value, + schema_name: &str, + doc: &RestDescription, +) -> Result<(), CliError> { + let mut errors = Vec::new(); + validate_value(body, schema_name, doc, "$", &mut errors); + + if !errors.is_empty() { + return Err(CliError::Validation(format!( + "Request body failed schema validation:\n- {}", + errors.join("\n- ") + ))); + } + + Ok(()) +} + +fn validate_value( + value: &Value, + schema_ref_name: &str, + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + let schema = match doc.schemas.get(schema_ref_name) { + Some(s) => s, + None => { + errors.push(format!("{path}: Schema '{schema_ref_name}' not found")); + return; + } + }; + + // If the top-level schema is an object + if schema.schema_type.as_deref() == Some("object") || !schema.properties.is_empty() { + if let Value::Object(obj) = value { + validate_properties(obj, &schema.properties, &schema.required, doc, path, errors); + } else { + errors.push(format!("{path}: Expected object")); + } + } +} + +fn validate_properties( + obj: &Map, + properties: &HashMap, + required_keys: &[String], + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + // Check required keys first + for req_key in required_keys { + if !obj.contains_key(req_key) { + errors.push(format!("{path}: Missing required property '{req_key}'")); + } + } + + // An empty properties map means "any additional properties are allowed" + // (JSON Schema default when additionalProperties is not explicitly false). + if properties.is_empty() { + return; + } + + let valid_keys: std::collections::HashSet<&String> = properties.keys().collect(); + + for (key, val) in obj { + let current_path = if path == "$" { + key.clone() + } else { + format!("{path}.{key}") + }; + + if !valid_keys.contains(key) { + errors.push(format!( + "{current_path}: Unknown property. Valid properties: {:?}", + valid_keys.iter().map(|k| k.as_str()).collect::>() + )); + continue; + } + + let prop_schema = &properties[key]; + validate_property(val, prop_schema, doc, ¤t_path, errors); + } +} + +fn validate_property( + value: &Value, + prop_schema: &crate::openapi::discovery::JsonSchemaProperty, + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + // 1. Resolve $ref if present + if let Some(ref_name) = &prop_schema.schema_ref { + validate_value(value, ref_name, doc, path, errors); + return; + } + + // 2. Type checking + if let Some(expected_type) = &prop_schema.prop_type { + let type_matches = match (expected_type.as_str(), value) { + ("string", Value::String(_)) => true, + ("integer", Value::Number(n)) => n.is_i64() || n.is_u64(), + ("number", Value::Number(_)) => true, + ("boolean", Value::Bool(_)) => true, + ("array", Value::Array(_)) => true, + ("object", Value::Object(_)) => true, + ("any", _) => true, + _ => false, + }; + + if !type_matches { + errors.push(format!( + "{path}: Expected type '{expected_type}', found {}", + get_value_type(value) + )); + return; // Stop further validation for this property if the type is wrong + } + } + + // 3. Array items validation + if prop_schema.prop_type.as_deref() == Some("array") { + if let Some(items_schema) = &prop_schema.items { + if let Value::Array(arr) = value { + for (i, item) in arr.iter().enumerate() { + let item_path = format!("{path}[{i}]"); + validate_property(item, items_schema, doc, &item_path, errors); + } + } + } + } + + // 4. Object properties validation + if prop_schema.prop_type.as_deref() == Some("object") && !prop_schema.properties.is_empty() { + if let Value::Object(obj) = value { + validate_properties(obj, &prop_schema.properties, &[], doc, path, errors); + } + } + + // 5. Enum validation + if let Some(enum_values) = &prop_schema.enum_values { + if let Value::String(s) = value { + if !enum_values.contains(s) { + errors.push(format!( + "{path}: Value '{s}' is not a valid enum member. Valid options: {enum_values:?}" + )); + } + } + } +} + +fn get_value_type(val: &Value) -> &'static str { + match val { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(n) if n.is_f64() => "number (float)", + Value::Number(_) => "integer", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Maps a MIME type to a file extension. +pub fn mime_to_extension(mime: &str) -> &str { + if mime.contains("pdf") { + "pdf" + } else if mime.contains("png") { + "png" + } else if mime.contains("jpeg") || mime.contains("jpg") { + "jpg" + } else if mime.contains("gif") { + "gif" + } else if mime.contains("csv") { + "csv" + } else if mime.contains("zip") { + "zip" + } else if mime.contains("xml") { + "xml" + } else if mime.contains("html") { + "html" + } else if mime.contains("plain") { + "txt" + } else if mime.contains("octet-stream") { + "bin" + } else if mime.contains("spreadsheet") || mime.contains("xlsx") { + "xlsx" + } else if mime.contains("document") || mime.contains("docx") { + "docx" + } else if mime.contains("presentation") || mime.contains("pptx") { + "pptx" + } else if mime.contains("script") { + "json" + } else { + "bin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{ + JsonSchema, JsonSchemaProperty, MethodParameter, RestDescription, RestMethod, + }; + use serde_json::json; + + // --------------------------------------------------------------- + // Retry helpers (`x-fern-retries`) + // --------------------------------------------------------------- + + fn enabled_cfg() -> RetriesConfig { + RetriesConfig::default() + } + + #[test] + fn test_is_retryable_status_set_matches_docs() { + // Matches fern TS SDK retryStatusCodes: recommended set: + // 408 / 429 / 502 / 503 / 504. + for s in [408u16, 429, 502, 503, 504] { + assert!(is_retryable_status(s), "{s} should retry"); + } + // 500 is deliberately NOT retried \u2014 see is_retryable_status + // docstring. 425 (Too Early), 501 Not Implemented, and other + // 5xx outside the recommended set are terminal. 4xx client + // errors won't change on retry, so they're terminal too. + for s in [200u16, 301, 400, 401, 403, 404, 422, 425, 500, 501, 505] { + assert!(!is_retryable_status(s), "{s} should NOT retry"); + } + } + + #[test] + fn test_method_allows_retry_idempotent_methods() { + // HTTP-spec-idempotent methods retry regardless of the + // `x-fern-idempotent` extension. + for m in ["GET", "HEAD", "OPTIONS", "DELETE", "PUT"] { + assert!(method_allows_retry(m, false), "{m} should retry by default"); + } + } + + #[test] + fn test_method_allows_retry_non_idempotent_methods_only_when_marked() { + // POST/PATCH only retry when the spec marks the op idempotent. + for m in ["POST", "PATCH"] { + assert!(!method_allows_retry(m, false), "{m} should NOT retry by default"); + assert!(method_allows_retry(m, true), "{m} retries when x-fern-idempotent"); + } + } + + #[test] + fn test_binary_body_is_stdin() { + // Stdin sentinels — retries must be disabled. + assert!(binary_body_is_stdin(Some("-"))); + assert!(binary_body_is_stdin(Some("@-"))); + // File paths — retries are safe (re-opens the file). + assert!(!binary_body_is_stdin(Some("/tmp/audio.mp3"))); + assert!(!binary_body_is_stdin(Some("@/tmp/audio.mp3"))); + // No binary body at all — retries decided by other policy. + assert!(!binary_body_is_stdin(None)); + } + + #[test] + fn test_parse_retry_after_numeric_seconds() { + let now = std::time::SystemTime::now(); + let d = parse_retry_after("5", now).expect("numeric form"); + assert_eq!(d, std::time::Duration::from_secs(5)); + } + + #[test] + fn test_parse_retry_after_zero() { + // `Retry-After: 0` means "retry now". + let d = parse_retry_after("0", std::time::SystemTime::now()).unwrap(); + assert_eq!(d, std::time::Duration::ZERO); + } + + #[test] + fn test_parse_retry_after_whitespace_and_empty() { + assert!(parse_retry_after("", std::time::SystemTime::now()).is_none()); + // Common server spelling has surrounding whitespace; we trim. + let d = parse_retry_after(" 10 ", std::time::SystemTime::now()).unwrap(); + assert_eq!(d, std::time::Duration::from_secs(10)); + } + + #[test] + fn test_parse_retry_after_http_date_future() { + // 60 seconds in the future expressed as IMF-fixdate. + let now = std::time::SystemTime::now(); + let target = now + std::time::Duration::from_secs(60); + let fmt = httpdate::fmt_http_date(target); + let d = parse_retry_after(&fmt, now).expect("http-date form parses"); + // Allow slight skew because `fmt_http_date` rounds to seconds. + assert!(d.as_secs() >= 59 && d.as_secs() <= 60, "got {d:?}"); + } + + #[test] + fn test_parse_retry_after_http_date_in_the_past_clamps_to_zero() { + // A server that emits a past timestamp \u2014 either clock-skew or + // an unusual "you can retry now" gesture \u2014 should collapse to + // an immediate retry rather than underflow. + let now = std::time::SystemTime::now(); + let target = now - std::time::Duration::from_secs(60); + let fmt = httpdate::fmt_http_date(target); + let d = parse_retry_after(&fmt, now).expect("past http-date parses"); + assert_eq!(d, std::time::Duration::ZERO); + } + + #[test] + fn test_parse_retry_after_garbage_returns_none() { + assert!( + parse_retry_after("nonsense", std::time::SystemTime::now()).is_none(), + "bad header surfaces None so the backoff fallback applies" + ); + } + + #[test] + fn test_compute_backoff_delay_no_jitter_is_deterministic() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 5, + base_delay_ms: 100, + factor: 2.0, + jitter: 0.0, + }; + // attempt=0 \u2192 100ms; attempt=1 \u2192 200; attempt=2 \u2192 400; ... + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::from_millis(100) + ); + assert_eq!( + compute_backoff_delay_with_rand(1, &cfg, 0.5), + std::time::Duration::from_millis(200) + ); + assert_eq!( + compute_backoff_delay_with_rand(2, &cfg, 0.5), + std::time::Duration::from_millis(400) + ); + assert_eq!( + compute_backoff_delay_with_rand(3, &cfg, 0.5), + std::time::Duration::from_millis(800) + ); + } + + #[test] + fn test_compute_backoff_delay_jitter_symmetric_around_raw() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 5, + base_delay_ms: 100, + factor: 2.0, + jitter: 0.5, + }; + // rand=0.5 \u2192 offset is zero \u2192 raw delay. + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::from_millis(100) + ); + // rand=0.0 \u2192 subtract half the jitter span. + // span = 100 * 0.5 = 50; offset = (0 - 0.5) * 50 = -25 \u2192 75ms + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.0), + std::time::Duration::from_millis(75) + ); + // rand=1.0 \u2192 add half the jitter span. offset = +25 \u2192 125ms + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 1.0), + std::time::Duration::from_millis(125) + ); + } + + #[test] + fn test_compute_backoff_delay_disabled_returns_zero() { + let cfg = RetriesConfig::disabled(); + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::ZERO + ); + } + + #[test] + fn test_compute_backoff_delay_default_entropy_produces_jitter() { + // Regression: an earlier implementation sampled entropy from + // `Instant::now().elapsed()`, which always returns ~0 nanos and + // pinned the jitter sample to a constant — defeating jitter. + // Sample the live `compute_backoff_delay` 64 times with a wide + // jitter band and assert we see at least two distinct values. + let cfg = RetriesConfig { + enabled: true, + max_attempts: 3, + base_delay_ms: 1000, + factor: 1.0, + jitter: 1.0, + }; + let mut samples = std::collections::HashSet::new(); + for _ in 0..64 { + samples.insert(compute_backoff_delay(0, &cfg).as_millis()); + // Tiny pause so the wall-clock sub-second component + // advances between samples in fast CI environments. + std::thread::sleep(std::time::Duration::from_micros(50)); + } + assert!( + samples.len() > 1, + "expected variance in jitter samples, got {samples:?}", + ); + } + + #[test] + fn test_decide_retry_no_retry_flag_short_circuits() { + // `--no-retry` is the user-facing debug opt-out. Mirrors the + // PR description's open design question: yes, full opt-out + // even for network errors so users can debug. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, /*no_retry=*/ true); + assert!(d.is_none(), "--no-retry disables all retries"); + } + + #[test] + fn test_decide_retry_disabled_config_no_retry() { + let cfg = RetriesConfig::disabled(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_none(), "disabled config never retries"); + } + + #[test] + fn test_decide_retry_max_attempts_cap() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 3, + base_delay_ms: 1, + factor: 1.0, + jitter: 0.0, + }; + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + // attempt 0 -> retry (allowed) + assert!(decide_retry(0, &outcome, &cfg, "GET", false, false).is_some()); + // attempt 1 -> retry (allowed) + assert!(decide_retry(1, &outcome, &cfg, "GET", false, false).is_some()); + // attempt 2 -> done; we've used all 3 attempts. Stop. + assert!(decide_retry(2, &outcome, &cfg, "GET", false, false).is_none()); + // attempt 3+ -> never. Defensive. + assert!(decide_retry(3, &outcome, &cfg, "GET", false, false).is_none()); + } + + #[test] + fn test_decide_retry_retryable_status_get_retries() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_non_retryable_status_no_retry() { + // 401 Unauthorized never retries \u2014 wait won't make creds valid. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(401), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_post_503_without_idempotent_no_retry() { + // Plain POST got 503 \u2014 the server may have processed it. + // Don't retry without an explicit idempotent marker. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_post_503_with_idempotent_retries() { + // POST marked idempotent (x-fern-idempotent) is safe to retry. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", true, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_post_429_always_safe() { + // 429 means the server *didn't* process the request \u2014 always + // safe to retry regardless of method idempotency. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(429), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_some(), "429 retries on non-idempotent methods"); + } + + #[test] + fn test_decide_retry_network_error_get_retries() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_network_error_post_without_idempotent_no_retry() { + // Network failure on a POST: ambiguous whether the server got + // the request. Mirror the per-method policy here too. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_honors_retry_after_numeric() { + // When the server provides Retry-After, honor it instead of + // the computed backoff (the server knows better than we do). + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: Some("7"), + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false) + .expect("should retry"); + assert_eq!(d, std::time::Duration::from_secs(7)); + } + + #[test] + fn test_decide_retry_falls_back_to_backoff_when_retry_after_invalid() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: Some("not-a-number"), + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false) + .expect("should retry"); + // Falls back to backoff math \u2014 not zero, not the parsed value. + assert!(d > std::time::Duration::ZERO); + } + + #[test] + fn test_binary_body_source_plain_path() { + match BinaryBodySource::parse("/tmp/audio.mp3") { + BinaryBodySource::File(p) => assert_eq!(p, "/tmp/audio.mp3"), + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_at_path_strips_prefix() { + match BinaryBodySource::parse("@/tmp/audio.mp3") { + BinaryBodySource::File(p) => assert_eq!(p, "/tmp/audio.mp3"), + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_dash_is_stdin() { + assert!(matches!(BinaryBodySource::parse("-"), BinaryBodySource::Stdin)); + } + + #[test] + fn test_binary_body_source_at_dash_is_stdin() { + // curl's spelling for stdin is `@-`; we accept it as an alias for `-`. + assert!(matches!(BinaryBodySource::parse("@-"), BinaryBodySource::Stdin)); + } + + #[test] + fn test_binary_body_source_double_at_is_literal_at_path() { + // Only the first `@` is stripped — matches curl's behavior for filenames + // that legitimately start with `@`. + match BinaryBodySource::parse("@@weird-name.mp3") { + BinaryBodySource::File(p) => assert_eq!(p, "@weird-name.mp3"), + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_header_params_not_in_query_string() { + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "user_id".to_string(), + MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + parameters.insert( + "X-Custom-Header".to_string(), + MethodParameter { + location: Some("header".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "limit".to_string(), + MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "users/{user_id}".to_string(), + parameters, + parameter_order: vec!["user_id".to_string()], + ..Default::default() + }; + + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = + r#"{"user_id": "123", "X-Custom-Header": "my-value", "limit": "10"}"#; + let input = + parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[]).unwrap(); + + // Header param should be in header_params + assert_eq!(input.header_params.len(), 1); + assert_eq!(input.header_params[0].0, "X-Custom-Header"); + assert_eq!(input.header_params[0].1, "my-value"); + + // Header param should NOT be in query_params + assert!( + !input + .query_params + .iter() + .any(|(k, _)| k == "X-Custom-Header"), + "Header param should not appear in query_params" + ); + + // Query param should still be in query_params + assert!( + input.query_params.iter().any(|(k, _)| k == "limit"), + "Query param should appear in query_params" + ); + } + + #[tokio::test] + async fn test_header_params_sent_as_http_headers() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "users".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/users".to_string(), + body: None, + query_params: Vec::new(), + header_params: vec![( + "X-Custom-Header".to_string(), + "header-value".to_string(), + )], + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built + .headers() + .get("X-Custom-Header") + .map(|v| v.to_str().unwrap()), + Some("header-value"), + "Header params should be sent as HTTP headers" + ); + assert_eq!( + built.headers().get("Accept").map(|v| v.to_str().unwrap()), + Some("application/json"), + "Default Accept prefers JSON for content negotiation" + ); + } + + #[tokio::test] + async fn test_default_accept_skipped_when_accept_in_params() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "users".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/users".to_string(), + body: None, + query_params: Vec::new(), + header_params: vec![("Accept".to_string(), "application/xml".to_string())], + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built.headers().get("Accept").map(|v| v.to_str().unwrap()), + Some("application/xml"), + "Explicit Accept in header params should not be overridden" + ); + } + + #[tokio::test] + async fn test_explicit_anonymous_endpoint_skips_auth() { + // `security: []` on an operation means "this endpoint is explicitly + // unauthenticated" — the executor must not attach credentials even + // when a credential-bearing provider is configured. Regression for + // the leaf/Any/All path: only RoutingAuthProvider honored this + // before; now the executor short-circuits universally. + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "public/ping".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/public/ping".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + // A bare bearer leaf — would normally attach Authorization. + let provider: crate::auth::DynAuthProvider = std::sync::Arc::new( + crate::auth::BearerAuthProvider::new( + "bearerAuth", + crate::auth::AuthCredentialSource::literal("tok"), + ), + ); + + let request = build_http_request( + &client, + &method, + &input, + &provider, + &EndpointAuthMetadata::explicit_anonymous(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert!( + built.headers().get(reqwest::header::AUTHORIZATION).is_none(), + "security: [] must opt out of auth even with a bearer provider" + ); + } + + #[test] + fn test_coerce_body_param_value_scalar_types() { + // CLI flags arrive as Value::String; coerce them per the schema's type. + assert_eq!( + coerce_body_param_value(&Value::String("42".into()), Some("integer")).unwrap(), + json!(42) + ); + assert_eq!( + coerce_body_param_value(&Value::String("2.5".into()), Some("number")).unwrap(), + json!(2.5) + ); + assert_eq!( + coerce_body_param_value(&Value::String("true".into()), Some("boolean")).unwrap(), + Value::Bool(true) + ); + assert_eq!( + coerce_body_param_value(&Value::String("false".into()), Some("boolean")).unwrap(), + Value::Bool(false) + ); + // String type passes through unchanged. + assert_eq!( + coerce_body_param_value(&Value::String("hello".into()), Some("string")).unwrap(), + json!("hello") + ); + // Already-typed values from `--params` JSON pass through. + assert_eq!( + coerce_body_param_value(&json!(99), Some("integer")).unwrap(), + json!(99) + ); + } + + #[test] + fn test_coerce_body_param_value_nested_decodes_json() { + // Object/array body fields accept a JSON string from the CLI flag. + let arr = coerce_body_param_value( + &Value::String(r#"["a","b"]"#.into()), + Some("array"), + ) + .unwrap(); + assert_eq!(arr, json!(["a", "b"])); + + let obj = coerce_body_param_value( + &Value::String(r#"{"city":"SF"}"#.into()), + Some("object"), + ) + .unwrap(); + assert_eq!(obj, json!({ "city": "SF" })); + } + + #[test] + fn test_coerce_body_param_value_rejects_bad_input() { + let err = coerce_body_param_value( + &Value::String("not-an-int".into()), + Some("integer"), + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => assert!(msg.contains("Invalid integer")), + _ => panic!("Expected Validation error"), + } + + let err = coerce_body_param_value( + &Value::String("yes".into()), + Some("boolean"), + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => assert!(msg.contains("Invalid boolean")), + _ => panic!("Expected Validation error"), + } + } + + #[test] + fn test_body_params_merge_into_body_via_params_json() { + // `--params` is the JSON-blob fallback that mirrors per-flag values; + // body-located params should land in the JSON body, not the query string. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "count".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("integer".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "Acme", "count": "3"}"#; + let input = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[]) + .unwrap(); + + // Body must contain both fields, with `count` coerced to a JSON integer. + let body = input.body.expect("body should be populated from body params"); + assert_eq!(body, json!({ "name": "Acme", "count": 3 })); + + // Body fields must NOT bleed into the query string or headers. + assert!(input.query_params.is_empty(), "no query params expected"); + assert!(input.header_params.is_empty(), "no header params expected"); + } + + #[test] + fn test_json_flag_overrides_body_field_flags() { + // When both per-field flags AND `--json` are set, `--json` wins on + // overlapping keys (mirrors `--params` overriding individual flags). + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "description".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "from-flag", "description": "kept-from-flag"}"#; + let body_json = r#"{"name": "from-json"}"#; + let input = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + Some(body_json), + false, + None, + &[], + ) + .unwrap(); + + let body = input.body.expect("body should be populated"); + assert_eq!( + body, + json!({ "name": "from-json", "description": "kept-from-flag" }), + "--json overrides overlapping per-field values, leaves the rest alone" + ); + } + + #[test] + fn test_required_body_field_missing_mentions_flag_json_and_params() { + // A required body field that isn't supplied at all should produce a + // validation error that names the per-field flag, --json, and + // --params, so the user knows every way to fix it. The previous + // message only mentioned --params, which was misleading once + // per-field body flags shipped. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("'name'"), "error names the missing field: {msg}"); + assert!(msg.contains("--name"), "error names the per-field flag: {msg}"); + assert!(msg.contains("--json"), "error names --json for body fields: {msg}"); + assert!(msg.contains("--params"), "error names --params: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_required_param_missing_uses_flag_name_override() { + // When a parameter has `flag_name_override` set (e.g. synthetic + // idempotency-key flags inject the wire name verbatim), the error + // message must suggest THAT flag — not a kebab of the wire name. + // Otherwise the suggestion points at a flag the user can't pass. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "Idempotency-Key".to_string(), + MethodParameter { + location: Some("header".to_string()), + param_type: Some("string".to_string()), + required: true, + flag_name_override: Some("idempotency-key".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--idempotency-key"), + "error must point at the actual flag name from flag_name_override: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_non_object_json_replaces_body_and_drops_per_field_flags() { + // A top-level non-object `--json` (array/scalar) has no shape to + // merge per-field flag values into, so flags are dropped and the + // `--json` payload is used wholesale. Lock in that behavior so + // future refactors of the body-assembly match don't accidentally + // start emitting nonsense (e.g. wrapping the array in an object + // with the flag values). + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + // `--name` is supplied via params; `--json` is a bare array. + let params_json = r#"{"name": "from-flag-loses"}"#; + let body_json = r#"[1, 2, 3]"#; + let input = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + Some(body_json), + false, + None, + &[], + ) + .unwrap(); + + let body = input.body.expect("body should be populated"); + assert_eq!( + body, + json!([1, 2, 3]), + "non-object --json must replace the body wholesale, not be merged" + ); + } + + #[test] + fn test_required_non_body_param_missing_omits_json_hint() { + // The --json hint is body-specific. A missing required query/path/ + // header param should NOT suggest --json — it would mislead the + // user into thinking the body matters here. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "limit".to_string(), + MethodParameter { + location: Some("query".to_string()), + param_type: Some("integer".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--limit"), "error names the per-field flag: {msg}"); + assert!(msg.contains("--params"), "error names --params: {msg}"); + assert!(!msg.contains("--json"), "non-body error should not mention --json: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_per_field_body_flags_path_runs_schema_validation() { + // Schema validation must run regardless of whether the body was + // built from --json or from per-field flags. The previous version + // only validated on the --json path, letting flag-only bodies skip + // schema checks even though clap-typed strings are more likely to + // produce shape mismatches than hand-written JSON. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + // Schema declares `name` as an integer — a string value from the + // per-field flag should be rejected by the schema validator. + let mut schema_props = std::collections::HashMap::new(); + schema_props.insert( + "name".to_string(), + crate::openapi::discovery::JsonSchemaProperty { + prop_type: Some("integer".to_string()), + ..Default::default() + }, + ); + let mut schemas = std::collections::HashMap::new(); + schemas.insert( + "ThingRequest".to_string(), + crate::openapi::discovery::JsonSchema { + schema_type: Some("object".to_string()), + properties: schema_props, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + request: Some(crate::openapi::discovery::SchemaRef { + schema_ref: Some("ThingRequest".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + schemas, + ..Default::default() + }; + + let params_json = r#"{"name": "not-an-integer"}"#; + let err = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("schema validation"), + "schema validator should fire on flag-only body: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_pagination_config_default() { + let config = PaginationConfig::default(); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + } + + #[test] + fn test_mime_to_extension_more_types() { + assert_eq!(mime_to_extension("text/plain"), "txt"); + assert_eq!(mime_to_extension("text/csv"), "csv"); + assert_eq!(mime_to_extension("application/zip"), "zip"); + assert_eq!(mime_to_extension("application/xml"), "xml"); + assert_eq!(mime_to_extension("text/html"), "html"); + assert_eq!(mime_to_extension("application/json"), "bin"); // Default for unknown specific json types if not scripts + assert_eq!( + mime_to_extension("application/vnd.google-apps.script"), + "json" + ); + assert_eq!( + mime_to_extension("application/vnd.google-apps.presentation"), + "pptx" + ); + } + + #[test] + fn test_validate_body_valid() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + let body = json!({ "name": "My File" }); + assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + } + + #[test] + fn test_validate_body_open_schema_allows_any_properties() { + // A schema with type=object but no properties defined is an open schema: + // any properties are allowed (JSON Schema default). + let schemas = HashMap::from([( + "Body".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::new(), + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "name": "foo", "count": 3, "nested": {"x": 1} }); + assert!( + validate_body_against_schema(&body, "Body", &doc).is_ok(), + "open object schema should accept any properties" + ); + } + + #[test] + fn test_validate_body_unknown_field() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + let body = json!({ "name": "My File", "invalidField": 123 }); + let result = validate_body_against_schema(&body, "File", &doc); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unknown property")); + } + + #[test] + fn test_validate_body_deep_validation() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + properties.insert( + "status".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + enum_values: Some(vec!["ACTIVE".to_string(), "INACTIVE".to_string()]), + ..Default::default() + }, + ); + properties.insert( + "count".to_string(), + JsonSchemaProperty { + prop_type: Some("integer".to_string()), + ..Default::default() + }, + ); + properties.insert( + "tags".to_string(), + JsonSchemaProperty { + prop_type: Some("array".to_string()), + items: Some(Box::new(JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + })), + ..Default::default() + }, + ); + properties.insert( + "parent".to_string(), + JsonSchemaProperty { + schema_ref: Some("Parent".to_string()), + ..Default::default() + }, + ); + + let mut parent_props = HashMap::new(); + parent_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + required: vec!["name".to_string(), "status".to_string()], + properties, + ..Default::default() + }, + ); + schemas.insert( + "Parent".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: parent_props, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + // Valid Request + let body = json!({ + "name": "My File", + "status": "ACTIVE", + "count": 10, + "tags": ["one", "two"], + "parent": { "id": "123" } + }); + assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + + // Missing Required Field + let body_missing = json!({ "name": "My File" }); + let err = validate_body_against_schema(&body_missing, "File", &doc).unwrap_err(); + assert!(err + .to_string() + .contains("Missing required property 'status'")); + + // Invalid Enum Value + let body_bad_enum = json!({ "name": "My File", "status": "UNKNOWN" }); + let err = validate_body_against_schema(&body_bad_enum, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("not a valid enum member")); + + // Invalid Type + let body_bad_type = json!({ "name": "My File", "status": "ACTIVE", "count": "10" }); + let err = validate_body_against_schema(&body_bad_type, "File", &doc).unwrap_err(); + assert!(err + .to_string() + .contains("Expected type 'integer', found string")); + + // Deep Schema Reference Validation Failure + let body_bad_ref = json!({ + "name": "My File", + "status": "ACTIVE", + "parent": { "invalidField": "123" } + }); + let err = validate_body_against_schema(&body_bad_ref, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("Unknown property")); + + // Expected Object Type Failure + let body_not_object = json!([]); + let err = validate_body_against_schema(&body_not_object, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("Expected object")); + } + #[tokio::test] + async fn test_build_multipart_body() { + let metadata = Some(json!({ "name": "test.txt", "mimeType": "text/plain" })); + let content = b"Hello world"; + + let (body, content_type) = build_multipart_body(&metadata, content, "text/plain").unwrap(); + + // Check content type has boundary + assert!(content_type.starts_with("multipart/related; boundary=")); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + + let body_str = String::from_utf8(body).unwrap(); + + // Verify structure + assert!(body_str.contains(boundary)); + assert!(body_str.contains("Content-Type: application/json")); + assert!(body_str.contains("{\"mimeType\":\"text/plain\",\"name\":\"test.txt\"}")); + assert!(body_str.contains("Content-Type: text/plain")); + assert!(body_str.contains("Hello world")); + } + + #[tokio::test] + async fn test_build_multipart_body_no_metadata() { + let metadata = None; + let content = b"Binary data"; + + let (body, content_type) = + build_multipart_body(&metadata, content, "application/octet-stream").unwrap(); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + let body_str = String::from_utf8(body).unwrap(); + + assert!(body_str.contains(boundary)); + assert!(body_str.contains("application/octet-stream")); + assert!(body_str.contains("Binary data")); + } + + #[test] + fn test_resolve_upload_mime_explicit_flag() { + let metadata = Some(json!({ "mimeType": "image/png" })); + let mime = resolve_upload_mime(Some("text/markdown"), Some("file.txt"), &metadata); + assert_eq!(mime, "text/markdown", "explicit flag takes top priority"); + } + + #[test] + fn test_resolve_upload_mime_extension_beats_metadata() { + let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" })); + let mime = resolve_upload_mime(None, Some("notes.md"), &metadata); + assert_eq!( + mime, "text/markdown", + "extension inference ranks above metadata mimeType" + ); + } + + #[test] + fn test_resolve_upload_mime_metadata_fallback_for_unknown_extension() { + let metadata = Some(json!({ "mimeType": "text/plain" })); + let mime = resolve_upload_mime(None, Some("file.unknown"), &metadata); + assert_eq!( + mime, "text/plain", + "metadata mimeType is used when extension is unrecognized" + ); + } + + #[test] + fn test_resolve_upload_mime_extension_when_no_metadata() { + let mime = resolve_upload_mime(None, Some("notes.md"), &None); + assert_eq!(mime, "text/markdown"); + + let mime = resolve_upload_mime(None, Some("page.html"), &None); + assert_eq!(mime, "text/html"); + + let mime = resolve_upload_mime(None, Some("data.csv"), &None); + assert_eq!(mime, "text/csv"); + } + + #[test] + fn test_resolve_upload_mime_fallback() { + let mime = resolve_upload_mime(None, Some("file.unknown"), &None); + assert_eq!(mime, "application/octet-stream"); + } + + #[test] + fn test_resolve_upload_mime_explicit_enables_import_conversion() { + let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" })); + let mime = resolve_upload_mime(Some("text/markdown"), Some("impact.md"), &metadata); + assert_eq!( + mime, "text/markdown", + "--upload-content-type overrides metadata for media part" + ); + } + + #[test] + fn test_build_multipart_bytes_with_metadata() { + let metadata = Some(json!({ "threadId": "thread-123" })); + let data = b"From: test@example.com\r\nSubject: Test\r\n\r\nBody"; + let (_, content_type, content_length) = + build_multipart_bytes(&metadata, data, "message/rfc822").unwrap(); + + assert!( + content_type.starts_with("multipart/related; boundary=fern_boundary_"), + "content_type should be multipart/related: {content_type}", + ); + // Content-length should cover: preamble + data + postamble + assert!( + content_length > data.len() as u64, + "content_length should exceed raw data size: {content_length}", + ); + } + + #[test] + fn test_build_multipart_bytes_without_metadata() { + let (_, content_type, content_length) = + build_multipart_bytes(&None, b"test body", "message/rfc822").unwrap(); + + assert!(content_type.starts_with("multipart/related; boundary=")); + assert!(content_length > 0); + } + + #[tokio::test] + async fn test_build_multipart_stream_content_length() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("small.txt"); + let file_content = b"Hello stream"; + std::fs::write(&file_path, file_content).unwrap(); + + let metadata_value = json!({ "name": "small.txt" }); + let metadata = Some(metadata_value.clone()); + let file_size = file_content.len() as u64; + + let (_body, content_type, declared_len) = build_multipart_stream( + &metadata, + file_path.to_str().unwrap(), + file_size, + "text/plain", + ) + .unwrap(); + + assert!(content_type.starts_with("multipart/related; boundary=")); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + + // Manually compute expected content length: + // preamble = "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{json}\r\n--{boundary}\r\nContent-Type: text/plain\r\n\r\n" + // postamble = "\r\n--{boundary}--\r\n" + let metadata_json = serde_json::to_string(&metadata_value).unwrap(); + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: text/plain\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + let expected = preamble.len() as u64 + file_size + postamble.len() as u64; + assert_eq!( + declared_len, expected, + "declared Content-Length must match expected preamble + file + postamble" + ); + } + + #[tokio::test] + async fn test_build_multipart_stream_large_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("large.bin"); + // 256 KB — larger than the default 64 KB ReaderStream chunk size + let data = vec![0xABu8; 256 * 1024]; + std::fs::write(&file_path, &data).unwrap(); + + let metadata = None; + let file_size = data.len() as u64; + + let (_body, _content_type, declared_len) = build_multipart_stream( + &metadata, + file_path.to_str().unwrap(), + file_size, + "application/octet-stream", + ) + .unwrap(); + + // Content-Length must account for the empty-metadata preamble + large file + postamble + assert!( + declared_len > file_size, + "Content-Length ({declared_len}) must be larger than file size ({file_size}) due to multipart framing" + ); + + // Verify exact arithmetic: preamble overhead + file_size + postamble + let boundary = _content_type.split("boundary=").nth(1).unwrap(); + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{{}}\r\n\ + --{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + let expected = preamble.len() as u64 + file_size + postamble.len() as u64; + assert_eq!( + declared_len, expected, + "Content-Length must match for multi-chunk files" + ); + } + + #[test] + fn test_build_url_basic() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files"); + } + + #[test] + fn test_build_url_override_replaces_spec_base() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + // Use a leading-slash path matching real OpenAPI spec output + let method = RestMethod { + path: "/files".to_string(), + flat_path: Some("/files".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url, "http://localhost:9000/files"); + } + + #[test] + fn test_build_url_override_trailing_slash_normalized() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + // Use a leading-slash path matching real OpenAPI spec output + let method = RestMethod { + path: "/users/me".to_string(), + flat_path: Some("/users/me".to_string()), + ..Default::default() + }; + let params = Map::new(); + + // With trailing slash on override + let (url_with, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000/")).unwrap(); + // Without trailing slash on override + let (url_without, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url_with, url_without); + assert_eq!(url_with, "http://localhost:9000/users/me"); + } + + #[test] + fn test_build_url_override_no_double_slash_with_leading_slash_path() { + // Regression test: OpenAPI paths start with /, override must not produce // + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/users/me".to_string(), + flat_path: Some("/users/me".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url, "http://localhost:9000/users/me"); + } + + // ----------------------------------------------------------------------- + // x-fern-base-path + // + // Exhaustive 2x2 matrix over the spec's `x-fern-base-path` value + // (with/without leading slash) and the base URL's trailing slash. The + // wire tests in tests/openapi_fixture_wire.rs exercise the same matrix + // end-to-end through the HTTP stack. + // ----------------------------------------------------------------------- + + fn base_path_doc(base_path: &str) -> RestDescription { + RestDescription { + base_path: Some(base_path.to_string()), + ..Default::default() + } + } + + fn things_method() -> RestMethod { + RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + } + } + + #[test] + fn test_build_url_base_path_leading_slash_x_server_trailing_slash() { + let doc = base_path_doc("/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example/"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_leading_slash_x_server_no_trailing_slash() { + let doc = base_path_doc("/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_no_leading_slash_x_server_trailing_slash() { + let doc = base_path_doc("v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example/"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_no_leading_slash_x_server_no_trailing_slash() { + let doc = base_path_doc("v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_applies_to_spec_root_url() { + // No base_url override, no doc.base_url — base_path applies on top + // of the effective root_url (which is what OpenAPI's `servers[0].url` + // becomes after parsing). + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/api/public".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/api/public/things"); + } + + #[test] + fn test_build_url_base_path_composes_with_per_operation_server() { + // Per-operation `servers[]` override is captured in `method.root_url` + // by the parser. `effective_root_url` returns it (taking precedence + // over the spec-level `doc.root_url`), and `apply_base_path` then + // prepends the base path on top of the per-op server. This test + // pins that composition — without it, a per-op upload-host override + // would silently lose the base path prefix. + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/uploads".to_string(), + flat_path: Some("/uploads".to_string()), + root_url: "https://upload.example.com".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_base_path_per_op_server_with_trailing_slash() { + // Same composition as the test above, but the per-op server URL + // carries a trailing slash — the slash-edge normalization runs at + // the per-op + base_path boundary too, not just at the doc.root_url + // + base_path boundary. + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/uploads".to_string(), + flat_path: Some("/uploads".to_string()), + root_url: "https://upload.example.com/".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_base_path_applies_to_doc_base_url() { + // doc.base_url (set when the spec's server includes a path + // component) is also augmented by base_path. + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/things"); + } + + #[test] + fn test_build_url_base_path_with_trailing_slash_normalized() { + // Authoring quirk: `x-fern-base-path: /v1/` should not produce + // double slashes against the operation path. + let doc = base_path_doc("/v1/"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_multi_segment() { + // Multi-segment base paths (e.g. `/api/v1`) are emitted verbatim; + // only the boundary slashes against the server URL and operation + // path are normalized. + let doc = base_path_doc("/api/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/api/v1/things"); + } + + #[test] + fn test_build_url_base_path_none_unchanged() { + // When `base_path` is None the URL is identical to the pre-feature + // behavior — this protects existing specs that don't use the + // extension from any drift. + let doc = RestDescription { + base_path: None, + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/things"); + } + + #[test] + fn test_build_url_base_path_preserves_path_substitution() { + // Path parameter substitution still happens against the operation + // path after base_path is prepended. + let doc = base_path_doc("/v1"); + let method = RestMethod { + path: "/things/{thingId}".to_string(), + flat_path: Some("/things/{thingId}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("thingId".to_string(), json!("abc")); + let (url, _) = build_url( + &doc, + &method, + ¶ms, + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things/abc"); + } + + #[test] + fn test_apply_base_path_helper_handles_edge_cases() { + // None → base returned verbatim. + assert_eq!(apply_base_path("http://x", None), "http://x"); + assert_eq!(apply_base_path("http://x/", None), "http://x/"); + + // Empty / slash-only base_path is a no-op — the helper returns + // the base verbatim and leaves trailing-slash normalization to + // build_url's existing operation-path joining logic. + assert_eq!(apply_base_path("http://x", Some("")), "http://x"); + assert_eq!(apply_base_path("http://x", Some("/")), "http://x"); + assert_eq!(apply_base_path("http://x/", Some("/")), "http://x/"); + } + + /// `x-fern-base-path` with a templated path parameter (e.g. + /// `/{tenant}/v1`) substitutes the placeholder from the operation's + /// parameters at request time, and the consumed parameter is NOT + /// echoed in the query string. Mirrors upstream Fern's behavior of + /// baking the base path into endpoint paths at Definition build + /// time and resolving placeholders uniformly with the rest of the + /// path-parameter renderer. + #[test] + fn test_build_url_base_path_templated_param_substitutes_and_does_not_leak_to_query() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things"); + assert!(qs.is_empty(), "tenant must be consumed by base_path, not leaked as query: {qs:?}"); + } + + /// Multi-placeholder base paths (e.g. `/{region}/{tenant}/v1`) are + /// rendered uniformly; both placeholder params are consumed by the + /// URL path and neither leaks to the query string. + #[test] + fn test_build_url_base_path_multi_templated_params_substitute() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{region}/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("region".to_string(), json!("us-east-1")); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/us-east-1/acme/v1/things"); + assert!(qs.is_empty(), "both placeholders must be consumed: {qs:?}"); + } + + /// A templated base path composes with operation-level path + /// parameters: the base path placeholder and the endpoint path + /// placeholder both substitute, and only non-path params survive + /// as query string entries. + #[test] + fn test_build_url_base_path_templated_with_operation_path_param_and_query() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let mut method_params: HashMap = + HashMap::new(); + method_params.insert( + "tenant".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "id".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "verbose".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "/things/{id}".to_string(), + flat_path: Some("/things/{id}".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + params.insert("id".to_string(), json!("thing-1")); + params.insert("verbose".to_string(), json!("true")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things/thing-1"); + assert_eq!(qs, vec![("verbose".to_string(), "true".to_string())]); + } + + /// A templated base path composes additively with `--base-url` + /// override, just like a literal base path does. The override + /// supplies the host; the templated base path still applies. + #[test] + fn test_build_url_base_path_templated_param_with_base_url_override() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, Some("https://staging.example.com")).unwrap(); + assert_eq!(url, "https://staging.example.com/acme/v1/things"); + assert!(qs.is_empty()); + } + + /// A param declared as `in: path` on the operation but whose + /// placeholder lives only in `x-fern-base-path` (not in the + /// operation's URL template) must NOT trigger the "path parameter + /// not in URL template" validation error — it's still a path + /// parameter, just one that the base path consumes. This is the + /// most natural customer pattern when their OpenAPI declares a + /// shared prefix param like `{tenant}` at the path-item level. + #[test] + fn test_build_url_base_path_templated_param_declared_as_path_param_does_not_error() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let mut method_params: HashMap = + HashMap::new(); + method_params.insert( + "tenant".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things"); + assert!(qs.is_empty()); + } + + /// When a placeholder in `x-fern-base-path` has no corresponding + /// parameter, the placeholder is left literal in the URL — same + /// fallback behavior as `render_path_template` on endpoint paths. + /// This avoids a hard error for partial fills (e.g. callers that + /// stub the base path) while still making the missing param + /// visible in the outgoing URL. + #[test] + fn test_build_url_base_path_templated_param_missing_value_leaves_placeholder() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let (url, qs) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/{tenant}/v1/things"); + assert!(qs.is_empty()); + } + + /// `doc.base_url` with a *path component* (i.e. the spec's + /// `servers[].url` includes a path) composes with `base_path` — + /// `apply_base_path` doesn't care whether the base is a bare host or + /// host+path; it just joins with one slash. + #[test] + fn test_build_url_base_path_doc_base_url_with_path_component() { + let doc = RestDescription { + base_url: Some("https://api.example.com/v2".to_string()), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v2/v1/things"); + } + + /// `base_path` is applied uniformly to the `is_upload` codepath too, + /// not just to the regular path. Currently unreachable for OpenAPI + /// specs (the OpenAPI parser never populates `media_upload`), but + /// pinning the wiring makes the code self-consistent — if a future + /// change ever exposes media uploads to OpenAPI, base_path won't + /// silently be dropped. + #[test] + fn test_build_url_base_path_applies_to_media_upload_branch() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/files".to_string(), + flat_path: Some("/files".to_string()), + supports_media_upload: true, + media_upload: Some(crate::openapi::discovery::MediaUpload { + protocols: Some(crate::openapi::discovery::MediaUploadProtocols { + simple: Some(crate::openapi::discovery::MediaUploadProtocol { + path: "/upload/files".to_string(), + ..Default::default() + }), + }), + ..Default::default() + }), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), true, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/upload/files"); + } + + #[test] + fn test_build_url_substitution() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files/{fileId}".to_string(), + flat_path: Some("files/{fileId}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("123")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files/123"); + } + + #[test] + fn test_build_url_query_params() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("q".to_string(), json!("search term")); + + let (url, query) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files"); + assert_eq!(query, vec![("q".to_string(), "search term".to_string())]); + } + + #[test] + fn test_build_url_repeated_query_param_expands_array() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut method_params = HashMap::new(); + method_params.insert( + "metadataHeaders".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + repeated: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "messages".to_string(), + flat_path: Some("messages".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert( + "metadataHeaders".to_string(), + json!(["Subject", "Date", "From"]), + ); + + let (_url, query) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + query, + vec![ + ("metadataHeaders".to_string(), "Subject".to_string()), + ("metadataHeaders".to_string(), "Date".to_string()), + ("metadataHeaders".to_string(), "From".to_string()), + ] + ); + } + + #[test] + fn test_build_url_encodes_path_parameter_chars() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "spreadsheetId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "range".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "spreadsheets/{spreadsheetId}/values/{range}".to_string(), + flat_path: Some("spreadsheets/{spreadsheetId}/values/{range}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("spreadsheetId".to_string(), json!("abc123")); + params.insert("range".to_string(), json!("hash#1!A1:B2")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/spreadsheets/abc123/values/hash%231%21A1%3AB2" + ); + } + + #[test] + fn test_build_url_plus_expansion_preserves_slashes() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert( + "name".to_string(), + json!("projects/p1/locations/us/topics/t1"), + ); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/v1/projects/p1/locations/us/topics/t1" + ); + } + + #[test] + fn test_build_url_plus_expansion_rejects_reserved_chars() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("name".to_string(), json!("projects/p1#frag?x=y")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err.to_string().contains("must not contain '?' or '#'")); + } + + #[test] + fn test_build_url_plus_expansion_rejects_path_traversal() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("name".to_string(), json!("projects/../../etc/passwd")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err.to_string().contains("path traversal")); + } + + #[test] + fn test_build_url_upload_endpoint_substitutes_path_params() { + let doc = RestDescription { + root_url: "https://www.googleapis.com/".to_string(), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "drive/v3/files/{fileId}".to_string(), + flat_path: Some("drive/v3/files/{fileId}".to_string()), + parameters, + media_upload: Some(crate::openapi::discovery::MediaUpload { + protocols: Some(crate::openapi::discovery::MediaUploadProtocols { + simple: Some(crate::openapi::discovery::MediaUploadProtocol { + path: "/upload/drive/v3/files/{fileId}".to_string(), + multipart: Some(true), + }), + }), + ..Default::default() + }), + ..Default::default() + }; + + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("abc/123")); + + let (url, _) = build_url(&doc, &method, ¶ms, true, None).unwrap(); + assert_eq!( + url, + "https://www.googleapis.com/upload/drive/v3/files/abc%2F123" + ); + } + + #[test] + fn test_build_url_does_not_replace_placeholder_like_values() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "v1/{parent}/{child}".to_string(), + flat_path: Some("v1/{parent}/{child}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("parent".to_string(), json!("literal-{child}-value")); + params.insert("child".to_string(), json!("ok")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/v1/literal-%7Bchild%7D-value/ok" + ); + } + + #[test] + fn test_build_url_errors_for_path_param_not_in_template() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("123")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err + .to_string() + .contains("Path parameter 'fileId' was provided but is not present")); + } + + #[test] + fn test_build_url_flatpath_fallback_on_mismatch() { + // Reproduces the Slides presentations.get bug where flatPath uses + // {presentationsId} (plural) but the parameter is presentationId (singular). + let doc = RestDescription { + base_url: Some("https://slides.googleapis.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "presentationId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/presentations/{+presentationId}".to_string(), + flat_path: Some("v1/presentations/{presentationsId}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("presentationId".to_string(), json!("abc123")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://slides.googleapis.com/v1/presentations/abc123"); + } + + #[test] + fn test_serialize_deep_object() { + let value = json!({"status": "active", "date": "2024-01-01"}); + let result = serialize_query_param( + "filter", + &value, + Some(&MethodParameter { + style: Some("deepObject".to_string()), + ..Default::default() + }), + ); + assert!(result.contains(&("filter[status]".to_string(), "active".to_string()))); + assert!(result.contains(&("filter[date]".to_string(), "2024-01-01".to_string()))); + } + + #[test] + fn test_serialize_form_explode_array() { + let value = json!(["a", "b", "c"]); + let result = serialize_query_param( + "tags", + &value, + Some(&MethodParameter { + style: Some("form".to_string()), + explode: Some(true), + ..Default::default() + }), + ); + assert_eq!( + result, + vec![ + ("tags".to_string(), "a".to_string()), + ("tags".to_string(), "b".to_string()), + ("tags".to_string(), "c".to_string()), + ] + ); + } + + #[test] + fn test_serialize_form_no_explode_array() { + let value = json!(["a", "b", "c"]); + let result = serialize_query_param( + "tags", + &value, + Some(&MethodParameter { + style: Some("form".to_string()), + explode: Some(false), + ..Default::default() + }), + ); + assert_eq!(result, vec![("tags".to_string(), "a,b,c".to_string())]); + } + + #[test] + fn test_serialize_default_style_is_form() { + // No style specified -> defaults to form with explode + let value = json!("hello"); + let result = serialize_query_param("q", &value, None); + assert_eq!(result, vec![("q".to_string(), "hello".to_string())]); + } + + #[test] + fn test_get_nested_str_simple() { + let val = json!({"nextPageToken": "tok123"}); + assert_eq!(get_nested_str(&val, "nextPageToken"), Some("tok123")); + } + + #[test] + fn test_get_nested_str_nested_path() { + let val = json!({"pagination": {"cursor": "abc"}}); + assert_eq!(get_nested_str(&val, "pagination.cursor"), Some("abc")); + } + + #[test] + fn test_get_nested_str_missing_returns_none() { + let val = json!({"other": "value"}); + assert_eq!(get_nested_str(&val, "nextPageToken"), None); + } + + #[test] + fn test_get_nested_str_non_string_returns_none() { + let val = json!({"count": 42}); + assert_eq!(get_nested_str(&val, "count"), None); + } + + // --------------------------------------------------------------- + // x-fern-sdk-return-value: dot-path resolution + // --------------------------------------------------------------- + + #[test] + fn test_get_nested_value_top_level_property() { + let val = json!({"data": [1, 2, 3], "meta": {}}); + assert_eq!(get_nested_value(&val, "data"), Some(&json!([1, 2, 3]))); + } + + #[test] + fn test_get_nested_value_nested_property() { + let val = json!({"result": {"items": ["x", "y"]}}); + assert_eq!( + get_nested_value(&val, "result.items"), + Some(&json!(["x", "y"])) + ); + } + + #[test] + fn test_get_nested_value_missing_top_returns_none() { + let val = json!({"other": 1}); + assert_eq!(get_nested_value(&val, "data"), None); + } + + #[test] + fn test_get_nested_value_missing_intermediate_returns_none() { + // First segment exists but the second doesn't — the executor + // must error rather than silently fall through. + let val = json!({"result": {"other": 1}}); + assert_eq!(get_nested_value(&val, "result.items"), None); + } + + #[test] + fn test_get_nested_value_returns_primitive_subvalue() { + // The extension is valid on a leaf primitive: e.g. an endpoint + // declaring `x-fern-sdk-return-value: id` on a wrapper response + // should surface the bare ID string. + let val = json!({"id": "abc-123", "name": "thing"}); + assert_eq!(get_nested_value(&val, "id"), Some(&json!("abc-123"))); + } + + #[test] + fn test_get_nested_value_empty_path_returns_none() { + let val = json!({"data": 1}); + assert_eq!(get_nested_value(&val, ""), None); + assert_eq!(get_nested_value(&val, " "), None); + } + + #[test] + fn test_get_nested_value_consecutive_dots_returns_none() { + // `a..b` would otherwise produce a segment lookup for the empty + // string, which always misses. Treat it explicitly as unresolved. + let val = json!({"a": {"b": 1}}); + assert_eq!(get_nested_value(&val, "a..b"), None); + } + + #[test] + fn test_get_nested_value_array_index() { + // Numeric segments index into arrays. `users.0.name` walks the + // first element of the `users` array and reads its `name`. + let val = json!({"users": [{"name": "alice"}, {"name": "bob"}]}); + assert_eq!( + get_nested_value(&val, "users.0.name"), + Some(&json!("alice")) + ); + assert_eq!( + get_nested_value(&val, "users.1.name"), + Some(&json!("bob")) + ); + } + + #[test] + fn test_get_nested_value_array_index_out_of_range_returns_none() { + let val = json!({"users": [{"name": "alice"}]}); + assert_eq!(get_nested_value(&val, "users.5.name"), None); + } + + #[test] + fn test_get_nested_value_array_index_on_object_returns_none() { + // `0` against a non-array, non-`"0"`-keyed object is a miss. + let val = json!({"users": {"alice": 1}}); + assert_eq!(get_nested_value(&val, "users.0"), None); + } + + #[test] + fn test_get_nested_value_object_key_named_zero_wins_over_array_index() { + // If an object happens to have a literal `"0"` key, prefer that + // over array indexing — the user's spec said "the property `0`", + // not "the zeroth element". We're not an array here anyway, but + // this also documents the precedence rule. + let val = json!({"0": "object-key-zero", "list": [10, 20, 30]}); + assert_eq!(get_nested_value(&val, "0"), Some(&json!("object-key-zero"))); + } + + #[test] + fn test_extract_return_value_top_level_resolves() { + let body = json!({"data": [1, 2], "meta": {"total": 2}}); + let out = extract_return_value(&body, Some("data"), false, "op").unwrap(); + assert_eq!(out, json!([1, 2])); + } + + #[test] + fn test_extract_return_value_nested_resolves() { + let body = json!({"result": {"items": [{"id": 1}]}}); + let out = extract_return_value(&body, Some("result.items"), false, "op").unwrap(); + assert_eq!(out, json!([{"id": 1}])); + } + + #[test] + fn test_extract_return_value_unresolved_path_errors() { + let body = json!({"foo": 1}); + let err = extract_return_value(&body, Some("data"), false, "things.list") + .expect_err("missing path must error"); + let msg = err.to_string(); + assert!( + msg.contains("'data'") && msg.contains("things.list"), + "error should name both path and operation id: {msg}", + ); + assert!( + msg.contains("--no-extract"), + "error should point users at the --no-extract escape hatch: {msg}", + ); + } + + #[test] + fn test_extract_return_value_no_path_returns_full_body() { + let body = json!({"data": [1], "meta": {}}); + let out = extract_return_value(&body, None, false, "op").unwrap(); + assert_eq!(out, body); + } + + #[test] + fn test_extract_return_value_no_extract_overrides_path() { + // The opt-out flag bypasses extraction entirely even when the + // spec declares a return path — used to debug responses that + // don't match the spec's promised shape. + let body = json!({"foo": 1}); + let out = extract_return_value(&body, Some("data"), true, "op") + .expect("no_extract=true must bypass extraction even if path would fail"); + assert_eq!( + out, body, + "no_extract returns the full body verbatim, including when the path would have errored", + ); + } + + #[test] + fn test_extract_return_value_resolved_null_is_preserved_not_errored() { + // `{"data": null}` + `return_value: "data"` is *not* an error — + // the spec promised a `data` field, the server delivered one, + // it just happens to be JSON null. Typed SDKs would surface + // this as a nullable response field; the CLI surfaces it as + // the literal `null`. + let body = json!({"data": null, "meta": {}}); + let out = extract_return_value(&body, Some("data"), false, "op") + .expect("resolved null is a valid extracted value"); + assert_eq!(out, json!(null)); + } + + #[test] + fn test_extract_return_value_descriptor_appears_verbatim_in_error() { + // When operationId is absent the caller passes a descriptor + // like "GET /reports". Make sure that descriptor survives the + // format string intact so users can locate the offending op. + let body = json!({"foo": 1}); + let err = extract_return_value(&body, Some("data"), false, "GET /reports") + .expect_err("missing path must error"); + let msg = err.to_string(); + assert!( + msg.contains("GET /reports"), + "descriptor must appear verbatim in error: {msg}", + ); + } + + #[test] + fn test_get_nested_value_path_through_array_with_index() { + // Composes `extract_return_value` with array indexing: paths + // like `data.0` extract the first element of an array. + let body = json!({"data": [{"id": "first"}, {"id": "second"}]}); + let out = extract_return_value(&body, Some("data.0"), false, "op").unwrap(); + assert_eq!(out, json!({"id": "first"})); + } + + #[tokio::test] + async fn test_handle_json_response_extracts_subvalue_capture() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"data":[{"id":1}],"meta":{"total":1}}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0], + json!([{"id": 1}]), + "captured value should be the extracted subvalue, not the full body", + ); + } + + #[tokio::test] + async fn test_handle_json_response_no_extract_keeps_full_body() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let body = r#"{"data":[{"id":1}],"meta":{"total":1}}"#; + let result = handle_json_response( + body, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + true, // no_extract + "things.list", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured[0], serde_json::from_str::(body).unwrap()); + } + + #[tokio::test] + async fn test_handle_json_response_extract_unresolved_errors() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let err = handle_json_response( + r#"{"foo":1}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + ) + .await + .expect_err("unresolved extract path must surface as a validation error"); + assert!( + err.to_string().contains("'data'"), + "error message should name the missing path: {err}", + ); + assert_eq!( + pages_fetched, 0, + "errors must abort before the page counter advances", + ); + } + + #[tokio::test] + async fn test_handle_json_response_pagination_with_extract_emits_subvalue_per_page() { + // Combined behavior check: per-op cursor pagination + extract. + // The full body is still used for pagination continuation (the + // cursor lives outside the extracted subvalue), but only the + // `data` subvalue is captured for the caller. + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "cursor".to_string(), + next_cursor: "next".to_string(), + results: "data".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"data":[{"id":1},{"id":2}],"next":"page-2"}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + ) + .await + .unwrap(); + + assert!(result, "cursor present → should continue pagination"); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0], + json!([{"id": 1}, {"id": 2}]), + "captured per-page value must be the extracted subvalue", + ); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "page-2"), + other => panic!("expected Cursor(Some(\"page-2\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_handle_json_response_capture_output() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"items":["a"]}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured.len(), 1); + assert_eq!(pages_fetched, 1); + } + + #[tokio::test] + async fn test_handle_json_response_non_json_body() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + "not json at all", + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(pages_fetched, 0); + } + + #[tokio::test] + async fn test_handle_json_response_pagination_continues() { + let pagination = PaginationConfig { + page_all: true, + page_limit: 10, + page_delay_ms: 0, + ..PaginationConfig::default() + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"items":[],"nextPageToken":"next-tok"}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "next-tok"), + other => panic!("expected Cursor(Some(\"next-tok\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_handle_json_response_pagination_at_limit() { + let pagination = PaginationConfig { + page_all: true, + page_limit: 5, + page_delay_ms: 0, + ..PaginationConfig::default() + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 4u32; // becomes 5 == page_limit, no continuation + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"items":[],"nextPageToken":"would-be-next"}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(pages_fetched, 5); + } + + // --------------------------------------------------------------- + // Per-operation x-fern-pagination: cursor + offset coverage + // --------------------------------------------------------------- + + fn page_all_pagination() -> PaginationConfig { + PaginationConfig { + page_all: true, + page_limit: 10, + page_delay_ms: 0, + ..PaginationConfig::default() + } + } + + #[tokio::test] + async fn test_per_op_cursor_pagination_continues_with_response_path() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"entries":[{"id":"1"}],"next_marker":"abc"}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "abc"), + other => panic!("expected Cursor(Some(\"abc\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_per_op_cursor_stops_on_empty_next_cursor() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"entries":[{"id":"2"}],"next_marker":""}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + } + + #[tokio::test] + async fn test_per_op_offset_pagination_advances_by_results_len() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: Some("meta.has_more".to_string()), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}],"meta":{"has_more":true}}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Offset(n) => assert_eq!(n, 3), + other => panic!("expected Offset(3), got {other:?}"), + } + } + + #[tokio::test] + async fn test_per_op_offset_stops_when_has_next_page_false() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: Some("meta.has_more".to_string()), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + + let result = handle_json_response( + r#"{"users":[{"id":1}],"meta":{"has_more":false}}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(!result); + } + + #[tokio::test] + async fn test_per_op_offset_step_stops_on_short_page() { + // `step: $request.limit` + caller's `--limit 50` → the executor + // gates the next page on `items.length >= 50`. The server returned + // only 3 rows (a short page), so pagination must stop even though + // `has_next_page` is unset. Matches upstream fern's hasNextPage + // check `items.length >= step`. + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "offset".to_string(), + results: "users".to_string(), + step: Some("limit".to_string()), + has_next_page: None, + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let request_query_params = vec![("limit".to_string(), "50".to_string())]; + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}]}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &request_query_params, + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!( + !result, + "short page (3 < 50) must end pagination per upstream `items.length >= step` gate" + ); + } + + #[tokio::test] + async fn test_per_op_offset_step_continues_on_full_page() { + // Full page: server returned `limit` items → continue and advance + // by `len(items)` (item-index semantics). + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "offset".to_string(), + results: "users".to_string(), + step: Some("limit".to_string()), + has_next_page: None, + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let request_query_params = vec![("limit".to_string(), "3".to_string())]; + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}]}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &request_query_params, + None, + false, + "test-op", + ) + .await + .unwrap(); + + assert!(result, "full page (3 >= 3) must continue pagination"); + match page_state { + PageState::Offset(n) => assert_eq!( + n, 3, + "offset advances by len(items), not by the step value" + ), + other => panic!("expected Offset(3), got {other:?}"), + } + } + + #[test] + fn test_resolve_step_target_from_request_param() { + // step: "limit" + query params containing limit=50 → Some(50). + let params = vec![("limit".to_string(), "50".to_string())]; + assert_eq!(resolve_step_target(Some("limit"), ¶ms), Some(50)); + } + + #[test] + fn test_resolve_step_target_literal_integer() { + // step is itself an integer literal (e.g. `step: "50"`) → Some(50). + assert_eq!(resolve_step_target(Some("50"), &[]), Some(50)); + } + + #[test] + fn test_resolve_step_target_unresolvable_returns_none() { + // step references a param the caller didn't supply → None, so the + // executor falls back to the legacy `items.len() > 0` check. + assert_eq!(resolve_step_target(Some("limit"), &[]), None); + } + + #[test] + fn test_resolve_step_target_none_returns_none() { + assert_eq!(resolve_step_target(None, &[]), None); + } + + #[test] + fn test_page_state_injection_heuristic_first_page() { + let state = PageState::Cursor(None); + assert_eq!(state.injection(None, "pageToken"), None); + } + + #[test] + fn test_page_state_injection_heuristic_with_token() { + let state = PageState::Cursor(Some("tok".to_string())); + assert_eq!( + state.injection(None, "pageToken"), + Some(("pageToken".to_string(), "tok".to_string())), + ); + } + + #[test] + fn test_page_state_injection_endpoint_cursor_uses_op_param_name() { + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let state = PageState::Cursor(Some("tok".to_string())); + assert_eq!( + state.injection(Some(&endpoint), "pageToken"), + Some(("marker".to_string(), "tok".to_string())), + ); + } + + #[test] + fn test_page_state_injection_offset_zero_skipped_on_first_page() { + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: None, + }; + let state = PageState::Offset(0); + assert_eq!(state.injection(Some(&endpoint), "pageToken"), None); + } + + #[test] + fn test_page_state_injection_offset_nonzero_injects() { + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: None, + }; + let state = PageState::Offset(42); + assert_eq!( + state.injection(Some(&endpoint), "pageToken"), + Some(("page_number".to_string(), "42".to_string())), + ); + } + + #[test] + fn test_mime_from_extension_various() { + assert_eq!(mime_from_extension("doc.txt"), Some("text/plain".to_string())); + assert_eq!(mime_from_extension("page.htm"), Some("text/html".to_string())); + assert_eq!(mime_from_extension("style.css"), Some("text/css".to_string())); + assert_eq!(mime_from_extension("data.xml"), Some("application/xml".to_string())); + assert_eq!(mime_from_extension("app.js"), Some("application/javascript".to_string())); + assert_eq!(mime_from_extension("doc.pdf"), Some("application/pdf".to_string())); + assert_eq!(mime_from_extension("arc.zip"), Some("application/zip".to_string())); + assert_eq!(mime_from_extension("file.gz"), Some("application/gzip".to_string())); + assert_eq!(mime_from_extension("file.gzip"), Some("application/gzip".to_string())); + assert_eq!(mime_from_extension("archive.tar"), Some("application/x-tar".to_string())); + assert_eq!(mime_from_extension("img.png"), Some("image/png".to_string())); + assert_eq!(mime_from_extension("photo.jpg"), Some("image/jpeg".to_string())); + assert_eq!(mime_from_extension("photo.jpeg"), Some("image/jpeg".to_string())); + assert_eq!(mime_from_extension("anim.gif"), Some("image/gif".to_string())); + assert_eq!(mime_from_extension("icon.svg"), Some("image/svg+xml".to_string())); + assert_eq!(mime_from_extension("img.webp"), Some("image/webp".to_string())); + assert_eq!(mime_from_extension("fav.ico"), Some("image/x-icon".to_string())); + assert_eq!(mime_from_extension("song.mp3"), Some("audio/mpeg".to_string())); + assert_eq!(mime_from_extension("sound.wav"), Some("audio/wav".to_string())); + assert_eq!(mime_from_extension("video.mp4"), Some("video/mp4".to_string())); + assert_eq!(mime_from_extension("clip.webm"), Some("video/webm".to_string())); + assert_eq!(mime_from_extension("config.yaml"), Some("application/yaml".to_string())); + assert_eq!(mime_from_extension("config.yml"), Some("application/yaml".to_string())); + assert_eq!(mime_from_extension("config.toml"), Some("application/toml".to_string())); + assert_eq!(mime_from_extension("word.doc"), Some("application/msword".to_string())); + assert_eq!(mime_from_extension("word.docx"), Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".to_string())); + assert_eq!(mime_from_extension("sheet.xls"), Some("application/vnd.ms-excel".to_string())); + assert_eq!(mime_from_extension("sheet.xlsx"), Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string())); + assert_eq!(mime_from_extension("slides.ppt"), Some("application/vnd.ms-powerpoint".to_string())); + assert_eq!(mime_from_extension("slides.pptx"), Some("application/vnd.openxmlformats-officedocument.presentationml.presentation".to_string())); + assert_eq!(mime_from_extension("module.wasm"), Some("application/wasm".to_string())); + assert_eq!(mime_from_extension("file.unknown"), None); + } + + #[test] + fn test_mime_to_extension_additional_branches() { + assert_eq!(mime_to_extension("image/gif"), "gif"); + // Use MIMEs that don't contain "xml" (which matches earlier in the chain) + assert_eq!(mime_to_extension("application/vnd.ms-excel.spreadsheet"), "xlsx"); + assert_eq!(mime_to_extension("application/vnd.ms-word.document.12"), "docx"); + assert_eq!(mime_to_extension("application/octet-stream"), "bin"); + assert_eq!(mime_to_extension("application/unknown-type"), "bin"); + } + + #[test] + fn test_resolve_upload_mime_strips_control_chars() { + let mime = resolve_upload_mime(Some("text/plain\rinjected"), None, &None); + assert_eq!(mime, "text/plaininjected"); + } + + #[test] + fn test_resolve_upload_mime_all_control_chars_falls_back() { + let mime = resolve_upload_mime(Some("\r\n\t"), None, &None); + assert_eq!(mime, "application/octet-stream"); + } + + #[test] + fn test_value_to_query_string_null() { + assert_eq!(value_to_query_string(&Value::Null), ""); + } + + #[test] + fn test_value_to_query_string_object_serializes() { + let val = json!({"key": "val"}); + let result = value_to_query_string(&val); + assert!(!result.is_empty()); + } + + #[test] + fn test_serialize_deep_object_non_object_value() { + let result = serialize_deep_object("filter", &json!("simple")); + assert_eq!(result, vec![("filter".to_string(), "simple".to_string())]); + } + + #[test] + fn test_serialize_deep_object_nested() { + // Multi-level nesting: {"meta":{"created_at":"today"}} with key "filter" + // should produce [("filter[meta][created_at]", "today")] + let value = json!({"meta": {"created_at": "today"}}); + let result = serialize_deep_object("filter", &value); + assert_eq!( + result, + vec![("filter[meta][created_at]".to_string(), "today".to_string())] + ); + } + + #[test] + fn test_serialize_deep_object_array_uses_repeated_keys() { + // Arrays must use repeated keys (filter[tags]=a&filter[tags]=b), + // consistent with the Fern Python and C# SDKs. Not indexed brackets. + let value = json!({"tags": ["a", "b"]}); + let mut result = serialize_deep_object("filter", &value); + result.sort(); // order not guaranteed + assert_eq!( + result, + vec![ + ("filter[tags]".to_string(), "a".to_string()), + ("filter[tags]".to_string(), "b".to_string()), + ] + ); + } + + #[test] + fn test_build_url_uses_root_url_and_service_path_when_no_base_url() { + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/files"); + } + + #[test] + fn test_build_url_method_root_url_overrides_doc_root_url() { + // Per-operation server override: method.root_url must win over doc.root_url. + // If this is broken, requests route to the wrong host (e.g. upload + // endpoints land on the general API host instead of the upload host). + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "uploads".to_string(), + root_url: "https://upload.example.com/".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_empty_method_root_url_falls_back_to_doc() { + // When method.root_url is empty (unset), doc.root_url must be used. + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + root_url: String::new(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/files"); + } + + #[test] + fn test_parse_and_validate_inputs_invalid_params_json() { + let doc = RestDescription::default(); + let method = RestMethod::default(); + let err = + parse_and_validate_inputs(&doc, &method, Some("{not json}"), None, false, None, &[]).unwrap_err(); + assert!(err.to_string().contains("Invalid --params JSON")); + } + + #[test] + fn test_parse_and_validate_inputs_invalid_body_json() { + let doc = RestDescription::default(); + let method = RestMethod::default(); + let err = + parse_and_validate_inputs(&doc, &method, None, Some("{not json}"), false, None, &[]).unwrap_err(); + assert!(err.to_string().contains("Invalid --json body")); + } + + #[test] + fn test_parse_and_validate_inputs_required_query_param_missing() { + let mut parameters = HashMap::new(); + parameters.insert( + "api_key".to_string(), + MethodParameter { + location: Some("query".to_string()), + required: true, + ..Default::default() + }, + ); + let doc = RestDescription::default(); + let method = RestMethod { + parameters, + ..Default::default() + }; + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[]).unwrap_err(); + assert!(err.to_string().contains("Required parameter 'api_key'")); + } + + #[tokio::test] + async fn test_build_http_request_unsupported_method() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "TRACE".to_string(), + path: "test".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let err = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("Unsupported HTTP method")); + } + + #[tokio::test] + async fn test_build_http_request_put_patch_delete() { + let client = reqwest::Client::new(); + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + for http_method in &["PUT", "PATCH", "DELETE"] { + let method = RestMethod { + http_method: http_method.to_string(), + path: "test".to_string(), + ..Default::default() + }; + let result = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await; + assert!(result.is_ok(), "Failed for method {http_method}"); + } + } + + #[test] + fn test_validate_value_schema_not_found() { + let doc = RestDescription::default(); + let mut errors = Vec::new(); + validate_value(&json!({}), "NonExistentSchema", &doc, "$", &mut errors); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("Schema 'NonExistentSchema' not found")); + } + + #[test] + fn test_resolve_next_path_absolute_url_overrides_base() { + // Server returned a fully-formed URL → use it verbatim. + let url = resolve_next_path( + "https://api.example.com/v1/things?cursor=a", + "https://other.example.com/v2/items?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://other.example.com/v2/items?cursor=b"); + } + + #[test] + fn test_resolve_next_path_absolute_path_keeps_scheme_and_host() { + // A `/`-prefixed path keeps the previous host but replaces the path. + let url = resolve_next_path( + "https://api.example.com/v1/things?cursor=a", + "/v1/things?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://api.example.com/v1/things?cursor=b"); + } + + #[test] + fn test_resolve_next_path_relative_path_inherits_directory() { + // No leading slash → resolved relative to the previous request's + // directory (browser-style URL resolution). + let url = resolve_next_path( + "https://api.example.com/v1/things", + "things?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://api.example.com/v1/things?cursor=b"); + } + + #[test] + fn test_resolve_next_path_rejects_invalid_base_url() { + let err = resolve_next_path("not a url", "/foo").unwrap_err(); + assert!(err.contains("not a valid URL"), "got: {err}"); + } + + #[test] + fn test_page_state_initial_for_each_form() { + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Cursor { + cursor: "c".into(), + next_cursor: "n".into(), + results: "r".into(), + })), + PageState::Cursor(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Offset { + offset: "o".into(), + results: "r".into(), + step: None, + has_next_page: None, + })), + PageState::Offset(0) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Uri { + next_uri: "n".into(), + results: "r".into(), + })), + PageState::NextUrl(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Path { + next_path: "n".into(), + results: "r".into(), + })), + PageState::NextUrl(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Custom { + results: "r".into(), + })), + PageState::Custom + )); + assert!(matches!(PageState::initial(None), PageState::Cursor(None))); + } + + #[test] + fn test_page_state_url_override_only_for_next_url() { + assert!(PageState::Cursor(None).url_override().is_none()); + assert!(PageState::Cursor(Some("tok".into())).url_override().is_none()); + assert!(PageState::Offset(5).url_override().is_none()); + assert!(PageState::NextUrl(None).url_override().is_none()); + assert!(PageState::Custom.url_override().is_none()); + let url = "https://api.example.com/v1/things?cursor=abc"; + assert_eq!( + PageState::NextUrl(Some(url.to_string())).url_override(), + Some(url) + ); + } + + #[test] + fn test_page_state_injection_uri_path_custom_no_query_param() { + // Uri/Path/Custom embed everything in the URL (or stop entirely); + // they must never push a cursor/offset query param. + let pagination = EndpointPagination::Uri { + next_uri: "next".into(), + results: "items".into(), + }; + assert!(PageState::NextUrl(Some("https://x".into())) + .injection(Some(&pagination), "page_token") + .is_none()); + let custom = EndpointPagination::Custom { + results: "items".into(), + }; + assert!(PageState::Custom.injection(Some(&custom), "page_token").is_none()); + } + + // ----------------------------------------------------------------- + // x-fern-streaming response decoding (`decode_stream_event`) + // + // The pure line decoder is the surface most likely to drift across + // server quirks (extra whitespace, comment lines, terminator + // variants), so we cover the full matrix here without touching the + // network. Wire-level integration is exercised in the tier-2 tests + // under tests/openapi_fixture_wire.rs. + // ----------------------------------------------------------------- + + // ----------------------------------------------------------------- + // SSE line decoding (`SseLineDecoder`) + // + // SSE is stateful — `data:` payloads are buffered across multiple + // lines and dispatched on a blank-line separator per the WHATWG + // spec. Tests below isolate the decoder so a regression in framing + // or multi-line concat points at the exact branch. + // ----------------------------------------------------------------- + + fn drive(lines: &[&str]) -> Vec { + let mut decoder = SseLineDecoder::default(); + let mut out = Vec::new(); + for line in lines { + if let Some(payload) = decoder.push_line(line) { + out.push(payload); + } + } + if let Some(payload) = decoder.flush() { + out.push(payload); + } + out + } + + #[test] + fn test_sse_decoder_strips_data_prefix_and_one_space() { + // `data: {"x":1}` decodes to `{"x":1}` — the single leading + // space after `data:` is consumed (matches the SSE spec). + let payloads = drive(&["data: {\"x\":1}", ""]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_no_space_after_data() { + // The space after `data:` is optional; the payload is + // preserved identically in both shapes. + let payloads = drive(&["data:{\"x\":1}", ""]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_skips_comments_and_unknown_fields() { + // Comments (`:`), `event:`, `id:`, `retry:`, and unknown + // fields are framing-only and must not pollute the dispatched + // payload. Only the `data:` line contributes to the event. + let payloads = drive(&[ + ": keepalive", + "event: message", + "id: 42", + "retry: 5000", + "data: {\"x\":1}", + "", + ]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_dispatches_on_blank_line_with_multiline_concat() { + // Three `data:` lines spanning a single pretty-printed JSON + // object — the WHATWG spec says they join with `\n` and + // dispatch as one event on the blank-line separator. The TS + // runtime's `iterSseEvents` loop does exactly this. + let payloads = drive(&[ + "data: {", + "data: \"foo\": 1", + "data: }", + "", + ]); + assert_eq!(payloads, vec!["{\n \"foo\": 1\n}".to_string()]); + } + + #[test] + fn test_sse_decoder_dispatches_two_events_separated_by_blank() { + let payloads = drive(&[ + "data: {\"step\":1}", + "", + "data: {\"step\":2}", + "", + ]); + assert_eq!( + payloads, + vec!["{\"step\":1}".to_string(), "{\"step\":2}".to_string(),] + ); + } + + #[test] + fn test_sse_decoder_flushes_final_event_without_blank_line() { + // EOF flush: when the server closes the connection without + // sending the trailing blank line, the buffered event must + // still be dispatched. Mirrors the TS post-loop + // `if (dataValue != null)` block. + let payloads = drive(&["data: {\"step\":1}"]); + assert_eq!(payloads, vec!["{\"step\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_blank_line_without_buffered_data_dispatches_nothing() { + // Resetting on blank without a buffered `data:` must not + // dispatch — an `event:` line followed by a blank line is + // discarded entirely. + let payloads = drive(&["event: ping", ""]); + assert!(payloads.is_empty(), "got unexpected events: {payloads:?}"); + } + + #[test] + fn test_decode_ndjson_emits_whole_line() { + let cfg = StreamingConfig::Json { terminator: None }; + assert_eq!( + decode_stream_event(&cfg, "{\"x\":1}"), + StreamEvent::Event("{\"x\":1}".to_string()) + ); + } + + #[test] + fn test_decode_ndjson_skips_blank_lines() { + // Some servers emit blank keepalive lines between records. + let cfg = StreamingConfig::Json { terminator: None }; + assert_eq!(decode_stream_event(&cfg, ""), StreamEvent::Skip); + } + + #[test] + fn test_decode_ndjson_terminator_only_when_configured() { + // Without a configured terminator, a literal `[DONE]` payload + // is just another event — NDJSON has no implicit sentinel. + let no_term = StreamingConfig::Json { terminator: None }; + assert_eq!( + decode_stream_event(&no_term, "[DONE]"), + StreamEvent::Event("[DONE]".to_string()) + ); + let with_term = StreamingConfig::Json { + terminator: Some("__END__".to_string()), + }; + assert_eq!( + decode_stream_event(&with_term, "__END__"), + StreamEvent::Terminate + ); + } + + #[test] + fn test_decode_text_emits_each_line_verbatim() { + // Plain-text format: no JSON parse, no SSE prefix strip, no + // terminator. Each non-empty line flows through as a string. + let cfg = StreamingConfig::Text; + assert_eq!( + decode_stream_event(&cfg, "hello world"), + StreamEvent::Event("hello world".to_string()) + ); + assert_eq!( + decode_stream_event(&cfg, "data: not stripped"), + StreamEvent::Event("data: not stripped".to_string()) + ); + assert_eq!( + decode_stream_event(&cfg, "{\"not\":\"parsed\"}"), + StreamEvent::Event("{\"not\":\"parsed\"}".to_string()) + ); + } + + #[test] + fn test_decode_text_skips_blank_lines() { + // Mirrors the C# generator's + // `if(!string.IsNullOrEmpty(line)) yield return line` guard. + let cfg = StreamingConfig::Text; + assert_eq!(decode_stream_event(&cfg, ""), StreamEvent::Skip); + } + + #[test] + fn test_project_text_event_bypasses_return_value_projection() { + // Text streams emit a raw line; `x-fern-sdk-return-value` + // and `--no-extract` are both no-ops because there's no + // JSON object to project against. + let cfg = StreamingConfig::Text; + let value = project_stream_event( + &cfg, + "raw line", + Some("$response.does.not.exist"), + false, + "test op", + ) + .expect("text projection must succeed"); + assert_eq!(value, Value::String("raw line".to_string())); + } +} + +#[tokio::test] +async fn test_execute_method_dry_run() { + let mut schemas = HashMap::new(); + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + crate::openapi::discovery::JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + schemas.insert( + "File".to_string(), + crate::openapi::discovery::JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + root_url: "https://example.googleapis.com/".to_string(), + service_path: "v1/".to_string(), + schemas, + ..Default::default() + }; + + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + id: Some("example.files.create".to_string()), + path: "files/{fileId}".to_string(), + parameter_order: vec!["fileId".to_string()], + parameters, + request: Some(crate::openapi::discovery::SchemaRef { + schema_ref: Some("File".to_string()), + parameter_name: None, + }), + ..Default::default() + }; + + let params_json = r#"{"fileId": "123"}"#; + let body_json = r#"{"name": "test.txt"}"#; + + let pagination = PaginationConfig::default(); + + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let result = execute_method( + &doc, + &method, + Some(params_json), + Some(body_json), + &crate::auth::no_auth_provider(), + None, + None, + None, + true, // dry_run + &pagination, + &crate::formatter::OutputPipeline::default(), + false, + None, + &http_config, + false, // no_extract + false, // no_retry + false, // no_stream + &[], + ) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_execute_method_missing_path_param() { + // Same setup but missing required fileId in params + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let doc = RestDescription::default(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "files/{fileId}".to_string(), + parameter_order: vec!["fileId".to_string()], + parameters, + ..Default::default() + }; + + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let result = execute_method( + &doc, + &method, + None, // No params provided + None, + &crate::auth::no_auth_provider(), + None, + None, + None, + true, + &PaginationConfig::default(), + &crate::formatter::OutputPipeline::default(), + false, + None, + &http_config, + false, // no_extract + false, // no_retry + false, // no_stream + &[], + ) + .await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Required path parameter")); +} + +#[test] +fn test_get_value_type_helper() { + assert_eq!(get_value_type(&json!(null)), "null"); + assert_eq!(get_value_type(&json!(true)), "boolean"); + assert_eq!(get_value_type(&json!(42)), "integer"); + assert_eq!(get_value_type(&json!(3.5)), "number (float)"); + assert_eq!(get_value_type(&json!("string")), "string"); + assert_eq!(get_value_type(&json!([1, 2])), "array"); + assert_eq!(get_value_type(&json!({"a": 1})), "object"); +} + +#[tokio::test] +async fn test_post_without_body_sets_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "messages/trash".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/messages/trash".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built + .headers() + .get("Content-Length") + .map(|v| v.to_str().unwrap()), + Some("0"), + "POST with no body must include Content-Length: 0" + ); +} + +#[tokio::test] +async fn test_post_with_body_does_not_add_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: Some(json!({"name": "test"})), + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + // When body is present, Content-Length should NOT be "0" + let cl = built + .headers() + .get("Content-Length") + .map(|v| v.to_str().unwrap().to_string()); + assert!(cl.is_none() || cl.as_deref() != Some("0")); +} + +#[tokio::test] +async fn test_get_does_not_set_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert!( + built.headers().get("Content-Length").is_none(), + "GET with no body should not have Content-Length header" + ); +} + +// --------------------------------------------------------------------------- +// BearerHeader auth method +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_bearer_header_sends_bearer_prefix() { + use crate::openapi::discovery::RestMethod; + + let client = crate::http::HttpConfig::new("test").unwrap().build_client().unwrap(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "/test".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let provider: DynAuthProvider = std::sync::Arc::new(crate::auth::HeaderAuthProvider::new( + "scheme", + "X-Auth", + crate::auth::AuthCredentialSource::literal("mytoken"), + true, + )); + let request = build_http_request( + &client, + &method, + &input, + &provider, + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + let header_val = built.headers().get("x-auth").and_then(|v| v.to_str().ok()); + assert_eq!(header_val, Some("Bearer mytoken")); +} diff --git a/cli/src/openapi/help.rs b/cli/src/openapi/help.rs new file mode 100644 index 00000000..dcd21282 --- /dev/null +++ b/cli/src/openapi/help.rs @@ -0,0 +1,518 @@ +//! JSON help output — renders `--help --format json` as a machine-readable +//! schema. When an agent passes both `--help` (or `-h`) and `--format json`, +//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. + +use serde_json::{json, Map, Value}; + +use crate::error::CliError; +use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; + +/// Renders JSON help for the given subcommand path and prints it to stdout. +pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + let output = match path.len() { + 0 => list_all_operations(doc), + 1 => list_resource_operations(doc, &path[0])?, + _ => { + // Try treating last element as a method name first. + // If that fails, the full path may resolve to a nested sub-resource — list its ops. + let resource_path: Vec<&str> = path[..path.len() - 1].iter().map(|s| s.as_str()).collect(); + let method_name = path[path.len() - 1].as_str(); + match operation_schema(doc, &resource_path, method_name) { + Ok(schema) => schema, + Err(_) => { + let full_path: Vec<&str> = path.iter().map(|s| s.as_str()).collect(); + list_nested_resource_operations(doc, &full_path)? + } + } + } + }; + + println!( + "{}", + serde_json::to_string_pretty(&output) + .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? + ); + Ok(()) +} + +fn list_all_operations(doc: &RestDescription) -> Value { + let mut ops: Vec = Vec::new(); + let mut names: Vec<_> = doc.resources.keys().collect(); + names.sort(); + for name in names { + collect_resource_ops(&doc.resources[name], &[name], &mut ops); + } + // Wrap the operations list in a top-level object that also exposes any + // `x-fern-sdk-variables` so a machine consumer can discover the global + // root flags (and their env-var fallbacks) without inspecting every + // operation. Falls back to the bare array when no variables are + // declared so existing consumers that expect a JSON array at the root + // are unaffected. + if doc.sdk_variables.is_empty() { + json!(ops) + } else { + json!({ + "sdkVariables": render_sdk_variables(&doc.sdk_variables), + "operations": ops, + }) + } +} + +fn render_sdk_variables( + vars: &[crate::openapi::discovery::SdkVariable], +) -> Vec { + vars.iter() + .map(|v| { + json!({ + "name": v.name, + "type": v.ty, + "description": v.description.as_deref().unwrap_or(""), + "globalFlag": format!("--{}", crate::text::to_kebab_flag(&v.name)), + "envVar": crate::text::to_screaming_snake(&v.name), + }) + }) + .collect() +} + +fn list_resource_operations(doc: &RestDescription, resource: &str) -> Result { + let res = doc + .resources + .get(resource) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {resource}")))?; + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, &[resource], &mut ops); + Ok(json!(ops)) +} + +fn list_nested_resource_operations(doc: &RestDescription, path: &[&str]) -> Result { + let first = path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + for segment in &path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, path, &mut ops); + Ok(json!(ops)) +} + +fn operation_schema(doc: &RestDescription, resource_path: &[&str], method_name: &str) -> Result { + let first = resource_path.first().ok_or_else(|| { + CliError::Validation("No resource specified".to_string()) + })?; + + let mut res = doc + .resources + .get(*first) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {first}")))?; + + for segment in &resource_path[1..] { + res = res + .resources + .get(*segment) + .ok_or_else(|| CliError::Validation(format!("Resource not found: {segment}")))?; + } + + let method = res.methods.get(method_name).ok_or_else(|| { + CliError::Validation(format!( + "Operation not found: {} {method_name}", + resource_path.join(" ") + )) + })?; + + Ok(build_schema(resource_path, method_name, method)) +} + +fn build_schema(resource_path: &[&str], method_name: &str, method: &RestMethod) -> Value { + let mut properties: Map = Map::new(); + let mut required: Vec = Vec::new(); + + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for name in param_names { + let param = &method.parameters[name]; + let mut prop = json!({ + "type": param.param_type.as_deref().unwrap_or("string"), + "description": param.description.as_deref().unwrap_or(""), + "location": param.location.as_deref().unwrap_or("query"), + }); + if let Some(enums) = ¶m.enum_values { + prop["enum"] = json!(enums); + // When `x-fern-enum` overrides are present, expose the + // per-value display name and description so JSON-help + // consumers can render them without reparsing the spec. + if let Some(fern_enum) = ¶m.fern_enum { + let mut by_wire: Map = Map::new(); + for wire in enums { + if let Some(entry) = fern_enum.get(wire) { + let mut obj = Map::new(); + if let Some(name) = &entry.display_name { + obj.insert("name".to_string(), Value::String(name.clone())); + } + if let Some(desc) = &entry.description { + obj.insert("description".to_string(), Value::String(desc.clone())); + } + if !obj.is_empty() { + by_wire.insert(wire.clone(), Value::Object(obj)); + } + } + } + if !by_wire.is_empty() { + prop["x-fern-enum"] = Value::Object(by_wire); + } + } + } + if let Some(availability) = param.availability { + prop["availability"] = json!(availability.as_str()); + } + // Variable-bound path parameters are NOT per-op required flags; their + // value comes from the root-level global flag (kebab-cased) with an + // env-var fallback (SCREAMING_SNAKE_CASE), or from `--params` JSON. + // Mark them explicitly so machine consumers (LLM agents, code + // generators) know not to surface a per-op `--` flag and can + // discover the right global/env fallbacks instead. + if let Some(var_name) = param.variable_reference.as_deref() { + prop["binding"] = json!("sdk-variable"); + prop["variable"] = json!(var_name); + prop["globalFlag"] = json!(format!("--{}", crate::text::to_kebab_flag(var_name))); + prop["envVar"] = json!(crate::text::to_screaming_snake(var_name)); + } else if param.required { + required.push(name.clone()); + } + properties.insert(name.clone(), prop); + } + required.sort(); + + let mut output = json!({ + "operation": format!("{}.{}", resource_path.join("."), method_name), + "httpMethod": method.http_method, + "path": method.path, + "description": method.description.as_deref().unwrap_or(""), + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }); + if let Some(availability) = method.availability { + output["availability"] = json!(availability.as_str()); + } + output +} + +fn collect_resource_ops(res: &RestResource, path: &[&str], ops: &mut Vec) { + let mut method_names: Vec<_> = res.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let m = &res.methods[method_name]; + let mut entry = json!({ + "operation": format!("{}.{}", path.join("."), method_name), + "httpMethod": m.http_method, + "path": m.path, + "description": m.description.as_deref().unwrap_or(""), + }); + if let Some(availability) = m.availability { + entry["availability"] = json!(availability.as_str()); + } + ops.push(entry); + } + let mut sub_names: Vec<_> = res.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let mut sub_path = path.to_vec(); + sub_path.push(sub_name); + collect_resource_ops(&res.resources[sub_name], &sub_path, ops); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{MethodParameter, RestMethod, RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut params = HashMap::new(); + params.insert( + "user_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user ID".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/users/{user_id}".to_string(), + description: Some("Get a user".to_string()), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_render_root_lists_all() { + let doc = make_doc(); + let output = list_all_operations(&doc); + let arr = output.as_array().unwrap(); + assert!(!arr.is_empty()); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_resource() { + let doc = make_doc(); + let output = list_resource_operations(&doc, "users").unwrap(); + let arr = output.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_operation_schema() { + let doc = make_doc(); + let schema = operation_schema(&doc, &["users"], "get").unwrap(); + assert_eq!(schema["httpMethod"], "GET"); + let required = schema["parameters"]["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "user_id")); + } + + #[test] + fn test_variable_bound_param_annotated_and_not_required_in_per_op_schema() { + // JSON help is the machine-readable contract for LLM agents. A + // variable-bound path parameter must NOT appear in the per-op + // `required` array (there is no per-op flag for it), and the + // property MUST carry enough metadata for an agent to resolve it + // via the root-level global flag, env var, or --params JSON. + let mut params = HashMap::new(); + params.insert( + "gardenId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Tenant id".to_string()), + location: Some("path".to_string()), + required: true, + variable_reference: Some("gardenId".to_string()), + ..Default::default() + }, + ); + // A plain (non-variable-bound) required path param on the same op + // MUST still show up in `required` as before. + params.insert( + "zoneId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Zone id".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "GET".to_string(), + path: "/gardens/{gardenId}/zones/{zoneId}".to_string(), + description: Some("List zones".to_string()), + parameters: params, + ..Default::default() + }; + let schema = build_schema(&["zones"], "get", &method); + let required = schema["parameters"]["required"].as_array().unwrap(); + assert!( + !required.iter().any(|v| v == "gardenId"), + "variable-bound param must not appear in per-op `required`, got: {required:?}", + ); + assert!( + required.iter().any(|v| v == "zoneId"), + "plain required path param must still be in `required`, got: {required:?}", + ); + + let garden = &schema["parameters"]["properties"]["gardenId"]; + assert_eq!(garden["binding"], "sdk-variable"); + assert_eq!(garden["variable"], "gardenId"); + assert_eq!(garden["globalFlag"], "--garden-id"); + assert_eq!(garden["envVar"], "GARDEN_ID"); + } + + #[test] + fn test_root_listing_surfaces_sdk_variables_when_declared() { + // With at least one `x-fern-sdk-variables` entry the root JSON + // help wraps the operations array in an object that exposes the + // variable definitions (name, type, description, derived flag, + // env var) so machine consumers can discover the root-level + // globals without scanning every operation. + let mut doc = make_doc(); + doc.sdk_variables = vec![crate::openapi::discovery::SdkVariable { + name: "gardenId".to_string(), + ty: "string".to_string(), + description: Some("Tenant id".to_string()), + }]; + let output = list_all_operations(&doc); + let obj = output.as_object().expect("expected wrapped object when sdk_variables present"); + let vars = obj["sdkVariables"].as_array().unwrap(); + assert_eq!(vars.len(), 1); + assert_eq!(vars[0]["name"], "gardenId"); + assert_eq!(vars[0]["globalFlag"], "--garden-id"); + assert_eq!(vars[0]["envVar"], "GARDEN_ID"); + assert_eq!(vars[0]["description"], "Tenant id"); + assert!( + obj["operations"].as_array().unwrap().iter().any(|op| op["operation"] == "users.get"), + "operations array must still list every op when wrapped", + ); + } + + #[test] + fn test_root_listing_stays_bare_array_when_no_sdk_variables() { + // Backwards-compat: specs without any `x-fern-sdk-variables` + // still produce a top-level JSON array (the shape every + // existing consumer expects). + let doc = make_doc(); + let output = list_all_operations(&doc); + assert!( + output.is_array(), + "root JSON help must stay a bare array when no sdk_variables are declared", + ); + } + + #[test] + fn test_render_json_help_nested_sub_resource_listing() { + // path.len() == 2 where last element is a sub-resource, not a method + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/organizations/{id}/memberships/{mid}".to_string(), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into()]; + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "sub-resource path should list operations, not error"); + } + + #[test] + fn test_render_nested_operation_schema() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/organizations/{org_id}/memberships/{membership_id}".to_string(), + description: Some("Get a membership".to_string()), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let schema = operation_schema(&doc, &["organizations", "memberships"], "get-membership").unwrap(); + assert_eq!(schema["operation"], "organizations.memberships.get-membership"); + assert_eq!(schema["httpMethod"], "GET"); + } + + #[test] + fn test_render_json_help_dispatches_nested_path() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/orgs/{id}/memberships/{mid}".to_string(), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into(), "get-membership".into()]; + // Should not error — previously would pass "memberships" as method name + let result = render_json_help(&doc, &path); + assert!(result.is_ok(), "nested path should resolve correctly"); + } +} diff --git a/cli/src/openapi/mod.rs b/cli/src/openapi/mod.rs new file mode 100644 index 00000000..d2a8c492 --- /dev/null +++ b/cli/src/openapi/mod.rs @@ -0,0 +1,11 @@ +mod app; +pub mod commands; +mod help; +pub mod executor; +pub mod overlay; +mod parser; +pub mod discovery; + +pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +pub use self::overlay::{apply_overlay, apply_overlays_to_spec, parse_overlay, validate_overlay}; +pub use self::parser::{deep_merge_yaml, load_openapi_spec, load_openapi_spec_from_value}; diff --git a/cli/src/openapi/overlay.rs b/cli/src/openapi/overlay.rs new file mode 100644 index 00000000..d3b0f3cd --- /dev/null +++ b/cli/src/openapi/overlay.rs @@ -0,0 +1,1898 @@ +//! OpenAPI Overlay support (v1.0.0) +//! +//! Applies [OpenAPI Overlays](https://spec.openapis.org/overlay/latest.html) to +//! an OpenAPI document represented as a generic JSON value. Each overlay contains +//! a list of *actions* whose `target` is a JSONPath (RFC 9535) expression. Actions +//! either **update** (deep-merge) or **remove** matched nodes. + +use serde::Deserialize; +use serde_json::Value; +use serde_json_path::JsonPath; + +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Overlay document types +// --------------------------------------------------------------------------- + +/// A single overlay action targeting nodes via a JSONPath expression. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayAction { + /// JSONPath (RFC 9535) expression selecting target nodes. + pub target: String, + /// Human-readable description of the action. + #[serde(default)] + pub description: Option, + /// Value to deep-merge into each matched node. Required when `remove` is + /// false/absent. + #[serde(default)] + pub update: Option, + /// When `true`, matched nodes are removed instead of updated. + #[serde(default)] + pub remove: bool, +} + +/// Metadata block inside an overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayInfo { + pub title: String, + pub version: String, +} + +/// A complete overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayDocument { + /// Overlay specification version (e.g. `"1.0.0"`). + pub overlay: String, + /// Metadata about this overlay. + pub info: OverlayInfo, + /// Optional base document this overlay extends. + #[serde(default)] + pub extends: Option, + /// Ordered list of actions to apply. + pub actions: Vec, +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse an overlay document from a YAML or JSON string. +pub fn parse_overlay(input: &str) -> Result { + // Try JSON first, then YAML + serde_json::from_str::(input) + .or_else(|_| { + let yaml_value: serde_yaml::Value = serde_yaml::from_str(input) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}")))?; + let json_value = yaml_to_json(yaml_value); + serde_json::from_value::(json_value) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}"))) + }) + .map_err(|e| match e { + CliError::Discovery(_) => e, + _ => CliError::Discovery(format!("Failed to parse overlay file: {e}")), + }) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate the structure of a parsed overlay document. +pub fn validate_overlay(overlay: &OverlayDocument) -> Result<(), CliError> { + if overlay.overlay.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'overlay' version field".to_string(), + )); + } + + if overlay.info.title.is_empty() || overlay.info.version.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'info.title' or 'info.version' field".to_string(), + )); + } + + if overlay.actions.is_empty() { + return Err(CliError::Validation( + "Overlay file must have at least one action".to_string(), + )); + } + + for (i, action) in overlay.actions.iter().enumerate() { + if action.target.is_empty() { + return Err(CliError::Validation(format!( + "Overlay action at index {i} missing required 'target' field" + ))); + } + if action.update.is_none() && !action.remove { + return Err(CliError::Validation(format!( + "Overlay action at index {i} must have either 'update' or 'remove'" + ))); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Application +// --------------------------------------------------------------------------- + +/// Apply an overlay document to an OpenAPI spec represented as a JSON value. +/// +/// Actions are applied sequentially; each one operates on the result of the +/// previous action. This function does **not** mutate the input — it returns a +/// new value. +pub fn apply_overlay(doc: &Value, overlay: &OverlayDocument) -> Result { + let mut output = doc.clone(); + + for (i, action) in overlay.actions.iter().enumerate() { + let path = JsonPath::parse(&action.target).map_err(|e| { + CliError::Validation(format!( + "Invalid JSONPath in overlay action {i} (target: '{}'): {e}", + action.target + )) + })?; + + if action.remove { + apply_remove(&mut output, &path); + } else if let Some(ref update) = action.update { + apply_update(&mut output, &path, update)?; + } + } + + Ok(output) +} + +/// Apply a remove action: delete all nodes matched by `path`. +fn apply_remove(doc: &mut Value, path: &JsonPath) { + let located = path.query_located(doc); + // Collect normalized paths; process in reverse so array indices stay valid + let mut paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + paths.sort_by(|a, b| b.cmp(a)); + + for segments in &paths { + remove_at_path(doc, segments); + } +} + +/// Apply an update (deep-merge) action to all nodes matched by `path`. +fn apply_update(doc: &mut Value, path: &JsonPath, update: &Value) -> Result<(), CliError> { + let located = path.query_located(doc); + let paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + + if paths.is_empty() { + return Ok(()); + } + + for segments in &paths { + if segments.is_empty() { + // Root target — merge directly into doc + if let Value::Object(_) = update { + deep_merge(doc, update); + } + } else { + merge_at_path(doc, segments, update); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Path navigation helpers +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum PathSegment { + Key(String), + Index(usize), +} + +/// Convert a `serde_json_path` `NormalizedPath` location into our own segment list. +fn normalized_path_to_segments( + location: &serde_json_path::NormalizedPath<'_>, +) -> Vec { + location + .iter() + .filter_map(|elem| { + if let Some(name) = elem.as_name() { + Some(PathSegment::Key(name.to_string())) + } else { + elem.as_index().map(PathSegment::Index) + } + }) + .collect() +} + + +/// Navigate to a path's parent and remove the target node. +fn remove_at_path(doc: &mut Value, segments: &[PathSegment]) { + if segments.is_empty() { + return; + } + + let (parent_segments, last) = segments.split_at(segments.len() - 1); + let last = &last[0]; + + let parent = navigate_to_mut(doc, parent_segments); + let Some(parent) = parent else { return }; + + match last { + PathSegment::Key(key) => { + if let Value::Object(map) = parent { + map.remove(key); + } + } + PathSegment::Index(idx) => { + if let Value::Array(arr) = parent { + if *idx < arr.len() { + arr.remove(*idx); + } + } + } + } +} + +/// Navigate to a path and deep-merge the update value. +fn merge_at_path(doc: &mut Value, segments: &[PathSegment], update: &Value) { + let target = navigate_to_mut(doc, segments); + let Some(target) = target else { return }; + + // Match Fern CLI behavior (applyOpenAPIOverlay.ts L74-77): when the target + // is an array and the update is NOT itself an array, append the value. + if let Value::Array(arr) = target { + if !update.is_array() { + arr.push(update.clone()); + return; + } + } + + deep_merge(target, update); +} + +/// Walk the JSON tree following the given segments, returning a mutable ref to +/// the target node, or `None` if the path does not exist. +fn navigate_to_mut<'a>(doc: &'a mut Value, segments: &[PathSegment]) -> Option<&'a mut Value> { + let mut current = doc; + for segment in segments { + current = match segment { + PathSegment::Key(key) => current.get_mut(key.as_str())?, + PathSegment::Index(idx) => current.get_mut(*idx)?, + }; + } + Some(current) +} + +// --------------------------------------------------------------------------- +// Deep merge +// --------------------------------------------------------------------------- + +/// Recursively merge `update` into `base`, matching lodash `merge` semantics. +/// +/// - Objects are merged key-by-key (recursive). +/// - Arrays are merged index-by-index: each element in `update` is deep-merged +/// into the corresponding index of `base`. If `update` is shorter, trailing +/// `base` elements are preserved. If `update` is longer, new elements are +/// appended. +/// - All other types are overwritten. +pub fn deep_merge(base: &mut Value, update: &Value) { + match (base, update) { + (Value::Object(base_map), Value::Object(update_map)) => { + for (key, update_val) in update_map { + let entry = base_map + .entry(key.clone()) + .or_insert(Value::Null); + deep_merge(entry, update_val); + } + } + (Value::Array(base_arr), Value::Array(update_arr)) => { + for (i, update_val) in update_arr.iter().enumerate() { + if i < base_arr.len() { + deep_merge(&mut base_arr[i], update_val); + } else { + base_arr.push(update_val.clone()); + } + } + } + (base, update) => { + *base = update.clone(); + } + } +} + +// --------------------------------------------------------------------------- +// YAML → JSON conversion +// --------------------------------------------------------------------------- + +/// Convert a `serde_yaml::Value` into a `serde_json::Value`. +fn yaml_to_json(yaml: serde_yaml::Value) -> Value { + match yaml { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::Bool(b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } + serde_yaml::Value::String(s) => Value::String(s), + serde_yaml::Value::Sequence(seq) => { + Value::Array(seq.into_iter().map(yaml_to_json).collect()) + } + serde_yaml::Value::Mapping(map) => { + let obj = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + serde_yaml::Value::String(s) => s, + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + _ => return None, + }; + Some((key, yaml_to_json(v))) + }) + .collect(); + Value::Object(obj) + } + serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value), + } +} + +/// Parse an OpenAPI spec string (YAML or JSON) into a `serde_json::Value`, +/// apply a list of overlay strings, and return the modified JSON value +/// serialised back to a YAML string suitable for `load_openapi_spec`. +pub fn apply_overlays_to_spec( + spec_yaml: &str, + overlay_strings: &[String], +) -> Result { + if overlay_strings.is_empty() { + return Ok(spec_yaml.to_string()); + } + + // Parse spec into a generic JSON value + let yaml_value: serde_yaml::Value = serde_yaml::from_str(spec_yaml) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + let mut doc = yaml_to_json(yaml_value); + + for (idx, overlay_str) in overlay_strings.iter().enumerate() { + let overlay = parse_overlay(overlay_str).map_err(|e| { + CliError::Discovery(format!("Failed to parse overlay {idx}: {e}")) + })?; + validate_overlay(&overlay).map_err(|e| { + CliError::Validation(format!("Invalid overlay {idx}: {e}")) + })?; + + tracing::debug!( + "Applying overlay \"{}\" v{}", + overlay.info.title, + overlay.info.version + ); + + doc = apply_overlay(&doc, &overlay)?; + } + + // Serialize back to YAML + serde_yaml::to_string(&doc) + .map_err(|e| CliError::Discovery(format!("Failed to serialize overlaid spec: {e}"))) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // -- deep_merge -- + + #[test] + fn test_deep_merge_objects() { + let mut base = json!({"a": 1, "b": {"c": 2}}); + let update = json!({"b": {"d": 3}, "e": 4}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1, "b": {"c": 2, "d": 3}, "e": 4})); + } + + #[test] + fn test_deep_merge_overwrites_primitives() { + let mut base = json!({"a": 1}); + let update = json!({"a": 2}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 2})); + } + + #[test] + fn test_deep_merge_nested() { + let mut base = json!({"a": {"b": {"c": 1, "d": 2}}}); + let update = json!({"a": {"b": {"c": 10, "e": 3}}}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": {"b": {"c": 10, "d": 2, "e": 3}}})); + } + + // -- parse_overlay -- + + #[test] + fn test_parse_overlay_yaml() { + let yaml = r#" +overlay: "1.0.0" +info: + title: Test Overlay + version: "1.0" +actions: + - target: "$.info" + update: + description: "Updated description" +"#; + let doc = parse_overlay(yaml).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.info.title, "Test Overlay"); + assert_eq!(doc.actions.len(), 1); + } + + #[test] + fn test_parse_overlay_json() { + let json_str = r#"{ + "overlay": "1.0.0", + "info": {"title": "Test", "version": "1.0"}, + "actions": [ + {"target": "$.info", "update": {"description": "hi"}} + ] + }"#; + let doc = parse_overlay(json_str).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.actions.len(), 1); + } + + // -- validate_overlay -- + + #[test] + fn test_validate_overlay_missing_version() { + let doc = OverlayDocument { + overlay: String::new(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_no_actions() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_target() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: String::new(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_update_no_remove() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: None, + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + // -- apply_overlay: update -- + + #[test] + fn test_overlay_update_simple_path() { + let doc = json!({ + "info": {"title": "Old", "version": "1.0"}, + "paths": {} + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({"title": "New", "description": "Added"})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "1.0"); + assert_eq!(result["info"]["description"], "Added"); + } + + #[test] + fn test_overlay_update_nested_path() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_object()); + } + + // -- apply_overlay: remove -- + + #[test] + fn test_overlay_remove_property() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User.properties.email".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_null()); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: wildcard -- + + #[test] + fn test_overlay_wildcard_update() { + let doc = json!({ + "paths": { + "/users": { + "get": {"summary": "Get users"} + }, + "/posts": { + "get": {"summary": "Get posts"} + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths.*.get".into(), + description: None, + update: Some(json!({"security": [{"Bearer": []}]})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["paths"]["/users"]["get"]["security"].is_array()); + assert!(result["paths"]["/posts"]["get"]["security"].is_array()); + } + + // -- apply_overlay: zero matches -- + + #[test] + fn test_overlay_zero_match_no_error() { + let doc = json!({"info": {"title": "Test"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.nonexistent.path".into(), + description: None, + update: Some(json!({"x": 1})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result, doc); + } + + // -- apply_overlay: sequential actions -- + + #[test] + fn test_overlay_sequential_actions() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![ + OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "properties": { + "id": {"type": "string"}, + "profile": {"type": "object", "properties": {"name": {"type": "string"}}} + } + })), + remove: false, + }, + OverlayAction { + target: "$.components.schemas.User.properties.profile".into(), + description: None, + update: Some(json!({ + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"} + } + })), + remove: false, + }, + ], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["email"]["type"], + "string" + ); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: root target -- + + #[test] + fn test_overlay_root_target() { + let doc = json!({"info": {"title": "Old"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$".into(), + description: None, + update: Some(json!({"info": {"title": "New", "version": "2.0"}})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "2.0"); + } + + // -- apply_overlays_to_spec -- + + #[test] + fn test_apply_overlays_to_spec_roundtrip() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: Add description + version: "1.0" +actions: + - target: "$.info" + update: + description: "A plant management API" +"#; + + let result = apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + // The result should be valid YAML that can be parsed + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + let info = &parsed["info"]; + assert_eq!(info["description"], serde_yaml::Value::String("A plant management API".into())); + // Original fields preserved + assert_eq!(info["title"], serde_yaml::Value::String("Test API".into())); + } + + #[test] + fn test_apply_overlays_to_spec_no_overlays() { + let spec = "openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\n"; + let result = apply_overlays_to_spec(spec, &[]).unwrap(); + assert_eq!(result, spec); + } + + // -- array removal -- + + #[test] + fn test_overlay_remove_array_element() { + let doc = json!({ + "paths": { + "/plants": { + "get": { + "parameters": [ + {"name": "id", "in": "query"}, + {"name": "limit", "in": "query"}, + {"name": "offset", "in": "query"} + ] + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths['/plants'].get.parameters[1]".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params.len(), 2); + assert_eq!(params[0]["name"], "id"); + assert_eq!(params[1]["name"], "offset"); + } + + // -- multiple overlays -- + + #[test] + fn test_apply_multiple_overlays() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: {} +"#; + let overlay1 = r#" +overlay: "1.0.0" +info: + title: Overlay 1 + version: "1.0" +actions: + - target: "$.info" + update: + description: "First overlay" +"#; + let overlay2 = r#" +overlay: "1.0.0" +info: + title: Overlay 2 + version: "1.0" +actions: + - target: "$.info" + update: + contact: + name: "Plant Store Support" +"#; + let result = apply_overlays_to_spec(spec, &[overlay1.to_string(), overlay2.to_string()]).unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!( + parsed["info"]["description"], + serde_yaml::Value::String("First overlay".into()) + ); + assert_eq!( + parsed["info"]["contact"]["name"], + serde_yaml::Value::String("Plant Store Support".into()) + ); + } + + // -- deep merge preserves existing keys -- + + #[test] + fn test_deep_merge_preserves_existing() { + let doc = json!({ + "components": { + "schemas": { + "Plant": { + "type": "object", + "properties": { + "species": {"type": "string"}, + "height": {"type": "number"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.Plant.properties".into(), + description: None, + update: Some(json!({ + "species": {"type": "string", "description": "The plant species"}, + "color": {"type": "string"} + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["height"]["type"], "number"); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["species"]["description"], + "The plant species" + ); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["color"]["type"], "string"); + } + + // ----------------------------------------------------------------------- + // Tests ported from Fern CLI TypeScript (applyOpenAPIOverlay.test.ts) + // These ensure behavioral parity with the Fern CLI overlay implementation. + // ----------------------------------------------------------------------- + + fn make_overlay(actions: Vec) -> OverlayDocument { + OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "Test".into(), version: "1.0".into() }, + extends: None, + actions, + } + } + + fn update_action(target: &str, update: Value) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: Some(update), + remove: false, + } + } + + fn remove_action(target: &str) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: None, + remove: true, + } + } + + /// Port of TS: "should merge updates into a schema at a JSONPath target" + #[test] + fn test_fern_merge_updates_into_schema() { + let doc = json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.UserUpdate", + json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }) + ); + } + + /// Port of TS: "should merge arrays of objects in OpenAPI paths" + /// Uses filter expression to target a specific array element. + #[test] + fn test_fern_merge_array_element_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.name=='id')]", + json!({ "name": "id", "in": "query", "required": true, "description": "Plant ID" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true, "description": "Plant ID" }, + { "name": "limit", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should replace arrays of primitives" + /// When both target and update are arrays, lodash-style index-by-index merge. + #[test] + fn test_fern_replace_primitive_arrays() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": ["annual", "perennial"] + }} + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.tags.enum", + json!(["tropical", "succulent"]), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["tags"]["enum"], + json!(["tropical", "succulent"]) + ); + } + + /// Port of TS: "should ignore updates if remove is true" + #[test] + fn test_fern_remove_ignores_update() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" }, + "toxicity": { "type": "string" } + } + }}} + }); + let overlay = make_overlay(vec![OverlayAction { + target: "$.components.schemas.Plant.properties.toxicity".into(), + description: None, + update: Some(json!({ "type": "string", "format": "enum" })), + remove: true, + }]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" } + } + }}} + }) + ); + } + + /// Port of TS: "should handle multiple consecutive array removals" + #[test] + fn test_fern_multiple_consecutive_array_removals() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![ + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'limit')]"), + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'offset')]"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true }, + { "name": "sort", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should handle merges to multiple items in an array" + #[test] + fn test_fern_merge_multiple_array_items_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "authorization", "in": "header", "required": true }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.in == 'query')]", + json!({ "description": "Query parameter" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params[0]["description"], "Query parameter"); + assert_eq!(params[1]["description"], "Query parameter"); + assert!(params[2].get("description").is_none()); // header param untouched + assert_eq!(params[3]["description"], "Query parameter"); + assert_eq!(params[4]["description"], "Query parameter"); + } + + /// Port of TS: "should handle multiple overlay actions" + #[test] + fn test_fern_multiple_overlay_actions() { + let doc = json!({ + "components": { "schemas": { + "PlantUpdate": { + "type": "object", + "properties": { "species": { "type": "string" } } + }, + "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.PlantUpdate", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["PlantUpdate"]["properties"]["color"].is_object()); + assert!(result["components"]["schemas"]["Plant"]["properties"]["species"].is_object()); + } + + /// Port of TS: "should handle actions on items inserted by earlier actions" + #[test] + fn test_fern_actions_on_items_from_earlier_actions() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + }}} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "habitat": { + "type": "object", + "properties": { "climate": { "type": "string" } } + } + } + }), + ), + update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { "type": "string" }, + "soil": { "type": "string", "format": "enum" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + assert!(habitat["climate"].is_object()); + assert_eq!(habitat["soil"]["format"], "enum"); + } + + /// Port of TS: "should handle wildcard path matching across multiple paths" + #[test] + fn test_fern_wildcard_across_multiple_paths() { + let doc = json!({ + "paths": { + "/plants": { + "get": { "summary": "Get plants", "operationId": "getPlants" }, + "post": { "summary": "Create plant", "operationId": "createPlant" } + }, + "/gardens": { + "get": { "summary": "Get gardens", "operationId": "getGardens" } + }, + "/nurseries": { + "get": { "summary": "Get nurseries", "operationId": "getNurseries" }, + "delete": { "summary": "Delete nursery", "operationId": "deleteNursery" } + } + } + }); + let overlay = make_overlay(vec![update_action( + "$.paths.*.get", + json!({ "security": [{ "Bearer": [] }] }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // All GET operations should have security + assert!(result["paths"]["/plants"]["get"]["security"].is_array()); + assert!(result["paths"]["/gardens"]["get"]["security"].is_array()); + assert!(result["paths"]["/nurseries"]["get"]["security"].is_array()); + // Non-GET operations should not + assert!(result["paths"]["/plants"]["post"].get("security").is_none()); + assert!(result["paths"]["/nurseries"]["delete"].get("security").is_none()); + } + + /// Port of TS: "should handle zero-match JSONPath expressions" + #[test] + fn test_fern_zero_match_continues_processing() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }}}, + "paths": { "/plants": { "get": { "summary": "Get plants" } } } + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.NonExistentSchema", + json!({ "type": "object" }), + ), + update_action( + "$.paths['/nonexistent'].post", + json!({ "summary": "Non-existent" }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" }, + "color": { "type": "string", "format": "hex" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Only the last valid action should have taken effect + assert!(result["components"]["schemas"]["Plant"]["properties"]["color"].is_object()); + // Original data untouched where no match + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle deep merge behavior" + #[test] + fn test_fern_deep_merge_preserves_nested_structure() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "habitat": { + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "humidity": { "type": "integer" } + } + }, + "soil": { + "type": "object", + "properties": { "ph": { "type": "string" } } + } + } + }, + "care": { + "type": "object", + "properties": { + "watering": { "type": "string", "default": "weekly" } + } + } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "rainfall": { "type": "string" } + } + }, + "soil": { + "type": "object", + "properties": { + "ph": { "type": "string" }, + "drainage": { "type": "string", "format": "enum" } + } + }, + "sunlight": { + "type": "object", + "properties": { + "hours": { "type": "integer", "default": 6 } + } + } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + // Existing humidity preserved + assert_eq!(habitat["climate"]["properties"]["humidity"]["type"], "integer"); + // New rainfall added + assert_eq!(habitat["soil"]["properties"]["drainage"]["format"], "enum"); + // New sunlight section added + assert_eq!(habitat["sunlight"]["properties"]["hours"]["default"], 6); + // care section untouched + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["care"]["properties"]["watering"]["default"], + "weekly" + ); + } + + /// Port of TS: "should handle root-level targeting" + #[test] + fn test_fern_root_level_targeting() { + let doc = json!({ + "openapi": "3.0.0", + "info": { "title": "Plant API", "version": "1.0.0" }, + "paths": { "/plants": { "get": { "summary": "Get plants" } } }, + "tags": [{ "name": "legacy", "description": "Legacy endpoints" }], + "components": { "securitySchemes": { + "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$", + json!({ + "openapi": "3.0.0", + "info": { + "title": "Plant API", + "version": "1.0.0", + "description": "API for managing plants and gardens", + "contact": { "name": "Garden Team", "email": "garden@example.com" } + }, + "servers": [ + { "url": "https://api.example.com/v1", "description": "Production" }, + { "url": "https://staging.example.com/v1", "description": "Staging" } + ], + "externalDocs": { + "description": "Plant care guide", + "url": "https://docs.example.com" + } + }), + ), + remove_action("$.tags"), + remove_action("$.components"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Added fields + assert_eq!(result["info"]["description"], "API for managing plants and gardens"); + assert!(result["servers"].is_array()); + assert_eq!(result["servers"].as_array().unwrap().len(), 2); + assert!(result["externalDocs"].is_object()); + // Removed fields + assert!(result.get("tags").is_none()); + assert!(result.get("components").is_none()); + // Preserved fields + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle array edge cases including empty arrays and + /// replacing complete arrays" + #[test] + fn test_fern_array_edge_cases_append_and_replace() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": [] + }, + "zones": { + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5"] + }, + "companions": { + "type": "array", + "items": { "type": "object" }, + "enum": [] + } + } + }}} + }); + let overlay = make_overlay(vec![ + // Replace whole tags object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.tags", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["tropical", "succulent"] + }), + ), + // Replace whole zones object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.zones", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5", "zone6", "zone7"] + }), + ), + // Append object to empty companions array + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "basil", "benefit": "pest control" }), + ), + // Append another object + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "marigold", "benefit": "pollination" }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let props = &result["components"]["schemas"]["Plant"]["properties"]; + assert_eq!(props["tags"]["enum"], json!(["tropical", "succulent"])); + assert_eq!(props["zones"]["enum"], json!(["zone5", "zone6", "zone7"])); + let companions = props["companions"]["enum"].as_array().unwrap(); + assert_eq!(companions.len(), 2); + assert_eq!(companions[0]["name"], "basil"); + assert_eq!(companions[1]["name"], "marigold"); + } + + /// Port of TS: "should not mutate the input data object" + #[test] + fn test_fern_does_not_mutate_input() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "species": { "type": "string" } } + }}} + }); + let original = doc.clone(); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + )]); + let _result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(doc, original); + } + + /// Port of TS: "should handle complex JSONPath expressions including + /// recursive descent and filters" — array index targeting + #[test] + fn test_fern_array_index_targeting() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer" } }, + { "name": "offset", "in": "query", "schema": { "type": "integer" } } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[0]", + json!({ + "name": "limit", "in": "query", + "schema": { "type": "integer", "minimum": 1, "maximum": 100 }, + "description": "Maximum number of items to return" + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = &result["paths"]["/plants"]["get"]["parameters"]; + assert_eq!(params[0]["description"], "Maximum number of items to return"); + assert_eq!(params[0]["schema"]["minimum"], 1); + // Second param untouched + assert!(params[1].get("description").is_none()); + } + + // -- Additional deep_merge tests for lodash parity -- + + /// Verify lodash-style index-by-index array merge + #[test] + fn test_deep_merge_arrays_index_by_index() { + let mut base = json!([1, 2, 3]); + let update = json!([10, 20]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 3])); + } + + /// Verify array merge with objects inside arrays + #[test] + fn test_deep_merge_arrays_of_objects() { + let mut base = json!([ + { "name": "a", "value": 1 }, + { "name": "b", "value": 2 } + ]); + let update = json!([ + { "name": "a", "value": 10, "extra": true } + ]); + deep_merge(&mut base, &update); + assert_eq!(base[0]["value"], 10); + assert_eq!(base[0]["extra"], true); + assert_eq!(base[1]["value"], 2); // second element preserved + } + + /// Verify array append appends objects to array target + #[test] + fn test_merge_at_path_array_append() { + let mut doc = json!({ "items": [] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({ "id": 1 })); + merge_at_path(&mut doc, &segments, &json!({ "id": 2 })); + assert_eq!(doc["items"], json!([{ "id": 1 }, { "id": 2 }])); + } + + /// Verify that update with longer array extends the base + #[test] + fn test_deep_merge_update_extends_shorter_array() { + let mut base = json!([1]); + let update = json!([10, 20, 30]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 1 verification: array append scope — widened guard pushes any + // non-array value (objects, strings, numbers, booleans, null) matching + // the Fern CLI TS behavior. + // ----------------------------------------------------------------------- + + #[test] + fn test_array_append_object() { + let mut doc = json!({ "items": [{"id": 1}] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({"id": 2})); + assert_eq!(doc["items"], json!([{"id": 1}, {"id": 2}])); + } + + #[test] + fn test_array_append_string() { + let mut doc = json!({ "tags": ["a", "b"] }); + let segments = vec![PathSegment::Key("tags".into())]; + merge_at_path(&mut doc, &segments, &json!("c")); + assert_eq!(doc["tags"], json!(["a", "b", "c"])); + } + + #[test] + fn test_array_append_number() { + let mut doc = json!({ "nums": [1, 2] }); + let segments = vec![PathSegment::Key("nums".into())]; + merge_at_path(&mut doc, &segments, &json!(3)); + assert_eq!(doc["nums"], json!([1, 2, 3])); + } + + #[test] + fn test_array_append_boolean() { + let mut doc = json!({ "flags": [true] }); + let segments = vec![PathSegment::Key("flags".into())]; + merge_at_path(&mut doc, &segments, &json!(false)); + assert_eq!(doc["flags"], json!([true, false])); + } + + #[test] + fn test_array_append_null() { + let mut doc = json!({ "items": [1] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &Value::Null); + assert_eq!(doc["items"], json!([1, null])); + } + + #[test] + fn test_array_replace_with_array() { + let mut doc = json!({ "items": [1, 2] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!([10, 20, 30])); + // Arrays merge index-by-index via deep_merge + assert_eq!(doc["items"], json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 2 verification: lodash merge vs deep_merge edge cases + // ----------------------------------------------------------------------- + + #[test] + fn test_deep_merge_arrays_of_arrays() { + let mut base = json!([[1, 2], [3, 4]]); + let update = json!([[10], [30, 40, 50]]); + deep_merge(&mut base, &update); + // Index-by-index: base[0] merges with [10], base[1] with [30,40,50] + assert_eq!(base, json!([[10, 2], [30, 40, 50]])); + } + + #[test] + fn test_deep_merge_mixed_type_arrays() { + let mut base = json!([1, "hello", {"a": 1}, [1, 2]]); + let update = json!([99, "world", {"b": 2}, [3]]); + deep_merge(&mut base, &update); + // Primitives replaced, objects merged, arrays merged index-by-index + assert_eq!(base, json!([99, "world", {"a": 1, "b": 2}, [3, 2]])); + } + + #[test] + fn test_deep_merge_sparse_like_arrays() { + // lodash.merge with sparse arrays fills gaps — our impl uses + // index-by-index so shorter base just gets extended + let mut base = json!([1]); + let update = json!([null, null, 3]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([null, null, 3])); + } + + #[test] + fn test_deep_merge_empty_arrays() { + let mut base = json!([1, 2, 3]); + let update = json!([]); + deep_merge(&mut base, &update); + // Empty update leaves base unchanged + assert_eq!(base, json!([1, 2, 3])); + } + + #[test] + fn test_deep_merge_nested_objects_in_arrays() { + let mut base = json!([{"a": {"x": 1}}, {"b": 2}]); + let update = json!([{"a": {"y": 2}}, {"c": 3}]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([{"a": {"x": 1, "y": 2}}, {"b": 2, "c": 3}])); + } + + #[test] + fn test_deep_merge_array_type_mismatch_replaces() { + // When base is object and update is array (or vice versa), replace + let mut base = json!({"a": 1}); + let update = json!([1, 2]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([1, 2])); + + let mut base = json!([1, 2]); + let update = json!({"a": 1}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1})); + } + + // ----------------------------------------------------------------------- + // Item 3 verification: YAML ↔ JSON roundtrip fidelity + // ----------------------------------------------------------------------- + + #[test] + fn test_yaml_roundtrip_strips_comments() { + let yaml_with_comments = r#" +openapi: "3.0.0" +info: + title: Test # inline comment + version: "1.0" +# full line comment +paths: {} +"#; + // Need a no-op overlay to trigger the YAML->JSON->YAML roundtrip + // (empty overlay list short-circuits and returns original string) + let noop_overlay = r#" +overlay: "1.0.0" +info: + title: noop + version: "1.0.0" +actions: + - target: "$.__nonexistent__" + update: + x: 1 +"#; + let result = apply_overlays_to_spec( + yaml_with_comments, + &[noop_overlay.to_string()], + ) + .unwrap(); + // Comments are stripped after roundtrip + assert!(!result.contains("# inline comment"), "inline comment should be stripped: {result}"); + assert!(!result.contains("# full line comment"), "line comment should be stripped: {result}"); + assert!(result.contains("title: Test")); + } + + #[test] + fn test_yaml_roundtrip_resolves_anchors() { + // serde_yaml resolves anchors/aliases during deserialization. + // Use a simple alias (not merge key) to verify resolution. + let yaml_with_anchors = r#" +base_url: &url "https://api.example.com" +servers: + - url: *url + description: production +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_anchors).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Alias is resolved to the concrete value + assert_eq!( + json_val["servers"][0]["url"], + "https://api.example.com" + ); + assert_eq!( + json_val["servers"][0]["description"], + "production" + ); + // The anchor definition is also present as a regular key + assert_eq!( + json_val["base_url"], + "https://api.example.com" + ); + } + + #[test] + fn test_yaml_roundtrip_strips_custom_tags() { + let yaml_with_tag = r#" +value: !custom_tag + inner: data +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_tag).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Custom tags are stripped, value preserved + assert_eq!(json_val["value"]["inner"], "data"); + } + + #[test] + fn test_yaml_roundtrip_with_overlay_preserves_structure() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API # comment will be stripped + version: "1.0" +paths: + /users: + get: + summary: List users +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: add-description + version: "1.0.0" +actions: + - target: "$.info" + update: + description: "Added by overlay" +"#; + let result = + apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + assert!(result.contains("description: Added by overlay")); + assert!(result.contains("title: Test API")); + assert!(!result.contains('#')); + } + + // ----------------------------------------------------------------------- + // Item 4 verification: special characters in JSON keys via overlay paths + // ----------------------------------------------------------------------- + + #[test] + fn test_overlay_key_with_special_chars() { + let doc = json!({ + "x-extension": {"value": 1}, + "paths": { + "/users/{id}": { + "get": {"summary": "get user"} + } + } + }); + let overlay = make_overlay(vec![ + update_action( + "$.paths['/users/{id}'].get", + json!({"description": "Get a user by ID"}), + ), + update_action( + "$['x-extension']", + json!({"extra": true}), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/users/{id}"]["get"]["description"], + "Get a user by ID" + ); + assert_eq!(result["x-extension"]["extra"], true); + assert_eq!(result["x-extension"]["value"], 1); + } + + #[test] + fn test_normalized_path_to_segments_direct() { + // Verify the iterator-based approach works for keys with special chars + let doc = json!({ + "it's": {"nested": true}, + "key[0]": "bracket-key" + }); + let path = serde_json_path::JsonPath::parse("$[\"it's\"]").unwrap(); + let located = path.query_located(&doc); + for node in located.iter() { + let segments = normalized_path_to_segments(node.location()); + assert_eq!(segments, vec![PathSegment::Key("it's".into())]); + } + } + + // ----------------------------------------------------------------------- + // Item 5: Integration test — apply overlay to fixture spec, verify result + // ----------------------------------------------------------------------- + + #[test] + fn test_overlay_on_fixture_spec() { + let spec = include_str!("__fixtures__/openapi.json"); + let overlay = r#" +overlay: "1.0.0" +info: + title: fixture-overlay + version: "1.0.0" +actions: + - target: "$.info" + update: + description: "Modified by overlay" + - target: "$.paths['/users'].get" + update: + x-fern-sdk-method-name: listAllUsers + - target: "$.paths['/users'].get.parameters" + update: + name: offset + in: query + schema: + type: integer + - target: "$.paths['/files/{file_id}/thumbnail']" + remove: true +"#; + let result = + apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + let doc: serde_json::Value = serde_yaml::from_str(&result).unwrap(); + + // Verify info.description was set + assert_eq!(doc["info"]["description"], "Modified by overlay"); + + // Verify method rename + assert_eq!( + doc["paths"]["/users"]["get"]["x-fern-sdk-method-name"], + "listAllUsers" + ); + + // Verify array append (new parameter added) + let params = doc["paths"]["/users"]["get"]["parameters"] + .as_array() + .unwrap(); + let has_offset = params.iter().any(|p| p["name"] == "offset"); + assert!(has_offset, "offset param should be appended: {params:?}"); + + // Verify remove + assert!( + doc["paths"]["/files/{file_id}/thumbnail"].is_null(), + "thumbnail path should be removed" + ); + + // Verify untouched paths still exist + assert!( + !doc["paths"]["/files/{file_id}"].is_null(), + "other file paths should remain" + ); + } + + // (Previously: an integration smoke that exercised the rich + // template fixture's groups/methods after overlay. Coverage moved + // to `tests/cli_integration.rs` + `tests/openapi_fixture_wire.rs` + // — both of which exec the openapi-fixture bin against the rich + // fixture and assert deeper than this lib test ever could. The + // remaining `test_overlay_on_fixture_spec` above already covers + // the overlay→merge→build_doc lib path against the tiny shipped + // fixture.) +} diff --git a/cli/src/openapi/parser.rs b/cli/src/openapi/parser.rs new file mode 100644 index 00000000..afa5c19d --- /dev/null +++ b/cli/src/openapi/parser.rs @@ -0,0 +1,7711 @@ +//! OpenAPI 3.0 Parser +//! +//! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` +//! representation used by the CLI command builder and executor. + +use std::collections::HashMap; + +use serde::{Deserialize, Deserializer}; + +use crate::text::to_kebab_flag; +use crate::openapi::discovery::{ + Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, + RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, + StreamingConfig, +}; +use crate::error::CliError; + +/// Deserialize `x-fern-sdk-group-name` as either a string scalar or a list of +/// strings. The Fern extension allows both forms; some specs use the scalar +/// form while internal fixtures use the list form for nesting. +fn deserialize_group_name<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrList { + String(String), + List(Vec), + } + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(StringOrList::String(s)) => Ok(Some(vec![s])), + Some(StringOrList::List(v)) => Ok(Some(v)), + } +} + +// --------------------------------------------------------------------------- +// YAML deep-merge (Fern overrides support) +// --------------------------------------------------------------------------- + +/// Recursively deep-merge `overrides` onto `base`, matching the Fern CLI's +/// `mergeWithOverrides` behavior (lodash `mergeWith` + `omitDeepBy(isNull)`). +/// +/// Maps merge key-by-key (override wins on leaf collisions). Arrays of objects +/// merge element-by-element by index; if the override array is shorter the base +/// tail is kept, if longer the override tail is appended. Arrays of primitives +/// (or mixed) replace wholesale. Scalars replace. Null values in overrides +/// delete the key from the base; null removal is applied recursively. +/// Keys whose descendants preserve `null` values during the post-merge +/// null-removal pass. Matches the Fern CLI's `OPENAPI_EXAMPLES_KEYS` constant +/// used as `allowNullKeys` in `loadOpenAPI.ts`. +const ALLOW_NULL_KEYS: &[&str] = &[ + "examples", + "example", + "x-fern-examples", + "x-code-samples", + "x-codeSamples", +]; + +pub fn deep_merge_yaml( + base: serde_yaml::Value, + overrides: serde_yaml::Value, +) -> serde_yaml::Value { + let merged = deep_merge_yaml_inner(base, overrides); + remove_nulls(merged, false) +} + +/// Returns `true` if every element in the YAML sequence is a mapping (object). +fn all_objects(seq: &[serde_yaml::Value]) -> bool { + seq.iter().all(|v| v.is_mapping()) +} + +/// Core merge without null-removal (applied once at the top level). +fn deep_merge_yaml_inner( + base: serde_yaml::Value, + overrides: serde_yaml::Value, +) -> serde_yaml::Value { + match (base, overrides) { + (serde_yaml::Value::Mapping(mut base_map), serde_yaml::Value::Mapping(override_map)) => { + for (key, override_val) in override_map { + if let Some(base_val) = base_map.remove(&key) { + base_map.insert(key, deep_merge_yaml_inner(base_val, override_val)); + } else { + base_map.insert(key, override_val); + } + } + serde_yaml::Value::Mapping(base_map) + } + ( + serde_yaml::Value::Sequence(base_seq), + serde_yaml::Value::Sequence(override_seq), + ) => { + // Fern parity: arrays of objects are merged element-by-element + // (by index). Arrays of primitives (or mixed) replace wholesale. + if all_objects(&base_seq) && all_objects(&override_seq) { + let mut result: Vec = Vec::with_capacity( + std::cmp::max(base_seq.len(), override_seq.len()), + ); + let mut base_iter = base_seq.into_iter(); + let mut ovr_iter = override_seq.into_iter(); + loop { + match (base_iter.next(), ovr_iter.next()) { + (Some(b), Some(o)) => result.push(deep_merge_yaml_inner(b, o)), + (Some(b), None) => result.push(b), + (None, Some(o)) => result.push(o), + (None, None) => break, + } + } + serde_yaml::Value::Sequence(result) + } else { + serde_yaml::Value::Sequence(override_seq) + } + } + // All other types: override replaces the base. + (_base, override_val) => override_val, + } +} + +/// Recursively walk a YAML value and remove any key whose value is `null`. +/// This matches the Fern CLI's `omitDeepBy(isNull)` post-merge pass. +/// +/// When `allow_nulls` is `true` (i.e. we are inside a key listed in +/// `ALLOW_NULL_KEYS`, such as `"examples"`), null values are preserved +/// instead of being stripped. The flag propagates to all descendants. +fn remove_nulls(value: serde_yaml::Value, allow_nulls: bool) -> serde_yaml::Value { + match value { + serde_yaml::Value::Mapping(map) => { + let mut cleaned = serde_yaml::Mapping::new(); + for (k, v) in map { + let key_str = k.as_str().unwrap_or(""); + let child_allow = allow_nulls || ALLOW_NULL_KEYS.contains(&key_str); + if !child_allow && v.is_null() { + continue; + } + cleaned.insert(k, remove_nulls(v, child_allow)); + } + serde_yaml::Value::Mapping(cleaned) + } + serde_yaml::Value::Sequence(seq) => { + serde_yaml::Value::Sequence( + seq.into_iter().map(|v| remove_nulls(v, allow_nulls)).collect(), + ) + } + other => other, + } +} + +// --------------------------------------------------------------------------- +// Serde structs for OpenAPI 3.0 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct OpenApiSpec { + info: OpenApiInfo, + #[serde(default)] + servers: Vec, + #[serde(default)] + paths: HashMap, + components: Option, + /// Spec-level default security. Each entry is an alternative; within an + /// entry the keys are scheme names (their values are the requested + /// OAuth2/OpenIDConnect scopes — empty arrays for HTTP/apiKey schemes). + /// Inherited by every operation that doesn't declare its own `security`. + #[serde(default)] + security: Option>>>, + /// Spec-root `x-fern-pagination` extension. Inherited by operations that + /// set `x-fern-pagination: true` instead of their own config block. + #[serde(default, rename = "x-fern-pagination")] + x_fern_pagination: Option, + /// Spec-root `x-fern-base-path` extension. Declares a common prefix + /// prepended to every operation path at request time. See + /// [`RestDescription::base_path`] for the runtime behavior. + #[serde(default, rename = "x-fern-base-path")] + x_fern_base_path: Option, + /// Spec-root [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) + /// extension. List of headers that idempotent operations accept. + #[serde(default, rename = "x-fern-idempotency-headers")] + x_fern_idempotency_headers: Option>, + /// Spec-root `x-fern-sdk-variables` extension. Lowered into + /// `RestDescription::sdk_variables` via `parse_sdk_variables`. + #[serde(default, rename = "x-fern-sdk-variables")] + x_fern_sdk_variables: Option, + /// Spec-root [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) + /// extension. May be a boolean shorthand (`true` enables defaults, + /// `false` disables) or an object describing the retry policy. + /// Inherited by every operation that omits its own block or sets it + /// to `true`. Mirrors upstream fern's `getFernRetriesExtension`, + /// extended with the optional `max_attempts` / `base_delay_ms` / + /// `factor` / `jitter` knobs the runtime retry loop consumes. + #[serde(default, rename = "x-fern-retries")] + x_fern_retries: Option, + /// Spec-root [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) + /// extension. List of headers stamped on every outgoing request. + #[serde(default, rename = "x-fern-global-headers")] + x_fern_global_headers: Option>, + /// Spec-root [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) + /// extension. Mirrors the upstream Fern OpenAPI importer's + /// `getFernGroups.ts`: a record mapping group identifiers to + /// `{ summary?, description? }` metadata. Lowered into + /// [`RestDescription::groups`] (keyed by the kebab-cased identifier + /// so it matches the resource keys built from + /// `x-fern-sdk-group-name`). + #[serde(default, rename = "x-fern-groups")] + x_fern_groups: Option>, +} + +/// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. +/// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` +/// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). +#[derive(Debug, Deserialize, Clone)] +struct RawIdempotencyHeader { + /// HTTP header name (e.g. `Idempotency-Key`). Required. + header: String, + /// Optional SDK/CLI parameter name override. + #[serde(default)] + name: Option, + /// Optional environment variable name supplying a default value. + #[serde(default)] + env: Option, +} + +/// Raw deserialized form of a single entry in `x-fern-global-headers`. +/// Mirrors the upstream Fern OpenAPI importer's `GlobalHeaderExtension` +/// shape (`fern-api/fern` `getGlobalHeaders.ts`): `header` is the only +/// required field; everything else tunes the SDK/CLI surface. +/// +/// Both `default` and `x-fern-default` are accepted for the baked-in +/// fallback value. When both are present, `x-fern-default` wins — +/// mirroring the broader Fern convention where the prefixed extension +/// is the explicit form. +#[derive(Debug, Deserialize, Clone)] +struct RawGlobalHeader { + /// HTTP header name (e.g. `X-API-Version`). Required. + header: String, + /// Optional SDK/CLI parameter name override. Drives the kebab-cased + /// flag name when present (`apiVersion` → `--api-version`). + #[serde(default)] + name: Option, + /// When `true`, the header is omitted from outgoing requests when + /// no value resolves. Defaults to `false` (required). + #[serde(default)] + optional: Option, + /// Optional environment variable name supplying a fallback value. + #[serde(default)] + env: Option, + /// Optional baked-in default value. Surfaced in `--help` and sent + /// on the wire when neither the flag nor the env var is supplied. + #[serde(default)] + default: Option, + /// Alternate baked-in default. Wins over `default` when both are + /// present (mirrors `x-fern-default` precedence elsewhere in the + /// Fern OpenAPI importer). + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, +} + +/// Raw deserialized form of a single entry in the document-root +/// `x-fern-groups` map. Mirrors the upstream Fern OpenAPI importer's +/// `XFernGroupsSchema` zod schema (`getFernGroups.ts` → +/// `{ summary?: string, description?: string }`). +/// +/// Both fields are optional; the matching IR shape exposed by fern +/// (`SdkGroupInfo` in `finalIr.yml`) preserves them verbatim and the +/// `display-name` token shown in the JSDoc comment of fern's extension +/// enum is *not* part of the enforced schema — `summary` is the +/// real field name on the wire. +#[derive(Debug, Deserialize, Clone, Default)] +struct RawFernGroup { + /// Short human-friendly label for the group. Surfaces as the + /// clap subcommand's `about()` line when set. + #[serde(default)] + summary: Option, + /// Longer prose description for the group. Surfaces as the + /// clap subcommand's `long_about()` when set. + #[serde(default)] + description: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiInfo { + title: Option, + version: String, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiServer { + url: String, + #[serde(default)] + description: Option, + /// Fern v2 spelling — the canonical extension name for naming a server. + /// When both v1 and v2 are present on the same entry, v1 wins to + /// mirror the upstream `fern-api/fern` OpenAPI importer, which + /// resolves the name via + /// `getExtension(server, [SERVER_NAME_V1, SERVER_NAME_V2])` — + /// `getExtension` returns the first matching key, so the v1 alias + /// `x-name` lands first. See + /// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts` + /// lines 72-75 and `.../src/getExtension.ts` lines 25-35. + #[serde(default, rename = "x-fern-server-name")] + x_fern_server_name: Option, + /// Fern v1 legacy alias. Recognized for backwards compatibility with + /// older specs that haven't migrated to `x-fern-server-name`. When + /// both extensions are present, this v1 spelling wins — see the + /// doc-comment on `x_fern_server_name` for the precedence citation. + #[serde(default, rename = "x-name")] + x_name: Option, +} + +impl OpenApiServer { + /// Resolve the server name, applying v1-over-v2 precedence to + /// match fern's `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` + /// first-match-wins behavior in + /// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. + /// Each extension is trimmed and treated as "absent" when it is + /// the empty string (or whitespace-only) before the fallback runs, + /// so a blank `x-name: ""` does not shadow a valid + /// `x-fern-server-name` (and vice versa). An empty extension would + /// otherwise leak into clap as a blank-string possible value and a + /// blank `--help` row, which is always a spec bug — drop it at the + /// source so downstream code never needs to handle it. + fn resolved_name(&self) -> Option { + fn trimmed_non_empty(s: &Option) -> Option { + s.as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + trimmed_non_empty(&self.x_name).or_else(|| trimmed_non_empty(&self.x_fern_server_name)) + } + + /// Lower the OpenAPI server entry into the internal + /// [`discovery::Server`] representation, applying the v1/v2 name + /// fallback (v1 wins; see [`Self::resolved_name`]). + fn to_discovery_server(&self) -> crate::openapi::discovery::Server { + crate::openapi::discovery::Server { + url: self.url.clone(), + name: self.resolved_name(), + description: self.description.clone(), + } + } +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiPathItem { + get: Option, + post: Option, + put: Option, + patch: Option, + delete: Option, + #[serde(default)] + parameters: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiOperation { + #[serde(rename = "operationId")] + operation_id: Option, + summary: Option, + description: Option, + #[serde(default)] + parameters: Vec, + #[serde(rename = "requestBody")] + request_body: Option, + #[serde(default)] + servers: Vec, + #[serde(default)] + tags: Option>, + #[serde(rename = "x-fern-sdk-group-name", default, deserialize_with = "deserialize_group_name")] + x_fern_sdk_group_name: Option>, + #[serde(rename = "x-fern-sdk-method-name")] + x_fern_sdk_method_name: Option, + /// Operation-level security override. `Some(vec![])` is meaningful — it + /// explicitly opts the operation out of the spec-level default, marking + /// it anonymous. `None` means "inherit the spec default". + #[serde(default)] + security: Option>>>, + /// Operation-level `x-fern-pagination`. May be: + /// - an object describing cursor / offset / uri / path / custom pagination (overrides root) + /// - the literal `true` (inherits the spec-root config) + /// - missing (falls back to the document-wide pagination heuristic) + #[serde(default, rename = "x-fern-pagination")] + x_fern_pagination: Option, + /// Fern extension: when `Some(true)`, the operation is dropped from + /// the generated CLI surface — it does not appear as a subcommand, in + /// `--help`, or in completions. `None` (the default) and `Some(false)` + /// both keep the operation. Stored as `Option` to mirror the + /// nullish-coalescing precedence used at the parameter level. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/ignore + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// OpenAPI standard `deprecated: true` flag on the operation. When + /// `x-fern-availability` is absent, a `true` here is lowered to + /// `Availability::Deprecated` so deprecated operations still surface + /// a `[DEPRECATED]` badge in help output. + #[serde(default)] + deprecated: bool, + /// Raw `x-fern-availability` extension on the operation. When present, + /// takes precedence over the standard `deprecated` flag. + #[serde(rename = "x-fern-availability", default)] + x_fern_availability: Option, + /// [`x-fern-idempotent: true`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotent) + /// marker. When `true`, the operation surfaces spec-root idempotency + /// headers as CLI flags; non-idempotent operations never send these + /// headers. + #[serde(rename = "x-fern-idempotent", default)] + x_fern_idempotent: Option, + /// Raw `x-fern-sdk-return-value` extension on the operation. Mirrors + /// fern-api/fern's `FernOpenAPIExtension.RESPONSE_PROPERTY` — a + /// dot-separated key path into the JSON response body identifying + /// the subvalue to surface to the caller. `None` (the default) + /// means the executor prints the full response. + #[serde(rename = "x-fern-sdk-return-value", default)] + x_fern_sdk_return_value: Option, + /// Raw operation-level `x-fern-streaming` extension. May be: + /// - the literal `true` (boolean shorthand → NDJSON, no terminator) + /// - the literal `false` (explicit opt-out) + /// - an object describing the stream (`format`, optional `terminator`, + /// and the `stream-condition` / `response-stream` / `response` keys + /// recognized upstream for parity — only `format` and `terminator` + /// affect runtime behavior) + /// - missing (no streaming) + /// + /// Resolved into [`StreamingConfig`] via `parse_streaming_extension`. + #[serde(rename = "x-fern-streaming", default)] + x_fern_streaming: Option, + /// Operation-level `x-fern-retries`. Same shape as the spec-root + /// block (boolean shorthand or object). A boolean defers to the + /// spec-root block; an object merges field-by-field over the + /// spec-root baseline. Missing inherits the spec root verbatim. + #[serde(default, rename = "x-fern-retries")] + x_fern_retries: Option, + /// Raw `x-fern-audiences` extension on the operation. Mirrors + /// fern-api/fern's OpenAPI importer + /// (`FernOpenAPIExtension.AUDIENCES = "x-fern-audiences"`): an + /// array of strings declaring which audiences the operation is + /// part of. Missing or empty means "no audience tag" — and is + /// filtered OUT when the binary's `main.rs` configures any preset + /// audience via [`crate::openapi::CliApp::audiences`], matching + /// fern's `audiences.some(a => operationAudiences.includes(a))` + /// check in `generateIr.ts:141` (which always evaluates false when + /// `operationAudiences` is `[]`). + #[serde(rename = "x-fern-audiences", default)] + x_fern_audiences: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum OpenApiParamOrRef { + /// A `$ref` to `components/parameters/`. The extension may also + /// be set on the ref-site object itself (Fern's overlay system and + /// OpenAPI 3.1 both allow extensions next to `$ref`); when present at + /// the ref site it wins over the resolved component's value. + Ref { + #[serde(rename = "$ref")] + ref_path: String, + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// Fern extension: an alias used as the CLI flag name while the + /// wire name (the resolved component's `name`) is still used in + /// the outgoing request. Set on the ref-site object — wins over + /// the value on the resolved component via fern's `??` + /// precedence (mirrors the `IGNORE` extension above). + #[serde(rename = "x-fern-parameter-name", default)] + x_fern_parameter_name: Option, + /// Ref-site `x-fern-default` value. Wins over the value on the + /// resolved component parameter (and over the standard + /// schema-level `default:`). Mirrors fern's importer precedence: + /// `getExtension(parameter, FERN_DEFAULT) ?? getExtension(resolvedParameter, FERN_DEFAULT)`. + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, + }, + Inline(Box), +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiParameter { + name: String, + #[serde(rename = "in")] + location: Option, + #[serde(default)] + required: bool, + description: Option, + schema: Option, + #[serde(default)] + style: Option, + #[serde(default)] + explode: Option, + /// Fern extension: when `Some(true)`, the parameter is dropped from + /// the generated CLI surface — no CLI flag, not sent in the request. + /// Stored as `Option` so we can mirror fern's precedence: a + /// ref-site `x-fern-ignore` wins over the value on the resolved + /// component parameter via `ref_site.or(resolved).unwrap_or(false)`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/ignore + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// Fern extension: alias used as the CLI flag name while the wire + /// name (`name`) is kept on the outgoing HTTP request. Mirrors + /// fern's OpenAPI importer (`parameterNameOverride`) and supports + /// the same precedence as `x-fern-ignore`: a ref-site value wins + /// over the resolved component's via `ref_site.or(resolved)`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/parameter-name + #[serde(rename = "x-fern-parameter-name", default)] + x_fern_parameter_name: Option, + /// OpenAPI standard `deprecated: true` flag on the parameter. When + /// `x-fern-availability` is absent, a `true` here is lowered to + /// `Availability::Deprecated` so deprecated parameter flags surface + /// a `[DEPRECATED]` badge in their `--help` description. + #[serde(default)] + deprecated: bool, + /// Raw `x-fern-availability` extension on the parameter. Takes + /// precedence over the standard `deprecated` flag. + #[serde(rename = "x-fern-availability", default)] + x_fern_availability: Option, + /// Fern extension: client-side default value for the parameter. + /// When present, the parameter becomes optional in the generated CLI + /// and the value is sent in the outgoing request when the user omits + /// the flag. Supports string, number, and boolean literals. + /// Wins over the standard `default:` on the parameter's `schema`. + /// A value placed at the **ref-site** (alongside `$ref`) wins over + /// the value on this resolved parameter — see `OpenApiParamOrRef::Ref`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/default + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, + /// Fern extension binding this path parameter to a spec-level + /// `x-fern-sdk-variables` entry. Honored only on `in: path` + /// parameters (mirroring Fern's openapi-ir-parser). + #[serde(rename = "x-fern-sdk-variable", default)] + x_fern_sdk_variable: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiParamSchema { + #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] + schema_type: Option, + #[serde(rename = "enum", default, deserialize_with = "deserialize_enum_values")] + enum_values: Option>, + default: Option, + format: Option, + /// Raw `x-fern-enum` map keyed by wire value, deserialized straight + /// off the YAML schema. Lowered to `discovery::FernEnumValue` in + /// `convert_fern_enum`. + #[serde(rename = "x-fern-enum", default)] + x_fern_enum: Option>, +} + +/// Raw `x-fern-enum` entry as it appears in the OpenAPI YAML. Kept +/// schema-faithful (the `casing` field is parsed-but-ignored) so the +/// shape matches the upstream Fern importer. +#[derive(Debug, Deserialize, Default)] +struct OpenApiFernEnumValue { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + /// Parsed but not lowered — the SDK codegen uses `casing` to derive + /// language-specific identifiers; cli-sdk uses the raw display name. + #[serde(default)] + #[allow(dead_code)] + casing: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiRequestBody { + content: Option>, + #[serde(rename = "x-fern-parameter-name")] + x_fern_parameter_name: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiMediaType { + schema: Option, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiSchemaObject { + #[serde(rename = "$ref")] + schema_ref: Option, + #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] + schema_type: Option, + description: Option, + #[serde(default)] + properties: HashMap, + items: Option>, + #[serde(default)] + required: Vec, + #[serde(rename = "enum", default, deserialize_with = "deserialize_enum_values")] + enum_values: Option>, + format: Option, + #[serde(default)] + read_only: bool, + #[serde( + default, + deserialize_with = "deserialize_additional_properties" + )] + additional_properties: Option>, +} + +/// Deserialize an OpenAPI `enum` field whose items may be strings, integers, or +/// booleans. Everything is coerced to `String`. +fn deserialize_enum_values<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct EnumVisitor; + + impl<'de> de::Visitor<'de> for EnumVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of scalar values") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(v) = seq.next_element::()? { + let s = match &v { + serde_yaml::Value::String(s) => s.clone(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + other => format!("{other:?}"), + }; + values.push(s); + } + Ok(Some(values)) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(EnumVisitor) +} + +/// Deserialize an OpenAPI `type` field that can be a plain string or an array +/// (e.g. `["string", "null"]` in OpenAPI 3.1). When it's an array, the first +/// non-`"null"` entry is used. +fn deserialize_type_field<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct TypeVisitor; + + impl<'de> de::Visitor<'de> for TypeVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(Some(v.to_string())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Some(v)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut types: Vec = Vec::new(); + while let Some(t) = seq.next_element::()? { + types.push(t); + } + Ok(types.into_iter().find(|t| t != "null")) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(TypeVisitor) +} + +/// Deserialize `additionalProperties` which can be a boolean or a schema object. +/// When it's `false`, we treat it as None. When `true`, we treat it as an empty schema. +fn deserialize_additional_properties<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct AdditionalPropertiesVisitor; + + impl<'de> de::Visitor<'de> for AdditionalPropertiesVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean or a schema object") + } + + fn visit_bool(self, v: bool) -> Result { + if v { + Ok(Some(Box::new(OpenApiSchemaObject::default()))) + } else { + Ok(None) + } + } + + fn visit_map>(self, map: M) -> Result { + let obj = OpenApiSchemaObject::deserialize(de::value::MapAccessDeserializer::new(map))?; + Ok(Some(Box::new(obj))) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(AdditionalPropertiesVisitor) +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiComponents { + #[serde(default)] + schemas: HashMap, + #[serde(default)] + parameters: HashMap, + #[serde(default)] + security_schemes: HashMap, +} + +/// Raw OpenAPI Security Scheme Object — the shape we deserialize. Lowered +/// to [`crate::openapi::discovery::SecurityScheme`] before being surfaced. +#[derive(Debug, Deserialize, Default)] +struct OpenApiSecurityScheme { + #[serde(rename = "type")] + type_field: Option, + /// `bearer` or `basic` for `type: http`. + scheme: Option, + /// `header`, `query`, or `cookie` for `type: apiKey`. + #[serde(rename = "in")] + location: Option, + /// Header/query/cookie name for `type: apiKey`. + name: Option, +} + +fn lower_security_scheme(raw: &OpenApiSecurityScheme) -> SecurityScheme { + let type_str = raw.type_field.as_deref().unwrap_or("").to_ascii_lowercase(); + match type_str.as_str() { + "http" => match raw.scheme.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("bearer") => SecurityScheme::HttpBearer, + Some("basic") => SecurityScheme::HttpBasic, + other => SecurityScheme::Other(format!("http/{}", other.unwrap_or(""))), + }, + "apikey" => { + let name = raw.name.clone().unwrap_or_default(); + match raw.location.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("header") => SecurityScheme::ApiKeyHeader { name }, + Some("query") => SecurityScheme::ApiKeyQuery { name }, + other => SecurityScheme::Other(format!("apiKey/{}", other.unwrap_or(""))), + } + } + "oauth2" => SecurityScheme::OAuth2, + other => SecurityScheme::Other(other.to_string()), + } +} + +// --------------------------------------------------------------------------- +// Helper: camelCase → kebab-case +/// Detect pagination config from the OpenAPI spec's components/parameters. +/// Looks for common patterns like "page_token" or "PageToken" params, +/// and checks response schemas for pagination objects. +fn detect_pagination_config(spec: &OpenApiSpec) -> (Option, Option) { + let components = match &spec.components { + Some(c) => c, + None => return (None, None), + }; + + // Check if there's a page_token parameter in components + for param in components.parameters.values() { + if param.name == "page_token" { + // Calendly-style: page_token query param, pagination.next_page_token response + return ( + Some("page_token".to_string()), + Some("pagination.next_page_token".to_string()), + ); + } + } + + (None, None) +} + +// --------------------------------------------------------------------------- +// x-fern-pagination: resolve per-operation pagination config from the +// OpenAPI extension. Mirrors the upstream Fern OpenAPI importer: +// https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi-to-ir/src/extensions/x-fern-pagination.ts +// --------------------------------------------------------------------------- + +const REQUEST_PREFIX: &str = "$request."; +const RESPONSE_PREFIX: &str = "$response."; + +/// Strip a leading `$request.` or `$response.` prefix from a JSONPath-style +/// reference. The runtime treats the remaining string as either a request +/// parameter name (for `$request.foo` → `foo`) or a dotted JSON path into +/// the response body (for `$response.pagination.next_cursor` → +/// `pagination.next_cursor`). +fn strip_pagination_prefix(value: &str) -> String { + value + .strip_prefix(REQUEST_PREFIX) + .or_else(|| value.strip_prefix(RESPONSE_PREFIX)) + .unwrap_or(value) + .to_string() +} + +/// Normalize a spec-level `x-fern-base-path` value: +/// - `None` and empty/whitespace-only strings collapse to `None`. +/// - Otherwise the string is trimmed of surrounding ASCII whitespace and +/// returned as-is (leading/trailing slashes preserved — `build_url` +/// normalizes them at request time). +fn normalize_base_path(raw: Option<&str>) -> Option { + let trimmed = raw?.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Resolve the `x-fern-pagination` extension for a single operation, +/// applying root-level inheritance. +/// +/// Mirrors upstream `fern-api/fern`'s `getFernPaginationExtension`: +/// - per-op block absent → `Ok(None)` (executor falls back to heuristic) +/// - per-op block is a boolean → look up the spec-root block +/// - root is a boolean too → `Err(...)` (matches upstream's +/// `CliError::ValidationError`) +/// - root is absent → `Ok(None)` (NOT an error — matches upstream) +/// - root is an object → parse the root block +/// - per-op block is an object → parse it directly +fn resolve_pagination_extension( + op_ext: Option<&serde_yaml::Value>, + root_ext: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + let value = match op_ext { + Some(v) => v, + None => return Ok(None), + }; + + if let serde_yaml::Value::Bool(_) = value { + return match root_ext { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::Bool(_)) => Err(CliError::Discovery(format!( + "Operation '{op_id}' sets `x-fern-pagination: ` but the spec-root \ + `x-fern-pagination` is also a boolean; the root must be an object describing \ + pagination (cursor / offset / next_uri / next_path / custom)." + ))), + Some(root) => parse_pagination_config(root, op_id, true), + }; + } + + parse_pagination_config(value, op_id, false) +} + +/// Parse a `x-fern-pagination` config object. Discrimination order mirrors +/// `fern-api/fern`'s `getPaginationExtension.ts`: +/// +/// 1. `cursor` → Cursor form +/// 2. `next_uri` → Uri form +/// 3. `next_path` → Path form +/// 4. `offset` → Offset form +/// 5. `type: "custom"` → Custom form +/// +/// Otherwise an "invalid pagination extension" error is returned, matching +/// upstream's `CliError`. +/// +/// `inherited` is purely used for error wording so the user can tell +/// whether the failure is in the per-op block or the inherited root block. +fn parse_pagination_config( + value: &serde_yaml::Value, + op_id: &str, + inherited: bool, +) -> Result, CliError> { + let map = match value { + serde_yaml::Value::Mapping(m) => m, + _ => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-pagination` for operation '{op_id}': expected an object, \ + got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(value) + ))); + } + }; + + if map.contains_key("cursor") { + let cursor = require_str_field(map, "cursor", op_id)?; + let next_cursor = require_str_field(map, "next_cursor", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Cursor { + cursor: strip_pagination_prefix(&cursor), + next_cursor: strip_pagination_prefix(&next_cursor), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("next_uri") { + let next_uri = require_str_field(map, "next_uri", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Uri { + next_uri: strip_pagination_prefix(&next_uri), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("next_path") { + let next_path = require_str_field(map, "next_path", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Path { + next_path: strip_pagination_prefix(&next_path), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("offset") { + let offset = require_str_field(map, "offset", op_id)?; + let results = require_str_field(map, "results", op_id)?; + let step = optional_str_field(map, "step", op_id)?; + let has_next_page = optional_str_field(map, "has-next-page", op_id)?; + return Ok(Some(PaginationConfig::Offset { + offset: strip_pagination_prefix(&offset), + results: strip_pagination_prefix(&results), + step: step.map(|s| strip_pagination_prefix(&s)), + has_next_page: has_next_page.map(|s| strip_pagination_prefix(&s)), + })); + } + + if matches!( + map.get(serde_yaml::Value::String("type".to_string())), + Some(serde_yaml::Value::String(t)) if t == "custom" + ) { + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Custom { + results: strip_pagination_prefix(&results), + })); + } + + Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': must declare one of `cursor`, \ + `next_uri`, `next_path`, `offset`, or `type: custom`. See \ + https://buildwithfern.com/learn/api-definitions/openapi/extensions/pagination" + ))) +} + +fn require_str_field( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, +) -> Result { + match map.get(serde_yaml::Value::String(field.to_string())) { + Some(serde_yaml::Value::String(s)) => Ok(s.clone()), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': field `{field}` must be \ + a string, got {}.", + describe_yaml_kind(other) + ))), + None => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': missing required field \ + `{field}`." + ))), + } +} + +fn optional_str_field( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, +) -> Result, CliError> { + match map.get(serde_yaml::Value::String(field.to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': field `{field}` must be \ + a string when present, got {}.", + describe_yaml_kind(other) + ))), + } +} + +// --------------------------------------------------------------------------- +// x-fern-streaming: resolve per-operation streaming config from the OpenAPI +// extension. Mirrors the upstream Fern OpenAPI importer: +// https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernStreamingExtension.ts +// --------------------------------------------------------------------------- + +/// Resolve `x-fern-streaming` for a single operation. Returns: +/// - `Ok(None)` — extension absent, or set to literal `false` (explicit opt-out). +/// - `Ok(Some(_))` — streaming enabled; runtime variant captures format + terminator. +/// - `Err(...)` — invalid shape (non-bool/non-object, unknown `format`, etc.). +/// +/// Boolean shorthand (`x-fern-streaming: true`) maps to NDJSON +/// (`StreamingConfig::Json`) with no terminator. This matches the +/// upstream importer's boolean handler exactly — see the +/// `getFernStreamingExtension.ts` comment that the boolean shorthand +/// emits `format: "json"` (so that callers who haven't picked a wire +/// format don't accidentally inherit OpenAI-style SSE semantics). +fn parse_streaming_extension( + value: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + let value = match value { + Some(v) => v, + None => return Ok(None), + }; + + if let serde_yaml::Value::Bool(b) = value { + return if *b { + Ok(Some(StreamingConfig::Json { terminator: None })) + } else { + Ok(None) + }; + } + + let map = match value { + serde_yaml::Value::Mapping(m) => m, + other => { + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': expected a boolean or \ + an object, got {}.", + describe_yaml_kind(other) + ))); + } + }; + + // `format` is optional in upstream's object schema. The upstream + // importer and the typed SDKs (TS / C#) default a format-less + // object to `json` (NDJSON), matching the boolean shorthand. The + // CLI mirrors that default so callers who omit `format` get the + // same wire shape as the typed SDKs would have produced. + let format = optional_str_field_named(map, "format", op_id, "x-fern-streaming")?; + let format = match format.as_deref() { + Some("sse") => StreamingFormat::Sse, + Some("json") | None => StreamingFormat::Json, + Some("text") => StreamingFormat::Text, + Some(other) => { + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': field `format` must be \ + `sse`, `json`, or `text`, got `{other}`." + ))); + } + }; + + let terminator = + optional_str_field_named(map, "terminator", op_id, "x-fern-streaming")?; + + if matches!(format, StreamingFormat::Text) && terminator.is_some() { + // Mirrors the IR (`TextStreamChunk` carries no `terminator` + // field) and the typed SDK generators — surfacing it at parse + // time keeps misconfigurations from silently no-op'ing at + // runtime. + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': field `terminator` is not \ + supported for `format: text` streams." + ))); + } + + Ok(Some(match format { + StreamingFormat::Sse => StreamingConfig::Sse { terminator }, + StreamingFormat::Json => StreamingConfig::Json { terminator }, + StreamingFormat::Text => StreamingConfig::Text, + })) +} + +enum StreamingFormat { + Sse, + Json, + Text, +} + +fn optional_str_field_named( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, + extension: &str, +) -> Result, CliError> { + match map.get(serde_yaml::Value::String(field.to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `{extension}` for operation '{op_id}': field `{field}` must be a string \ + when present, got {}.", + describe_yaml_kind(other) + ))), + } +} + +// --------------------------------------------------------------------------- +// x-fern-retries: resolve per-operation retry policy from the OpenAPI +// extension. Mirrors the upstream Fern OpenAPI importer's tagged shape +// (`getFernRetriesExtension.ts` — `{ disabled: bool }`) and extends it with +// the optional knobs the cli-sdk runtime retry loop consumes (max attempts, +// backoff base, factor, jitter). The extra knobs are forward-compatible with +// the upstream importer. +// --------------------------------------------------------------------------- + +/// Resolve the `x-fern-retries` extension for a single operation, applying +/// root-level inheritance and per-operation overrides. +/// +/// Precedence — matches the pagination resolver's shape and the upstream +/// fern importer's nullish coalescing: +/// - per-op block absent → inherit the spec-root block (or `None` when also absent) +/// - per-op `true` → spec-root config, or all-defaults when root is also absent +/// - per-op `false` (or `{ disabled: true }`) → disabled regardless of root +/// - per-op object → root values, overridden field-by-field by the op block; +/// when root is also `true`/absent the op object stacks on top of defaults +fn resolve_retries_extension( + op_ext: Option<&serde_yaml::Value>, + root_ext: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + // Build the baseline from the root block, if any. Root-`false` / + // `{ disabled: true }` propagates by default to operations that don't + // override it. + let root_baseline = match root_ext { + None | Some(serde_yaml::Value::Null) => None, + Some(v) => parse_retries_value(v, op_id, /*inherited=*/ true)?, + }; + + let op = match op_ext { + // Op missing → inherit the root baseline (or `None` when also absent). + Some(v) => v, + None => return Ok(root_baseline), + }; + + // Op is a boolean. + if let serde_yaml::Value::Bool(b) = op { + if !*b { + // `false` disables retries on this operation regardless of root. + return Ok(Some(RetriesConfig::disabled())); + } + // `true` adopts the root baseline; falls back to all-defaults when + // root is absent or also a boolean. + return Ok(Some(root_baseline.unwrap_or_default())); + } + + // Op is an object. The root baseline (if enabled) is the starting + // config; the op fields override field-by-field. When the root is + // explicitly disabled, the op block re-enables retries (the more + // specific block wins). + let baseline = match root_baseline { + Some(cfg) if cfg.enabled => cfg, + _ => RetriesConfig::default(), + }; + + let map = match op { + serde_yaml::Value::Mapping(m) => m, + other => { + return Err(CliError::Discovery(format!( + "Invalid operation-level `x-fern-retries` for operation '{op_id}': expected \ + an object or boolean, got {}.", + describe_yaml_kind(other) + ))); + } + }; + + let config = apply_retries_object(baseline, map, op_id, /*inherited=*/ false)?; + + // `max_attempts: 0` is treated identically to `disabled: true` so the + // executor doesn't have to special-case the count itself. + if config.max_attempts == 0 { + return Ok(Some(RetriesConfig::disabled())); + } + + Ok(Some(config)) +} + +/// Parse a standalone `x-fern-retries` value (root or operation) into a +/// [`RetriesConfig`]. Used for the root baseline: takes the raw extension +/// value and returns the resolved config (or `None` when the value is +/// `null`). Bool/object are handled inline; unknown shapes error out. +fn parse_retries_value( + value: &serde_yaml::Value, + op_id: &str, + inherited: bool, +) -> Result, CliError> { + match value { + serde_yaml::Value::Null => Ok(None), + serde_yaml::Value::Bool(true) => Ok(Some(RetriesConfig::default())), + serde_yaml::Value::Bool(false) => Ok(Some(RetriesConfig::disabled())), + serde_yaml::Value::Mapping(map) => { + let config = + apply_retries_object(RetriesConfig::default(), map, op_id, inherited)?; + if config.max_attempts == 0 { + return Ok(Some(RetriesConfig::disabled())); + } + Ok(Some(config)) + } + other => Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': expected an object or \ + boolean, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))), + } +} + +/// Apply the fields of an `x-fern-retries` object on top of an existing +/// [`RetriesConfig`]. Unknown keys are ignored (forward-compatible). +fn apply_retries_object( + mut config: RetriesConfig, + map: &serde_yaml::Mapping, + op_id: &str, + inherited: bool, +) -> Result { + // Canonical fern shape: `{ disabled: true | false }`. + if let Some(v) = map.get(serde_yaml::Value::String("disabled".to_string())) { + match v { + serde_yaml::Value::Bool(disabled) => { + if *disabled { + return Ok(RetriesConfig::disabled()); + } + config.enabled = true; + } + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `disabled` \ + must be a boolean, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + } + } + + // `max` / `max_attempts` / `max-attempts` — accept all three spellings + // since the upstream IR has not yet settled on one; the fern docs + // refer to "max retry attempts" colloquially. + if let Some(v) = retries_field(map, &["max_attempts", "max-attempts", "max"]) { + let parsed = match v { + serde_yaml::Value::Number(n) => n.as_u64(), + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must be a non-negative integer, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + }; + let parsed = parsed.ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must be a non-negative integer.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?; + config.max_attempts = u32::try_from(parsed).map_err(|_| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must fit in a u32, got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?; + } + + if let Some(v) = retries_field(map, &["base_delay_ms", "base-delay-ms", "base"]) { + let parsed = match v { + serde_yaml::Value::Number(n) => n.as_u64().ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field \ + `base_delay_ms` must be a non-negative integer.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?, + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field \ + `base_delay_ms` must be an integer, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + }; + config.base_delay_ms = parsed; + } + + if let Some(v) = retries_field(map, &["factor", "backoff_factor", "backoff-factor"]) { + let parsed = retries_required_f64(v, "factor", op_id, inherited)?; + if parsed < 1.0 { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `factor` must be \ + >= 1.0, got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + ))); + } + config.factor = parsed; + } + + if let Some(v) = retries_field(map, &["jitter"]) { + let parsed = retries_required_f64(v, "jitter", op_id, inherited)?; + if !(0.0..=1.0).contains(&parsed) { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `jitter` must be \ + in [0.0, 1.0], got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + ))); + } + config.jitter = parsed; + } + + Ok(config) +} + +/// First-of-aliases lookup for `x-fern-retries` field reads. Returns the +/// first matching value (any present alias) so authors can use either +/// `max_attempts` / `max-attempts` / `max` (or the corresponding +/// `base_delay_ms` / `base-delay-ms` / `base`) interchangeably. +fn retries_field<'a>( + map: &'a serde_yaml::Mapping, + aliases: &[&str], +) -> Option<&'a serde_yaml::Value> { + for alias in aliases { + if let Some(v) = map.get(serde_yaml::Value::String((*alias).to_string())) { + return Some(v); + } + } + None +} + +fn retries_required_f64( + value: &serde_yaml::Value, + field: &str, + op_id: &str, + inherited: bool, +) -> Result { + match value { + serde_yaml::Value::Number(n) => n.as_f64().ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `{field}` must be \ + a finite number.", + if inherited { "inherited" } else { "operation-level" }, + )) + }), + other => Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `{field}` must be a \ + number, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))), + } +} + +fn describe_yaml_kind(value: &serde_yaml::Value) -> &'static str { + match value { + serde_yaml::Value::Null => "null", + serde_yaml::Value::Bool(_) => "boolean", + serde_yaml::Value::Number(_) => "number", + serde_yaml::Value::String(_) => "string", + serde_yaml::Value::Sequence(_) => "array", + serde_yaml::Value::Mapping(_) => "object", + serde_yaml::Value::Tagged(_) => "tagged value", + } +} + +// --------------------------------------------------------------------------- + +fn camel_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for ch in s.chars() { + if !ch.is_ascii_alphanumeric() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + while result.ends_with('-') { + result.pop(); + } + result +} + +/// Tokenize a string the way Fern's OpenAPI importer does: camelCase-only +/// strings split on each capital letter; everything else splits on +/// non-alphanumeric runs. All tokens lowercased, empties dropped. +fn tokenize(s: &str) -> Vec { + let is_camel_case = s.chars().next().is_some_and(|c| c.is_ascii_lowercase()) + && s.chars().all(|c| c.is_ascii_alphanumeric()) + && s.chars().any(|c| c.is_ascii_uppercase()); + + let raw: Vec = if is_camel_case { + let mut tokens = Vec::new(); + let mut current = String::new(); + for c in s.chars() { + if c.is_ascii_uppercase() && !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + current.push(c); + } + if !current.is_empty() { + tokens.push(current); + } + tokens + } else { + s.split(|c: char| !c.is_ascii_alphanumeric()) + .map(str::to_string) + .collect() + }; + + raw.into_iter() + .filter(|t| !t.is_empty()) + .map(|t| t.to_lowercase()) + .collect() +} + +/// Inject one synthetic header `MethodParameter` per spec-root +/// idempotency header into an idempotent operation's parameter map. The +/// existing header-parameter pathway in `commands.rs` and `executor.rs` +/// then handles flag exposure (kebab-cased `--`) and on-the-wire +/// header transmission (`location: "header"`). +/// +/// The parameter key (HashMap key) is the on-the-wire header name +/// (used directly as the HTTP header). The kebab-cased `--` +/// derives from [`IdempotencyHeader::name`] when present +/// (`MethodParameter.flag_name_override`), otherwise from the header. +/// This mirrors the upstream Fern OpenAPI importer, where `name` +/// becomes the SDK parameter identifier. +/// +/// Spec-declared parameters with the same HashMap key win — we do not +/// overwrite them, which preserves any per-operation customization +/// (e.g. an `Idempotency-Key` param declared explicitly in `parameters:` +/// with a custom description). +fn inject_idempotency_header_params( + params: &mut HashMap, + idempotency_headers: &[IdempotencyHeader], +) { + for h in idempotency_headers { + if params.contains_key(&h.header) { + continue; + } + let description = h + .name + .as_ref() + .map(|n| format!("Idempotency header `{}` (param `{}`).", h.header, n)) + .unwrap_or_else(|| format!("Idempotency header `{}`.", h.header)); + let flag_name_override = h.name.as_ref().map(|n| to_kebab_flag(n)); + params.insert( + h.header.clone(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some(description), + location: Some("header".to_string()), + env_var: h.env.clone(), + flag_name_override, + ..Default::default() + }, + ); + } +} + +/// Mirror Fern's OpenAPI importer behavior: when an operation's group is +/// derived from a tag (no `x-fern-sdk-group-name`), strip tag tokens that +/// prefix the operationId. `tag="Customers", operationId="customersList"` +/// → `list`. No-op when the operationId doesn't start with the tag tokens. +fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { + let tag_tokens = tokenize(tag); + let op_tokens = tokenize(operation_id); + if tag_tokens.is_empty() || op_tokens.len() <= tag_tokens.len() { + return operation_id.to_string(); + } + for (i, t) in tag_tokens.iter().enumerate() { + if op_tokens.get(i) != Some(t) { + return operation_id.to_string(); + } + } + op_tokens[tag_tokens.len()..].join("-") +} + +// --------------------------------------------------------------------------- +// Schema conversion helpers +// --------------------------------------------------------------------------- + +fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { + if let Some(ref_path) = &obj.schema_ref { + let name = strip_ref_prefix(ref_path); + return JsonSchema { + schema_ref: Some(name), + ..Default::default() + }; + } + + let properties = obj + .properties + .iter() + .map(|(k, v)| (k.clone(), convert_schema_property(v))) + .collect(); + + JsonSchema { + id: None, + schema_type: obj.schema_type.clone(), + description: obj.description.clone(), + properties, + schema_ref: None, + items: obj.items.as_ref().map(|i| Box::new(convert_schema_property(i))), + required: obj.required.clone(), + additional_properties: obj + .additional_properties + .as_ref() + .map(|ap| Box::new(convert_schema_property(ap))), + } +} + +fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { + if let Some(ref_path) = &obj.schema_ref { + let name = strip_ref_prefix(ref_path); + return JsonSchemaProperty { + schema_ref: Some(name), + ..Default::default() + }; + } + + let properties = obj + .properties + .iter() + .map(|(k, v)| (k.clone(), convert_schema_property(v))) + .collect(); + + JsonSchemaProperty { + prop_type: obj.schema_type.clone(), + description: obj.description.clone(), + schema_ref: None, + format: obj.format.clone(), + items: obj.items.as_ref().map(|i| Box::new(convert_schema_property(i))), + properties, + read_only: obj.read_only, + default: None, + enum_values: obj.enum_values.clone(), + additional_properties: obj + .additional_properties + .as_ref() + .map(|ap| Box::new(convert_schema_property(ap))), + } +} + +fn strip_ref_prefix(ref_path: &str) -> String { + // Handles "#/components/schemas/Foo" and "#/components/parameters/Foo" + ref_path + .rsplit('/') + .next() + .unwrap_or(ref_path) + .to_string() +} + +// --------------------------------------------------------------------------- +// x-fern-global-headers +// --------------------------------------------------------------------------- + +/// Lower a YAML scalar (string, integer, float, bool) used as a global +/// header's `default` into the on-the-wire string form. Returns `None` +/// for nulls, sequences, and mappings — those shapes aren't meaningful +/// as an HTTP header value, so we drop them rather than send something +/// nonsensical like `Some(["a","b"])` on the wire. +fn lower_global_header_default(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Bool(b) => Some(b.to_string()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + // Null, Sequence, Mapping, Tagged — not a valid header value. + _ => None, + } +} + +/// Lower the spec-root `x-fern-global-headers` block into the canonical +/// [`GlobalHeader`] discovery types. Mirrors the upstream Fern OpenAPI +/// importer's `getGlobalHeaders.ts`: entries without a `header` are +/// rejected at deserialize-time by serde; everything else is optional +/// and falls back to sensible defaults (required, no env, no default). +/// +/// `x-fern-default` wins over `default` when both are present. +fn lower_global_headers(raws: &[RawGlobalHeader]) -> Vec { + raws.iter() + .map(|raw| { + let default_yaml = raw.x_fern_default.as_ref().or(raw.default.as_ref()); + GlobalHeader { + header: raw.header.clone(), + name: raw.name.clone(), + optional: raw.optional.unwrap_or(false), + env: raw.env.clone(), + default: default_yaml.and_then(lower_global_header_default), + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// x-fern-groups +// --------------------------------------------------------------------------- + +/// Lower the document-root `x-fern-groups` block into the canonical +/// [`SdkGroupInfo`] discovery type, keyed by the kebab-cased group +/// identifier so it matches the resource-tree keys built from +/// `x-fern-sdk-group-name`. +/// +/// Mirrors fern's `getFernGroups.ts` / `SdkGroupInfo` IR shape +/// (`{ summary?, description? }`). Entries are kept verbatim — fern +/// does not invent additional fields, and neither do we. Empty +/// entries (both fields `None`) are preserved so the lookup tells +/// "no metadata" from "explicitly empty metadata", though both +/// render the same in `--help` today. +fn lower_fern_groups(raws: &HashMap) -> HashMap { + raws.iter() + .map(|(key, raw)| { + ( + camel_to_kebab(key), + SdkGroupInfo { + summary: raw.summary.clone(), + description: raw.description.clone(), + }, + ) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// x-fern-sdk-variables +// --------------------------------------------------------------------------- + +/// Lower the spec-root `x-fern-sdk-variables` block into a flat list of +/// [`SdkVariable`] entries. Mirrors Fern's openapi-ir-parser +/// `getVariableDefinitions.ts`: each variable is keyed by name, declares +/// a schema with `type` and optional `description`, and is only honored +/// when `type` is `string`. Non-string entries are logged and dropped so +/// the rest of the spec still loads — matching the upstream importer's +/// `Variable has unsupported schema` behavior without failing +/// the whole spec load (the CLI is intentionally permissive). +fn parse_sdk_variables(mapping: Option<&serde_yaml::Mapping>) -> Vec { + let Some(mapping) = mapping else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(mapping.len()); + for (name_val, schema_val) in mapping { + let name = match name_val.as_str() { + Some(s) => s.to_string(), + None => { + tracing::warn!( + "x-fern-sdk-variables entry has non-string key {:?}; skipping", + name_val + ); + continue; + } + }; + let schema_map = match schema_val.as_mapping() { + Some(m) => m, + None => { + tracing::warn!( + "x-fern-sdk-variables entry '{name}' is not an object; skipping" + ); + continue; + } + }; + let ty = schema_map + .get(serde_yaml::Value::String("type".into())) + .and_then(|v| v.as_str()) + .unwrap_or("string") + .to_string(); + if ty != "string" { + tracing::warn!( + "x-fern-sdk-variables entry '{name}' has unsupported type '{ty}'; \ + only string variables are supported today (skipping)" + ); + continue; + } + let description = schema_map + .get(serde_yaml::Value::String("description".into())) + .and_then(|v| v.as_str()) + .map(str::to_string); + out.push(SdkVariable { + name, + ty, + description, + }); + } + out +} + +// --------------------------------------------------------------------------- +// Parameter conversion +// --------------------------------------------------------------------------- + +fn convert_parameter( + param: &OpenApiParameter, + ref_site_default: Option<&serde_yaml::Value>, +) -> (String, MethodParameter) { + let (param_type, enum_values, schema_default, format, fern_enum) = match ¶m.schema { + Some(s) => ( + s.schema_type.clone(), + s.enum_values.clone(), + s.default.as_ref(), + s.format.clone(), + convert_fern_enum(s.x_fern_enum.as_ref()), + ), + None => (None, None, None, None, None), + }; + + // `x-fern-default` is the only source of a client-side default — + // i.e. a value the CLI will (a) advertise in `--help` via clap's + // `[default: ...]` and (b) substitute into the outgoing request + // when the user omits the flag. Within the extension, ref-site wins + // over the resolved component parameter, mirroring fern's + // openapi-ir-parser precedence: + // getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT) + let client_yaml_default: Option<&serde_yaml::Value> = + ref_site_default.or(param.x_fern_default.as_ref()); + let default_value = client_yaml_default.and_then(yaml_value_to_json); + + // The OpenAPI standard `default:` keyword on a parameter's schema + // describes server-side behavior — it tells the client what the API + // will do if the value is omitted, not what the client should send. + // We surface it in `--help` as a documentation hint only. + // + // When `x-fern-default` is present it supersedes the documentation + // hint for display too (showing two different defaults would confuse + // users), so we drop the schema default in that case. + let documentation_default_value = if default_value.is_some() { + None + } else { + schema_default.and_then(yaml_value_to_json) + }; + + // Operation-level `x-fern-availability` wins; otherwise fall back to + // OpenAPI's standard `deprecated: true` flag so flags marked deprecated + // in the source spec still surface a `[DEPRECATED]` badge in `--help`. + let availability = match param.x_fern_availability { + Some(a) => Some(a), + None if param.deprecated => Some(Availability::Deprecated), + None => None, + }; + + // `x-fern-sdk-variable` is only honored on `in: path` parameters — + // Fern's IR drops references on query/header/cookie params with a + // log line, and so do we (the parameter still surfaces as a normal + // per-op flag). + let variable_reference = match param.x_fern_sdk_variable.as_deref() { + Some(name) if param.location.as_deref() == Some("path") => Some(name.to_string()), + Some(name) => { + tracing::warn!( + "x-fern-sdk-variable '{name}' on non-path parameter '{}' is ignored", + param.name + ); + None + } + None => None, + }; + + let mp = MethodParameter { + param_type, + description: param.description.clone(), + location: param.location.clone(), + required: param.required, + format, + default_value, + documentation_default_value, + enum_values, + style: param.style.clone(), + explode: param.explode, + deprecated: param.deprecated, + availability, + fern_enum, + variable_reference, + ..Default::default() + }; + + (param.name.clone(), mp) +} + +/// Lower the raw YAML `x-fern-enum` map into the internal representation. +/// Drops entries whose `name` and `description` are both empty/whitespace +/// so downstream clap rendering doesn't emit blank labels or help text. +/// Returns `None` if the extension is absent or every entry was empty — +/// `None` is the signal cli-sdk uses to mean "fall back to wire values". +fn convert_fern_enum( + raw: Option<&HashMap>, +) -> Option> { + let raw = raw?; + let normalize = |s: &Option| -> Option { + s.as_ref().and_then(|v| { + let t = v.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }) + }; + let mut out: HashMap = HashMap::new(); + for (wire, entry) in raw { + let display_name = normalize(&entry.name); + let description = normalize(&entry.description); + if display_name.is_none() && description.is_none() { + continue; + } + out.insert( + wire.clone(), + crate::openapi::discovery::FernEnumValue { + display_name, + description, + }, + ); + } + if out.is_empty() { None } else { Some(out) } +} + +/// Convert a `serde_yaml::Value` into a `serde_json::Value` for storage on +/// `MethodParameter::default_value` (from `x-fern-default`) and +/// `MethodParameter::documentation_default_value` (from the standard +/// OpenAPI `default:` keyword). Mirrors YAML's scalar coverage so a +/// `100` keeps its integer type, `true` keeps its boolean type, and +/// `"abc"` stays a string. Tagged values are unwrapped; `~`/`null` +/// collapses to `Value::Null`. +fn yaml_value_to_json(v: &serde_yaml::Value) -> Option { + match v { + serde_yaml::Value::Null => Some(serde_json::Value::Null), + serde_yaml::Value::Bool(b) => Some(serde_json::Value::Bool(*b)), + serde_yaml::Value::Number(n) => { + if let Some(u) = n.as_u64() { + Some(serde_json::Value::Number(u.into())) + } else if let Some(i) = n.as_i64() { + Some(serde_json::Value::Number(i.into())) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f).map(serde_json::Value::Number) + } else { + None + } + } + serde_yaml::Value::String(s) => Some(serde_json::Value::String(s.clone())), + serde_yaml::Value::Sequence(seq) => Some(serde_json::Value::Array( + seq.iter().filter_map(yaml_value_to_json).collect(), + )), + serde_yaml::Value::Mapping(map) => { + let mut obj = serde_json::Map::new(); + for (k, val) in map { + let key = match k { + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other).ok()?.trim().to_string(), + }; + if let Some(jv) = yaml_value_to_json(val) { + obj.insert(key, jv); + } + } + Some(serde_json::Value::Object(obj)) + } + serde_yaml::Value::Tagged(t) => yaml_value_to_json(&t.value), + } +} + +fn resolve_parameter<'a>( + por: &'a OpenApiParamOrRef, + components: &'a Option, +) -> Option<&'a OpenApiParameter> { + match por { + OpenApiParamOrRef::Inline(p) => Some(p.as_ref()), + OpenApiParamOrRef::Ref { ref_path, .. } => { + let name = strip_ref_prefix(ref_path); + components + .as_ref() + .and_then(|c| c.parameters.get(&name)) + } + } +} + +/// Resolve the effective `x-fern-parameter-name` for a parameter using +/// the same precedence as `x-fern-ignore`: a value placed at the +/// **ref-site** object (alongside `$ref`) wins over the value on the +/// **resolved component parameter**. Inline parameters short-circuit to +/// their own value. Returns `None` when no alias is set. +/// +/// Implements the same semantics as fern's openapi-ir-parser +/// (`getParameterName.ts` + the `??` chain used for `x-fern-ignore`): +/// ```ts +/// const alias = +/// getExtension(parameter, PARAMETER_NAME) ?? +/// getExtension(resolvedParameter, PARAMETER_NAME); +/// ``` +fn resolve_parameter_display_name( + por: &OpenApiParamOrRef, + components: &Option, +) -> Option { + match por { + OpenApiParamOrRef::Inline(p) => p.x_fern_parameter_name.clone(), + OpenApiParamOrRef::Ref { + x_fern_parameter_name: ref_site, + .. + } => { + let resolved = resolve_parameter(por, components) + .and_then(|p| p.x_fern_parameter_name.clone()); + ref_site.clone().or(resolved) + } + } +} + +/// Resolve the effective `x-fern-ignore` value for a parameter, mirroring +/// fern's precedence: a value on the **ref-site object** (placed next to +/// `$ref`) wins over the value on the **resolved component parameter**. +/// Inline parameters are a single site, so they short-circuit. Returns +/// `false` when no flag is set at any level. +/// +/// Implements the same semantics as fern's openapi-ir-parser: +/// ```ts +/// const shouldIgnore = +/// getExtension(parameter, IGNORE) ?? +/// getExtension(resolvedParameter, IGNORE); +/// ``` +fn parameter_should_ignore( + por: &OpenApiParamOrRef, + components: &Option, +) -> bool { + match por { + OpenApiParamOrRef::Inline(p) => p.x_fern_ignore.unwrap_or(false), + OpenApiParamOrRef::Ref { + x_fern_ignore: ref_site, + .. + } => { + let resolved = resolve_parameter(por, components).and_then(|p| p.x_fern_ignore); + ref_site.or(resolved).unwrap_or(false) + } + } +} + +// --------------------------------------------------------------------------- +// Core conversion +// --------------------------------------------------------------------------- + +/// Load and convert an OpenAPI 3.0 YAML spec into the internal `RestDescription`. +pub fn load_openapi_spec(yaml_str: &str, cli_name: &str) -> Result { + let value: serde_yaml::Value = serde_yaml::from_str(yaml_str) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + load_openapi_spec_from_value(value, cli_name) +} + +/// Load and convert an OpenAPI spec from a pre-parsed `serde_yaml::Value`. +/// +/// This is the workhorse behind both [`load_openapi_spec`] (plain string) and +/// the overrides path where a base spec and override YAML are deep-merged into +/// a single `Value` before deserialization. +pub fn load_openapi_spec_from_value( + value: serde_yaml::Value, + cli_name: &str, +) -> Result { + let spec: OpenApiSpec = serde_yaml::from_value(value) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + + let root_url = spec + .servers + .first() + .map(|s| s.url.clone()) + .unwrap_or_default(); + + // Lower the spec's top-level `servers:` array into the internal + // representation. Order is preserved so callers can rely on + // "first server is the default" — the same rule that + // populates `root_url` above. + let top_level_servers: Vec = spec + .servers + .iter() + .map(OpenApiServer::to_discovery_server) + .collect(); + + // Convert component schemas. + // + // TODO(FER-9864): mirror fern's component-schema + property-level + // `x-fern-ignore` here once body fields surface as CLI flags. Fern's + // openapi-ir-parser drops ignored schemas in `convertSchemas.ts` and + // ignored properties in `convertObject.ts`; the CLI today only exposes + // operations + parameters, so those levels are a no-op for now and + // intentionally left unhandled. + let schemas: HashMap = spec + .components + .as_ref() + .map(|c| { + c.schemas + .iter() + .map(|(name, obj)| (name.clone(), convert_schema_object(obj))) + .collect() + }) + .unwrap_or_default(); + + // Lower components.securitySchemes to discovery types + let security_schemes: HashMap = spec + .components + .as_ref() + .map(|c| { + c.security_schemes + .iter() + .map(|(name, raw)| (name.clone(), lower_security_scheme(raw))) + .collect() + }) + .unwrap_or_default(); + + // Detect pagination token parameter name from components/parameters + let (pagination_query_param, pagination_response_path) = detect_pagination_config(&spec); + + // Normalize `x-fern-base-path`: trim ASCII whitespace and treat an empty + // string as absent so downstream slash-joining doesn't have to worry about + // a degenerate "" case. Leading/trailing slashes are preserved here — + // `build_url` is what normalizes them into exactly one slash between + // segments, so we don't lose authoring intent at parse time. + let base_path = normalize_base_path(spec.x_fern_base_path.as_deref()); + + // Lower spec-root `x-fern-idempotency-headers` into discovery types. Each + // entry will be materialized as a CLI flag on every idempotent operation + // below; non-idempotent operations never see these headers. + let idempotency_headers: Vec = spec + .x_fern_idempotency_headers + .as_ref() + .map(|raws| { + raws.iter() + .map(|raw| IdempotencyHeader { + header: raw.header.clone(), + name: raw.name.clone(), + env: raw.env.clone(), + }) + .collect() + }) + .unwrap_or_default(); + + // Lower the spec-root `x-fern-sdk-variables` block once. Variables + // surface as global flags later in `CliApp::run_async`; storing them + // on `RestDescription` keeps the parser as the single source of + // truth for both flag registration and per-operation substitution. + let sdk_variables = parse_sdk_variables(spec.x_fern_sdk_variables.as_ref()); + + // Spec-root `x-fern-retries`. Operations inherit this block when they + // either omit `x-fern-retries` or set it to `true`. Parsed once here + // so per-op resolution stays a cheap merge. + let spec_root_retries = parse_retries_value( + spec.x_fern_retries.as_ref().unwrap_or(&serde_yaml::Value::Null), + /*op_id=*/ "", + /*inherited=*/ true, + )?; + + // Lower the spec-root `x-fern-global-headers` block once. Globals + // surface as root flags in `CliApp::run_async` and are stamped on + // every outgoing request by the executor (per-operation parameters + // with the same wire-name still win). + let global_headers: Vec = spec + .x_fern_global_headers + .as_ref() + .map(|raws| lower_global_headers(raws)) + .unwrap_or_default(); + + // Lower the document-root `x-fern-groups` extension. Keys are + // kebab-cased so they match the resource-tree keys built from + // `x-fern-sdk-group-name` further down. Mirrors fern's + // `XFernGroupsSchema` (record of `{ summary?, description? }`). + let groups: HashMap = spec + .x_fern_groups + .as_ref() + .map(lower_fern_groups) + .unwrap_or_default(); + + let mut doc = RestDescription { + name: cli_name.to_string(), + version: spec.info.version.clone(), + title: spec.info.title.clone(), + description: spec.info.description.clone(), + root_url: root_url.clone(), + servers: top_level_servers, + service_path: String::new(), + base_path, + schemas, + security_schemes, + pagination_token_query_param: pagination_query_param, + pagination_token_response_path: pagination_response_path, + idempotency_headers, + sdk_variables, + retries: spec_root_retries.clone(), + global_headers, + groups, + ..Default::default() + }; + + // Spec-level security default. Inherited by every operation that + // doesn't declare its own `security:` block. An operation's + // `security: []` (explicit empty) overrides the default with anonymous. + let spec_default_security = spec.security.clone(); + + // Spec-root `x-fern-pagination`. Per-op `x-fern-pagination: true` + // inherits this block; per-op missing-or-`false` ignores it. + let spec_root_pagination = spec.x_fern_pagination.clone(); + + // Spec-root `x-fern-retries`. Per-op `x-fern-retries: true` adopts + // this block; per-op `false` or `{ disabled: true }` overrides it; + // per-op object merges over it field-by-field. + let spec_root_retries_raw = spec.x_fern_retries.clone(); + + // Build a reference to the component schemas for $ref body resolution. + let empty_component_schemas: HashMap = HashMap::new(); + let component_schemas: &HashMap = spec + .components + .as_ref() + .map(|c| &c.schemas) + .unwrap_or(&empty_component_schemas); + + // Process each path + method + #[allow(clippy::type_complexity)] + let http_methods: &[(&str, fn(&OpenApiPathItem) -> &Option)] = &[ + ("GET", |p: &OpenApiPathItem| &p.get), + ("POST", |p: &OpenApiPathItem| &p.post), + ("PUT", |p: &OpenApiPathItem| &p.put), + ("PATCH", |p: &OpenApiPathItem| &p.patch), + ("DELETE", |p: &OpenApiPathItem| &p.delete), + ]; + + for (path, path_item) in &spec.paths { + for &(http_method, accessor) in http_methods { + let operation = match accessor(path_item) { + Some(op) => op, + None => continue, + }; + + // Fern parity: `x-fern-ignore: true` drops the operation from the + // generated CLI surface entirely. The operation does not appear + // as a subcommand, in `--help`, or in completions. Log message + // mirrors fern's openapi-ir-parser wording so the two systems + // produce consistent diagnostics. + if operation.x_fern_ignore.unwrap_or(false) { + tracing::debug!( + "{} {} is marked with x-fern-ignore. Skipping.", + http_method, + path + ); + continue; + } + + // Resolve group name: prefer x-fern-sdk-group-name, fall back to first tag + let fern_group; + let tag_group; + let group_name: &Vec = match &operation.x_fern_sdk_group_name { + Some(g) if !g.is_empty() => g, + _ => match operation.tags.as_ref().and_then(|t| t.first()) { + Some(tag) => { + tag_group = vec![tag.clone()]; + &tag_group + } + None => { + // Fall back to first path segment as group + let segment = path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("default") + .to_string(); + fern_group = vec![segment]; + &fern_group + } + }, + }; + + // Resolve method name: prefer x-fern-sdk-method-name, fall back to operationId or http+path. + // When the group came from a tag (no x-fern-sdk-group-name), strip + // tag tokens that prefix the operationId so e.g. `Customers` tag + // + `customersList` operation → method `list` rather than + // `customers-list`. Mirrors Fern's OpenAPI importer. + let method_name = match &operation.x_fern_sdk_method_name { + Some(m) => m.clone(), + None => match &operation.operation_id { + Some(id) => { + let stripped = if operation.x_fern_sdk_group_name.is_none() { + match operation.tags.as_ref().and_then(|t| t.first()) { + Some(tag) => strip_tag_prefix(id, tag), + None => id.clone(), + } + } else { + id.clone() + }; + camel_to_kebab(&stripped) + } + None => format!( + "{}-{}", + http_method.to_lowercase(), + path.trim_start_matches('/').replace('/', "-") + ), + }, + }; + + // Collect parameters (path-level + operation-level). Parameters + // marked `x-fern-ignore: true` are dropped — they don't surface + // as CLI flags and aren't sent in the outgoing request. + // + // The flag is read with fern's precedence: a value placed at + // the **ref-site** object (alongside `$ref`) wins over the + // value on the resolved component parameter. This matches + // OpenAPI 3.1's allowance of sibling fields next to `$ref` and + // fern's overlay system, which routinely uses ref-site ignores. + let mut params = HashMap::new(); + for por in path_item.parameters.iter().chain(operation.parameters.iter()) { + if parameter_should_ignore(por, &spec.components) { + tracing::debug!( + "{} {} has a parameter marked with x-fern-ignore. Skipping.", + http_method, + path + ); + continue; + } + let display_name = resolve_parameter_display_name(por, &spec.components); + if let Some(p) = resolve_parameter(por, &spec.components) { + // Ref-site `x-fern-default` (placed alongside `$ref`) wins + // over the value on the resolved component parameter — + // mirrors fern's importer precedence for `getExtension`. + let ref_site_default = match por { + OpenApiParamOrRef::Ref { x_fern_default, .. } => x_fern_default.as_ref(), + OpenApiParamOrRef::Inline(_) => None, + }; + let (name, mut mp) = convert_parameter(p, ref_site_default); + mp.display_name = display_name; + params.insert(name, mp); + } + } + + // Handle request body — also harvests body-located parameters so + // the command builder can render per-field flags alongside `--json`. + let (request, binary_request_body, body_params) = extract_request_body( + &operation.request_body, + operation.operation_id.as_deref().unwrap_or("unknown"), + &mut doc.schemas, + component_schemas, + ); + // Skip body fields whose names collide with existing path/query/header + // params — those win, since the spec's `parameters` array is the + // canonical source for non-body inputs. + for (name, param) in body_params { + params.entry(name).or_insert(param); + } + + let description = operation + .summary + .clone() + .or_else(|| operation.description.clone()); + + let method_root_url = operation.servers + .first() + .map(|s| s.url.clone()) + .unwrap_or_else(|| root_url.clone()); + + // Per-op `servers:` overrides replace the global default for + // this operation. Lower them into the internal representation + // so the executor can route the global `--server ` flag + // against per-op named entries before falling back to + // `method_root_url` (the first per-op server). + let method_servers: Vec = operation + .servers + .iter() + .map(OpenApiServer::to_discovery_server) + .collect(); + + // OpenAPI inheritance: operation-level `security` (including an + // explicit empty array) takes precedence; otherwise inherit the + // spec-level default; if neither is present the operation has no + // declared policy. + let security_requirements = match &operation.security { + Some(reqs) => Some(reqs.clone()), + None => spec_default_security.clone(), + }; + + let pagination = resolve_pagination_extension( + operation.x_fern_pagination.as_ref(), + spec_root_pagination.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + let retries = resolve_retries_extension( + operation.x_fern_retries.as_ref(), + spec_root_retries_raw.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + // `x-fern-availability` wins; otherwise fall back to OpenAPI's + // standard `deprecated: true` flag so deprecated ops still get + // a `[DEPRECATED]` badge without requiring the extension. + let availability = match operation.x_fern_availability { + Some(a) => Some(a), + None if operation.deprecated => Some(Availability::Deprecated), + None => None, + }; + + let idempotent = operation.x_fern_idempotent.unwrap_or(false); + + // `x-fern-audiences` is an array of strings; missing means + // `[]`. Stored verbatim so the command-tree filter can + // mirror fern's `some(...)` membership check exactly. See + // discovery.rs `RestMethod::audiences` for the rationale on + // why this is parser-recorded but only consumed at the + // command-tree layer. + let audiences = operation.x_fern_audiences.clone().unwrap_or_default(); + + // Materialize idempotency-header flags on idempotent operations + // ONLY. Each spec-root `x-fern-idempotency-headers` entry becomes + // a synthetic header MethodParameter so the existing + // header-parameter pathway (clap flag → executor request + // header) handles the value. Non-idempotent siblings get no + // such parameter and therefore never send these headers on the + // wire, even if the user passes the flag explicitly (clap + // rejects it as unknown). + if idempotent { + inject_idempotency_header_params(&mut params, &doc.idempotency_headers); + } + + let return_value = operation + .x_fern_sdk_return_value + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let streaming = parse_streaming_extension( + operation.x_fern_streaming.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + // Mutual exclusivity: an operation that's both streamed and + // paginated is incoherent — pagination drives a loop of + // requests against fully-buffered responses, while + // streaming consumes a single open response incrementally. + // The upstream Fern IR doesn't generate a meaningful + // combination either; mirror that by failing at parse time + // so spec authors get a single clear error instead of an + // ambiguous runtime fallback. + if streaming.is_some() && pagination.is_some() { + return Err(CliError::Discovery(format!( + "Operation '{}' declares both `x-fern-streaming` and \ + `x-fern-pagination`, which are mutually exclusive. Streaming \ + operations open a single long-lived response; paginated \ + operations issue multiple requests against unary responses.", + operation.operation_id.as_deref().unwrap_or("unknown"), + ))); + } + + + let rest_method = RestMethod { + id: operation.operation_id.clone(), + description, + http_method: http_method.to_string(), + path: path.clone(), + parameters: params, + request, + root_url: method_root_url, + servers: method_servers, + binary_request_body, + security_requirements, + pagination, + availability, + idempotent, + return_value, + streaming, + retries, + audiences, + ..Default::default() + }; + + // Walk group_name to create/find nested resources + let kebab_groups: Vec = + group_name.iter().map(|g| camel_to_kebab(g)).collect(); + + insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + } + } + + // Fern parity: if every operation under a path/group was ignored, prune + // the now-empty group so it doesn't appear as a subcommand with no + // leaves in `--help` or completions. + prune_empty_resources(&mut doc.resources); + + Ok(doc) +} + +/// Recursively drop resources that contain no methods and no non-empty +/// nested resources. Called after all paths have been processed so that +/// `x-fern-ignore`-only paths don't leave orphan groups in the command tree. +fn prune_empty_resources(resources: &mut HashMap) { + resources.retain(|_, resource| { + prune_empty_resources(&mut resource.resources); + !resource.methods.is_empty() || !resource.resources.is_empty() + }); +} + +/// Walk the group name list to find or create nested resources and insert the method. +fn insert_method_into_resources( + resources: &mut HashMap, + groups: &[String], + method_name: &str, + method: RestMethod, +) { + if groups.is_empty() { + return; + } + + let resource = resources + .entry(groups[0].clone()) + .or_default(); + + if groups.len() == 1 { + resource.methods.insert(method_name.to_string(), method); + } else { + insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + } +} + +/// Extract request body info from an OpenAPI requestBody. +/// +/// Maximum recursion depth for flattening nested request body object properties +/// into dot-notation flags. Mirrors `MAX_INPUT_DEPTH` in `graphql/parser.rs`. +/// Properties at depth >= MAX_BODY_DEPTH are not flattened — `--json` remains +/// the only way to supply them. +const MAX_BODY_DEPTH: u8 = 3; + +/// Returns `(json_schema, binary_body, body_params)`: +/// - `json_schema`: a SchemaRef for the JSON request body (if `application/json` is declared). +/// - `binary_body`: metadata when the operation expects a raw binary body +/// (any non-JSON / non-form media type). +/// - `body_params`: per-field flag map; when the body is an inline object schema, +/// each property up to MAX_BODY_DEPTH is exposed as a body-located [`MethodParameter`] +/// with dotted keys for nested fields. `$ref` bodies are resolved from +/// `component_schemas` and their properties flattened with the same depth rules. +fn extract_request_body( + request_body: &Option, + operation_id: &str, + schemas: &mut HashMap, + component_schemas: &HashMap, +) -> (Option, Option, HashMap) { + let Some(body) = request_body.as_ref() else { + return (None, None, HashMap::new()); + }; + let Some(content) = body.content.as_ref() else { + return (None, None, HashMap::new()); + }; + + if let Some(media) = content.get("application/json") { + if let Some(schema_obj) = media.schema.as_ref() { + if let Some(ref_path) = &schema_obj.schema_ref { + let name = strip_ref_prefix(ref_path); + // Resolve the $ref from components/schemas and flatten its properties. + let body_params = component_schemas + .get(&name) + .map(|resolved| flatten_body_params(resolved, component_schemas, 0)) + .unwrap_or_default(); + return ( + Some(SchemaRef { + schema_ref: Some(name), + ..Default::default() + }), + None, + body_params, + ); + } + + let body_params = flatten_body_params(schema_obj, component_schemas, 0); + + let synthetic_name = format!("{operation_id}_request"); + let converted = convert_schema_object(schema_obj); + schemas.insert(synthetic_name.clone(), converted); + + return ( + Some(SchemaRef { + schema_ref: Some(synthetic_name), + ..Default::default() + }), + None, + body_params, + ); + } + } + + // No JSON body declared — look for a binary content type. Form bodies + // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their + // own flag UX and are explicitly excluded here. + let Some((content_type, media)) = content.iter().find(|(ct, _)| { + let ct = ct.as_str(); + ct != "application/x-www-form-urlencoded" && ct != "multipart/form-data" + }) else { + return (None, None, HashMap::new()); + }; + + let is_binary_format = media + .schema + .as_ref() + .and_then(|s| s.format.as_deref()) + .map(|f| f == "binary") + .unwrap_or(false); + + let flag_name = body + .x_fern_parameter_name + .as_deref() + .map(camel_to_kebab) + .unwrap_or_else(|| { + if is_binary_format { + "file".to_string() + } else { + "body".to_string() + } + }); + + ( + None, + Some(BinaryRequestBody { + content_type: content_type.clone(), + flag_name, + }), + HashMap::new(), + ) +} + +/// Recursively walk an object schema and emit one body-located [`MethodParameter`] +/// per property, up to `MAX_BODY_DEPTH` levels deep. Nested object properties +/// use dotted keys (e.g. `"name.first"`). Array properties set `repeated: true` +/// so the command builder renders `ArgAction::Append`. Read-only properties are +/// skipped. Non-object schemas at the root return an empty map. +fn flatten_body_params( + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, +) -> HashMap { + flatten_body_params_prefix(schema, component_schemas, depth, "") +} + +fn flatten_body_params_prefix( + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, + prefix: &str, +) -> HashMap { + let mut out = HashMap::new(); + if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + return out; + } + let required: std::collections::HashSet<&str> = + schema.required.iter().map(String::as_str).collect(); + for (name, prop) in &schema.properties { + if prop.read_only { + continue; + } + let full_key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + + // $ref property: resolve from component_schemas before checking type. + if let Some(ref_path) = &prop.schema_ref { + let ref_name = strip_ref_prefix(ref_path); + if let Some(resolved) = component_schemas.get(&ref_name) { + if resolved.schema_type.as_deref() == Some("object") { + let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); + if !nested.is_empty() { + out.extend(nested); + continue; + } + } + // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. + let is_array = resolved.schema_type.as_deref() == Some("array"); + out.insert( + full_key, + MethodParameter { + param_type: if is_array { + Some("string".to_string()) + } else { + resolved.schema_type.clone() + }, + description: prop.description.clone().or_else(|| resolved.description.clone()), + location: Some("body".to_string()), + required: required.contains(name.as_str()), + format: resolved.format.clone(), + enum_values: resolved.enum_values.clone(), + repeated: is_array, + ..Default::default() + }, + ); + } + // Unresolvable $ref — skip rather than emitting a typeless flag. + continue; + } + + let prop_type = prop.schema_type.as_deref(); + + // Nested object: recurse to emit dot-notation flags. If nothing comes + // back (no sub-properties or depth limit hit), fall through to the default insert below. + if prop_type == Some("object") { + let nested = flatten_body_params_prefix(prop, component_schemas, depth + 1, &full_key); + if !nested.is_empty() { + out.extend(nested); + continue; + } + } + + let is_array = prop_type == Some("array"); + out.insert( + full_key, + MethodParameter { + param_type: if is_array { + Some("string".to_string()) + } else { + prop.schema_type.clone() + }, + description: prop.description.clone(), + location: Some("body".to_string()), + required: required.contains(name.as_str()), + format: prop.format.clone(), + enum_values: prop.enum_values.clone(), + repeated: is_array, + ..Default::default() + }, + ); + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_camel_to_kebab() { + assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); + assert_eq!(camel_to_kebab("eventTypes"), "event-types"); + assert_eq!(camel_to_kebab("users"), "users"); + assert_eq!(camel_to_kebab("dataCompliance"), "data-compliance"); + assert_eq!(camel_to_kebab("ABC"), "a-b-c"); + // Tags from OpenAPI specs often contain spaces or hyphens — these + // should collapse to a single hyphen, not preserve a space before + // the next word's leading character. + assert_eq!(camel_to_kebab("Channel Settings"), "channel-settings"); + assert_eq!(camel_to_kebab("Attribute Values"), "attribute-values"); + assert_eq!(camel_to_kebab("Metafields Batch"), "metafields-batch"); + assert_eq!(camel_to_kebab("foo--bar"), "foo-bar"); + assert_eq!(camel_to_kebab("CustomerList"), "customer-list"); + } + + /// Locks `build.rs::to_kebab` and `parser.rs::camel_to_kebab` to the + /// same output. They must be byte-for-byte equivalent so the smoke-test + /// constants emitted by build.rs match what the parser produces at + /// runtime. If this test fails after a build.rs edit, sync the two impls. + #[test] + fn test_build_rs_to_kebab_matches_parser_camel_to_kebab() { + // Inline copy of build.rs::to_kebab — drift here is the whole point + // of the test, so we can't just call it. + fn build_rs_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for ch in s.chars() { + if !ch.is_ascii_alphanumeric() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + while result.ends_with('-') { + result.pop(); + } + result + } + for case in [ + "scheduledEvents", + "Metadata taxonomies", // hit the bug that started this + "Channel Settings", + "foo--bar", + "CustomerList", + "ABC", + "with.dot.separators", + "trailing---dashes-", + "leading---dashes", + "_leading_underscore", + ] { + assert_eq!( + build_rs_to_kebab(case), + camel_to_kebab(case), + "drift between build.rs::to_kebab and parser::camel_to_kebab for input {case:?}" + ); + } + } + + #[test] + fn test_tokenize_camel_and_other() { + // camelCase: split on capitals + assert_eq!(tokenize("getCustomers"), vec!["get", "customers"]); + assert_eq!(tokenize("customersList"), vec!["customers", "list"]); + // snake_case / spaces / mixed: split on non-alphanumeric + assert_eq!(tokenize("customer_addresses"), vec!["customer", "addresses"]); + assert_eq!(tokenize("Customer Addresses"), vec!["customer", "addresses"]); + // already a single token + assert_eq!(tokenize("customers"), vec!["customers"]); + } + + #[test] + fn test_strip_tag_prefix_strips_when_op_starts_with_tag() { + // Fern parity: `Customers` tag + `customersList` operationId → `list`. + assert_eq!(strip_tag_prefix("customersList", "Customers"), "list"); + // Multi-token tag ("Customer Addresses") matches multi-token op prefix. + assert_eq!( + strip_tag_prefix("customerAddressesList", "Customer Addresses"), + "list" + ); + } + + #[test] + fn test_strip_tag_prefix_no_strip_when_no_overlap() { + // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); + } + + #[test] + fn test_method_name_strips_tag_prefix_with_tag_grouping() { + // Tag-driven group + operationId starts with tag → method = remainder. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: customersList + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("list"), "method should be `list` after strip"); + } + + #[test] + fn test_method_name_keeps_operation_id_when_no_tag_overlap() { + // operationId doesn't start with tag → method stays as full kebab'd + // operationId. Matches Fern's behavior. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("get-customers")); + } + + #[test] + fn test_binary_request_body_flag_name_defaults_to_file_for_format_binary() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /upload: + post: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: upload + operationId: uploadFile + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let upload = &doc.resources["files"].methods["upload"]; + let binary = upload.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "application/octet-stream"); + assert_eq!(binary.flag_name, "file"); + } + + #[test] + fn test_binary_request_body_honors_x_fern_parameter_name() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /audio: + post: + x-fern-sdk-group-name: audio + x-fern-sdk-method-name: send + operationId: sendAudio + requestBody: + x-fern-parameter-name: audioFile + content: + audio/mpeg: + schema: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let send = &doc.resources["audio"].methods["send"]; + let binary = send.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "audio/mpeg"); + assert_eq!(binary.flag_name, "audio-file"); + } + + #[test] + fn test_binary_request_body_defaults_to_body_when_not_binary_format() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /text: + post: + x-fern-sdk-group-name: text + x-fern-sdk-method-name: send + operationId: sendText + requestBody: + content: + text/plain: + schema: + type: string + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let send = &doc.resources["text"].methods["send"]; + let binary = send.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "text/plain"); + assert_eq!(binary.flag_name, "body"); + } + + #[test] + fn test_group_name_accepts_scalar_string() { + // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare + // string; the parser should accept it as a single-element list. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /transcripts: + get: + x-fern-sdk-group-name: transcripts + x-fern-sdk-method-name: list + operationId: listTranscripts + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert!(doc.resources.contains_key("transcripts")); + assert!(doc.resources["transcripts"].methods.contains_key("list")); + } + + #[test] + fn test_method_name_skips_strip_when_explicit_group_name() { + // x-fern-sdk-group-name is the source of truth; tag-driven strip is + // bypassed so the operationId surfaces verbatim. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + x-fern-sdk-group-name: ["customers"] + operationId: customersList + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!( + customers.methods.contains_key("customers-list"), + "explicit group-name disables tag-prefix strip" + ); + } + + #[test] + fn test_nested_group_names() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /parent/{id}/child: + get: + operationId: get-child + summary: Get a child resource + x-fern-sdk-group-name: + - parent + - child + x-fern-sdk-method-name: get-child + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.resources.contains_key("parent")); + let parent = &doc.resources["parent"]; + assert!(parent.methods.is_empty()); + assert!(parent.resources.contains_key("child")); + let child = &parent.resources["child"]; + assert!(child.methods.contains_key("get-child")); + } + + // ----------------------------------------------------------------- + // x-fern-ignore — operation-level + parameter-level + // ----------------------------------------------------------------- + + #[test] + fn test_x_fern_ignore_drops_operation() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + x-fern-ignore: true + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!( + !doc.resources.contains_key("users"), + "ignored operation's group should be pruned when no other ops remain" + ); + } + + #[test] + fn test_x_fern_ignore_drops_parameter() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: keep_me + in: query + schema: + type: string + - name: drop_me + in: query + x-fern-ignore: true + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + list.parameters.contains_key("keep_me"), + "non-ignored param should survive" + ); + assert!( + !list.parameters.contains_key("drop_me"), + "ignored param should be absent from operation" + ); + } + + #[test] + fn test_x_fern_ignore_mixed_path_keeps_non_ignored_ops() { + // Same path, two operations: GET is ignored, POST is kept. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + x-fern-ignore: true + responses: + '200': + description: OK + post: + operationId: users-create + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: create + responses: + '201': + description: Created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let users = &doc.resources["users"]; + assert!(!users.methods.contains_key("list"), "ignored op absent"); + assert!(users.methods.contains_key("create"), "non-ignored op kept"); + } + + #[test] + fn test_x_fern_ignore_prunes_empty_nested_group() { + // A nested group whose only leaf is ignored should be pruned all the + // way up — the empty parent group must not appear as a subcommand + // with no children. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /parent/child: + get: + operationId: only-op + x-fern-sdk-group-name: ["parent", "child"] + x-fern-sdk-method-name: get + x-fern-ignore: true + responses: + '200': + description: OK + /siblings: + get: + operationId: siblings-list + x-fern-sdk-group-name: ["siblings"] + x-fern-sdk-method-name: list + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!( + !doc.resources.contains_key("parent"), + "empty parent group should be pruned after only child is ignored" + ); + assert!( + doc.resources.contains_key("siblings"), + "unrelated groups must remain" + ); + } + + #[test] + fn test_x_fern_ignore_default_false_keeps_operation_and_parameter() { + // Sanity check: omitting `x-fern-ignore` keeps the operation and + // its parameters exactly as before — no behavior change for specs + // that don't use the extension. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: filter + in: query + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!(list.parameters.contains_key("filter")); + } + + #[test] + fn test_x_fern_ignore_at_parameter_ref_site_drops_parameter() { + // Fern parity: when `x-fern-ignore: true` lives on the **ref-site** + // object (alongside `$ref`), the parameter is dropped even when the + // referenced component itself has no ignore flag. Mirrors fern's + // openapi-ir-parser precedence: + // getExtension(parameter, IGNORE) ?? getExtension(resolvedParameter, IGNORE) + // — ref-site wins, fallback to resolved. OpenAPI 3.1 explicitly + // allows sibling fields next to `$ref`, and fern's overlay system + // routinely places ignores at the ref site. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Filter' + x-fern-ignore: true + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: OK +components: + parameters: + Filter: + name: filter + in: query + schema: + type: string + Cursor: + name: cursor + in: query + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + !list.parameters.contains_key("filter"), + "ref-site x-fern-ignore should drop the parameter even when the resolved component has no flag" + ); + assert!( + list.parameters.contains_key("cursor"), + "ref to a non-ignored component should still produce a parameter" + ); + } + + #[test] + fn test_x_fern_ignore_at_component_drops_parameter_via_any_ref() { + // Mirror image of the ref-site test: when the **resolved component** + // carries the ignore flag and the ref site does not, every $ref to + // that component should drop the parameter. This is the fallback + // half of fern's `??` precedence. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Legacy' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: OK +components: + parameters: + Legacy: + name: legacy + in: query + x-fern-ignore: true + schema: + type: string + Cursor: + name: cursor + in: query + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + !list.parameters.contains_key("legacy"), + "component-level x-fern-ignore should drop the parameter when reached via $ref" + ); + assert!(list.parameters.contains_key("cursor")); + } + + // ----------------------------------------------------------------- + // x-fern-parameter-name — alias the CLI flag while keeping the + // original wire name on the outgoing HTTP request. Mirrors fern's + // openapi-ir-parser `parameterNameOverride` (see + // packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertParameters.ts). + // ----------------------------------------------------------------- + + #[test] + fn test_x_fern_parameter_name_inline_sets_display_name() { + // Canonical Fern example: a header parameter named `X-Fern-Version` + // is renamed to `version` on the SDK / CLI surface. The map key + // stays the wire name so the executor still sends it as a header + // with the original name. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: X-Fern-Version + in: header + x-fern-parameter-name: version + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("X-Fern-Version") + .expect("parameter should still be keyed by wire name"); + assert_eq!( + p.display_name.as_deref(), + Some("version"), + "display_name should hold the x-fern-parameter-name alias" + ); + assert_eq!(p.location.as_deref(), Some("header")); + } + + #[test] + fn test_x_fern_parameter_name_absent_leaves_display_name_none() { + // Sanity: when the extension is absent, `display_name` stays + // `None` so downstream code falls back to the wire name when + // building the CLI flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: filter + in: query + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list.parameters.get("filter").expect("filter param missing"); + assert!( + p.display_name.is_none(), + "missing x-fern-parameter-name should leave display_name = None" + ); + } + + #[test] + fn test_x_fern_parameter_name_at_ref_site_wins_over_component() { + // Ref-site precedence (matches the `??` chain fern uses for both + // x-fern-ignore and x-fern-parameter-name). The component-level + // alias is `legacyName`, but the ref-site override is `newName` + // — the ref-site value wins. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/LegacyParam' + x-fern-parameter-name: newName + responses: + '200': + description: OK +components: + parameters: + LegacyParam: + name: legacy_param + in: query + x-fern-parameter-name: legacyName + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("legacy_param") + .expect("wire name (param name) should still be the map key"); + assert_eq!( + p.display_name.as_deref(), + Some("newName"), + "ref-site x-fern-parameter-name should win over the resolved component value" + ); + } + + #[test] + fn test_x_fern_parameter_name_falls_back_to_component_when_ref_site_absent() { + // The fallback half of the `??` precedence: when the ref site has + // no alias, the resolved component's `x-fern-parameter-name` is + // used. This is the common case for shared parameter components. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/SharedHeader' + responses: + '200': + description: OK +components: + parameters: + SharedHeader: + name: X-Fern-Version + in: header + x-fern-parameter-name: version + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("X-Fern-Version") + .expect("wire name should be the map key"); + assert_eq!( + p.display_name.as_deref(), + Some("version"), + "component-level x-fern-parameter-name should be honored when ref site has none" + ); + } + + #[test] + fn test_x_fern_parameter_name_kebab_normalization_via_commands_builder() { + // The parser stores the raw alias as-is; kebab-casing is the + // command builder's responsibility (see `to_kebab_flag` in + // src/text.rs). This test pins the parser contract: the value + // stored on `MethodParameter::display_name` must match what the + // spec wrote, so the flag-builder can canonicalize it itself. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: X-Some-Wire-Header + in: header + x-fern-parameter-name: customerAccountId + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = &list.parameters["X-Some-Wire-Header"]; + // Raw value, exactly as the spec wrote it. `to_kebab_flag` + // converts `customerAccountId` → `customer-account-id`. + assert_eq!(p.display_name.as_deref(), Some("customerAccountId")); + // And the unit test for kebab normalization itself already lives + // in `src/text.rs` — see `test_to_kebab_flag`. + assert_eq!( + crate::text::to_kebab_flag(p.display_name.as_deref().unwrap()), + "customer-account-id" + ); + } + + // ----------------------------------------------------------------- + // x-fern-default vs. OpenAPI standard `default:` + // + // We split the two sources because they mean different things: + // * `x-fern-default` is a CLIENT-SIDE default — the CLI sends it + // on the wire when the user omits the flag, and it shows in + // `--help` via clap's `[default: ...]`. Stored on + // `MethodParameter::default_value`. + // * `default:` (OpenAPI standard) is a DOCUMENTATION HINT about + // server behavior. It is rendered as ` [API default: ...]` in + // `--help` but never sent on the wire. Stored on + // `MethodParameter::documentation_default_value`. + // + // Within `x-fern-default`, fern's openapi-ir-parser precedence + // applies: ref-site beats the resolved component parameter, i.e. + // getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT). + // + // When `x-fern-default` is present, the schema `default:` is + // dropped from `documentation_default_value` too so `--help` + // doesn't render two conflicting `[default: ...]` lines. + // ----------------------------------------------------------------- + + fn fern_default_yaml(parameters_block: &str) -> String { + format!( + r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: +{parameters_block} + responses: + '200': + description: OK +"# + ) + } + + #[test] + fn test_default_value_absent_when_no_default_anywhere() { + // Sanity check: omitting both `default:` and `x-fern-default` + // leaves both fields `None` — no clap default and no help-text + // suffix get emitted. + let yaml = fern_default_yaml( + " - name: cursor\n in: query\n schema:\n type: string", + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let cursor = doc.resources["users"].methods["list"] + .parameters + .get("cursor") + .unwrap(); + assert!(cursor.default_value.is_none()); + assert!(cursor.documentation_default_value.is_none()); + } + + #[test] + fn test_standard_openapi_default_lowers_as_documentation_only() { + // OpenAPI's standard `default:` describes server behavior and is + // doc-only for the CLI: it must populate the documentation field + // (so `--help` can mention it) but must NOT populate the + // client-side default field — sending it on the wire when the + // caller omits the flag would change the API contract. Numbers + // keep their JSON type so the help-text suffix renders `25` not + // `"25"`. + let yaml = fern_default_yaml( + " - name: limit\n in: query\n schema:\n type: integer\n default: 100", + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let limit = doc.resources["users"].methods["list"] + .parameters + .get("limit") + .unwrap(); + assert!( + limit.default_value.is_none(), + "schema `default:` must not produce a client-side default" + ); + assert_eq!( + limit.documentation_default_value, + Some(serde_json::Value::Number(100.into())), + "schema `default: 100` should round-trip as a JSON number on the documentation field" + ); + } + + #[test] + fn test_x_fern_default_alone_lowers_as_client_default() { + // `x-fern-default` with no standard `default:` is plumbed into + // the client-side `default_value` field. Covers string, boolean, + // and integer scalar forms — the documentation field stays + // `None` because there is no schema `default:` to surface. + let yaml = fern_default_yaml( + r#" - name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + - name: enabled + in: query + x-fern-default: true + schema: + type: boolean + - name: pageSize + in: query + x-fern-default: 50 + schema: + type: integer"#, + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let params = &doc.resources["users"].methods["list"].parameters; + assert_eq!( + params["region"].default_value, + Some(serde_json::Value::String("us-east-1".to_string())) + ); + assert!(params["region"].documentation_default_value.is_none()); + assert_eq!( + params["enabled"].default_value, + Some(serde_json::Value::Bool(true)) + ); + assert!(params["enabled"].documentation_default_value.is_none()); + assert_eq!( + params["pageSize"].default_value, + Some(serde_json::Value::Number(50.into())) + ); + assert!(params["pageSize"].documentation_default_value.is_none()); + } + + #[test] + fn test_x_fern_default_supersedes_schema_default_for_help_too() { + // When both are present we want the client-side default field + // populated AND the documentation field cleared, so `--help` + // doesn't render two conflicting `[default: ...]` lines. The + // user-visible default is what the CLI will actually do (send + // `50`); the underlying server default is intentionally hidden + // because the API author opted into overriding it. + let yaml = fern_default_yaml( + r#" - name: limit + in: query + x-fern-default: 50 + schema: + type: integer + default: 100"#, + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let limit = doc.resources["users"].methods["list"] + .parameters + .get("limit") + .unwrap(); + assert_eq!( + limit.default_value, + Some(serde_json::Value::Number(50.into())), + "x-fern-default must drive the client-side default" + ); + assert!( + limit.documentation_default_value.is_none(), + "schema.default should not also be surfaced when x-fern-default is set" + ); + } + + #[test] + fn test_x_fern_default_at_ref_site_wins_over_resolved_component() { + // Ref-site precedence: when `x-fern-default` is placed alongside + // a `$ref`, it wins over the value on the resolved component + // parameter. Mirrors fern's `getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT)`. The schema + // `default:` (a doc hint) is also suppressed because the + // client-side default takes over the `--help` slot. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + x-fern-default: "eu-west-1" + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert_eq!( + region.default_value, + Some(serde_json::Value::String("eu-west-1".to_string())), + "ref-site x-fern-default must win over both component-level x-fern-default and schema.default" + ); + assert!( + region.documentation_default_value.is_none(), + "schema.default should be suppressed when a client-side default exists" + ); + } + + #[test] + fn test_x_fern_default_from_resolved_component_when_no_ref_site_override() { + // Fallback half of the precedence: with no ref-site + // `x-fern-default`, the value on the resolved component + // parameter populates the client-side default, and the schema + // `default:` is still suppressed because the client-side slot + // is taken. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert_eq!( + region.default_value, + Some(serde_json::Value::String("us-east-1".to_string())) + ); + assert!(region.documentation_default_value.is_none()); + } + + #[test] + fn test_schema_default_via_ref_lowers_as_documentation_only() { + // Even when the parameter is reached via `$ref`, a schema-level + // `default:` with no `x-fern-default` anywhere must NOT become a + // client-side default. It populates the documentation field so + // `--help` can surface `[API default: us-west-2]` without + // forcing the CLI to send the value on the wire. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert!( + region.default_value.is_none(), + "schema.default reached via $ref must stay doc-only" + ); + assert_eq!( + region.documentation_default_value, + Some(serde_json::Value::String("us-west-2".to_string())) + ); + } + + #[test] + fn test_inline_request_body_produces_per_field_body_params() { + // An inline object schema in `requestBody` should expose each top-level + // property as a body-located MethodParameter so that the command builder + // can render per-field flags. Read-only fields are skipped, and required + // fields keep their `required` bit so the executor can enforce them. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + post: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + count: + type: integer + tags: + type: array + items: + type: string + server_generated_id: + type: string + readOnly: true + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["things"].methods["create"]; + + let name = create + .parameters + .get("name") + .expect("name should be a body param"); + assert_eq!(name.location.as_deref(), Some("body")); + assert_eq!(name.param_type.as_deref(), Some("string")); + assert!(name.required, "name is in `required` and should be marked"); + + let count = create + .parameters + .get("count") + .expect("count should be a body param"); + assert_eq!(count.location.as_deref(), Some("body")); + assert_eq!(count.param_type.as_deref(), Some("integer")); + assert!(!count.required); + + // Array body properties become repeated flags (repeated: true, param_type: string). + let tags = create + .parameters + .get("tags") + .expect("tags should be a body param"); + assert_eq!(tags.location.as_deref(), Some("body")); + assert!(tags.repeated, "array body prop should have repeated: true"); + assert_eq!(tags.param_type.as_deref(), Some("string")); + + // Read-only fields don't get a flag — they're server-managed. + assert!( + !create.parameters.contains_key("server_generated_id"), + "readOnly properties should be skipped" + ); + } + + #[test] + fn test_body_depth_3_plus_not_flattened() { + // Mirrors MAX_INPUT_DEPTH in graphql/parser.rs: depths 0, 1, 2 are + // flattened into dot-notation flags; depth >= 3 is not. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + post: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + address: + type: object + properties: + city: + type: string + location: + type: object + properties: + street: + type: string + geo: + type: object + properties: + lat: + type: number + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["users"].methods["create"]; + + // Depth-0: top-level scalar. + assert!(create.parameters.contains_key("name"), "depth-0 'name' should be a flag"); + + // Depth-1: one level of nesting. + assert!(create.parameters.contains_key("address.city"), "depth-1 'address.city' should be a flag"); + + // Depth-2: two levels of nesting — now emitted (matches GraphQL behaviour). + assert!(create.parameters.contains_key("address.location.street"), "depth-2 'address.location.street' should be a flag"); + + // Depth-3: NOT emitted — beyond MAX_BODY_DEPTH. + assert!(!create.parameters.contains_key("address.location.geo.lat"), "depth-3 'address.location.geo.lat' must not be a flag"); + // address.location.geo surfaces as a plain object flag (depth limit hit, recursion returns empty). + assert!(create.parameters.contains_key("address.location.geo"), "depth-2 object at limit should surface as plain flag"); + } + + #[test] + fn test_ref_property_within_inline_schema_resolved() { + // A property within an inline body schema that uses $ref should be + // resolved from components/schemas rather than emitted as a typeless flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /orders: + post: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + properties: + note: + type: string + address: + $ref: '#/components/schemas/Address' + responses: + "201": + description: Created order +components: + schemas: + Address: + type: object + properties: + city: + type: string + zip: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["orders"].methods["create"]; + + // Top-level scalar — present as-is. + assert!(create.parameters.contains_key("note"), "'note' should be a flag"); + + // $ref to an object at depth 0 — resolved and flattened into dot-notation flags. + assert!(create.parameters.contains_key("address.city"), "'address.city' should be a flag after $ref resolution"); + assert!(create.parameters.contains_key("address.zip"), "'address.zip' should be a flag after $ref resolution"); + + // The $ref itself should NOT appear as a typeless flag. + assert!(!create.parameters.contains_key("address"), "'address' $ref should not appear as a bare typeless flag"); + } + + #[test] + fn test_inline_body_does_not_clobber_query_params_with_same_name() { + // If a body schema property collides with an existing query/path/header + // parameter, the spec's `parameters` array wins — body-flag generation + // shouldn't silently turn a query param into a body param. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + post: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: create + parameters: + - name: name + in: query + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["things"].methods["create"]; + + // `name` was claimed by the query param first — it stays a query param. + let name = &create.parameters["name"]; + assert_eq!(name.location.as_deref(), Some("query")); + + // `description` doesn't collide, lands in the body normally. + let description = &create.parameters["description"]; + assert_eq!(description.location.as_deref(), Some("body")); + } + + #[test] + fn test_per_operation_server_override() { + let yaml = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +servers: + - url: "https://api.example.com" +paths: + /upload: + post: + servers: + - url: "https://upload.example.com" + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + responses: + "200": + description: ok + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Upload operation has its own server — should use it + let upload = doc.resources["uploads"].methods["create"].clone(); + assert_eq!(upload.root_url, "https://upload.example.com"); + // Users operation has no server override — falls back to spec-level + let users = doc.resources["users"].methods["list"].clone(); + assert_eq!(users.root_url, "https://api.example.com"); + } + + // ------------------------------------------------------------------ + // x-fern-idempotent + x-fern-idempotency-headers (FER-9864 P1). + // ------------------------------------------------------------------ + + /// Spec-root `x-fern-idempotency-headers` lowers to + /// `RestDescription.idempotency_headers` with the same shape, and an + /// operation marked `x-fern-idempotent: true` carries that flag + /// through to `RestMethod.idempotent`. + #[test] + fn test_idempotency_headers_parsed_from_spec_root() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key + env: API_IDEMPOTENCY_KEY + - header: X-Trace-Id +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.idempotency_headers.len(), 2, "both header entries parsed"); + assert_eq!(doc.idempotency_headers[0].header, "Idempotency-Key"); + assert_eq!(doc.idempotency_headers[0].name.as_deref(), Some("idempotency_key")); + assert_eq!(doc.idempotency_headers[0].env.as_deref(), Some("API_IDEMPOTENCY_KEY")); + assert_eq!(doc.idempotency_headers[1].header, "X-Trace-Id"); + assert!(doc.idempotency_headers[1].name.is_none()); + assert!(doc.idempotency_headers[1].env.is_none()); + } + + /// `x-fern-idempotent: true` toggles `RestMethod.idempotent` and + /// synthesizes one header `MethodParameter` per spec-root entry. A + /// sibling operation without the extension is unaffected — its + /// parameter map contains no idempotency-header entries, which is + /// what guarantees the flags are not surfaced and the header is not + /// sent on non-idempotent ops. + #[test] + fn test_idempotent_op_surfaces_header_param_non_idempotent_does_not() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key + env: API_IDEMPOTENCY_KEY +paths: + /payments: + get: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: list + operationId: payments_list + responses: + "200": + description: ok + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let payments = doc.resources.get("payments").expect("payments group"); + + // Idempotent op + let create = payments.methods.get("create").expect("create method"); + assert!(create.idempotent, "create is x-fern-idempotent: true"); + let idem_param = create + .parameters + .get("Idempotency-Key") + .expect("synthetic idempotency header parameter exists"); + assert_eq!(idem_param.location.as_deref(), Some("header")); + assert_eq!(idem_param.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + + // Non-idempotent sibling + let list = payments.methods.get("list").expect("list method"); + assert!(!list.idempotent, "list is not idempotent"); + assert!( + !list.parameters.contains_key("Idempotency-Key"), + "non-idempotent op must not surface idempotency-header params", + ); + } + + /// An operation marked idempotent but with no spec-root header + /// definitions still flips `idempotent = true`; no synthetic + /// parameters are added because there are no headers to inject. + #[test] + fn test_idempotent_op_without_spec_root_headers_has_no_synthetic_params() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.idempotency_headers.is_empty()); + let create = &doc.resources["payments"].methods["create"]; + assert!(create.idempotent); + assert!( + create.parameters.is_empty(), + "no synthetic params without spec-root header definitions", + ); + } + + /// When the `IdempotencyHeader` entry sets `name`, the synthetic + /// `MethodParameter` carries a `flag_name_override` derived from + /// `to_kebab_flag(name)`. The HashMap key remains the wire header + /// name (so the executor still sends the correct HTTP header). + /// This is the case the upstream Fern OpenAPI importer's SDK + /// parameter naming covers — a header like `X-Trace-Id` with + /// `name: trace_id` materializes as `--trace-id` on the CLI, not + /// `--x-trace-id`. + #[test] + fn test_idempotent_op_uses_name_for_flag_derivation() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: X-Trace-Id + name: trace_id + - header: Idempotency-Key +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["payments"].methods["create"]; + + // X-Trace-Id with `name: trace_id` → wire key stays + // `X-Trace-Id`, but the flag becomes `--trace-id`. + let trace = create.parameters.get("X-Trace-Id").unwrap(); + assert_eq!(trace.flag_name_override.as_deref(), Some("trace-id")); + assert_eq!(trace.location.as_deref(), Some("header")); + + // No `name` → no override; flag derives from the header name + // via the existing `to_kebab_flag` path in `commands.rs`. + let idem = create.parameters.get("Idempotency-Key").unwrap(); + assert!(idem.flag_name_override.is_none()); + } + + /// Spec-declared parameters always win over a synthetic injection + /// with the same key — a per-operation `Idempotency-Key` declaration + /// keeps its description, schema, and any other customizations the + /// author put on it. + #[test] + fn test_spec_declared_param_wins_over_injection() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + env: API_IDEMPOTENCY_KEY +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + parameters: + - name: Idempotency-Key + in: header + description: Custom description from author. + schema: + type: string + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create + .parameters + .get("Idempotency-Key") + .expect("declared param present"); + assert_eq!(p.description.as_deref(), Some("Custom description from author.")); + assert!( + p.env_var.is_none(), + "spec-declared param does not pick up env_var from the spec-root extension", + ); + } + + // ------------------------------------------------------------------ + // x-fern-global-headers (FER-9864 P2). + // ------------------------------------------------------------------ + + /// Absent extension → empty `global_headers` (the default-empty + /// `Vec` codepath). Pins the wire-compat baseline so a spec that + /// does not opt in is not changed. + #[test] + fn test_global_headers_absent_yields_empty_vec() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.global_headers.is_empty()); + } + + /// Full entry round-trips every field through the parser into + /// `RestDescription.global_headers`. Mirrors the upstream Fern + /// importer shape from `getGlobalHeaders.ts`. + #[test] + fn test_global_headers_full_entry_round_trips() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-API-Version + name: apiVersion + optional: false + env: API_VERSION + default: "2024-01-01" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.global_headers.len(), 1); + let h = &doc.global_headers[0]; + assert_eq!(h.header, "X-API-Version"); + assert_eq!(h.name.as_deref(), Some("apiVersion")); + assert!(!h.optional); + assert_eq!(h.env.as_deref(), Some("API_VERSION")); + assert_eq!(h.default.as_deref(), Some("2024-01-01")); + } + + /// Optional fields absent → defaults applied: `name` and `env` and + /// `default` are `None`, `optional` falls back to `false` (matching + /// upstream Fern's `?? false` default). + #[test] + fn test_global_headers_minimal_entry_uses_defaults() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Trace-Id +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let h = &doc.global_headers[0]; + assert_eq!(h.header, "X-Trace-Id"); + assert!(h.name.is_none()); + assert!(!h.optional, "optional defaults to false (i.e. required)"); + assert!(h.env.is_none()); + assert!(h.default.is_none()); + } + + /// `optional: true` lowers to `GlobalHeader.optional = true`. + /// Surfaces the required/optional toggle that the CLI registration + /// path consumes to decide whether to error on a missing value. + #[test] + fn test_global_headers_optional_true_lowers_to_optional() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Optional + optional: true +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.global_headers[0].optional); + } + + /// `default` accepts string / bool / number — they all lower to a + /// string for the outgoing HTTP header. Anything else (null / + /// sequence / mapping) drops to `None` rather than crashing. + #[test] + fn test_global_headers_default_accepts_scalar_types() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-String + default: "literal" + - header: X-Bool + default: true + - header: X-Number + default: 42 + - header: X-Null + default: null +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let by_header = |name: &str| -> &crate::openapi::discovery::GlobalHeader { + doc.global_headers + .iter() + .find(|h| h.header == name) + .expect("header parsed") + }; + assert_eq!(by_header("X-String").default.as_deref(), Some("literal")); + assert_eq!(by_header("X-Bool").default.as_deref(), Some("true")); + assert_eq!(by_header("X-Number").default.as_deref(), Some("42")); + assert!( + by_header("X-Null").default.is_none(), + "`null` is not a usable HTTP header value, so it drops to None" + ); + } + + /// `x-fern-default` takes precedence over `default` when both are + /// present, mirroring the upstream Fern importer where the + /// Fern-namespaced field is the authoritative source for header + /// defaults. + #[test] + fn test_global_headers_x_fern_default_overrides_default() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Stage + default: "production" + x-fern-default: "development" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.global_headers[0].default.as_deref(), + Some("development"), + "x-fern-default wins over default" + ); + } + + /// Multiple entries preserve declaration order. The registration + /// pass in `app.rs` later iterates this Vec to register flags, and + /// help-text ordering follows source order — pin that here so the + /// surface is stable across refactors. + #[test] + fn test_global_headers_preserves_declaration_order() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: First + - header: Second + - header: Third +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let headers: Vec<&str> = + doc.global_headers.iter().map(|h| h.header.as_str()).collect(); + assert_eq!(headers, vec!["First", "Second", "Third"]); + } + + // ------------------------------------------------------------------ + // x-fern-groups (FER-9864 P3). + // + // Document-root extension that decorates `x-fern-sdk-group-name` + // groups with `summary` / `description` metadata for the help + // surface. Shape mirrors the upstream Fern OpenAPI importer's + // `XFernGroupsSchema` zod schema and matching `SdkGroupInfo` IR + // type: + // fern-api/fern packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernGroups.ts:8-14 + // fern-api/fern packages/cli/api-importers/openapi/openapi-ir/fern/definition/finalIr.yml:51-54 + // ------------------------------------------------------------------ + + const X_FERN_GROUPS_SPEC_SKELETON: &str = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + + /// Baseline: with no `x-fern-groups` extension on the document + /// root, `RestDescription::groups` is the empty map. This is the + /// "feature opted out" path — every consumer that calls + /// `doc.groups.get(...)` falls back to the legacy + /// `Operations on ''` rendering. + #[test] + fn test_x_fern_groups_absent_yields_empty_map() { + let doc = load_openapi_spec(X_FERN_GROUPS_SPEC_SKELETON, "test").unwrap(); + assert!(doc.groups.is_empty()); + } + + /// Single-group case: both `summary` and `description` flow + /// through to `SdkGroupInfo` verbatim. Verifies the kebab-cased + /// lookup key matches the resource-tree key the command builder + /// uses. + #[test] + fn test_x_fern_groups_single_group_round_trips() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + description: Long-form prose explaining the things group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert_eq!(info.summary.as_deref(), Some("Things Operations")); + assert_eq!( + info.description.as_deref(), + Some("Long-form prose explaining the things group.") + ); + } + + /// Multiple groups parse independently. Order is irrelevant for + /// the HashMap lookup, so the test asserts on per-key shape rather + /// than iteration order. + #[test] + fn test_x_fern_groups_multiple_groups_parse_independently() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + widgets: + summary: Widgets Operations + description: A second group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok + /widgets: + get: + x-fern-sdk-group-name: [widgets] + x-fern-sdk-method-name: list + operationId: widgets_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.groups.len(), 2); + assert_eq!( + doc.groups["things"].summary.as_deref(), + Some("Things Operations"), + ); + assert!(doc.groups["things"].description.is_none()); + assert_eq!( + doc.groups["widgets"].summary.as_deref(), + Some("Widgets Operations"), + ); + assert_eq!( + doc.groups["widgets"].description.as_deref(), + Some("A second group."), + ); + } + + /// Summary-only entry: `description` stays `None` so the command + /// builder falls back to the `about()` text when rendering + /// `--long-help`. + #[test] + fn test_x_fern_groups_summary_only_keeps_description_none() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert_eq!(info.summary.as_deref(), Some("Things Operations")); + assert!(info.description.is_none()); + } + + /// Description-only entry: `summary` stays `None`. The command + /// builder then keeps the legacy `Operations on ''` about + /// line while still surfacing the description via + /// `long_about()`. + #[test] + fn test_x_fern_groups_description_only_keeps_summary_none() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + description: Long-form prose about the group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert!(info.summary.is_none()); + assert_eq!( + info.description.as_deref(), + Some("Long-form prose about the group."), + ); + } + + /// Group keys are kebab-cased so they line up with the resource + /// keys the command builder produces from `x-fern-sdk-group-name` + /// (which itself runs through `camel_to_kebab`). A `myGroup` entry + /// surfaces as `my-group`; the original casing is intentionally + /// not preserved. + #[test] + fn test_x_fern_groups_keys_are_kebab_cased() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + myGroup: + summary: Pretty Label +paths: + /things: + get: + x-fern-sdk-group-name: [myGroup] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.groups.contains_key("my-group")); + assert!(!doc.groups.contains_key("myGroup")); + assert_eq!( + doc.groups["my-group"].summary.as_deref(), + Some("Pretty Label"), + ); + } + + /// Unrelated extra fields inside a group entry are ignored + /// rather than rejected. Fern's `getFernGroups.ts` schema is a + /// `z.object({ summary, description })` (no `.strict()`), so the + /// importer also tolerates extras — we mirror that to stay + /// forward-compatible with the documented `groups:` nesting + /// field on the wire (which the cli-sdk does not consume). + #[test] + fn test_x_fern_groups_tolerates_unknown_fields() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + groups: [other] +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.groups["things"].summary.as_deref(), + Some("Things Operations"), + ); + } + + // ------------------------------------------------------------------ + // Security scheme parsing + per-operation security inheritance. + // ------------------------------------------------------------------ + + fn first_method<'a>(doc: &'a RestDescription, group: &str, method: &str) -> &'a RestMethod { + doc.resources + .get(group) + .unwrap_or_else(|| panic!("resource '{group}' missing")) + .methods + .get(method) + .unwrap_or_else(|| panic!("method '{method}' on '{group}' missing")) + } + + #[test] + fn test_parses_http_bearer_security_scheme() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("bearerAuth"), + Some(&SecurityScheme::HttpBearer), + ); + } + + #[test] + fn test_parses_http_basic_security_scheme() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + basicAuth: + type: http + scheme: basic +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("basicAuth"), + Some(&SecurityScheme::HttpBasic), + ); + } + + #[test] + fn test_parses_apikey_header_and_query() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + headerKey: + type: apiKey + in: header + name: X-Api-Key + queryKey: + type: apiKey + in: query + name: api_key +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("headerKey"), + Some(&SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }), + ); + assert_eq!( + doc.security_schemes.get("queryKey"), + Some(&SecurityScheme::ApiKeyQuery { + name: "api_key".to_string(), + }), + ); + } + + #[test] + fn test_parses_oauth2_security_scheme_as_oauth2() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + oauthScheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://x.com/token + scopes: + read: read scope +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("oauthScheme"), + Some(&SecurityScheme::OAuth2), + ); + } + + #[test] + fn test_unknown_security_type_falls_through_to_other() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + weird: + type: mutualTLS +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + match doc.security_schemes.get("weird") { + Some(SecurityScheme::Other(s)) => assert_eq!(s, "mutualtls"), + other => panic!("unexpected scheme: {other:?}"), + } + } + + #[test] + fn test_operation_inherits_spec_level_security() { + // Top-level `security: [{bearerAuth: []}]` is inherited by an + // operation that doesn't declare its own. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let reqs = m + .security_requirements + .as_ref() + .expect("inherited requirements present"); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("bearerAuth")); + } + + #[test] + fn test_operation_security_overrides_spec_default() { + // Operation declares its own `security` — that wins over the spec + // default, even if it picks a different scheme. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } + apiKey: { type: apiKey, in: header, name: X-Api-Key } +paths: + /admin: + get: + x-fern-sdk-group-name: ["admin"] + x-fern-sdk-method-name: ping + security: + - apiKey: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "admin", "ping"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("apiKey")); + assert!(!reqs[0].contains_key("bearerAuth")); + } + + #[test] + fn test_explicit_empty_operation_security_means_anonymous() { + // `security: []` on an operation is meaningful — it explicitly opts + // out of the spec-level default, marking the endpoint anonymous. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } +paths: + /public: + get: + x-fern-sdk-group-name: ["public"] + x-fern-sdk-method-name: ping + security: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "public", "ping"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert!( + reqs.is_empty(), + "explicit empty array should produce Some(vec![]), got {reqs:?}", + ); + } + + #[test] + fn test_no_security_anywhere_leaves_requirements_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + assert!(m.security_requirements.is_none()); + } + + #[test] + fn test_spec_level_empty_security_inherited_as_anonymous() { + // `security: []` at the spec root means every operation is + // anonymous by default unless it declares its own. Inheritance + // should propagate the explicit empty vec through. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: [] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert!( + reqs.is_empty(), + "spec-level explicit anonymous should propagate, got {reqs:?}", + ); + } + + #[test] + fn test_security_scheme_type_and_scheme_are_case_insensitive() { + // OpenAPI doesn't formally constrain casing on `type` / `scheme`; + // real-world specs vary. Match generously. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + a: + type: HTTP + scheme: Bearer + b: + type: ApiKey + in: HEADER + name: X-Api-Key +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.security_schemes.get("a"), Some(&SecurityScheme::HttpBearer)); + assert_eq!( + doc.security_schemes.get("b"), + Some(&SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }), + ); + } + + #[test] + fn test_operation_can_reference_undeclared_scheme() { + // An operation referencing a scheme not in components.securitySchemes + // is preserved verbatim — Phase 3's RoutingAuthProvider will simply + // have no binding for it and fall through. Some real-world specs + // reference externally-configured schemes this way. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /thing: + get: + x-fern-sdk-group-name: ["thing"] + x-fern-sdk-method-name: get + security: + - externalScheme: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "thing", "get"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("externalScheme")); + // No declaration in components.securitySchemes — that's fine. + assert!(doc.security_schemes.is_empty()); + } + + #[test] + fn test_or_of_ands_security_requirements() { + // The classic `[{a: []}, {b: [], c: []}]` shape: satisfy a alone, OR + // (b AND c). Verifies we preserve the structure verbatim. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + a: { type: http, scheme: bearer } + b: { type: apiKey, in: header, name: X-B } + c: { type: apiKey, in: header, name: X-C } +paths: + /complex: + get: + x-fern-sdk-group-name: ["complex"] + x-fern-sdk-method-name: get + security: + - a: [] + - b: [] + c: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "complex", "get"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 2); + // First alternative: just `a`. + assert!(reqs[0].contains_key("a")); + assert_eq!(reqs[0].len(), 1); + // Second alternative: `b` AND `c`. + assert!(reqs[1].contains_key("b")); + assert!(reqs[1].contains_key("c")); + assert_eq!(reqs[1].len(), 2); + } + + // ----------------------------------------------------------------------- + // deep_merge_yaml tests — matches Fern CLI mergeWithOverrides behavior + // ----------------------------------------------------------------------- + + // -- Scalar / map basics ------------------------------------------------ + + #[test] + fn test_deep_merge_scalars_override_wins() { + let base: serde_yaml::Value = serde_yaml::from_str("title: Original").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("title: Overridden").unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["title"], serde_yaml::Value::String("Overridden".into())); + } + + #[test] + fn test_deep_merge_adds_new_keys() { + let base: serde_yaml::Value = serde_yaml::from_str("a: 1").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("b: 2").unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["a"], serde_yaml::Value::Number(1.into())); + assert_eq!(merged["b"], serde_yaml::Value::Number(2.into())); + } + + /// Fern CLI test: "should handle nested object merging" + #[test] + fn test_deep_merge_nested_object_merging() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + config: + settings: + theme: light + notifications: true + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + config: + settings: + theme: dark + sound: false + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["config"]["settings"]["theme"], serde_yaml::Value::String("dark".into())); + assert_eq!(merged["config"]["settings"]["notifications"], serde_yaml::Value::Bool(true)); + assert_eq!(merged["config"]["settings"]["sound"], serde_yaml::Value::Bool(false)); + } + + /// Fern CLI test: "deep-merges nested objects rather than replacing them" + #[test] + fn test_deep_merge_nested_sibling_keys() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + foo: + bar: + existingKey: original + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + foo: + bar: + newKey: added + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!( + merged["foo"]["bar"]["existingKey"], + serde_yaml::Value::String("original".into()) + ); + assert_eq!( + merged["foo"]["bar"]["newKey"], + serde_yaml::Value::String("added".into()) + ); + } + + #[test] + fn test_deep_merge_nested_openapi_paths() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + paths: + /users: + get: + summary: List users + operationId: listUsers + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + paths: + /users: + get: + x-fern-sdk-group-name: [users] + x-fern-sdk-method-name: list + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!( + merged["paths"]["/users"]["get"]["summary"], + serde_yaml::Value::String("List users".into()) + ); + assert_eq!( + merged["paths"]["/users"]["get"]["operationId"], + serde_yaml::Value::String("listUsers".into()) + ); + assert_eq!( + merged["paths"]["/users"]["get"]["x-fern-sdk-method-name"], + serde_yaml::Value::String("list".into()) + ); + } + + // -- Null deletion (omitDeepBy(isNull)) --------------------------------- + + #[test] + fn test_deep_merge_null_deletes_key() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + info: + title: API + description: A description + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + info: + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["info"]["title"], serde_yaml::Value::String("API".into())); + let info = merged["info"].as_mapping().unwrap(); + assert!(!info.contains_key("description"), "null should delete the key"); + } + + /// Fern CLI test: "removes null values from merged result" + #[test] + fn test_deep_merge_null_removes_from_merged_result() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + title: Title + description: A description + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["title"], serde_yaml::Value::String("Title".into())); + assert!(!merged.as_mapping().unwrap().contains_key("description")); + } + + /// Nulls inside non-allowlisted keys are still removed. + #[test] + fn test_deep_merge_removes_pre_existing_nulls_outside_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + type: object + properties: + name: + type: string + description: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let name = merged["properties"]["name"].as_mapping().unwrap(); + assert!(name.contains_key("type")); + assert!(!name.contains_key("description"), "null outside examples should be removed"); + } + + /// Fern CLI parity: nulls inside `examples` keys are preserved + /// (allowNullKeys = ["examples"]). + #[test] + fn test_deep_merge_preserves_nulls_inside_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + type: object + properties: + name: + type: string + examples: + example1: John + example2: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let examples = merged["properties"]["name"]["examples"].as_mapping().unwrap(); + assert!(examples.contains_key("example1")); + assert!(examples.contains_key("example2"), "null inside examples should be preserved"); + assert!(examples.get("example2").unwrap().is_null()); + } + + /// Nulls deeply nested under an `examples` key are also preserved. + #[test] + fn test_deep_merge_preserves_nulls_deeply_nested_under_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + examples: + myExample: + value: + name: John + email: null + nested: + field: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let value = &merged["examples"]["myExample"]["value"]; + assert!(value["email"].is_null(), "null under examples descendant preserved"); + assert!(value["nested"]["field"].is_null(), "deeply nested null under examples preserved"); + } + + /// Nulls outside `examples` are removed even when siblings of examples. + #[test] + fn test_deep_merge_mixed_examples_and_non_examples_nulls() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + schema: + description: null + examples: + ex1: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let schema = merged["schema"].as_mapping().unwrap(); + assert!(!schema.contains_key("description"), "null outside examples removed"); + let examples = schema.get("examples").unwrap().as_mapping().unwrap(); + assert!(examples.contains_key("ex1"), "null inside examples preserved"); + } + + /// Null deletion should be recursive through deeply nested maps. + #[test] + fn test_deep_merge_null_deletes_deeply_nested() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + a: + b: + c: + keep: true + remove_me: value + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + a: + b: + c: + remove_me: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let c = merged["a"]["b"]["c"].as_mapping().unwrap(); + assert!(c.contains_key("keep")); + assert!(!c.contains_key("remove_me")); + } + + // -- Array of primitives: replaced wholesale (Fern parity) -------------- + + /// Fern CLI test: "should replace arrays of primitives" + #[test] + fn test_deep_merge_primitive_arrays_replaced_wholesale() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + tags: [tag1, tag2] + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + tags: [tag3, tag4] + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let tags = merged["tags"].as_sequence().unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0], serde_yaml::Value::String("tag3".into())); + assert_eq!(tags[1], serde_yaml::Value::String("tag4".into())); + } + + #[test] + fn test_deep_merge_primitive_array_shorter_override_replaces() { + let base: serde_yaml::Value = serde_yaml::from_str("tags: [a, b, c]").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("tags: [x]").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let tags = merged["tags"].as_sequence().unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0], serde_yaml::Value::String("x".into())); + } + + // -- Arrays of objects: merged element-by-element (Fern parity) --------- + + /// Fern CLI test: "should merge arrays of objects" + #[test] + fn test_deep_merge_object_arrays_merged_by_index() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - id: 1 + name: Item 1 + - id: 2 + name: Item 2 + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - id: 1 + description: Updated Item 1 + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let items = merged["items"].as_sequence().unwrap(); + // Element 0 merged: base {id:1, name: Item 1} + override {id:1, description: Updated Item 1} + assert_eq!(items[0]["id"], serde_yaml::Value::Number(1.into())); + assert_eq!(items[0]["name"], serde_yaml::Value::String("Item 1".into())); + assert_eq!(items[0]["description"], serde_yaml::Value::String("Updated Item 1".into())); + // Element 1 carried from base (override only has 1 element) + assert_eq!(items.len(), 2); + assert_eq!(items[1]["id"], serde_yaml::Value::Number(2.into())); + assert_eq!(items[1]["name"], serde_yaml::Value::String("Item 2".into())); + } + + /// Override array longer than base — extra elements appended. + #[test] + fn test_deep_merge_object_arrays_override_longer() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + servers: + - url: "https://a.com" + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + servers: + - url: "https://a-patched.com" + - url: "https://b.com" + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let servers = merged["servers"].as_sequence().unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0]["url"], serde_yaml::Value::String("https://a-patched.com".into())); + assert_eq!(servers[1]["url"], serde_yaml::Value::String("https://b.com".into())); + } + + /// OpenAPI parameters array (array of objects) should merge by index. + #[test] + fn test_deep_merge_parameters_array_merges_by_index() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + parameters: + - name: limit + in: query + required: false + - name: offset + in: query + required: false + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + parameters: + - description: Maximum number of results + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let params = merged["parameters"].as_sequence().unwrap(); + assert_eq!(params.len(), 2); + // First param: merged with override + assert_eq!(params[0]["name"], serde_yaml::Value::String("limit".into())); + assert_eq!(params[0]["description"], serde_yaml::Value::String("Maximum number of results".into())); + // Second param: untouched from base + assert_eq!(params[1]["name"], serde_yaml::Value::String("offset".into())); + } + + // -- Mixed arrays (primitives + objects): replaced wholesale ------------- + + #[test] + fn test_deep_merge_mixed_array_replaced_wholesale() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + mixed: + - name: obj + - just_a_string + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + mixed: + - replaced: true + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let mixed = merged["mixed"].as_sequence().unwrap(); + // Base had mixed types → override replaces wholesale + assert_eq!(mixed.len(), 1); + } + + // -- Enum arrays (primitives) in schemas -------------------------------- + + #[test] + fn test_deep_merge_enum_array_replaced() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + accountStatus: + type: string + enum: [active, suspended, deleted] + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + accountStatus: + enum: [active, suspended, deleted, inactive] + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let enums = merged["accountStatus"]["enum"].as_sequence().unwrap(); + assert_eq!(enums.len(), 4); + assert_eq!(enums[3], serde_yaml::Value::String("inactive".into())); + // type preserved from base + assert_eq!(merged["accountStatus"]["type"], serde_yaml::Value::String("string".into())); + } + + // -- Overrides-resolution fixture parity -------------------------------- + + /// Matches the Fern CLI overrides-resolution fixture: override adds a new + /// property (lastName) to an existing schema, preserving existing ones. + #[test] + fn test_deep_merge_override_adds_schema_property() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserUpdate: + type: object + properties: + name: + type: string + email: + type: string + nullable: true + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserUpdate: + type: object + properties: + name: + type: string + lastName: + type: string + email: + type: string + nullable: true + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let props = merged["components"]["schemas"]["UserUpdate"]["properties"] + .as_mapping().unwrap(); + assert!(props.contains_key("name")); + assert!(props.contains_key("lastName"), "new property from override"); + assert!(props.contains_key("email")); + } + + /// Override introduces an entirely new schema that doesn't exist in the base. + #[test] + fn test_deep_merge_override_adds_new_schema() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + User: + type: object + properties: + id: + type: string + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserStats: + type: object + properties: + totalLogins: + type: integer + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let schemas = merged["components"]["schemas"].as_mapping().unwrap(); + assert!(schemas.contains_key("User"), "base schema preserved"); + assert!(schemas.contains_key("UserStats"), "new schema from override"); + } + + // -- Sequential override application ------------------------------------ + + #[test] + fn test_deep_merge_multiple_overrides_applied_sequentially() { + let base: serde_yaml::Value = serde_yaml::from_str("a: 1\nb: 2\nc: 3").unwrap(); + let ovr1: serde_yaml::Value = serde_yaml::from_str("a: 10\nd: 4").unwrap(); + let ovr2: serde_yaml::Value = serde_yaml::from_str("a: 100\nb: null").unwrap(); + let merged = deep_merge_yaml(deep_merge_yaml(base, ovr1), ovr2); + assert_eq!(merged["a"], serde_yaml::Value::Number(100.into())); + assert!(!merged.as_mapping().unwrap().contains_key("b")); + assert_eq!(merged["c"], serde_yaml::Value::Number(3.into())); + assert_eq!(merged["d"], serde_yaml::Value::Number(4.into())); + } + + // -- Empty overrides is identity ---------------------------------------- + + #[test] + fn test_deep_merge_empty_override_is_identity() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + info: + title: API + version: "1.0" + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base.clone(), overrides); + // With the exception of pre-existing nulls being removed, result + // should match. This base has none, so it should be identical. + assert_eq!(merged["info"]["title"], base["info"]["title"]); + assert_eq!(merged["info"]["version"], base["info"]["version"]); + } + + // -- End-to-end: override adds Fern extensions, parser reflects them ---- + + #[test] + fn test_deep_merge_override_adds_fern_extensions_to_spec() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list +"#; + // Without overrides: method name from operationId + let doc_no_override = load_openapi_spec(base_yaml, "t").unwrap(); + let customers = &doc_no_override.resources["customers"]; + assert!(customers.methods.contains_key("get-customers")); + + // With overrides: method name from x-fern-sdk-method-name + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc_with_override = load_openapi_spec_from_value(merged, "t").unwrap(); + let customers = &doc_with_override.resources["customers"]; + assert!( + customers.methods.contains_key("list"), + "override should set method name to 'list', got keys: {:?}", + customers.methods.keys().collect::>() + ); + } + + /// Multi-operation override: adds fern extensions to multiple endpoints. + #[test] + fn test_deep_merge_multi_operation_fern_extensions() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } + post: + tags: [Customers] + operationId: createCustomer + responses: { "201": { description: created } } + /orders: + get: + tags: [Orders] + operationId: getOrders + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list + post: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: create + /orders: + get: + x-fern-sdk-group-name: [orders] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc = load_openapi_spec_from_value(merged, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("list")); + assert!(customers.methods.contains_key("create")); + let orders = &doc.resources["orders"]; + assert!(orders.methods.contains_key("list")); + } + + /// Override re-groups an operation into a different resource. + #[test] + fn test_deep_merge_override_changes_group_name() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /admin/users: + get: + tags: [Admin] + operationId: adminListUsers + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /admin/users: + get: + x-fern-sdk-group-name: [admin, users] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc = load_openapi_spec_from_value(merged, "t").unwrap(); + let admin = &doc.resources["admin"]; + let users = &admin.resources["users"]; + assert!( + users.methods.contains_key("list"), + "override should place method under admin.users" + ); + } + + // -- Null removal inside arrays of objects ------------------------------ + + #[test] + fn test_deep_merge_null_removed_inside_object_array() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - name: keep + remove: value + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - remove: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let item = &merged["items"].as_sequence().unwrap()[0]; + let map = item.as_mapping().unwrap(); + assert!(map.contains_key("name")); + assert!(!map.contains_key("remove"), "null inside object array element should be removed"); + } + + // -- Verification: allowNullKeys covers all Fern CLI keys --------------- + + #[test] + fn test_allow_null_keys_covers_all_fern_cli_keys() { + assert!(ALLOW_NULL_KEYS.contains(&"examples")); + assert!(ALLOW_NULL_KEYS.contains(&"example")); + assert!(ALLOW_NULL_KEYS.contains(&"x-fern-examples")); + assert!(ALLOW_NULL_KEYS.contains(&"x-code-samples")); + assert!(ALLOW_NULL_KEYS.contains(&"x-codeSamples")); + assert_eq!(ALLOW_NULL_KEYS.len(), 5, "should have exactly 5 keys matching Fern CLI"); + } + + #[test] + fn test_null_preserved_under_example_singular() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + schema: + example: null + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base.clone(), serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let map = merged.as_mapping().unwrap(); + let schema = map.get("schema").unwrap().as_mapping().unwrap(); + assert!(schema.contains_key("example"), "'example' (singular) null should be preserved"); + assert!(!schema.contains_key("description"), "'description' null should be removed"); + } + + #[test] + fn test_null_preserved_under_x_fern_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + x-fern-examples: + - value: null + "#).unwrap(); + let merged = deep_merge_yaml(base, serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let seq = merged["x-fern-examples"].as_sequence().unwrap(); + let item = seq[0].as_mapping().unwrap(); + assert!(item.get("value").unwrap().is_null(), "null under x-fern-examples should be preserved"); + } + + #[test] + fn test_null_preserved_under_x_code_samples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + x-code-samples: + - lang: python + source: null + "#).unwrap(); + let merged = deep_merge_yaml(base, serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let item = &merged["x-code-samples"].as_sequence().unwrap()[0]; + assert!(item["source"].is_null(), "null under x-code-samples should be preserved"); + } + + // -- Verification: all_objects heuristic -------------------------------- + + #[test] + fn test_all_objects_empty_arrays_vacuous_truth() { + assert!(all_objects(&[]), "empty array should pass all_objects (vacuous truth)"); + let base: serde_yaml::Value = serde_yaml::from_str("items: []").unwrap(); + let ovr: serde_yaml::Value = serde_yaml::from_str("items: []").unwrap(); + let merged = deep_merge_yaml(base, ovr); + assert_eq!(merged["items"].as_sequence().unwrap().len(), 0, "two empty arrays merge to empty"); + } + + #[test] + fn test_all_objects_servers_array_is_all_objects() { + let yaml = r#" +servers: + - url: https://api.example.com + - url: https://api2.example.com +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let servers = val["servers"].as_sequence().unwrap(); + assert!(all_objects(servers), "servers array should be all objects → index-merge path"); + } + + #[test] + fn test_all_objects_tags_array_is_primitives() { + let yaml = r#" +tags: + - Customers + - Orders +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tags = val["tags"].as_sequence().unwrap(); + assert!(!all_objects(tags), "string array should NOT be all objects → replace path"); + } + + // --------------------------------------------------------------- + // `x-fern-pagination` resolution + // --------------------------------------------------------------- + + fn yaml(input: &str) -> serde_yaml::Value { + serde_yaml::from_str(input).expect("valid yaml in test fixture") + } + + #[test] + fn test_strip_pagination_prefix_request_and_response() { + assert_eq!(strip_pagination_prefix("$request.cursor"), "cursor"); + assert_eq!( + strip_pagination_prefix("$response.pagination.next_cursor"), + "pagination.next_cursor" + ); + // No prefix: returned verbatim. This matches the upstream importer, + // which is intentionally lenient about callers that already passed + // a dotted path. + assert_eq!(strip_pagination_prefix("plain"), "plain"); + } + + #[test] + fn test_resolve_pagination_cursor_form_strips_prefixes() { + let op = yaml( + r#" +cursor: $request.starting_after +next_cursor: $response.pagination.next +results: $response.data +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("cursor form should produce Some(...)"); + match cfg { + PaginationConfig::Cursor { + cursor, + next_cursor, + results, + } => { + assert_eq!(cursor, "starting_after"); + assert_eq!(next_cursor, "pagination.next"); + assert_eq!(results, "data"); + } + other => panic!("expected Cursor, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_offset_form_with_step_and_has_next_page() { + let op = yaml( + r#" +offset: $request.page +results: $response.users +step: $request.page_size +has-next-page: $response.meta.has_more +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listUsers") + .unwrap() + .expect("offset form should produce Some(...)"); + match cfg { + PaginationConfig::Offset { + offset, + results, + step, + has_next_page, + } => { + assert_eq!(offset, "page"); + assert_eq!(results, "users"); + assert_eq!(step.as_deref(), Some("page_size")); + assert_eq!(has_next_page.as_deref(), Some("meta.has_more")); + } + other => panic!("expected Offset, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_inherits_root_when_op_is_true() { + let root = yaml( + r#" +cursor: $request.cursor +next_cursor: $response.next_cursor +results: $response.items +"#, + ); + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .unwrap() + .expect("true should inherit root config"); + match cfg { + PaginationConfig::Cursor { + cursor, + next_cursor, + results, + } => { + assert_eq!(cursor, "cursor"); + assert_eq!(next_cursor, "next_cursor"); + assert_eq!(results, "items"); + } + other => panic!("expected Cursor, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_op_false_inherits_root_like_upstream() { + // Upstream `getFernPaginationExtension.ts` treats *any* boolean — + // including `false` — as "look up the root extension". Mirror that + // exactly so cli-sdk has parity with the rest of the Fern toolchain. + let root = yaml( + r#" +cursor: $request.cursor +next_cursor: $response.next_cursor +results: $response.items +"#, + ); + let op = serde_yaml::Value::Bool(false); + let cfg = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .unwrap() + .expect("false should still resolve via root (upstream parity)"); + assert!(matches!(cfg, PaginationConfig::Cursor { .. })); + } + + #[test] + fn test_resolve_pagination_missing_extension_returns_none() { + let cfg = resolve_pagination_extension(None, None, "listFoos").unwrap(); + assert!(cfg.is_none(), "absent extension → fall back to heuristic"); + } + + #[test] + fn test_resolve_pagination_op_true_without_root_returns_none() { + // Upstream returns `undefined` (no pagination) when the op asks to + // inherit but no root block exists. It does *not* raise. Mirror. + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos").unwrap(); + assert!( + cfg.is_none(), + "true without root → no pagination (upstream parity)" + ); + } + + #[test] + fn test_resolve_pagination_discrimination_order_matches_upstream() { + // Upstream's `convertPaginationExtension` discriminates by checking + // `cursor` first, then `next_uri`, then `next_path`, then `offset`. + // When multiple keys collide we must pick the cursor branch — the + // first one — to stay consistent with how user specs are + // interpreted by the rest of the Fern toolchain. + let op = yaml( + r#" +cursor: $request.cursor +offset: $request.page +results: $response.items +next_cursor: $response.next +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("should resolve to cursor variant"); + assert!( + matches!(cfg, PaginationConfig::Cursor { .. }), + "cursor should win when both `cursor` and `offset` are present" + ); + } + + #[test] + fn test_resolve_pagination_unknown_form_errors() { + // Just `results` — no discriminator. Upstream throws + // `Invalid pagination extension`; we surface a discovery error + // referencing every valid form so the user can debug. + let op = yaml("results: $response.items\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("unknown form should error"); + let msg = format!("{err}"); + assert!(msg.contains("cursor"), "got: {msg}"); + assert!(msg.contains("next_uri"), "got: {msg}"); + assert!(msg.contains("next_path"), "got: {msg}"); + assert!(msg.contains("offset"), "got: {msg}"); + assert!(msg.contains("custom"), "got: {msg}"); + } + + #[test] + fn test_resolve_pagination_non_object_form_errors() { + let op = yaml("- not\n- an\n- object\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("sequence should error"); + assert!( + format!("{err}").contains("expected an object"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_cursor_form_requires_all_fields() { + // `next_cursor` is missing. + let op = yaml( + r#" +cursor: $request.starting_after +results: $response.data +"#, + ); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("missing next_cursor should error"); + assert!( + format!("{err}").contains("next_cursor"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_uri_form() { + let op = yaml( + r#" +next_uri: $response.next +results: $response.items +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("uri form should resolve"); + match cfg { + PaginationConfig::Uri { next_uri, results } => { + assert_eq!(next_uri, "next"); + assert_eq!(results, "items"); + } + other => panic!("expected Uri, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_path_form() { + let op = yaml( + r#" +next_path: $response.links.next +results: $response.entries +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("path form should resolve"); + match cfg { + PaginationConfig::Path { next_path, results } => { + assert_eq!(next_path, "links.next"); + assert_eq!(results, "entries"); + } + other => panic!("expected Path, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_custom_form() { + let op = yaml( + r#" +type: custom +results: $response.items +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("custom form should resolve"); + match cfg { + PaginationConfig::Custom { results } => assert_eq!(results, "items"), + other => panic!("expected Custom, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_custom_form_rejects_unknown_type() { + // `type: anythingElse` is not a valid discriminator, so we fall + // through to the "unknown form" error. + let op = yaml( + r#" +type: nonsense +results: $response.items +"#, + ); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("non-custom `type` should error"); + assert!( + format!("{err}").contains("`type: custom`"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_op_bool_with_root_bool_is_validation_error() { + // Mirrors upstream: when both per-op and root are booleans, raise. + let root = serde_yaml::Value::Bool(true); + let op = serde_yaml::Value::Bool(true); + let err = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .expect_err("root-also-bool should error"); + assert!( + format!("{err}").contains("spec-root"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_uri_form_requires_both_fields() { + // `results` is missing. + let op = yaml("next_uri: $response.next\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("missing results should error"); + assert!(format!("{err}").contains("results"), "got: {err}"); + } + + #[test] + fn test_resolve_pagination_offset_form_with_optional_fields() { + let op = yaml( + r#" +offset: $request.page +results: $response.items +step: $request.page_size +has-next-page: $response.has_more +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("offset form should resolve"); + match cfg { + PaginationConfig::Offset { + offset, + results, + step, + has_next_page, + } => { + assert_eq!(offset, "page"); + assert_eq!(results, "items"); + assert_eq!(step.as_deref(), Some("page_size")); + assert_eq!(has_next_page.as_deref(), Some("has_more")); + } + other => panic!("expected Offset, got {other:?}"), + } + } + + // ------------------------------------------------------------------ + // x-fern-availability — operation level + // ------------------------------------------------------------------ + + /// Build a single-operation spec with the given `extra` YAML injected + /// inside the GET operation. Returns the parsed `RestMethod`. + fn parse_op_with_extra(extra: &str) -> RestDescription { + let yaml = format!( + r#" +openapi: "3.0.0" +info: {{ title: T, version: "1.0" }} +servers: [{{ url: "https://x.com" }}] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list +{extra} + responses: {{ "200": {{ description: ok }} }} +"# + ); + load_openapi_spec(&yaml, "t").unwrap() + } + + #[test] + fn test_operation_availability_beta() { + let doc = parse_op_with_extra(" x-fern-availability: beta"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Beta)); + } + + #[test] + fn test_operation_availability_pre_release() { + let doc = parse_op_with_extra(" x-fern-availability: pre-release"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::PreRelease)); + } + + #[test] + fn test_operation_availability_generally_available_canonical() { + let doc = parse_op_with_extra(" x-fern-availability: generally-available"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::GenerallyAvailable)); + } + + #[test] + fn test_operation_availability_alias_ga() { + let doc = parse_op_with_extra(" x-fern-availability: ga"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::GenerallyAvailable)); + } + + #[test] + fn test_operation_availability_alpha() { + let doc = parse_op_with_extra(" x-fern-availability: alpha"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Alpha)); + } + + #[test] + fn test_operation_availability_preview() { + let doc = parse_op_with_extra(" x-fern-availability: preview"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Preview)); + } + + #[test] + fn test_operation_availability_legacy() { + let doc = parse_op_with_extra(" x-fern-availability: legacy"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Legacy)); + } + + /// `stable` is NOT a valid Fern availability — the Fern OpenAPI + /// importer accepts only `ga` (and the canonical + /// `generally-available`). Make sure cli-sdk rejects `stable` for + /// parity, so it can't silently work in one tool and not the other. + #[test] + fn test_operation_availability_stable_is_rejected() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + x-fern-availability: stable + responses: { "200": { description: ok } } +"#; + let err = load_openapi_spec(yaml, "t") + .expect_err("`stable` must NOT be accepted — only `ga` and `generally-available` are"); + let msg = err.to_string(); + assert!( + msg.contains("unknown variant") || msg.contains("variant `stable`"), + "expected serde deser error mentioning the unknown variant `stable`, got: {msg}", + ); + } + + /// Locks in the canonical wire spelling for `pre-release` so the + /// kebab-case rename can't drift. The Fern OpenAPI IR importer + /// collapses `pre-release` into `Beta`; cli-sdk deliberately keeps + /// `PreRelease` distinct (see `Availability` enum docs). + #[test] + fn test_operation_availability_pre_release_wire_spelling() { + let doc = parse_op_with_extra(" x-fern-availability: pre-release"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::PreRelease), + "`pre-release` must deser to its own variant, not collapse to Beta", + ); + assert_eq!(m.availability.unwrap().as_str(), "pre-release"); + assert_eq!(m.availability.unwrap().badge(), Some("[PRE-RELEASE]")); + } + + #[test] + fn test_operation_availability_deprecated_value() { + let doc = parse_op_with_extra(" x-fern-availability: deprecated"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Deprecated)); + } + + #[test] + fn test_operation_availability_absent_defaults_to_none() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, None, "no extension and no deprecated flag → no badge"); + } + + #[test] + fn test_operation_openapi_deprecated_true_falls_back_to_deprecated() { + let doc = parse_op_with_extra(" deprecated: true"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::Deprecated), + "OpenAPI standard `deprecated: true` should lower to Availability::Deprecated when x-fern-availability is absent", + ); + } + + #[test] + fn test_operation_x_fern_availability_overrides_openapi_deprecated() { + // Both set — x-fern-availability wins. + let doc = parse_op_with_extra( + " deprecated: true\n x-fern-availability: beta", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::Beta), + "explicit x-fern-availability must override OpenAPI deprecated:true", + ); + } + + // ------------------------------------------------------------------ + // x-fern-availability — parameter level + // ------------------------------------------------------------------ + + #[test] + fn test_parameter_availability_beta() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: legacy_filter + in: query + x-fern-availability: beta + schema: { type: string } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let p = m.parameters.get("legacy_filter").expect("param missing"); + assert_eq!(p.availability, Some(Availability::Beta)); + } + + #[test] + fn test_parameter_openapi_deprecated_falls_back_to_deprecated_availability() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: legacy_filter + in: query + deprecated: true + schema: { type: string } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let p = m.parameters.get("legacy_filter").expect("param missing"); + assert_eq!(p.availability, Some(Availability::Deprecated)); + assert!(p.deprecated, "raw deprecated flag is still preserved"); + } + + // ----------------------------------------------------------------------- + // x-fern-base-path + // ----------------------------------------------------------------------- + + /// Spec without `x-fern-base-path` → `RestDescription.base_path` is None. + #[test] + fn test_x_fern_base_path_absent_yields_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path, None); + } + + /// Spec with leading-slash `x-fern-base-path` is captured verbatim. + #[test] + fn test_x_fern_base_path_with_leading_slash_captured_verbatim() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: /v1 +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path.as_deref(), Some("/v1")); + } + + /// Spec without a leading slash on `x-fern-base-path` is captured as + /// authored — `build_url` normalizes slashes at request time so the + /// parser does not reshape the user's input. + #[test] + fn test_x_fern_base_path_without_leading_slash_captured_verbatim() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: api/public +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path.as_deref(), Some("api/public")); + } + + /// Empty / whitespace-only `x-fern-base-path` collapses to None so + /// the executor's slash-edge logic doesn't have to handle the empty + /// case. + #[test] + fn test_x_fern_base_path_empty_string_collapses_to_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: "" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path, None); + } + + /// `x-fern-base-path` does not affect the command tree — operations + /// are still grouped by `x-fern-sdk-group-name` only, not nested + /// under the base path. + #[test] + fn test_x_fern_base_path_does_not_affect_command_tree() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: /v1 +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + // Resource grouping is unaffected — no `v1` namespace inserted. + assert!(doc.resources.contains_key("things")); + assert!(!doc.resources.contains_key("v1")); + // The operation's stored path is also unchanged — base_path is + // only applied at URL-construction time, not baked into method.path. + let m = &doc.resources["things"].methods["list"]; + assert_eq!(m.path, "/things"); + } + + /// `normalize_base_path` helper: trims surrounding whitespace, treats + /// empty/whitespace-only as absent, otherwise returns the raw value. + /// Direct coverage of the helper independent of YAML parsing. + #[test] + fn test_normalize_base_path() { + assert_eq!(normalize_base_path(None), None); + assert_eq!(normalize_base_path(Some("")), None); + assert_eq!(normalize_base_path(Some(" ")), None); + assert_eq!(normalize_base_path(Some("/v1")), Some("/v1".to_string())); + assert_eq!(normalize_base_path(Some("v1")), Some("v1".to_string())); + assert_eq!(normalize_base_path(Some(" /v1 ")), Some("/v1".to_string())); + } + + // ------------------------------------------------------------------ + // x-fern-sdk-return-value + // + // Mirrors upstream `FernOpenAPIExtension.RESPONSE_PROPERTY` — the + // extension is a string referencing a property on the response body. + // Stored on `RestMethod.return_value` as `Option`, with + // leading/trailing whitespace trimmed and empty/whitespace-only + // values normalized to `None` so downstream code only sees a + // resolvable path or nothing. + // ------------------------------------------------------------------ + + #[test] + fn test_operation_return_value_absent_is_none() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value, None, + "no x-fern-sdk-return-value → return_value is None (executor prints full body)", + ); + } + + #[test] + fn test_operation_return_value_top_level_path() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: data"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.return_value.as_deref(), Some("data")); + } + + #[test] + fn test_operation_return_value_nested_dotted_path() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: result.items"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value.as_deref(), + Some("result.items"), + "dotted paths are preserved verbatim; the executor walks them at runtime", + ); + } + + #[test] + fn test_operation_return_value_empty_string_is_none() { + // Empty / whitespace-only is meaningless for path resolution. + // Normalize to `None` so the executor can't be tripped into + // emitting a confusing "path '' did not resolve" error. + let doc = parse_op_with_extra(" x-fern-sdk-return-value: \"\""); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.return_value, None); + } + + #[test] + fn test_operation_return_value_whitespace_trimmed() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: \" data \""); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value.as_deref(), + Some("data"), + "surrounding whitespace is trimmed; an inner space would still survive", + ); + } + + // ------------------------------------------------------------------ + // Named-server parsing — `x-fern-server-name` (v2) and `x-name` (v1). + // ------------------------------------------------------------------ + + #[test] + fn test_named_server_v2_spelling_is_parsed() { + // Fern v2 canonical spelling `x-fern-server-name` populates + // `Server.name`. The first server in declaration order remains + // the default — its URL is what drives `RestDescription.root_url` + // for the no-flag case. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: Production + description: "Production environment" + - url: "https://staging.example.com" + x-fern-server-name: Staging +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[0].url, "https://api.example.com"); + assert_eq!( + doc.servers[0].description.as_deref(), + Some("Production environment"), + ); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + let named: Vec<_> = doc.named_servers().collect(); + assert_eq!(named.len(), 2); + } + + #[test] + fn test_named_server_v1_alias_x_name_is_recognized() { + // Older specs that haven't migrated to `x-fern-server-name` use + // the legacy alias `x-name`. The parser accepts it for + // backwards compatibility. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: LegacyProd +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); + } + + #[test] + fn test_named_server_empty_v1_falls_through_to_v2() { + // Defensive parity: an `x-name: ""` on the same entry as a + // valid `x-fern-server-name: Production` must not shadow the + // v2 value. The parser treats empty/whitespace-only extensions + // as "absent" before applying the v1-over-v2 fallback, so a + // blank legacy alias falls through to the canonical Fern + // spelling instead of dropping the server's name entirely. + // Mirrors the existing `test_empty_and_whitespace_server_names_are_dropped` + // guarantee on the v2 side. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: "" + x-fern-server-name: Production + - url: "https://whitespace.example.com" + x-name: " " + x-fern-server-name: Staging +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + } + + #[test] + fn test_named_server_empty_v2_still_falls_back_to_v1() { + // Symmetric case: an empty `x-fern-server-name: ""` must not + // suppress a valid `x-name: OldProd` on the same entry. Even + // though v1 wins outright when both are present, this test + // pins the per-field trim+filter behavior so future refactors + // can't regress into the "first field always wins, even when + // blank" trap. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: OldProd + x-fern-server-name: "" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("OldProd")); + } + + #[test] + fn test_named_server_v1_wins_when_both_present() { + // When both v2 (`x-fern-server-name`) and v1 (`x-name`) are + // present on the same entry, v1 wins to mirror fern's + // `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` first-match + // semantics in + // `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. + // Fern's order is the source of truth — don't flip this even + // if v2-wins reads more naturally. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: NewName + x-name: OldName +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("OldName")); + } + + #[test] + fn test_no_named_servers_when_extensions_absent() { + // Plain OpenAPI servers without either extension carry no name. + // The CLI surface stays unchanged for these specs — no + // `--server` flag is exposed downstream. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert!(doc.servers[0].name.is_none()); + assert_eq!(doc.named_servers().count(), 0); + } + + #[test] + fn test_per_operation_servers_override_is_captured() { + // Per-operation `servers:` blocks lower into + // `RestMethod.servers` independently of the top-level set, and + // they are authoritative for that operation (the executor + // resolves `--server ` against this list first when it's + // non-empty). + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: Production +paths: + /uploads: + post: + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + servers: + - url: "https://upload.example.com" + x-fern-server-name: Upload + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "uploads", "create"); + assert_eq!(m.servers.len(), 1); + assert_eq!(m.servers[0].name.as_deref(), Some("Upload")); + assert_eq!(m.servers[0].url, "https://upload.example.com"); + // Top-level set is preserved separately. + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + } + + #[test] + fn test_empty_and_whitespace_server_names_are_dropped() { + // Empty or whitespace-only `x-fern-server-name` / `x-name` + // values would leak into clap's allowed-list as blank strings + // and into the `Servers:` help block as a blank-named row. The + // parser trims and filters them at the source so downstream + // code never has to defend against this. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://blank.example" + x-fern-server-name: "" + - url: "https://whitespace.example" + x-fern-server-name: " " + - url: "https://blank-legacy.example" + x-name: "" + - url: "https://trimmed.example" + x-fern-server-name: " Production " +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 4, "unnamed entries are still preserved"); + // First three entries' names are filtered out (empty after trim). + assert!(doc.servers[0].name.is_none()); + assert!(doc.servers[1].name.is_none()); + assert!(doc.servers[2].name.is_none()); + // Surrounding whitespace is trimmed. + assert_eq!(doc.servers[3].name.as_deref(), Some("Production")); + // Only the trimmed-but-non-empty entry is selectable via --server. + assert_eq!(doc.named_servers().count(), 1); + } + + // ------------------------------------------------------------------ + // x-fern-enum — per-value overrides on parameter enums + // + // Mirrors the upstream Fern importer + // (packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/ + // extensions/getFernEnum.ts), which models the extension as + // `Record`. cli-sdk + // consumes only `name` (display alias) and `description`; `casing` + // is an SDK-codegen concern. + // ------------------------------------------------------------------ + + fn parse_users_list_user_type(extra_indented: &str) -> MethodParameter { + let yaml = format!( + r#" +openapi: "3.0.0" +info: {{ title: T, version: "1.0" }} +servers: [{{ url: "https://x.com" }}] +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: user_type + in: query + schema: + type: string + enum: [all, managed, external] +{extra_indented} + responses: {{ "200": {{ description: ok }} }} +"# + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let m = first_method(&doc, "users", "list"); + m.parameters + .get("user_type") + .expect("user_type param missing") + .clone() + } + + /// Absent extension: `fern_enum` stays `None` and the wire values + /// flow through unchanged. + #[test] + fn test_x_fern_enum_absent_yields_none() { + let p = parse_users_list_user_type(""); + assert!( + p.fern_enum.is_none(), + "no x-fern-enum should produce None, got {:?}", + p.fern_enum + ); + assert_eq!( + p.enum_values.as_deref(), + Some(["all", "managed", "external"].as_slice()) + .map(|s| s.iter().map(|v| v.to_string()).collect::>()) + .as_deref(), + ); + } + + /// Every value carries both `name` and `description`: the parser + /// should preserve each per-value override keyed by the wire value. + #[test] + fn test_x_fern_enum_full_override_round_trips_per_value_fields() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: All + description: Every user, including external collaborators. + managed: + name: Managed + description: Users your enterprise manages. + external: + name: External + description: External collaborators only.", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + assert_eq!(map.len(), 3, "every enum value should have an entry"); + + let all = map.get("all").expect("`all` entry missing"); + assert_eq!(all.display_name.as_deref(), Some("All")); + assert_eq!( + all.description.as_deref(), + Some("Every user, including external collaborators."), + ); + + let managed = map.get("managed").expect("`managed` entry missing"); + assert_eq!(managed.display_name.as_deref(), Some("Managed")); + assert_eq!( + managed.description.as_deref(), + Some("Users your enterprise manages."), + ); + + let external = map.get("external").expect("`external` entry missing"); + assert_eq!(external.display_name.as_deref(), Some("External")); + assert_eq!( + external.description.as_deref(), + Some("External collaborators only."), + ); + } + + /// Partial override: only some wire values appear under `x-fern-enum`, + /// and listed entries may set only one of `name` / `description`. + /// Missing entries must NOT synthesize blank overrides — they stay + /// out of the map so downstream code falls back to the raw wire + /// value with no description. + #[test] + fn test_x_fern_enum_partial_override_skips_missing_entries() { + let p = parse_users_list_user_type( + " x-fern-enum: + managed: + description: Users your enterprise manages. + external: + name: External", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + + assert!( + !map.contains_key("all"), + "values absent from x-fern-enum must not appear in the map; got {map:?}", + ); + + let managed = map.get("managed").expect("`managed` entry missing"); + assert_eq!( + managed.display_name, None, + "`managed` set only description; display_name should remain None", + ); + assert_eq!( + managed.description.as_deref(), + Some("Users your enterprise manages."), + ); + + let external = map.get("external").expect("`external` entry missing"); + assert_eq!(external.display_name.as_deref(), Some("External")); + assert_eq!( + external.description, None, + "`external` set only name; description should remain None", + ); + } + + /// Empty / whitespace-only `name` and `description` strings are + /// treated the same as absent, and an entry with both empty fields + /// is dropped entirely. Without this guard, downstream clap rendering + /// would emit empty help strings and a meaningless display alias. + #[test] + fn test_x_fern_enum_drops_empty_entries() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: \"\" + description: \" \" + managed: + name: Managed", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + assert!( + !map.contains_key("all"), + "entries with only whitespace fields must be dropped, got {map:?}", + ); + assert!(map.contains_key("managed")); + } + + /// `resolve_enum_display_to_wire` is the bridge between the CLI + /// surface (which accepts either display name or wire value) and + /// the HTTP layer (which only ever sees the wire value). This test + /// pins the contract end to end: parser → `MethodParameter` → + /// resolution. + #[test] + fn test_x_fern_enum_display_to_wire_round_trip() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: All + managed: + name: Managed + description: Managed users. + external: {}", + ); + + // Display name → wire value + assert_eq!(p.resolve_enum_display_to_wire("All").as_ref(), "all"); + assert_eq!( + p.resolve_enum_display_to_wire("Managed").as_ref(), + "managed" + ); + + // Wire value passes through untouched + assert_eq!(p.resolve_enum_display_to_wire("all").as_ref(), "all"); + assert_eq!( + p.resolve_enum_display_to_wire("external").as_ref(), + "external", + "value with empty x-fern-enum entry must round-trip as-is", + ); + + // Unknown input is returned unchanged (clap rejects this before + // we ever hit the executor; we only assert non-mutation here). + assert_eq!(p.resolve_enum_display_to_wire("Bogus").as_ref(), "Bogus"); + } + + /// Without `x-fern-enum`, the resolver must be a pure identity — + /// the param-level helper should never block requests on enums + /// that don't opt into the extension. + #[test] + fn test_resolve_enum_display_to_wire_identity_without_fern_enum() { + let p = parse_users_list_user_type(""); + assert!(p.fern_enum.is_none()); + assert_eq!( + p.resolve_enum_display_to_wire("managed").as_ref(), + "managed" + ); + assert_eq!( + p.resolve_enum_display_to_wire("unknown").as_ref(), + "unknown" + ); + } + + // ----------------------------------------------------------------- + // x-fern-sdk-variables / x-fern-sdk-variable + // ----------------------------------------------------------------- + + #[test] + fn test_sdk_variables_parses_string_entries_with_descriptions() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Garden API + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + gardenId: + type: string + description: The garden tenant identifier. + zoneId: + type: string +paths: {} +"#; + let doc = load_openapi_spec(yaml, "garden").unwrap(); + assert_eq!(doc.sdk_variables.len(), 2, "expected two declared variables"); + // Preserves declaration order so --help renders deterministically. + assert_eq!(doc.sdk_variables[0].name, "gardenId"); + assert_eq!(doc.sdk_variables[0].ty, "string"); + assert_eq!( + doc.sdk_variables[0].description.as_deref(), + Some("The garden tenant identifier."), + ); + assert_eq!(doc.sdk_variables[1].name, "zoneId"); + assert_eq!(doc.sdk_variables[1].description, None); + } + + #[test] + fn test_sdk_variables_skips_non_string_types() { + // Fern docs say only strings are supported today. Non-string + // entries are dropped (with a warn-level log); the parser stays + // permissive so downstream behavior degrades to "missing flag" + // rather than a hard load failure. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + count: + type: integer + name: + type: string +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.sdk_variables.len(), 1); + assert_eq!(doc.sdk_variables[0].name, "name"); + } + + #[test] + fn test_sdk_variable_marks_path_parameter() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Garden API + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + gardenId: + type: string +paths: + /gardens/{gardenId}/zones: + get: + operationId: zones-list + x-fern-sdk-group-name: ["zones"] + x-fern-sdk-method-name: list + parameters: + - name: gardenId + in: path + required: true + x-fern-sdk-variable: gardenId + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "garden").unwrap(); + let method = doc + .resources + .get("zones") + .and_then(|r| r.methods.get("list")) + .expect("zones.list missing"); + let param = method + .parameters + .get("gardenId") + .expect("gardenId param missing"); + assert_eq!( + param.variable_reference.as_deref(), + Some("gardenId"), + "path parameter should be marked variable-bound", + ); + } + + #[test] + fn test_sdk_variable_on_non_path_parameter_is_ignored() { + // Fern's IR only honors variable references on `in: path` + // parameters; references on query/header/cookie are logged and + // dropped so the parameter still surfaces as a normal flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + tenant: + type: string +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: tenant + in: query + x-fern-sdk-variable: tenant + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let method = doc + .resources + .get("things") + .and_then(|r| r.methods.get("list")) + .expect("things.list missing"); + let param = method.parameters.get("tenant").expect("tenant missing"); + assert!( + param.variable_reference.is_none(), + "x-fern-sdk-variable on a query parameter should NOT mark it variable-bound", + ); + } + + #[test] + fn test_plain_path_param_without_variable_reference() { + // Regression guard: a path parameter without `x-fern-sdk-variable` + // must continue to surface as a normal per-operation flag (no + // accidental variable_reference inheritance). + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /files/{file_id}: + get: + operationId: files-get + x-fern-sdk-group-name: ["files"] + x-fern-sdk-method-name: get + parameters: + - name: file_id + in: path + required: true + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let method = doc + .resources + .get("files") + .and_then(|r| r.methods.get("get")) + .expect("files.get missing"); + let param = method.parameters.get("file_id").expect("file_id missing"); + assert_eq!(param.variable_reference, None); + assert_eq!(param.location.as_deref(), Some("path")); + } + + #[test] + fn test_sdk_variables_absent_yields_empty_vec() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert!(doc.sdk_variables.is_empty()); + } + + // --------------------------------------------------------------------- + // x-fern-streaming parsing + // + // Exercises every form the upstream importer recognizes plus the + // failure modes we explicitly validate. Each test isolates one + // shape so a regression points at the exact branch in + // `parse_streaming_extension`. + // --------------------------------------------------------------------- + + /// Shared helper to parse a spec stub with the given + /// `x-fern-streaming` value and return the resolved streaming + /// config for a single hardcoded operation. + fn streaming_for(extension_yaml: &str) -> Result, CliError> { + let yaml = format!( + r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /stream: + post: + operationId: streamChat + x-fern-streaming: {extension_yaml} + responses: + "200": + description: ok +"# + ); + let doc = load_openapi_spec(&yaml, "stream-spec")?; + Ok(doc + .resources + .get("stream") + .and_then(|r| r.methods.get("stream-chat")) + .and_then(|m| m.streaming.clone())) + } + + #[test] + fn test_streaming_boolean_true_is_ndjson() { + // Upstream's boolean shorthand picks NDJSON (so that callers + // who haven't chosen a wire format don't get SSE semantics). + let result = streaming_for("true").unwrap(); + assert_eq!(result, Some(StreamingConfig::Json { terminator: None })); + } + + #[test] + fn test_streaming_boolean_false_is_none() { + let result = streaming_for("false").unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_streaming_object_format_sse() { + let result = streaming_for("{ format: sse }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Sse { terminator: None })); + } + + #[test] + fn test_streaming_object_format_json() { + let result = streaming_for("{ format: json }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Json { terminator: None })); + } + + #[test] + fn test_streaming_object_sse_with_terminator() { + let result = streaming_for(r#"{ format: sse, terminator: "[DONE]" }"#).unwrap(); + assert_eq!( + result, + Some(StreamingConfig::Sse { + terminator: Some("[DONE]".to_string()) + }) + ); + } + + #[test] + fn test_streaming_object_default_format_is_json() { + // Matches the typed SDKs (TS / C#) and the upstream importer: + // an object with no `format` field defaults to NDJSON, the + // same as the boolean shorthand. Callers that want SSE must + // declare `format: sse` explicitly. + let result = streaming_for(r#"{ terminator: "[END]" }"#).unwrap(); + assert_eq!( + result, + Some(StreamingConfig::Json { + terminator: Some("[END]".to_string()) + }) + ); + } + + #[test] + fn test_streaming_object_format_text() { + // `format: text` mirrors Fern IR's `TextStreamChunk` variant + // (see `packages/ir-sdk/.../http.yml`). No terminator field + // and no payload type — raw lines are emitted verbatim. + let result = streaming_for("{ format: text }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Text)); + } + + #[test] + fn test_streaming_text_rejects_terminator() { + // `TextStreamChunk` has no `terminator` field; flagging it at + // parse time keeps misconfigurations from silently no-op'ing + // at runtime. + let err = streaming_for(r#"{ format: text, terminator: "EOF" }"#).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`terminator` is not supported for `format: text`"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_streaming_invalid_format_errors() { + let err = streaming_for("{ format: websocket }").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`format` must be `sse`, `json`, or `text`"), + "unexpected error: {msg}" + ); + assert!(msg.contains("websocket"), "unexpected error: {msg}"); + } + + #[test] + fn test_streaming_invalid_kind_errors() { + // A scalar that isn't a boolean is meaningless. + let err = streaming_for(r#""sse""#).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("expected a boolean or an object"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_streaming_and_pagination_mutually_exclusive() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /events: + get: + operationId: listEvents + x-fern-streaming: true + x-fern-pagination: + cursor: cursor + next_cursor: $response.next_cursor + results: $response.events + responses: + "200": + description: ok +"#; + let err = load_openapi_spec(yaml, "stream-page").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`x-fern-streaming`") + && msg.contains("`x-fern-pagination`") + && msg.contains("mutually exclusive"), + "expected mutual-exclusion error, got: {msg}" + ); + } + + // --------------------------------------------------------------- + // `x-fern-retries` resolution + // --------------------------------------------------------------- + + #[test] + fn test_resolve_retries_absent_returns_none() { + // Neither root nor op declared the extension. Operations + // without an explicit policy stay opt-in — the executor + // returns `None` and skips the retry wrapper entirely. + let cfg = resolve_retries_extension(None, None, "getFoo").unwrap(); + assert!(cfg.is_none()); + } + + #[test] + fn test_resolve_retries_op_true_no_root_uses_defaults() { + // `x-fern-retries: true` on an op without a root block + // materializes the cli-sdk runtime defaults (max=2, + // base=250ms, factor=2.0, jitter=0.1) — conservative for an + // interactive CLI where users expect fast, observable failures. + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("op:true materializes defaults"); + assert_eq!(cfg, RetriesConfig::default()); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, crate::openapi::discovery::DEFAULT_RETRY_MAX_ATTEMPTS); + assert_eq!(cfg.base_delay_ms, crate::openapi::discovery::DEFAULT_RETRY_BASE_DELAY_MS); + } + + #[test] + fn test_resolve_retries_op_false_disables_regardless_of_root() { + // Per-op `false` short-circuits to `disabled` even when the + // root block enabled retries (op specificity > spec defaults). + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\n"); + let op = serde_yaml::Value::Bool(false); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op:false yields explicit disabled config"); + assert!(!cfg.enabled); + assert_eq!(cfg, RetriesConfig::disabled()); + } + + #[test] + fn test_resolve_retries_op_missing_inherits_root_object() { + // Op block missing → inherit the root config verbatim. + let root = yaml("max_attempts: 7\nbase_delay_ms: 250\nfactor: 3.0\njitter: 0.0\n"); + let cfg = resolve_retries_extension(None, Some(&root), "getFoo") + .unwrap() + .expect("missing op inherits root config"); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, 7); + assert_eq!(cfg.base_delay_ms, 250); + assert!((cfg.factor - 3.0).abs() < f64::EPSILON); + assert!((cfg.jitter - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_retries_op_true_inherits_root_object() { + // `x-fern-retries: true` on the op should adopt the root + // baseline (not start over from defaults). This is the + // shorthand authors use to opt every endpoint into a spec-wide + // retry policy. + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\n"); + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op:true adopts root baseline"); + assert_eq!(cfg.max_attempts, 5); + assert_eq!(cfg.base_delay_ms, 1000); + } + + #[test] + fn test_resolve_retries_op_object_overrides_root_field_by_field() { + // Per-op object merges over the root baseline. Fields the op + // doesn't mention keep the root values; fields it does mention + // override. Matches the pagination resolver's field-by-field + // merge semantics. + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\nfactor: 2.0\njitter: 0.2\n"); + let op = yaml("max_attempts: 10\n"); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op object merges over root"); + assert_eq!(cfg.max_attempts, 10, "op overrides root"); + assert_eq!(cfg.base_delay_ms, 1000, "root inherited"); + assert!((cfg.factor - 2.0).abs() < f64::EPSILON); + assert!((cfg.jitter - 0.2).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_retries_root_disabled_inherited_by_default() { + // Spec-root `{ disabled: true }` should propagate by default + // to operations that don't declare their own block. + let root = yaml("disabled: true\n"); + let cfg = resolve_retries_extension(None, Some(&root), "getFoo") + .unwrap() + .expect("disabled root inherited"); + assert!(!cfg.enabled); + } + + #[test] + fn test_resolve_retries_op_object_reenables_after_root_disabled() { + // An explicit per-op object takes precedence over a disabled + // root. Authors can opt a single endpoint back in even when + // the spec-level policy is off. + let root = yaml("disabled: true\n"); + let op = yaml("max_attempts: 4\n"); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("per-op object re-enables"); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, 4); + } + + #[test] + fn test_resolve_retries_upstream_disabled_object() { + // Canonical upstream shape: `{ disabled: true }` per + // `getFernRetriesExtension.ts`. We must round-trip it. + let op = yaml("disabled: true\n"); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("disabled:true yields explicit disabled"); + assert_eq!(cfg, RetriesConfig::disabled()); + } + + #[test] + fn test_resolve_retries_max_zero_treated_as_disabled() { + // `max_attempts: 0` means "never retry" — equivalent to + // `disabled: true`. Normalize here so the executor doesn't + // have to special-case the count. + let op = yaml("max_attempts: 0\n"); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("max=0 normalizes to disabled"); + assert!(!cfg.enabled); + } + + #[test] + fn test_resolve_retries_max_attempts_alias_spellings() { + // `max` / `max-attempts` / `max_attempts` are interchangeable + // (forward-compat with upstream which may pick any one). + let op_snake = yaml("max_attempts: 6\n"); + let op_kebab = yaml("max-attempts: 6\n"); + let op_short = yaml("max: 6\n"); + for op in [op_snake, op_kebab, op_short] { + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .unwrap(); + assert_eq!(cfg.max_attempts, 6); + } + } + + #[test] + fn test_resolve_retries_invalid_max_negative_errors() { + // Negative values must be rejected (u32 can't hold them) — + // surface a clear discovery error. + let op = yaml("max_attempts: -1\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("max_attempts"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_factor_below_one_errors() { + // Backoff factor < 1.0 would mean delays shrink, which is + // nonsensical. Reject to catch authoring bugs. + let op = yaml("factor: 0.5\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("factor"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_jitter_out_of_range_errors() { + // Jitter is a fraction in [0, 1]; anything else is an + // authoring bug. + let op = yaml("jitter: 1.5\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("jitter"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_shape_errors() { + // Arrays/strings are not a valid shape. Mirror the + // pagination resolver's strict typing. + let op = yaml("- 1\n- 2\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("x-fern-retries"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_disabled_non_bool_errors() { + // `disabled` must be boolean. Surface authoring bugs early. + let op = yaml("disabled: yes-please\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("disabled"), "{msg}"); + } + + #[test] + fn test_load_openapi_spec_with_root_retries() { + // End-to-end: a spec with a root `x-fern-retries` block and + // no per-op blocks. Every operation inherits the root config. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-retries: + max_attempts: 5 + base_delay_ms: 250 +paths: + /foo: + get: + operationId: getFoo + x-fern-sdk-method-name: get + x-fern-sdk-group-name: foo + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let root_cfg = doc.retries.as_ref().expect("root retries set"); + assert_eq!(root_cfg.max_attempts, 5); + assert_eq!(root_cfg.base_delay_ms, 250); + + let foo = doc.resources.get("foo").expect("foo resource"); + let get = foo + .methods + .values() + .find(|m| m.id.as_deref() == Some("getFoo")) + .expect("getFoo"); + let op_cfg = get.retries.as_ref().expect("op inherited retries"); + assert_eq!(op_cfg.max_attempts, 5); + assert_eq!(op_cfg.base_delay_ms, 250); + } + + // ------------------------------------------------------------------ + // x-fern-audiences (operation level) + // + // Mirrors fern-api/fern's OpenAPI importer + // (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/operation/convertHttpOperation.ts:330`): + // + // audiences: getExtension(operation, FernOpenAPIExtension.AUDIENCES) ?? [] + // + // — i.e. an array-of-strings extension on the operation object, + // defaulting to `[]` when missing. Filtering itself happens at the + // command-tree-build stage (see + // `crate::openapi::commands::filter_doc_by_audiences`), so the + // parser's job is to faithfully surface what the spec declares. + // ------------------------------------------------------------------ + + #[test] + fn test_x_fern_audiences_missing_yields_empty_vec() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert!( + m.audiences.is_empty(), + "missing x-fern-audiences should yield empty vec, got: {:?}", + m.audiences + ); + } + + #[test] + fn test_x_fern_audiences_explicit_empty_yields_empty_vec() { + // Mirrors fern: an explicitly empty `x-fern-audiences: []` is + // indistinguishable from "missing" — both lower to `[]` in the IR. + let doc = parse_op_with_extra(" x-fern-audiences: []"); + let m = first_method(&doc, "things", "list"); + assert!(m.audiences.is_empty()); + } + + #[test] + fn test_x_fern_audiences_single_value() { + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.audiences, vec!["public".to_string()]); + } + + #[test] + fn test_x_fern_audiences_multiple_values_preserve_order() { + // fern stores audiences as a `string[]` without dedup or sort + // (`convertHttpOperation.ts:330` is a direct passthrough). We + // do the same — preserve user-declared order so downstream + // consumers can rely on the spec's listing. + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public\n - internal\n - beta", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.audiences, + vec![ + "public".to_string(), + "internal".to_string(), + "beta".to_string(), + ], + ); + } + + #[test] + fn test_x_fern_audiences_preserves_duplicate_entries() { + // Defensive: don't silently dedup. The fern importer passes + // the raw array through, so mirroring that means duplicate + // entries land verbatim in the IR (the audience filter does + // its own membership check and is dedup-tolerant). + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public\n - public", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.audiences, + vec!["public".to_string(), "public".to_string()], + ); + } +} diff --git a/cli/src/output.rs b/cli/src/output.rs new file mode 100644 index 00000000..6ae0f1be --- /dev/null +++ b/cli/src/output.rs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared output helpers for terminal sanitization, coloring, and stderr +//! messaging. +//! +//! Every function that prints untrusted content to the terminal should use +//! these helpers to prevent escape-sequence injection, Unicode spoofing, +//! and to respect `NO_COLOR` / non-TTY environments. + +use crate::error::CliError; + +// ── Dangerous character detection ───────────────────────────────────── + +/// Returns `true` for Unicode characters that are dangerous in terminal +/// output but not caught by `char::is_control()`: zero-width chars, bidi +/// overrides, Unicode line/paragraph separators, and directional isolates. +/// +/// Using `matches!` with char ranges gives O(1) per character instead of the +/// O(M) linear scan that a slice `.contains()` would require. +pub(crate) fn is_dangerous_unicode(c: char) -> bool { + matches!(c, + // zero-width: ZWSP, ZWNJ, ZWJ, BOM/ZWNBSP + '\u{200B}'..='\u{200D}' | '\u{FEFF}' | + // bidi: LRE, RLE, PDF, LRO, RLO + '\u{202A}'..='\u{202E}' | + // line / paragraph separators + '\u{2028}'..='\u{2029}' | + // directional isolates: LRI, RLI, FSI, PDI + '\u{2066}'..='\u{2069}' + ) +} + +// ── Sanitization ────────────────────────────────────────────────────── + +/// Strip dangerous characters from untrusted text before printing to the +/// terminal. Removes ASCII control characters (except `\n` and `\t`, +/// which are preserved for readability) and dangerous Unicode characters +/// (bidi overrides, zero-width chars, line/paragraph separators). +pub(crate) fn sanitize_for_terminal(text: &str) -> String { + text.chars() + .filter(|&c| { + if c == '\n' || c == '\t' { + return true; + } + if c.is_control() { + return false; + } + !is_dangerous_unicode(c) + }) + .collect() +} + +/// Rejects strings containing control characters (C0: U+0000–U+001F, +/// C1: U+0080–U+009F, and DEL: U+007F) or dangerous Unicode characters +/// such as zero-width chars, bidi overrides, and line/paragraph separators. +/// +/// Used for validating CLI argument values at the parse boundary. +pub(crate) fn reject_dangerous_chars(value: &str, flag_name: &str) -> Result<(), CliError> { + for c in value.chars() { + if c.is_control() { + return Err(CliError::Validation(format!( + "{flag_name} contains invalid control characters" + ))); + } + if is_dangerous_unicode(c) { + return Err(CliError::Validation(format!( + "{flag_name} contains invalid Unicode characters" + ))); + } + } + Ok(()) +} + +// ── Color ───────────────────────────────────────────────────────────── + +/// Returns true when stderr is connected to an interactive terminal and +/// `NO_COLOR` is not set, meaning ANSI color codes will be visible. +pub(crate) fn stderr_supports_color() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +/// Wrap `text` in ANSI bold + the given color code, resetting afterwards. +/// Returns the plain text unchanged when stderr is not a TTY or `NO_COLOR` +/// is set. +pub(crate) fn colorize(text: &str, ansi_color: &str) -> String { + if stderr_supports_color() && ansi_color.chars().all(|c| c.is_ascii_digit()) { + format!("\x1b[1;{ansi_color}m{text}\x1b[0m") + } else { + text.to_string() + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + // ── sanitize_for_terminal ───────────────────────────────────── + + #[test] + fn sanitize_strips_ansi_escape_sequences() { + let input = "normal \x1b[31mred text\x1b[0m end"; + let sanitized = sanitize_for_terminal(input); + assert_eq!(sanitized, "normal [31mred text[0m end"); + assert!(!sanitized.contains('\x1b')); + } + + #[test] + fn sanitize_preserves_newlines_and_tabs() { + let input = "line1\nline2\ttab"; + assert_eq!(sanitize_for_terminal(input), "line1\nline2\ttab"); + } + + #[test] + fn sanitize_strips_bell_and_backspace() { + let input = "hello\x07bell\x08backspace"; + assert_eq!(sanitize_for_terminal(input), "hellobellbackspace"); + } + + #[test] + fn sanitize_strips_carriage_return() { + let input = "real\rfake"; + assert_eq!(sanitize_for_terminal(input), "realfake"); + } + + #[test] + fn sanitize_strips_bidi_overrides() { + let input = "hello\u{202E}dlrow"; + assert_eq!(sanitize_for_terminal(input), "hellodlrow"); + } + + #[test] + fn sanitize_strips_zero_width_chars() { + assert_eq!(sanitize_for_terminal("foo\u{200B}bar"), "foobar"); + assert_eq!(sanitize_for_terminal("foo\u{FEFF}bar"), "foobar"); + } + + #[test] + fn sanitize_strips_line_separators() { + assert_eq!(sanitize_for_terminal("line1\u{2028}line2"), "line1line2"); + assert_eq!(sanitize_for_terminal("para1\u{2029}para2"), "para1para2"); + } + + #[test] + fn sanitize_strips_directional_isolates() { + assert_eq!(sanitize_for_terminal("a\u{2066}b\u{2069}c"), "abc"); + } + + #[test] + fn sanitize_preserves_normal_unicode() { + assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ"); + } + + // ── reject_dangerous_chars ──────────────────────────────────── + + #[test] + fn reject_clean_string() { + assert!(reject_dangerous_chars("hello/world", "test").is_ok()); + } + + #[test] + fn reject_tab() { + assert!(reject_dangerous_chars("hello\tworld", "test").is_err()); + } + + #[test] + fn reject_newline() { + assert!(reject_dangerous_chars("hello\nworld", "test").is_err()); + } + + #[test] + fn reject_del() { + assert!(reject_dangerous_chars("hello\x7Fworld", "test").is_err()); + } + + #[test] + fn reject_zero_width_space() { + assert!(reject_dangerous_chars("foo\u{200B}bar", "test").is_err()); + } + + #[test] + fn reject_bom() { + assert!(reject_dangerous_chars("foo\u{FEFF}bar", "test").is_err()); + } + + #[test] + fn reject_rtl_override() { + assert!(reject_dangerous_chars("foo\u{202E}bar", "test").is_err()); + } + + #[test] + fn reject_line_separator() { + assert!(reject_dangerous_chars("foo\u{2028}bar", "test").is_err()); + } + + #[test] + fn reject_paragraph_separator() { + assert!(reject_dangerous_chars("foo\u{2029}bar", "test").is_err()); + } + + #[test] + fn reject_zero_width_joiner() { + assert!(reject_dangerous_chars("foo\u{200D}bar", "test").is_err()); + } + + #[test] + fn reject_preserves_normal_unicode() { + assert!(reject_dangerous_chars("日本語", "test").is_ok()); + assert!(reject_dangerous_chars("café", "test").is_ok()); + assert!(reject_dangerous_chars("αβγ", "test").is_ok()); + } + + #[test] + fn reject_c1_control_csi() { + // U+009B is the C1 "Control Sequence Introducer" — can inject + // terminal escape sequences just like ESC+[ + assert!(reject_dangerous_chars("foo\u{009B}bar", "test").is_err()); + } + + // ── colorize ────────────────────────────────────────────────── + + #[test] + fn colorize_returns_text_in_no_color_mode() { + // In test environment, stderr is typically not a TTY + let result = colorize("hello", "31"); + // Either plain text (no TTY) or colored (TTY) — we just verify + // it contains the original text + assert!(result.contains("hello")); + } +} diff --git a/cli/src/text.rs b/cli/src/text.rs new file mode 100644 index 00000000..b66cb444 --- /dev/null +++ b/cli/src/text.rs @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: Apache-2.0 + +/// Max chars for CLI `--help` method descriptions (terminal-width friendly). +pub const CLI_DESCRIPTION_LIMIT: usize = 200; + +/// Convert a parameter name to an idiomatic kebab-case CLI flag. +/// +/// Handles snake_case (`min_start_time` → `min-start-time`), camelCase +/// (`pageToken` → `page-token`), and Header-Case names that already +/// contain dashes (`Idempotency-Key` → `idempotency-key`). Adjacent +/// separator characters never produce double dashes — both `_` and `-` +/// collapse to a single `-`, and an uppercase letter that immediately +/// follows a separator is *not* preceded by an additional dash. +pub fn to_kebab_flag(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for (i, ch) in s.chars().enumerate() { + if ch == '_' || ch == '-' { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if i > 0 && !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + result +} + +/// Convert an identifier to SCREAMING_SNAKE_CASE, the canonical env-var +/// spelling for `--` flags. +/// +/// Mirrors [`to_kebab_flag`] then uppercases and swaps hyphens for +/// underscores: `pageToken` → `PAGE_TOKEN`, `min_start_time` → +/// `MIN_START_TIME`, `garden-id` → `GARDEN_ID`. Used by +/// `x-fern-sdk-variables` to derive the env-var fallback for each global. +pub fn to_screaming_snake(s: &str) -> String { + to_kebab_flag(s).to_ascii_uppercase().replace('-', "_") +} + +/// Truncates a description string to `max_chars` using smart boundaries. +/// +/// When `strip_links` is true, markdown links `[text](url)` are replaced with +/// just `text` to reclaim character budget (useful for CLI help / frontmatter). +/// When false, links are preserved (useful for skill body text where agents can +/// follow URLs). +/// +/// Truncation strategy: +/// 1. If a complete sentence (ending in `. `) fits within the limit, truncate there. +/// 2. Otherwise, break at the last word boundary (space) and append `…`. +/// 3. If no space exists, hard-cut at `max_chars - 1` and append `…`. +pub fn truncate_description(desc: &str, max_chars: usize, strip_links: bool) -> String { + if max_chars == 0 { + return String::new(); + } + + let cleaned = if strip_links { + strip_markdown_links(desc) + } else { + desc.to_string() + }; + let trimmed = cleaned.trim(); + + // Count chars (UTF-8 safe) + let char_count = trimmed.chars().count(); + if char_count <= max_chars { + return trimmed.to_string(); + } + + // Collect the first `max_chars` characters as a string to search within. + let prefix: String = trimmed.chars().take(max_chars).collect(); + + // Try to find the last complete sentence within the limit. + // A sentence ends with ". " followed by more text, or "." at the end of + // the prefix. We look for the last ". " to find a sentence boundary. + if let Some(sentence_end) = find_last_sentence_boundary(&prefix) { + let truncated: String = trimmed.chars().take(sentence_end).collect(); + return truncated; + } + + // Fall back to last word boundary (space) within the limit. + if let Some(last_space) = rfind_char_boundary(&prefix, ' ') { + let truncated: String = trimmed.chars().take(last_space).collect(); + return format!("{truncated}…"); + } + + // Hard cut — no spaces at all + let truncated: String = trimmed.chars().take(max_chars - 1).collect(); + format!("{truncated}…") +} + +/// Strips markdown-style links `[text](url)` and replaces them with just `text`. +fn strip_markdown_links(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let chars: Vec = s.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + if chars[i] == '[' { + // Look for the closing ] followed by ( + if let Some(close_bracket) = find_char_from(&chars, ']', i + 1) { + if close_bracket + 1 < len && chars[close_bracket + 1] == '(' { + if let Some(close_paren) = find_char_from(&chars, ')', close_bracket + 2) { + // Found a complete [text](url) — emit just the text + result.extend(&chars[i + 1..close_bracket]); + i = close_paren + 1; + continue; + } + } + } + } + result.push(chars[i]); + i += 1; + } + + result +} + +/// Finds the character-index of `target` starting from position `from`. +fn find_char_from(chars: &[char], target: char, from: usize) -> Option { + chars[from..] + .iter() + .position(|&c| c == target) + .map(|p| from + p) +} + +/// Finds the last sentence boundary within a char-indexed string. +/// A sentence boundary is a position right after ". " where we can cleanly cut. +/// Returns the char-count to include (up to and including the period). +fn find_last_sentence_boundary(prefix: &str) -> Option { + let chars: Vec = prefix.chars().collect(); + let mut last_boundary = None; + + for (i, _) in chars.iter().enumerate() { + if chars[i] == '.' { + let after_period = i + 1; + // Sentence boundary: period followed by a space, or period at end of prefix + if after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ') + { + last_boundary = Some(after_period); + } + } + } + + last_boundary +} + +/// Finds the last occurrence of `target` in a string, returning its char-index. +fn rfind_char_boundary(s: &str, target: char) -> Option { + let chars: Vec = s.chars().collect(); + chars.iter().rposition(|&c| c == target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_desc_unchanged() { + let desc = "Lists all files."; + assert_eq!(truncate_description(desc, 200, true), "Lists all files."); + } + + #[test] + fn truncate_at_sentence_boundary() { + let desc = "Creates a file in Drive. This method supports multipart upload. See the guide for details on how to use it."; + // At limit 30, only the first sentence fits before the sentence boundary. + let result = truncate_description(desc, 30, true); + assert_eq!(result, "Creates a file in Drive."); + + // At limit 70, both first and second sentences fit. + let result = truncate_description(desc, 70, true); + assert_eq!( + result, + "Creates a file in Drive. This method supports multipart upload." + ); + } + + #[test] + fn truncate_at_word_boundary() { + let desc = "Create a guest user with access to a subset of Workspace capabilities"; + let result = truncate_description(desc, 50, true); + // Should cut at the last space before char 50 + assert!(result.ends_with('…')); + assert!(result.len() <= 55); // 50 chars + ellipsis + assert!(!result.contains("capabil")); // Should not cut mid-word + } + + #[test] + fn hard_cut_no_spaces() { + let desc = "abcdefghijklmnopqrstuvwxyz"; + let result = truncate_description(desc, 10, true); + assert_eq!(result, "abcdefghi…"); + } + + #[test] + fn strips_markdown_links() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha."; + let result = truncate_description(desc, 200, true); + assert_eq!( + result, + "Create a guest user with access to a subset of Workspace capabilities. This feature is in Alpha." + ); + assert!(!result.contains("https://")); + assert!(!result.contains('[')); + } + + #[test] + fn preserves_links_when_strip_links_false() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha."; + let result = truncate_description(desc, 500, false); + assert!(result.contains("https://support.google.com")); + assert!(result.contains("[subset of Workspace capabilities]")); + } + + #[test] + fn strips_markdown_links_and_truncates() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is currently in Alpha. Please reach out to support if you are interested in enabling this feature."; + let result = truncate_description(desc, 120, true); + // After stripping the link, the sentence boundary should work. + assert!(result.contains("subset of Workspace capabilities.")); + assert!(!result.contains("https://")); + } + + #[test] + fn multibyte_safe() { + let desc = "Résumé création für Ñoño — a long description that should be safely truncated at word boundaries without panicking on multi-byte chars"; + let result = truncate_description(desc, 30, true); + assert!(result.ends_with('…') || result.chars().count() <= 30); + } + + #[test] + fn empty_and_whitespace() { + assert_eq!(truncate_description("", 100, true), ""); + assert_eq!(truncate_description(" ", 100, true), ""); + assert_eq!(truncate_description("", 0, true), ""); + } + + #[test] + fn test_strip_markdown_links() { + assert_eq!(strip_markdown_links("[text](http://example.com)"), "text"); + assert_eq!( + strip_markdown_links("Use [this link](http://a.com) and [that](http://b.com) too"), + "Use this link and that too" + ); + assert_eq!(strip_markdown_links("no links here"), "no links here"); + // Incomplete link syntax should be left alone + assert_eq!(strip_markdown_links("[broken"), "[broken"); + assert_eq!(strip_markdown_links("[text]no-parens"), "[text]no-parens"); + } + + #[test] + fn preserves_sentence_ending_at_limit() { + let desc = "Deletes a user."; + assert_eq!(truncate_description(desc, 15, true), "Deletes a user."); + } + + #[test] + fn does_not_cut_url_looking_periods() { + // Periods in URLs or abbreviations like "v1." shouldn't be treated as sentence ends + // unless followed by a space + let desc = "See the docs at developers.google.com for more details on this API endpoint"; + let result = truncate_description(desc, 50, true); + // Should truncate at word boundary, not at "developers." + assert!(result.ends_with('…')); + } + + #[test] + fn sentence_boundary_at_exact_limit() { + // Period falls exactly at the end of the prefix — should still detect it + let desc = "This is a complete sentence. And more text follows here."; + let result = truncate_description(desc, 28, true); + assert_eq!(result, "This is a complete sentence."); + } + + #[test] + fn zero_max_chars() { + assert_eq!(truncate_description("anything", 0, true), ""); + } + + #[test] + fn test_to_kebab_flag() { + // snake_case + assert_eq!(to_kebab_flag("page_token"), "page-token"); + assert_eq!(to_kebab_flag("user_id"), "user-id"); + assert_eq!(to_kebab_flag("min_start_time"), "min-start-time"); + assert_eq!(to_kebab_flag("a_b_c"), "a-b-c"); + // camelCase + assert_eq!(to_kebab_flag("pageToken"), "page-token"); + assert_eq!(to_kebab_flag("userId"), "user-id"); + assert_eq!(to_kebab_flag("minStartTime"), "min-start-time"); + assert_eq!(to_kebab_flag("eventTypeURI"), "event-type-u-r-i"); + // already kebab or simple + assert_eq!(to_kebab_flag("simple"), "simple"); + assert_eq!(to_kebab_flag("uuid"), "uuid"); + assert_eq!(to_kebab_flag(""), ""); + // Header-Case (HTTP header names — idempotency headers, custom + // headers — pass through to the flag builder as-is via the + // synthetic-parameter path). + assert_eq!(to_kebab_flag("Idempotency-Key"), "idempotency-key"); + assert_eq!(to_kebab_flag("X-Request-Id"), "x-request-id"); + assert_eq!(to_kebab_flag("Content-Type"), "content-type"); + // Defensive: doubled separators in mixed-case inputs collapse. + assert_eq!(to_kebab_flag("foo--bar"), "foo-bar"); + assert_eq!(to_kebab_flag("foo__bar"), "foo-bar"); + assert_eq!(to_kebab_flag("-leading-dash"), "leading-dash"); + } + + #[test] + fn test_to_screaming_snake() { + // camelCase → SCREAMING_SNAKE + assert_eq!(to_screaming_snake("gardenId"), "GARDEN_ID"); + assert_eq!(to_screaming_snake("pageToken"), "PAGE_TOKEN"); + // snake_case stays underscore-delimited and uppercases + assert_eq!(to_screaming_snake("min_start_time"), "MIN_START_TIME"); + // kebab inputs flatten the same way as camel + assert_eq!(to_screaming_snake("garden-id"), "GARDEN_ID"); + // single token + assert_eq!(to_screaming_snake("uuid"), "UUID"); + assert_eq!(to_screaming_snake(""), ""); + } +} diff --git a/cli/src/validate.rs b/cli/src/validate.rs new file mode 100644 index 00000000..8371b999 --- /dev/null +++ b/cli/src/validate.rs @@ -0,0 +1,839 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared input validation helpers. +//! +//! These functions harden CLI inputs against adversarial or accidentally +//! malformed values — especially important when the CLI is invoked by an +//! LLM agent rather than a human operator. + +use crate::error::CliError; +use std::path::{Path, PathBuf}; + +use crate::output::reject_dangerous_chars as reject_control_chars; + +/// Validates that `dir` is a safe output directory. +/// +/// The path is resolved relative to CWD. The function rejects paths that +/// would escape above CWD (e.g. `../../.ssh`) or contain null bytes / +/// control characters. +/// +/// Returns the canonicalized path on success. +pub fn validate_safe_output_dir(dir: &str) -> Result { + reject_control_chars(dir, "--output-dir")?; + + let path = Path::new(dir); + + // Reject absolute paths — force everything relative to CWD + if path.is_absolute() { + return Err(CliError::Validation(format!( + "--output-dir must be a relative path, got absolute path '{dir}'" + ))); + } + + // Canonicalize CWD and resolve the target under it + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + let resolved = cwd.join(path); + + // If the directory already exists, canonicalize. Otherwise, canonicalize + // the longest existing prefix and append the remaining segments. + let canonical = if resolved.exists() { + resolved.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to resolve --output-dir '{dir}': {e}")) + })? + } else { + normalize_non_existing(&resolved)? + }; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(CliError::Validation(format!( + "--output-dir '{dir}' resolves to '{}' which is outside the current directory", + canonical.display() + ))); + } + + Ok(canonical) +} + +/// Validates that `dir` is a safe directory for reading files (e.g. `--dir` +/// in `script +push`). +/// +/// Similar to [`validate_safe_output_dir`] but also follows symlinks +/// safely and ensures the resolved path stays under CWD. +pub fn validate_safe_dir_path(dir: &str) -> Result { + reject_control_chars(dir, "--dir")?; + + let path = Path::new(dir); + + // "." is always safe (CWD itself) + if dir == "." { + return std::env::current_dir().map_err(|e| { + CliError::Validation(format!("Failed to determine current directory: {e}")) + }); + } + + if path.is_absolute() { + return Err(CliError::Validation(format!( + "--dir must be a relative path, got absolute path '{dir}'" + ))); + } + + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + let resolved = cwd.join(path); + + let canonical = resolved + .canonicalize() + .map_err(|e| CliError::Validation(format!("Failed to resolve --dir '{dir}': {e}")))?; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(CliError::Validation(format!( + "--dir '{dir}' resolves to '{}' which is outside the current directory", + canonical.display() + ))); + } + + Ok(canonical) +} + +/// Validates that a file path (e.g. `--upload` or `--output`) is safe. +/// +/// Rejects paths that escape above CWD via `..` traversal, contain +/// control characters, or follow symlinks to locations outside CWD. +/// Absolute paths are allowed (reading an existing file from a known +/// location is legitimate) but the resolved target must still live +/// under CWD. +/// +/// # TOCTOU caveat +/// +/// This is a best-effort defence-in-depth check. A local attacker with +/// write access to a parent directory could replace a path component +/// between this validation and the subsequent I/O. Fully eliminating +/// TOCTOU would require `openat(O_NOFOLLOW)` on each path component, +/// which is tracked as a follow-up for Unix platforms. +pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result { + reject_control_chars(path_str, flag_name)?; + + let path = Path::new(path_str); + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + + // For existing files, canonicalize to resolve symlinks. + // For non-existing files, get the prefix canonicalized then normalize + // the remaining components to resolve any `..` or `.` segments. + let canonical = if resolved.exists() { + resolved.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to resolve {flag_name} '{path_str}': {e}")) + })? + } else { + let raw = normalize_non_existing(&resolved)?; + // normalize_non_existing does NOT resolve `..` in the non-existent + // suffix. We must resolve them here to prevent bypass via paths like + // `non_existent/../../etc/passwd`. + normalize_dotdot(&raw) + }; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(CliError::Validation(format!( + "{flag_name} '{}' resolves to '{}' which is outside the current directory", + path_str, + canonical.display() + ))); + } + + Ok(canonical) +} + +/// Resolve `.` and `..` components in a path without touching the filesystem. +fn normalize_dotdot(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + c => out.push(c), + } + } + out +} + +// reject_control_chars is now a re-export from crate::output (see top of file) + +/// Resolves a path that may not exist yet by canonicalizing the existing +/// prefix and appending remaining components. +fn normalize_non_existing(path: &Path) -> Result { + let mut resolved = PathBuf::new(); + let mut remaining = Vec::new(); + + // Walk backwards until we find a component that exists + let mut current = path.to_path_buf(); + loop { + if current.exists() { + resolved = current + .canonicalize() + .map_err(|e| CliError::Validation(format!("Failed to canonicalize path: {e}")))?; + break; + } + if let Some(name) = current.file_name() { + remaining.push(name.to_os_string()); + } else { + // We've exhausted the path without finding an existing prefix + return Err(CliError::Validation(format!( + "Cannot resolve path '{}'", + path.display() + ))); + } + current = match current.parent() { + Some(p) => p.to_path_buf(), + None => break, + }; + } + + // Append remaining segments (in reverse since we collected them backwards) + for seg in remaining.into_iter().rev() { + resolved.push(seg); + } + + Ok(resolved) +} + +/// Characters to encode in a single URL path segment. Keeps RFC 3986 §2.3 +/// unreserved characters that commonly appear in resource IDs (`-` and `_`) +/// unencoded; encodes everything else including `.` (dots appear in email-style +/// calendar IDs and should not carry path semantics). +use percent_encoding::{AsciiSet, CONTROLS}; +const PATH_SEGMENT: &AsciiSet = &CONTROLS + .add(b' ').add(b'!').add(b'"').add(b'#').add(b'$').add(b'%') + .add(b'&').add(b'\'').add(b'(').add(b')').add(b'*').add(b'+') + .add(b',').add(b'.').add(b'/').add(b':').add(b';').add(b'<') + .add(b'=').add(b'>').add(b'?').add(b'@').add(b'[').add(b'\\') + .add(b']').add(b'^').add(b'`').add(b'{').add(b'|').add(b'}') + .add(b'~'); + +/// Percent-encode a value for use as a single URL path segment (e.g., file ID, +/// calendar ID, message ID). Hyphens and underscores are left unencoded since +/// they are unreserved per RFC 3986 and ubiquitous in resource IDs. +pub fn encode_path_segment(s: &str) -> String { + use percent_encoding::utf8_percent_encode; + utf8_percent_encode(s, PATH_SEGMENT).to_string() +} + +/// Percent-encode a value for use in URI path templates where `/` should stay +/// as a path separator (e.g., RFC 6570 `{+name}` expansions). +/// +/// Each path segment is encoded independently, then joined with `/`, so +/// dangerous characters like `#`/`?` are still escaped while hierarchical +/// resource names such as `projects/p/locations/l` remain readable. +pub fn encode_path_preserving_slashes(s: &str) -> String { + s.split('/') + .map(encode_path_segment) + .collect::>() + .join("/") +} + +/// Validate a multi-segment resource name (e.g., `spaces/ABC`, `subscriptions/123`). +/// Rejects path traversal, control characters, and URL-special characters including `%` +/// to prevent URL-encoded bypasses. Returns the validated name or an error. +pub fn validate_resource_name(s: &str) -> Result<&str, CliError> { + if s.is_empty() { + return Err(CliError::Validation( + "Resource name must not be empty".to_string(), + )); + } + if s.split('/').any(|seg| seg == "..") { + return Err(CliError::Validation(format!( + "Resource name must not contain path traversal ('..') segments: {s}" + ))); + } + if s.chars() + .any(|c| c == '\0' || c.is_control() || crate::output::is_dangerous_unicode(c)) + { + return Err(CliError::Validation(format!( + "Resource name contains invalid characters: {s}" + ))); + } + // Reject URL-special characters that could inject query params or fragments + if s.contains('?') || s.contains('#') { + return Err(CliError::Validation(format!( + "Resource name must not contain '?' or '#': {s}" + ))); + } + // Reject '%' to prevent URL-encoded bypasses (e.g. %2e%2e for ..) + if s.contains('%') { + return Err(CliError::Validation(format!( + "Resource name must not contain '%' (URL encoding bypass attempt): {s}" + ))); + } + Ok(s) +} + +/// Validate an API identifier (service name, version string) for use in +/// cache filenames and discovery URLs. Only alphanumeric characters, hyphens, +/// underscores, and dots are allowed to prevent path traversal and injection. +pub fn validate_api_identifier(s: &str) -> Result<&str, CliError> { + if s.is_empty() { + return Err(CliError::Validation( + "API identifier must not be empty".to_string(), + )); + } + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err(CliError::Validation(format!( + "API identifier contains invalid characters (only alphanumeric, '-', '_', '.' allowed): {s}" + ))); + } + Ok(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use tempfile::tempdir; + + // --- validate_safe_output_dir --- + + #[test] + #[serial] + fn test_output_dir_relative_subdir() { + // Create a real temp dir and change into it for the test + let dir = tempdir().unwrap(); + // Canonicalize to handle macOS /var -> /private/var symlink + let canonical_dir = dir.path().canonicalize().unwrap(); + let sub = canonical_dir.join("output"); + fs::create_dir_all(&sub).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[test] + #[serial] + fn test_output_dir_rejects_symlink_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + // Create a directory inside the tempdir + let allowed_dir = canonical_dir.join("allowed"); + fs::create_dir(&allowed_dir).unwrap(); + + // Create a symlink pointing OUTSIDE the tempdir (e.g. to /tmp) + let symlink_path = canonical_dir.join("sneaky_link"); + #[cfg(unix)] + std::os::unix::fs::symlink("/tmp", &symlink_path).unwrap(); + #[cfg(windows)] + return; // Skip on Windows due to privilege requirements for symlinks + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + // Try to validate the symlink resolving outside CWD + let result = validate_safe_output_dir("sneaky_link"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("outside the current directory"), "got: {msg}"); + } + + #[test] + #[serial] + fn test_output_dir_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("../../.ssh"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("outside the current directory"), "got: {msg}"); + } + + #[test] + fn test_output_dir_rejects_absolute() { + assert!(validate_safe_output_dir("/tmp/evil").is_err()); + } + + #[test] + fn test_output_dir_rejects_null_bytes() { + assert!(validate_safe_output_dir("foo\0bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_control_chars() { + assert!(validate_safe_output_dir("foo\x01bar").is_err()); + } + + #[test] + #[serial] + fn test_output_dir_non_existing_subdir() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("new/nested/dir"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!( + result.is_ok(), + "expected Ok for non-existing subdir, got: {result:?}" + ); + } + + // --- validate_safe_dir_path --- + + #[test] + fn test_dir_path_cwd() { + assert!(validate_safe_dir_path(".").is_ok()); + } + + #[test] + #[serial] + fn test_dir_path_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_dir_path("../../etc"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err()); + } + + #[test] + fn test_dir_path_rejects_absolute() { + assert!(validate_safe_dir_path("/usr/local").is_err()); + } + + // --- reject_control_chars --- + + #[test] + fn test_reject_control_chars_clean() { + assert!(reject_control_chars("hello/world", "test").is_ok()); + } + + #[test] + fn test_reject_control_chars_tab() { + assert!(reject_control_chars("hello\tworld", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_newline() { + assert!(reject_control_chars("hello\nworld", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_del() { + assert!(reject_control_chars("hello\x7Fworld", "test").is_err()); + } + + // -- encode_path_segment -------------------------------------------------- + + #[test] + fn test_encode_path_segment_plain_id() { + assert_eq!(encode_path_segment("abc123"), "abc123"); + } + + #[test] + fn test_encode_path_segment_hyphenated_id() { + // Hyphens and underscores are unreserved (RFC 3986 §2.3) and common in + // resource IDs (UUIDs, slugs). They must not be percent-encoded. + assert_eq!(encode_path_segment("file-123"), "file-123"); + assert_eq!(encode_path_segment("my_resource_id"), "my_resource_id"); + assert_eq!( + encode_path_segment("550e8400-e29b-41d4-a716-446655440000"), + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_encode_path_segment_email() { + // Calendar IDs are often email addresses + let encoded = encode_path_segment("user@gmail.com"); + assert!(!encoded.contains('@')); + assert!(!encoded.contains('.')); + } + + #[test] + fn test_encode_path_segment_query_injection() { + // LLM might include query params in an ID by mistake + let encoded = encode_path_segment("fileid?fields=name"); + assert!(!encoded.contains('?')); + assert!(!encoded.contains('=')); + } + + #[test] + fn test_encode_path_segment_fragment_injection() { + let encoded = encode_path_segment("fileid#section"); + assert!(!encoded.contains('#')); + } + + #[test] + fn test_encode_path_segment_path_traversal() { + // Encoding makes traversal segments harmless + let encoded = encode_path_segment("../../etc/passwd"); + assert!(!encoded.contains('/')); + assert!(!encoded.contains("..")); + } + + #[test] + fn test_encode_path_segment_unicode() { + // LLM might pass unicode characters + let encoded = encode_path_segment("日本語ID"); + assert!(!encoded.contains('æ—¥')); + } + + #[test] + fn test_encode_path_segment_spaces() { + let encoded = encode_path_segment("my file id"); + assert!(!encoded.contains(' ')); + } + + #[test] + fn test_encode_path_segment_already_encoded() { + // LLM might double-encode by passing pre-encoded values + let encoded = encode_path_segment("user%40gmail.com"); + // The % itself gets encoded to %25, so %40 becomes %2540 + // This prevents double-encoding issues at the HTTP layer + assert!(encoded.contains("%2540")); + } + + #[test] + fn test_encode_path_preserving_slashes_hierarchical_name() { + let encoded = encode_path_preserving_slashes("projects/p1/locations/us/topics/t1"); + assert_eq!(encoded, "projects/p1/locations/us/topics/t1"); + } + + #[test] + fn test_encode_path_preserving_slashes_escapes_reserved_chars() { + let encoded = encode_path_preserving_slashes("hash#1/child?x=y"); + assert_eq!(encoded, "hash%231/child%3Fx%3Dy"); + } + + #[test] + fn test_encode_path_preserving_slashes_spaces_and_unicode() { + let encoded = encode_path_preserving_slashes("タイムライン 1/列 A"); + assert!(!encoded.contains(' ')); + assert!(encoded.contains('/')); + } + + // -- validate_resource_name ----------------------------------------------- + + #[test] + fn test_validate_resource_name_valid() { + assert!(validate_resource_name("spaces/ABC123").is_ok()); + assert!(validate_resource_name("subscriptions/my-sub").is_ok()); + assert!(validate_resource_name("@default").is_ok()); + assert!(validate_resource_name("projects/p1/topics/t1").is_ok()); + } + + #[test] + fn test_validate_resource_name_traversal() { + assert!(validate_resource_name("../../etc/passwd").is_err()); + assert!(validate_resource_name("spaces/../other").is_err()); + assert!(validate_resource_name("..").is_err()); + } + + #[test] + fn test_validate_resource_name_control_chars() { + assert!(validate_resource_name("spaces/\0bad").is_err()); + assert!(validate_resource_name("spaces/\nbad").is_err()); + assert!(validate_resource_name("spaces/\rbad").is_err()); + assert!(validate_resource_name("spaces/\tbad").is_err()); + } + + #[test] + fn test_validate_resource_name_empty() { + assert!(validate_resource_name("").is_err()); + } + + #[test] + fn test_validate_resource_name_query_injection() { + // LLMs might append query strings or fragments to resource names + assert!(validate_resource_name("spaces/ABC?key=val").is_err()); + assert!(validate_resource_name("spaces/ABC#fragment").is_err()); + } + + #[test] + fn test_validate_resource_name_error_messages_are_clear() { + let err = validate_resource_name("").unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + + let err = validate_resource_name("../bad").unwrap_err(); + assert!(err.to_string().contains("path traversal")); + + let err = validate_resource_name("bad\0id").unwrap_err(); + assert!(err.to_string().contains("invalid characters")); + } + + #[test] + fn test_validate_resource_name_percent_bypass() { + // %2e%2e is .. + assert!(validate_resource_name("%2e%2e").is_err()); + assert!(validate_resource_name("spaces/%2e%2e/etc").is_err()); + // Just % should be rejected too + assert!(validate_resource_name("spaces/100%").is_err()); + } + + // --- reject_control_chars Unicode --- + + #[test] + fn test_reject_control_chars_zero_width_space() { + // U+200B zero-width space + assert!(reject_control_chars("foo\u{200B}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_bom() { + // U+FEFF byte-order mark / zero-width no-break space + assert!(reject_control_chars("foo\u{FEFF}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_rtl_override() { + // U+202E RIGHT-TO-LEFT OVERRIDE + assert!(reject_control_chars("foo\u{202E}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_unicode_line_separator() { + // U+2028 LINE SEPARATOR + assert!(reject_control_chars("foo\u{2028}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_paragraph_separator() { + // U+2029 PARAGRAPH SEPARATOR + assert!(reject_control_chars("foo\u{2029}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_zero_width_joiner() { + // U+200D ZERO WIDTH JOINER + assert!(reject_control_chars("foo\u{200D}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_normal_unicode_ok() { + // CJK, accented characters and emoji should pass + assert!(reject_control_chars("日本語", "test").is_ok()); + assert!(reject_control_chars("café", "test").is_ok()); + assert!(reject_control_chars("αβγ", "test").is_ok()); + } + + // --- path validator Unicode (via validate_safe_output_dir) --- + + #[test] + fn test_output_dir_rejects_zero_width_chars() { + // U+200B in a path segment + assert!(validate_safe_output_dir("foo\u{200B}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_rtl_override() { + assert!(validate_safe_output_dir("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_unicode_line_separator() { + assert!(validate_safe_output_dir("foo\u{2028}bar").is_err()); + } + + // --- validate_resource_name Unicode --- + + #[test] + fn test_validate_resource_name_zero_width_chars() { + // U+200B, U+200D, U+FEFF all rejected + assert!(validate_resource_name("foo\u{200B}bar").is_err()); + assert!(validate_resource_name("foo\u{200D}bar").is_err()); + assert!(validate_resource_name("foo\u{FEFF}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_unicode_line_seps() { + assert!(validate_resource_name("foo\u{2028}bar").is_err()); + assert!(validate_resource_name("foo\u{2029}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_rtl_override() { + assert!(validate_resource_name("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_bidi_embedding() { + // U+202A LEFT-TO-RIGHT EMBEDDING, U+202B RIGHT-TO-LEFT EMBEDDING + assert!(validate_resource_name("foo\u{202A}bar").is_err()); + assert!(validate_resource_name("foo\u{202B}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_homoglyphs_pass_through() { + // Cyrillic lookalikes are intentionally allowed (homoglyph detection + // is out of scope for this validator — see validate_resource_name docs). + assert!(validate_resource_name("spaces/ΑΒС").is_ok()); // Cyrillic С + } + + #[test] + fn test_validate_resource_name_overlong_accepted() { + // No length limit — documents current behaviour. + let long = "a".repeat(10_000); + assert!(validate_resource_name(&long).is_ok()); + } + + // --- validate_api_identifier --- + + #[test] + fn test_validate_api_identifier_valid() { + assert_eq!(validate_api_identifier("drive").unwrap(), "drive"); + assert_eq!(validate_api_identifier("v3").unwrap(), "v3"); + assert_eq!( + validate_api_identifier("directory_v1").unwrap(), + "directory_v1" + ); + assert_eq!( + validate_api_identifier("admin.reports_v1").unwrap(), + "admin.reports_v1" + ); + assert_eq!(validate_api_identifier("v2beta1").unwrap(), "v2beta1"); + } + + #[test] + fn test_validate_api_identifier_rejects_path_traversal() { + assert!(validate_api_identifier("../etc/passwd").is_err()); + assert!(validate_api_identifier("foo/../bar").is_err()); + } + + #[test] + fn test_validate_api_identifier_rejects_special_chars() { + assert!(validate_api_identifier("drive?key=val").is_err()); + assert!(validate_api_identifier("drive#frag").is_err()); + assert!(validate_api_identifier("drive%2f..").is_err()); + assert!(validate_api_identifier("v3 ").is_err()); + assert!(validate_api_identifier("v3\n").is_err()); + } + + #[test] + fn test_validate_api_identifier_empty() { + assert!(validate_api_identifier("").is_err()); + } + + // --- validate_safe_file_path --- + + #[test] + #[serial] + fn test_file_path_relative_is_ok() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + fs::write(canonical_dir.join("test.txt"), "data").unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("test.txt", "--upload"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[test] + #[serial] + fn test_file_path_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("../../etc/passwd", "--upload"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err(), "path traversal should be rejected"); + assert!( + result.unwrap_err().to_string().contains("outside"), + "error should mention 'outside'" + ); + } + + #[test] + fn test_file_path_rejects_control_chars() { + let result = validate_safe_file_path("file\x00.txt", "--output"); + assert!(result.is_err(), "null bytes should be rejected"); + } + + #[test] + #[serial] + fn test_file_path_rejects_symlink_escape() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + // Create a symlink that points outside the directory + #[cfg(unix)] + { + let link_path = canonical_dir.join("escape"); + std::os::unix::fs::symlink("/tmp", &link_path).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("escape/secret.txt", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err(), "symlink escape should be rejected"); + } + } + + #[test] + #[serial] + fn test_file_path_rejects_traversal_via_nonexistent_prefix() { + // Regression: non_existent/../../etc/passwd could bypass starts_with + // because normalize_non_existing preserves ".." in the non-existent + // suffix. The normalize_dotdot fix resolves this. + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("doesnt_exist/../../etc/passwd", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!( + result.is_err(), + "traversal via non-existent prefix should be rejected" + ); + } +} diff --git a/cli/src/websocket/auth.rs b/cli/src/websocket/auth.rs new file mode 100644 index 00000000..4b74abfd --- /dev/null +++ b/cli/src/websocket/auth.rs @@ -0,0 +1,532 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket authentication: query-param, header, and first-message +//! variants. Each variant takes an [`AuthCredentialSource`] directly — the +//! WS path deliberately bypasses [`AuthProvider`](crate::auth::AuthProvider) +//! (which is shaped around `reqwest::RequestBuilder`); see +//! `docs/adr/0001-auth-provider-no-cred-extraction.md`. + +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use secrecy::ExposeSecret; +use serde_json::Value; + +use crate::auth::AuthCredentialSource; +use crate::error::CliError; + +/// Percent-encoding set for query-string components: encode everything that +/// is not in the application/x-www-form-urlencoded "safe" set, plus the +/// reserved characters that would otherwise terminate the value (`&`, `=`, +/// `#`, `+`, ` `, `/`, `?`). Mirrors the `url::form_urlencoded` set without +/// adding `url` as a direct dep. +const QUERY_VALUE: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'&') + .add(b'+') + .add(b'/') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + +/// Where the WS handshake / first frame puts the credential. +/// +/// Variants take an [`AuthCredentialSource`] directly rather than a +/// resolved string so the same `cli > env > file` precedence patterns +/// users already configure for the HTTP path work without extra plumbing. +/// +/// # `AuthCredentialSource::Cli` footgun +/// +/// `AuthCredentialSource::cli("token")` is bound to a clap argument and +/// resolves to `None` until [`AuthCredentialSource::finalize`] is called +/// against the parsed matches. The HTTP path runs `finalize` automatically +/// inside `CliApp::run`; the WS path is invoked from a custom-command +/// handler that does *not* go through that finalize step. If you want +/// CLI-bound creds, either: +/// +/// - prefer `AuthCredentialSource::from_env(...)` so the same scheme used +/// by `auth_scheme_env` Just Works; +/// - or call `source = source.finalize(matches)` yourself before passing +/// the source into `WsAuth::*`. +/// +/// Missing creds surface as `CliError::Auth` so the failure mode is loud, +/// not silent. +pub enum WsAuth { + /// Append the credential as a query parameter on the connect URL. + /// Example: `wss://api.example.com/stream?authorization=`. + QueryParam(String, AuthCredentialSource), + /// Send the credential as an HTTP header on the WS upgrade request. + /// Example: a standard `X-Api-Key: ` header. + /// + /// # Header-value prefixes (footgun) + /// + /// The source's resolved value becomes the *entire* header value. + /// Some APIs require `Authorization: Token ` — the literal word + /// `Token` is part of the value, NOT a scheme the library prepends. + /// **Prefer the convenience constructors [`WsAuth::bearer`] / + /// [`WsAuth::token`]** rather than baking the prefix into a literal + /// or closure by hand; they're auditable in one place and impossible + /// to misspell. + Header(String, AuthCredentialSource), + /// Send multiple HTTP headers on the WS upgrade request. Use when the + /// API requires more than one header on the handshake (e.g. an auth + /// header plus an API-version header). Each pair is validated + /// against the WS-protocol reserved-header deny-list and each source + /// must resolve to a non-empty value. + Headers(Vec<(String, AuthCredentialSource)>), + /// Merge the credential into the *first* outbound JSON frame as the + /// named field. Useful for APIs that authenticate via a "configure + /// session" message on the first text frame. + FirstMessage(String, AuthCredentialSource), + /// No auth (anonymous connection, or auth handled by the caller + /// outside this module). + None, +} + +impl WsAuth { + /// `Authorization: Bearer ` convenience. Prepends the literal + /// `Bearer ` to the resolved credential so callers cannot + /// accidentally double-prefix or omit it. Use for any RFC-6750 + /// bearer-token API. + pub fn bearer(source: AuthCredentialSource) -> Self { + WsAuth::Header("Authorization".into(), prefix_source(source, "Bearer ")) + } + + /// `Authorization: Token ` convenience. Prepends the literal + /// `Token ` to the resolved credential. Use for APIs that treat the + /// word `Token` as part of the value (not a scheme tungstenite + /// prepends) — callers that miss this footgun get a confusing 401 + /// from the upgrade. + pub fn token(source: AuthCredentialSource) -> Self { + WsAuth::Header("Authorization".into(), prefix_source(source, "Token ")) + } + + /// Apply auth to the URL and header list before the handshake. + /// + /// For [`WsAuth::QueryParam`] this appends `?key=value` (or `&key=value`) + /// to `url`. For [`WsAuth::Header`] it pushes `(name, value)` onto + /// `headers`. For [`WsAuth::FirstMessage`] and [`WsAuth::None`] it's + /// a no-op — `FirstMessage` is applied by [`Self::merge_into_first_message`] + /// before the first send. + /// + /// Returns an error if the credential is required (i.e. variant is + /// not `None`) but the source resolves to `None` — that's almost + /// certainly a misconfiguration the user should see immediately. + pub fn apply_to_url_and_headers( + &self, + url: &mut String, + headers: &mut Vec<(String, String)>, + ) -> Result<(), CliError> { + match self { + WsAuth::QueryParam(key, source) => { + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for query param `{key}` is unset" + )) + })?; + append_query_param(url, key, secret.expose_secret()); + Ok(()) + } + WsAuth::Header(name, source) => { + apply_single_header(name, source, headers) + } + WsAuth::Headers(pairs) => { + for (name, source) in pairs { + apply_single_header(name, source, headers)?; + } + Ok(()) + } + WsAuth::FirstMessage(_, _) | WsAuth::None => Ok(()), + } + } + + /// Merge the credential into the first outbound JSON frame. + /// + /// Used only for [`WsAuth::FirstMessage`]; other variants are a no-op. + /// The frame must be a JSON object — merging a top-level field into a + /// non-object value is a misconfiguration and surfaces as `Validation`. + pub fn merge_into_first_message(&self, msg: &mut Value) -> Result<(), CliError> { + if let WsAuth::FirstMessage(field, source) = self { + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for first-message field `{field}` is unset" + )) + })?; + // Pre-compute the type name string so the error closure + // doesn't borrow `msg` while `as_object_mut` already holds a + // mutable borrow. + let observed = type_name(msg); + let obj = msg.as_object_mut().ok_or_else(|| { + CliError::Validation(format!( + "WebSocket auth: first message must be a JSON object to inject `{field}` \ + (got {observed})" + )) + })?; + obj.insert(field.clone(), Value::String(secret.expose_secret().to_string())); + } + Ok(()) + } +} + +/// Shared body for `Header` / `Headers` application — validates the name +/// against the reserved-handshake-header deny-list and resolves the source. +fn apply_single_header( + name: &str, + source: &AuthCredentialSource, + headers: &mut Vec<(String, String)>, +) -> Result<(), CliError> { + // Reject WS-protocol headers — letting a customer set `Host`, + // `Upgrade`, `Connection`, or `Sec-WebSocket-*` would silently + // clobber the auto-generated values from `IntoClientRequest` and + // produce a confusing handshake failure. Fail loudly. + if is_reserved_handshake_header(name) { + return Err(CliError::Validation(format!( + "WebSocket auth: header `{name}` is a WS-protocol \ + header and cannot be set via WsAuth — the handshake \ + machinery sets it automatically" + ))); + } + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for header `{name}` is unset" + )) + })?; + headers.push((name.to_string(), secret.expose_secret().to_string())); + Ok(()) +} + +/// Append `?key=value` (or `&key=value` if a `?` is already present) to a +/// URL string. Percent-encodes the value so credentials with `&`, `=`, or +/// other URL-special characters survive the round-trip intact. +fn append_query_param(url: &mut String, key: &str, value: &str) { + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str(&utf8_percent_encode(key, QUERY_VALUE).to_string()); + url.push('='); + url.push_str(&utf8_percent_encode(value, QUERY_VALUE).to_string()); +} + +/// Wrap `source` so its resolved value gets `prefix` prepended. +/// `AuthCredentialSource` derives `Clone`, so the move-closure can keep +/// re-resolving each request without consuming the original source. +fn prefix_source(source: AuthCredentialSource, prefix: &'static str) -> AuthCredentialSource { + AuthCredentialSource::closure(move || { + source + .resolve() + .map(|s| format!("{prefix}{}", s.expose_secret())) + }) +} + +/// Names of HTTP headers the WS handshake machinery sets itself. Setting +/// any of them via `WsAuth::Header` would either clobber the correct value +/// or get clobbered by tungstenite — both end in a confusing handshake +/// failure. Reject up front. +fn is_reserved_handshake_header(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + matches!( + lower.as_str(), + "host" + | "upgrade" + | "connection" + | "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-extensions" + | "sec-websocket-protocol" + | "sec-websocket-accept" + ) +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn literal(v: &str) -> AuthCredentialSource { + AuthCredentialSource::literal(v) + } + + #[test] + fn query_param_appends_to_clean_url() { + let mut url = "wss://api.example.com/v1/socket".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("authorization".into(), literal("bearer-token")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert!(url.starts_with("wss://api.example.com/v1/socket?")); + assert!(url.contains("authorization=bearer-token")); + assert!(headers.is_empty()); + } + + #[test] + fn query_param_appends_with_ampersand_when_query_present() { + let mut url = "wss://api.example.com/v1/socket?agent_id=abc".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("authorization".into(), literal("tok")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/v1/socket?agent_id=abc&authorization=tok"); + } + + #[test] + fn query_param_percent_encodes_special_chars() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("token".into(), literal("a&b=c d")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + // Percent-encoded: & → %26, = → %3D, space → %20 (we encode all + // reserved characters consistently rather than using application/ + // x-www-form-urlencoded's `+` for space — wss:// query strings + // tend to round-trip the percent form more reliably across libs.) + assert!(url.contains("token=a%26b%3Dc%20d"), "url: {url}"); + } + + #[test] + fn header_adds_to_header_list_does_not_touch_url() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::Header("xi-api-key".into(), literal("sk-test")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert_eq!(headers, vec![("xi-api-key".to_string(), "sk-test".to_string())]); + } + + #[test] + fn first_message_is_noop_at_handshake() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::FirstMessage("xi_api_key".into(), literal("sk-fm")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert!(headers.is_empty()); + } + + #[test] + fn none_is_noop() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::None + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert!(headers.is_empty()); + } + + #[test] + fn missing_credential_for_header_surfaces_as_auth_error() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + // Empty literal resolves to None — same path as a missing env var. + let err = WsAuth::Header("xi-api-key".into(), literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + assert!(err.to_string().contains("xi-api-key")); + } + + #[test] + fn missing_credential_for_query_param_surfaces_as_auth_error() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::QueryParam("authorization".into(), literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + assert!(err.to_string().contains("authorization")); + } + + #[test] + fn first_message_merges_field_into_json_object() { + let mut msg = serde_json::json!({"text": "hello", "voice_settings": {"stability": 0.5}}); + WsAuth::FirstMessage("xi_api_key".into(), literal("sk-merged")) + .merge_into_first_message(&mut msg) + .unwrap(); + assert_eq!(msg["xi_api_key"], "sk-merged"); + assert_eq!(msg["text"], "hello"); + } + + #[test] + fn first_message_rejects_non_object() { + let mut msg = serde_json::json!(["not", "an", "object"]); + let err = WsAuth::FirstMessage("xi_api_key".into(), literal("sk")) + .merge_into_first_message(&mut msg) + .expect_err("array first frame should error"); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn first_message_missing_credential_errors() { + let mut msg = serde_json::json!({}); + let err = WsAuth::FirstMessage("xi_api_key".into(), literal("")) + .merge_into_first_message(&mut msg) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + } + + #[test] + fn header_rejects_ws_protocol_reserved_names() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + for reserved in &[ + "Host", + "host", + "Upgrade", + "Connection", + "Sec-WebSocket-Key", + "Sec-WebSocket-Version", + "Sec-WebSocket-Protocol", + ] { + let err = WsAuth::Header((*reserved).into(), literal("x")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err(reserved); + assert!(matches!(err, CliError::Validation(_)), + "reserved `{reserved}` should validation-error, got: {err:?}"); + } + // Sanity: a non-reserved name passes. + assert!(WsAuth::Header("X-My-Custom".into(), literal("x")) + .apply_to_url_and_headers(&mut url, &mut headers) + .is_ok()); + } + + #[test] + fn headers_variant_emits_all_pairs_in_order() { + let mut url = "wss://api.example.com/v1/realtime?model=test".to_string(); + let mut headers = Vec::new(); + WsAuth::Headers(vec![ + ( + "Authorization".into(), + literal("Bearer sk-test"), + ), + ( + "X-Api-Version".into(), + literal("v1"), + ), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![ + ("Authorization".to_string(), "Bearer sk-test".to_string()), + ("X-Api-Version".to_string(), "v1".to_string()), + ] + ); + // URL is unchanged. + assert_eq!(url, "wss://api.example.com/v1/realtime?model=test"); + } + + #[test] + fn headers_variant_rejects_reserved_names_per_pair() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::Headers(vec![ + ("X-Custom".into(), literal("ok")), + ("Upgrade".into(), literal("nope")), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("reserved header should error"); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn headers_variant_missing_credential_errors() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::Headers(vec![ + ("Authorization".into(), literal("Bearer xyz")), + ("X-Api-Version".into(), literal("")), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + // Auth-error message names the missing header. + assert!(err.to_string().contains("X-Api-Version")); + } + + #[test] + fn bearer_helper_prepends_literal_bearer_space() { + let mut url = "wss://api.example.com/v1/realtime".to_string(); + let mut headers = Vec::new(); + WsAuth::bearer(literal("sk-test")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Bearer sk-test".to_string())] + ); + } + + #[test] + fn token_helper_prepends_literal_token_space() { + let mut url = "wss://api.example.com/v1/listen".to_string(); + let mut headers = Vec::new(); + WsAuth::token(literal("dg_secret")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Token dg_secret".to_string())] + ); + } + + #[test] + fn bearer_helper_surfaces_missing_credential_loudly() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + // Empty literal source resolves to None — should bubble up as + // CliError::Auth like the underlying Header variant. + let err = WsAuth::bearer(literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("empty cred should error"); + assert!(matches!(err, CliError::Auth(_))); + } + + #[test] + fn token_helper_does_not_double_prefix_already_prefixed_value() { + // If a customer mistakenly passes "Token foo" to `WsAuth::token`, + // they get "Token Token foo" — documented surprise; we don't + // try to detect "already prefixed" since that would be fragile. + // This test is explicit so future refactors don't accidentally + // start stripping prefixes (which would also be wrong). + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + WsAuth::token(literal("Token already-prefixed")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers[0].1, + "Token Token already-prefixed", + "token() always prepends — by design" + ); + } + + #[test] + fn other_variants_skip_merge_into_first_message() { + let mut msg = serde_json::json!({"text": "hi"}); + WsAuth::Header("xi-api-key".into(), literal("k")) + .merge_into_first_message(&mut msg) + .unwrap(); + WsAuth::QueryParam("auth".into(), literal("k")) + .merge_into_first_message(&mut msg) + .unwrap(); + WsAuth::None.merge_into_first_message(&mut msg).unwrap(); + // No mutation expected from any of these variants. + assert_eq!(msg, serde_json::json!({"text": "hi"})); + } +} diff --git a/cli/src/websocket/client.rs b/cli/src/websocket/client.rs new file mode 100644 index 00000000..0823a541 --- /dev/null +++ b/cli/src/websocket/client.rs @@ -0,0 +1,667 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `WebSocketClient` — async bidirectional WS client driven by an +//! [`OutputPipeline`](crate::formatter::OutputPipeline). See `mod.rs` +//! for the module-level overview. + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}; + +use crate::error::CliError; +use crate::formatter::OutputPipeline; +use crate::http::HttpConfig; + +use super::auth::WsAuth; +use super::error::{classify_close_frame, map_handshake_error, map_stream_error}; + +/// Inbound-frame autoresponder. +/// +/// Called once per inbound JSON frame. Returning `Some(reply)` causes the +/// client to (a) send `reply` as an outbound text frame and (b) **elide** +/// the inbound frame from stdout — useful for application-level ping/pong +/// where the inbound is protocol overhead, not user-visible payload. +/// Returning `None` lets the inbound flow through to +/// [`OutputPipeline::emit`]. +/// +/// Write your own closure for app-level ping/pong or any other +/// inbound-frame responder pattern your API requires. +/// +/// # Stateful autoresponders +/// +/// The closure is `Fn`, not `FnMut`, because the recv loop borrows it by +/// shared reference. If you need state (counter, throttle, per-event-id +/// dedupe), reach for interior mutability: +/// +/// ```ignore +/// use std::sync::atomic::{AtomicU64, Ordering}; +/// let count = std::sync::Arc::new(AtomicU64::new(0)); +/// let count_inner = count.clone(); +/// let responder: AutoResponder = std::sync::Arc::new(move |frame| { +/// count_inner.fetch_add(1, Ordering::Relaxed); +/// /* ... */ +/// None +/// }); +/// ``` +/// +/// Naïve `let mut n = 0; Arc::new(move |f| { n += 1; ... })` fails to +/// compile — the compiler error points at the closure body, not the +/// trait bound, which is easy to misread. +pub type AutoResponder = Arc Option + Send + Sync>; + +/// Configuration for a single WS connection. +pub struct WsConfig { + /// Connect URL (`wss://...` for TLS, `ws://...` for plaintext mocks). + pub url: String, + /// Where the credential goes (query / header / first-message / none). + pub auth: WsAuth, + /// Optional autoresponder. See [`AutoResponder`]. + pub auto_responder: Option, + /// Output pipeline applied to each inbound frame the autoresponder + /// did *not* claim. Pass via [`OutputPipeline::from_matches`] from + /// the custom-command handler so `--format` (and future + /// `--jq`/`--fields`/`--template`) flow through automatically. + pub output_pipeline: OutputPipeline, + /// If true, forward stdin lines as outbound text frames. EOF on stdin + /// triggers a clean WS Close(1000) and exit 0. + pub stdin_input: bool, + /// Validate each stdin line as JSON before sending. Invalid lines are + /// written to stderr as a warning and dropped (the connection is *not* + /// terminated). Default `true`. Set false only when the wire protocol + /// is non-JSON. + pub stdin_validate_json: bool, + /// JSON keys to recursively elide from each inbound frame before + /// emitting. Use to strip base64 audio blobs that would otherwise + /// flood a terminal. + pub strip_audio_keys: Vec, + /// Hint string woven into mid-stream / abnormal-close error messages. + /// Defaults to a generic "check auth, network, keepalive/timeout" + /// nudge; override when wiring an API with a more specific common + /// failure mode. + pub abnormal_close_hint: String, +} + +impl WsConfig { + /// Build a minimal config with no auth and a default pipeline. Useful + /// for in-process mock tests; production callers always fill in the + /// auth + autoresponder + stdin fields. + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + auth: WsAuth::None, + auto_responder: None, + output_pipeline: OutputPipeline::default(), + stdin_input: false, + stdin_validate_json: true, + strip_audio_keys: Vec::new(), + abnormal_close_hint: super::error::ABNORMAL_CLOSE_HINT.to_string(), + } + } + +} + +/// A connected WS client ready to send and receive frames. +pub struct WebSocketClient { + stream: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + config: WsConfig, + /// Tracks whether [`WebSocketClient::send`] has run yet. Used to + /// merge [`WsAuth::FirstMessage`] into the first outbound frame. + first_send_done: bool, +} + +impl WebSocketClient { + /// Connect to a WS endpoint, applying auth and reading TLS knobs from + /// `http_config`. + /// + /// Honored in v1: + /// - `_CONNECT_TIMEOUT_SECS` — applied as a handshake deadline. + /// + /// Resolved but not yet wired to the tungstenite connector + /// (deferred — misconfigurations still surface as a `CliError` at + /// `resolve()` time, before the handshake is attempted): + /// - `_CA_BUNDLE` / `_EXTRA_CA_CERTS` / `SSL_CERT_FILE` + /// - `_INSECURE` / `_INSECURE_SKIP_VERIFY` + /// - `_PROXY` / `_NO_PROXY` + /// + /// Not applicable to streaming transports: + /// - `_TIMEOUT_SECS` — bounds total request lifetime for the + /// reqwest path; a streaming WS connection has no defined "total + /// lifetime" so the value is ignored here. Use + /// `_CONNECT_TIMEOUT_SECS` for the handshake deadline. + /// + /// Default trust roots come from whichever TLS backend the feature + /// gate selects (`native-tls` reads the OS keychain; `rustls` uses + /// Mozilla's bundled webpki roots). + pub async fn connect( + mut config: WsConfig, + http_config: &HttpConfig, + ) -> Result { + // Resolve transport config up front. Even though v1 doesn't yet + // translate CA bundle / insecure into a tungstenite Connector, + // calling resolve() surfaces a misconfigured CA path immediately + // rather than after a confusing TLS error during handshake. + let resolved = http_config.resolve()?; + + // Apply URL/header auth. FirstMessage is deferred to first send(). + let mut url = config.url.clone(); + let mut headers: Vec<(String, String)> = Vec::new(); + config.auth.apply_to_url_and_headers(&mut url, &mut headers)?; + + // Build the handshake request. Using IntoClientRequest on a parsed + // URI gets us all the required WS handshake headers + // (Sec-WebSocket-Key/Version/Upgrade); we then layer our custom + // headers on top. + let uri: tokio_tungstenite::tungstenite::http::Uri = url.parse().map_err(|e| { + CliError::Validation(format!("invalid WebSocket URL `{url}`: {e}")) + })?; + let mut request = uri + .into_client_request() + .map_err(map_handshake_error)?; + for (name, value) in &headers { + let header_value = HeaderValue::from_str(value).map_err(|e| { + CliError::Validation(format!( + "WebSocket header `{name}` contains invalid characters: {e}" + )) + })?; + let header_name: tokio_tungstenite::tungstenite::http::HeaderName = + name.parse().map_err(|e| { + CliError::Validation(format!("invalid WebSocket header name `{name}`: {e}")) + })?; + request.headers_mut().insert(header_name, header_value); + } + + // Sync the URL on the WsConfig with what we actually connected to, + // so anything downstream that reads it (logging, error messages) + // reflects the post-auth-apply form. + config.url = url; + + // Connect, with optional handshake deadline. + let connect_fut = tokio_tungstenite::connect_async(request); + let connect_result = if let Some(deadline) = resolved.connect_timeout { + tokio::time::timeout(deadline, connect_fut).await.map_err(|_| { + CliError::Other(anyhow::anyhow!( + "WebSocket handshake timed out after {}s", + deadline.as_secs(), + )) + })? + } else { + connect_fut.await + }; + + let (stream, _response) = connect_result.map_err(map_handshake_error)?; + Ok(Self { + stream, + config, + first_send_done: false, + }) + } + + /// Send a JSON value as a WS text frame. Applies + /// [`WsAuth::FirstMessage`] merging on the very first send, then + /// becomes a plain serialize-and-send. + pub async fn send(&mut self, msg: &Value) -> Result<(), CliError> { + let mut to_send = msg.clone(); + if !self.first_send_done { + self.config.auth.merge_into_first_message(&mut to_send)?; + self.first_send_done = true; + } + let text = serde_json::to_string(&to_send).map_err(|e| { + CliError::Validation(format!("failed to serialize WS frame: {e}")) + })?; + let hint = self.config.abnormal_close_hint.clone(); + self.stream + .send(Message::Text(text)) + .await + .map_err(|e| map_stream_error(e, &hint)) + } + + /// Send raw bytes as a WS binary frame. + /// + /// Required by APIs that ship PCM audio (or any other binary payload) + /// on the wire. Callers typically drive this from their own + /// audio-capture loop (`cpal` mic, file reader, etc.) rather than from + /// the stdin path — stdin forwarding stays JSON-text only in v1 + /// (see ADR-0002 follow-ups). + /// + /// # `WsAuth::FirstMessage` interaction + /// + /// `FirstMessage` auth merges the credential into the first outbound + /// JSON frame. Binary frames have no JSON object to merge into, so + /// calling `send_binary` as the *very first* outbound when `FirstMessage` + /// auth is configured silently drops the credential. We error loudly + /// instead: send a JSON frame first (typically a per-API "configure + /// session" message that *should* carry the credential), then call + /// `send_binary` for audio chunks. + pub async fn send_binary(&mut self, bytes: Vec) -> Result<(), CliError> { + if !self.first_send_done && matches!(self.config.auth, WsAuth::FirstMessage(_, _)) { + return Err(CliError::Validation( + "WebSocket: send_binary called before any send() with WsAuth::FirstMessage \ + configured — the auth credential would never reach the server. Send your \ + session-init JSON frame via `send(...)` first; binary frames after." + .into(), + )); + } + let hint = self.config.abnormal_close_hint.clone(); + self.stream + .send(Message::Binary(bytes)) + .await + .map_err(|e| map_stream_error(e, &hint)) + } + + /// Run the recv loop until either `shutdown` fires or the server + /// closes the connection. On graceful shutdown / server `Close(1000)`, + /// returns `Ok(())`. Other terminations map per the matrix in + /// [`super::error`]. + /// + /// `shutdown` is intentionally a generic future rather than a + /// `tokio_util::sync::CancellationToken` — keeps the dep surface + /// small, and lets tests pass a `oneshot::Receiver` without dragging + /// the SIGINT machinery into unit tests. Production wires this to + /// [`tokio::signal::ctrl_c`] via [`Self::run_recv_loop`]. + pub async fn run_until_shutdown(self, shutdown: F) -> Result<(), CliError> + where + F: std::future::Future + Send + Unpin, + { + let WebSocketClient { + stream, + config, + first_send_done, + } = self; + let (mut sink, mut source) = stream.split(); + + let stdin_input = config.stdin_input; + let stdin_validate_json = config.stdin_validate_json; + let abnormal_hint = config.abnormal_close_hint.clone(); + // Keep the first-send bookkeeping live across the loop so the + // stdin branch can honor `WsAuth::FirstMessage` — without this, + // a caller combining `stdin_input = true` with `FirstMessage` + // auth would have the auth field silently dropped from the first + // outbound frame. + let mut first_send_done = first_send_done; + + // Bounded channel: stdin reader → recv loop. Bound is 64; when + // full, the reader blocks on `tx.send`, propagating backpressure + // back through the OS pipe buffer to the user's writer side. + // The `_stdin_tx_keepalive` binding holds the sender alive when + // we're not spawning a reader — without it the rx would return + // `None` on first recv, which the select! arm interprets as + // EOF (= clean shutdown) and exits immediately. Combined with + // the `if stdin_input` guard below this is belt-and-braces. + let (stdin_tx, mut stdin_rx) = mpsc::channel::(64); + let _stdin_tx_keepalive; + let stdin_handle = if stdin_input { + _stdin_tx_keepalive = None; + Some(tokio::spawn(stdin_reader_task(stdin_tx, stdin_validate_json))) + } else { + _stdin_tx_keepalive = Some(stdin_tx); + None + }; + + // Use the owned `Stdout` rather than a `StdoutLock`. Holding a + // lock across the recv loop's await points blocks any other + // thread that tries to write to stdout — and `StdoutLock` isn't + // `Send`, so the future itself wouldn't be `Send` either, which + // breaks `tokio::spawn`. `Stdout::write_all` locks internally + // per call, which is the right granularity for our throughput. + let mut stdout = std::io::stdout(); + let pipeline = config.output_pipeline.clone(); + let auto_responder = config.auto_responder.clone(); + let strip_keys: Vec = config.strip_audio_keys.clone(); + + let mut shutdown = shutdown; + + let exit_reason: Result<(), CliError> = loop { + tokio::select! { + // Bias toward shutdown — if a Ctrl+C fires the same + // instant as a frame arrives, the user expects the close + // path to win. + biased; + _ = &mut shutdown => { + break Ok(()); + } + line = stdin_rx.recv(), if stdin_input => { + match line { + Some(text) => { + // If `WsAuth::FirstMessage` is configured and + // we haven't sent yet, parse → merge → re-serialize + // so the auth credential lands in the very first + // outbound frame. Lines after the first ship as-is + // (matching `WebSocketClient::send`'s contract that + // FirstMessage applies only once per connection). + let to_send = if !first_send_done + && matches!(config.auth, WsAuth::FirstMessage(_, _)) + { + match serde_json::from_str::(&text) { + Ok(mut v) => { + if let Err(e) = + config.auth.merge_into_first_message(&mut v) + { + break Err(e); + } + match serde_json::to_string(&v) { + Ok(s) => s, + Err(e) => { + break Err(CliError::Validation(format!( + "failed to re-serialize first stdin \ + frame after merging FirstMessage \ + auth: {e}" + ))); + } + } + } + Err(e) => { + // FirstMessage auth requires merging + // into a JSON object — a non-JSON first + // stdin line breaks the contract loudly + // rather than silently dropping creds. + break Err(CliError::Validation(format!( + "FirstMessage auth requires the first stdin \ + frame to be valid JSON (got parse error: \ + {e}). If your wire protocol allows non-JSON \ + frames, call `client.send(...)` once with \ + the auth-bearing frame before \ + `run_until_shutdown`." + ))); + } + } + } else { + text + }; + first_send_done = true; + if let Err(e) = sink.send(Message::Text(to_send)).await { + break Err(map_stream_error(e, &abnormal_hint)); + } + } + None => { + // stdin EOF — clean exit per resolution sheet. + // The Close(1000) frame is sent after the loop + // unwinds (`exit_reason.is_ok()` branch below). + break Ok(()); + } + } + } + msg = source.next() => { + let result = handle_inbound( + msg, + &mut sink, + &auto_responder, + &pipeline, + &strip_keys, + &mut stdout, + &abnormal_hint, + ).await; + match result { + FrameDisposition::Continue => continue, + FrameDisposition::Stop(r) => break r, + } + } + } + }; + + // Send Close(1000) on graceful exit. We swallow the error from + // close() because the connection may already be closed (server + // initiated the close, network is gone, etc.) — `exit_reason` + // is the authoritative outcome. + // + // Note: when the server initiated the close, tungstenite has + // already queued the echo internally before our `Message::Close` + // reaches `sink.send`. Tungstenite's close-state machine treats + // user-side `send(Close)` as a no-op outside the Active state, + // so this is *not* a double-frame on the wire — just a wasted + // method call. Cheap and keeps the source readable. + if exit_reason.is_ok() { + let _ = tokio::time::timeout( + Duration::from_secs(2), + sink.send(Message::Close(Some(CloseFrame { + code: CloseCode::Normal, + reason: "".into(), + }))), + ) + .await; + } + + // Abort the stdin reader task. NOTE: `abort()` does NOT unwind a + // blocking read inside `tokio::io::stdin()` — the underlying + // blocking thread continues until the OS hands it a line or EOF. + // In `run_recv_loop` this is fine because the process is about + // to exit and the OS reclaims everything. Future stdin-driven + // *tests* will need a fake stdin (e.g. a custom `AsyncRead` + // injected via a future `WsConfig.stdin_source` field) to avoid + // leaking a blocking thread per test. + if let Some(handle) = stdin_handle { + handle.abort(); + } + + exit_reason + } + + /// Convenience wrapper that runs the recv loop until either + /// [`tokio::signal::ctrl_c`] fires or the server closes. + pub async fn run_recv_loop(self) -> Result<(), CliError> { + // Wrap the signal future so its concrete unit-typed shape lines + // up with the `Future + Unpin` bound on + // `run_until_shutdown`. Box the future to satisfy Unpin without + // requiring callers to pin manually. + let shutdown = Box::pin(async { + let _ = tokio::signal::ctrl_c().await; + }); + self.run_until_shutdown(shutdown).await + } +} + +enum FrameDisposition { + Continue, + Stop(Result<(), CliError>), +} + +/// Single-frame handler — invoked once per item from the WS source. +/// Returns whether the loop should keep going or break with a result. +async fn handle_inbound( + msg: Option>, + sink: &mut futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + auto_responder: &Option, + pipeline: &OutputPipeline, + strip_keys: &[String], + stdout: &mut std::io::Stdout, + abnormal_hint: &str, +) -> FrameDisposition { + match msg { + None => FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "WebSocket stream ended without a close frame — {abnormal_hint}" + )))), + Some(Err(e)) => FrameDisposition::Stop(Err(map_stream_error(e, abnormal_hint))), + Some(Ok(Message::Close(frame))) => { + FrameDisposition::Stop(classify_close_frame(frame.as_ref(), abnormal_hint)) + } + // WS protocol-level Ping/Pong are auto-handled by tungstenite; we + // never see them as user-payload. Frame is also internal. None of + // them should emit to stdout. + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => { + FrameDisposition::Continue + } + Some(Ok(Message::Binary(b))) => { + // v1: inbound binary frames are not emitted to stdout (most + // streaming APIs send JSON inbound and only accept binary + // outbound). Warn visibly so callers hitting an API that + // *does* stream binary back know their stream produced + // unprintable bytes — silence would look like a hung pipe. + eprintln!( + "warning: dropped {}-byte inbound WebSocket binary frame \ + (v1 does not emit binary inbound; plumb a handler via \ + WsConfig in a future release if your API needs this)", + b.len(), + ); + FrameDisposition::Continue + } + Some(Ok(Message::Text(text))) => { + // Parse as JSON. If parsing fails, treat as transport-level + // garbage from the server — surface it. + let value: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { + return FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "WebSocket received unparseable JSON: {e}: {}", + truncate(&text, 200), + )))); + } + }; + + // Autoresponder first: if it claims the frame, send the reply + // and elide. No emit. + if let Some(responder) = auto_responder { + if let Some(reply) = responder(&value) { + let reply_text = match serde_json::to_string(&reply) { + Ok(s) => s, + Err(e) => { + return FrameDisposition::Stop(Err(CliError::Other( + anyhow::anyhow!("autoresponder produced unserializable JSON: {e}"), + ))); + } + }; + if let Err(e) = sink.send(Message::Text(reply_text)).await { + return FrameDisposition::Stop(Err(map_stream_error(e, abnormal_hint))); + } + return FrameDisposition::Continue; + } + } + + // Strip audio-shaped keys before emit (recursive). + let to_emit = if strip_keys.is_empty() { + value + } else { + let mut v = value; + strip_keys_recursive(&mut v, strip_keys); + v + }; + + // Emit through the pipeline. `paginated=true` so each frame + // emits as compact NDJSON (one object per line). + if let Err(e) = pipeline.emit(stdout, &to_emit, true, false) { + return FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "failed to emit WebSocket frame: {e}" + )))); + } + FrameDisposition::Continue + } + } +} + +/// Recursively remove keys whose name matches any entry in `keys`. Walks +/// objects and arrays in place. Linear in the JSON value's node count. +fn strip_keys_recursive(value: &mut Value, keys: &[String]) { + match value { + Value::Object(map) => { + for k in keys { + map.remove(k); + } + for (_, v) in map.iter_mut() { + strip_keys_recursive(v, keys); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + strip_keys_recursive(v, keys); + } + } + _ => {} + } +} + +/// Stdin reader task: pushes validated lines onto the bounded sender. +/// When stdin EOFs, drops the sender so the main loop sees `None` on +/// its next `recv()` and exits cleanly. Blank lines silently skipped; +/// invalid JSON warned to stderr and dropped (connection stays up). +async fn stdin_reader_task(tx: mpsc::Sender, validate_json: bool) { + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + loop { + match reader.next_line().await { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + if validate_json { + if let Err(e) = serde_json::from_str::(&line) { + eprintln!( + "warning: stdin line is not valid JSON, dropping: {} ({})", + truncate(&line, 80), + e, + ); + continue; + } + } + if tx.send(line).await.is_err() { + // Receiver dropped — the recv loop has exited. Stop. + return; + } + } + Ok(None) => return, // EOF — drop tx by returning + Err(e) => { + eprintln!("warning: stdin read error: {e}"); + return; + } + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_keys_removes_top_level_and_nested() { + let mut value = serde_json::json!({ + "audio_base_64": "AAAA...", + "text": "hello", + "agent_response": { + "audio_base_64": "BBBB...", + "transcript": "world", + }, + "items": [ + {"audio_base_64": "CCCC...", "id": 1}, + {"audio_base_64": "DDDD...", "id": 2}, + ], + }); + strip_keys_recursive(&mut value, &["audio_base_64".to_string()]); + assert!(value.get("audio_base_64").is_none()); + assert_eq!(value["text"], "hello"); + assert!(value["agent_response"].get("audio_base_64").is_none()); + assert_eq!(value["agent_response"]["transcript"], "world"); + assert!(value["items"][0].get("audio_base_64").is_none()); + assert!(value["items"][1].get("audio_base_64").is_none()); + assert_eq!(value["items"][0]["id"], 1); + } + + #[test] + fn strip_keys_noop_when_keys_absent() { + let mut value = serde_json::json!({"text": "hi", "n": 1}); + strip_keys_recursive(&mut value, &["audio_base_64".to_string()]); + assert_eq!(value, serde_json::json!({"text": "hi", "n": 1})); + } +} diff --git a/cli/src/websocket/error.rs b/cli/src/websocket/error.rs new file mode 100644 index 00000000..8e6d5a31 --- /dev/null +++ b/cli/src/websocket/error.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket failure → [`CliError`] mapping. +//! +//! # The matrix (v1) +//! +//! | Phase | Failure mode | `CliError` | Exit | +//! |---|---|---|---| +//! | handshake | DNS / TCP refused / reset | `Other` | 5 | +//! | handshake | TLS cert error | `Other` | 5 | +//! | handshake | 401 / 403 Upgrade rejected | `Auth` | 2 | +//! | handshake | 404 / wrong URL | `Discovery` | 4 | +//! | handshake | 5xx | `Api { code, .. }` | 1 | +//! | mid-stream | server `Close(1000)` Normal Closure | `Ok(())` | **0** | +//! | mid-stream | server `Close(1001..=1015)` abnormal | `Other` (hint included) | 5 | +//! | mid-stream | TCP drop / read timeout / inactivity | `Other` (hint included) | 5 | +//! | local | bad URL given to [`WsConfig::url`](super::WsConfig) | `Validation` | 3 | +//! | local | unparseable JSON from server | `Other` | 5 | +//! +//! The abnormal-close hint nudges users toward the most common failure +//! mode: auth / network / app-level keepalive misses. + +use tokio_tungstenite::tungstenite; + +use crate::error::CliError; + +/// Default hint appended to abnormal-close errors. API-neutral by +/// design — it's the message a user of *any* WS-using CLI should +/// understand. Override per-CLI by setting +/// [`super::WsConfig::abnormal_close_hint`] with API-specific guidance. +pub const ABNORMAL_CLOSE_HINT: &str = + "connection ended abnormally; check auth, network, and the API's keepalive/timeout requirements"; + +/// Map a `tungstenite::Error` raised during the handshake phase to a +/// [`CliError`] following the matrix above. Public so an external caller +/// implementing its own handshake wrapper (e.g. for unit-testing the +/// matrix in isolation) can reuse the mapping. +pub fn map_handshake_error(err: tungstenite::Error) -> CliError { + use tungstenite::Error as TE; + + match err { + TE::Http(response) => { + // The HTTP-status-bearing handshake failure: the server + // accepted the TCP connection but rejected the Upgrade. + let status = response.status().as_u16(); + // Best-effort body capture for the error message. Tungstenite + // exposes it as `Option>`. + let body = response + .into_body() + .and_then(|b| String::from_utf8(b).ok()) + .unwrap_or_default(); + match status { + 401 | 403 => CliError::Auth(format!( + "WebSocket upgrade rejected with {status}: {}", + truncate(&body, 200), + )), + 404 => CliError::Discovery(format!( + "WebSocket endpoint not found (404): {}", + truncate(&body, 200), + )), + 500..=599 => CliError::Api { + code: status, + message: format!("WebSocket upgrade failed: {}", truncate(&body, 200)), + reason: "wsHandshakeServerError".into(), + }, + _ => CliError::Other(anyhow::anyhow!( + "WebSocket upgrade failed with status {status}: {}", + truncate(&body, 200), + )), + } + } + TE::Url(e) => { + // tungstenite couldn't even parse / route the URL — caller + // gave us garbage. + CliError::Validation(format!("invalid WebSocket URL: {e}")) + } + // Everything else (Io, Tls, ConnectionClosed before negotiation, + // protocol violations during the upgrade) is transport-shaped. + other => CliError::Other(anyhow::anyhow!("WebSocket handshake failed: {other}")), + } +} + +/// Map a `tungstenite::Error` raised mid-stream (after handshake) to a +/// [`CliError`]. Always returns an `Err`; the recv loop maps `Ok` paths +/// (clean close 1000, polite close 1001) directly. `hint` is the message +/// the user should investigate — pass the WS config's +/// [`super::WsConfig::abnormal_close_hint`] (or the default). +pub(crate) fn map_stream_error(err: tungstenite::Error, hint: &str) -> CliError { + use tungstenite::Error as TE; + + match err { + TE::ConnectionClosed | TE::AlreadyClosed => CliError::Other(anyhow::anyhow!( + "WebSocket connection closed unexpectedly — {hint}" + )), + TE::Io(e) => CliError::Other(anyhow::anyhow!( + "WebSocket I/O error mid-stream: {e} — {hint}" + )), + other => CliError::Other(anyhow::anyhow!( + "WebSocket protocol error mid-stream: {other}" + )), + } +} + +/// Classify a server-initiated close frame. +/// +/// Returns `Ok(())` for **success-shaped** closures: +/// - `1000 Normal Closure` — protocol-correct end-of-session. +/// - `1001 Going Away` — peer is leaving (page navigation, server +/// shutdown, *or* session-cap expiry like a long-running session +/// hitting a server-side hard limit). For long-running sessions this +/// is the polite way to say "we're done"; treating it as an error +/// would cause shell pipelines to spuriously fail on a clean +/// end-of-session. +/// +/// Returns `Err` for everything else, with `hint` woven into the message +/// when supplied. `hint` is what the user should investigate; pass +/// [`ABNORMAL_CLOSE_HINT`] for the generic default, or supply an +/// API-specific string (see [`super::WsConfig::abnormal_close_hint`]). +pub(crate) fn classify_close_frame( + frame: Option<&tungstenite::protocol::CloseFrame<'_>>, + hint: &str, +) -> Result<(), CliError> { + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + + let Some(frame) = frame else { + // No close frame at all — the peer just hung up. Treat as abnormal. + return Err(CliError::Other(anyhow::anyhow!( + "WebSocket peer closed without a close frame — {hint}" + ))); + }; + match frame.code { + CloseCode::Normal => Ok(()), + CloseCode::Away => { + // 1001 "Going Away" — log to stderr so the user sees that + // the session ended for a benign reason, but don't fail the + // exit code. + let reason_suffix = if frame.reason.is_empty() { + String::new() + } else { + format!(" ({})", frame.reason) + }; + eprintln!( + "websocket: session ended with code 1001 going away{reason_suffix}" + ); + Ok(()) + } + _ => { + let code: u16 = frame.code.into(); + Err(CliError::Other(anyhow::anyhow!( + "WebSocket closed with code {code}{} — {hint}", + if frame.reason.is_empty() { + String::new() + } else { + format!(" ({})", frame.reason) + }, + ))) + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + // Truncate on a char boundary for safety; the body may be UTF-8 + // and slicing in the middle of a multibyte sequence panics. + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode}; + use std::borrow::Cow; + + fn frame(code: u16, reason: &'static str) -> CloseFrame<'static> { + CloseFrame { + code: CloseCode::from(code), + reason: Cow::Borrowed(reason), + } + } + + #[test] + fn close_1000_is_ok() { + assert!(classify_close_frame(Some(&frame(1000, "")), ABNORMAL_CLOSE_HINT).is_ok()); + } + + #[test] + fn close_1001_going_away_is_ok() { + // 1001 = peer is leaving (page nav, server shutdown, session-cap + // expiry). Treated as a clean end-of-session for long-running + // sessions that hit a server-side hard limit and similar + // "polite hangup" patterns. + assert!(classify_close_frame(Some(&frame(1001, "session cap")), ABNORMAL_CLOSE_HINT).is_ok()); + } + + #[test] + fn close_1006_is_err_with_hint() { + let err = classify_close_frame(Some(&frame(1006, "")), ABNORMAL_CLOSE_HINT).unwrap_err(); + assert!(err.to_string().contains("1006")); + assert!(err.to_string().contains(ABNORMAL_CLOSE_HINT)); + } + + #[test] + fn close_with_reason_includes_reason_in_message() { + let err = classify_close_frame(Some(&frame(1011, "internal error")), ABNORMAL_CLOSE_HINT) + .unwrap_err(); + assert!(err.to_string().contains("internal error")); + } + + #[test] + fn missing_close_frame_is_abnormal_err() { + let err = classify_close_frame(None, ABNORMAL_CLOSE_HINT).unwrap_err(); + assert!(err.to_string().contains(ABNORMAL_CLOSE_HINT)); + } + + #[test] + fn custom_hint_replaces_default_in_message() { + let custom = "custom hint: check KeepAlive cadence + audio format"; + let err = classify_close_frame(Some(&frame(1006, "")), custom).unwrap_err(); + assert!(err.to_string().contains(custom)); + assert!(!err.to_string().contains(ABNORMAL_CLOSE_HINT), + "default hint should NOT appear when a custom one was passed"); + } + + #[test] + fn handshake_url_error_maps_to_validation() { + let err = map_handshake_error(tungstenite::Error::Url( + tungstenite::error::UrlError::NoHostName, + )); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn truncate_respects_char_boundary() { + // U+1F600 is 4 bytes in UTF-8. Truncating at byte 2 would split it. + let s = "ab😀cd"; + let truncated = truncate(s, 3); + // Should fall back to a char boundary at or before 3. + assert!(truncated.starts_with("ab")); + } +} diff --git a/cli/src/websocket/mod.rs b/cli/src/websocket/mod.rs new file mode 100644 index 00000000..090000ee --- /dev/null +++ b/cli/src/websocket/mod.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket bidirectional client. +//! +//! Used by custom commands that need to graft a long-lived bidirectional +//! connection onto the CLI (realtime streaming, conversational APIs, +//! etc.). The recv loop emits each inbound JSON frame through +//! [`crate::formatter::OutputPipeline`] so format / color / future +//! jq/fields/template flags compose for free. +//! +//! # Composition with [`AppContext`](crate::openapi::AppContext) +//! +//! Custom-command handlers are synchronous, but the WS client is async. +//! Bridge with the same `block_in_place` + `Handle::current().block_on(...)` +//! pattern that [`AppContext::execute`](crate::openapi::AppContext::execute) +//! uses internally — see [`WebSocketClient::connect`] for an example. +//! +//! # Auth +//! +//! `WsAuth::{QueryParam, Header, FirstMessage}` each take an +//! [`AuthCredentialSource`](crate::auth::AuthCredentialSource) directly — +//! the WS module does **not** call into [`AuthProvider`](crate::auth::AuthProvider) +//! because that surface is reqwest-shaped. See +//! `docs/adr/0001-auth-provider-no-cred-extraction.md`. +//! +//! # TLS +//! +//! `WebSocketClient::connect` honors compile-time roots from +//! `CliApp::extra_root_cert` and resolves the same env vars as the +//! reqwest path via [`HttpConfig::resolve`](crate::http::HttpConfig::resolve) +//! — `_CA_BUNDLE`, `_INSECURE`, `_CONNECT_TIMEOUT_SECS`. +//! Proxy support (`_PROXY`) is not implemented in v1; document it as +//! a follow-up. +//! +//! # Graceful shutdown +//! +//! [`WebSocketClient::run_until_shutdown`] takes any future. Production +//! wires it to [`tokio::signal::ctrl_c`] via the convenience wrapper +//! [`WebSocketClient::run_recv_loop`]; tests wire it to a `oneshot` +//! receiver. + +mod auth; +mod client; +mod error; + +pub use auth::WsAuth; +pub use client::{AutoResponder, WebSocketClient, WsConfig}; +pub use error::map_handshake_error; diff --git a/cli/tests/auth_routing_wire.rs b/cli/tests/auth_routing_wire.rs new file mode 100644 index 00000000..f7d2f0be --- /dev/null +++ b/cli/tests/auth_routing_wire.rs @@ -0,0 +1,728 @@ +/// Wire test for the spec-aware auth provider architecture. +/// +/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a +/// hand-built `RestDescription`, with three methods exercising distinct +/// requirement shapes: +/// +/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. +/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. +/// - `things.ping` declares no `security_requirements` → falls back to the +/// `AnyAuthProvider` default, which tries the bindings in registration +/// order; the bearer binding wins. +/// +/// Each test mounts an `expect(1)` mock that *only* matches the expected +/// header. A wrong header on the wire would miss the mock, get a 404 from +/// the catch-all, and surface as a test failure — wiremock panics at drop +/// time on unfulfilled `expect(1)` mocks. +use std::collections::HashMap; + +use fern_cli_sdk::auth::{ + build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, + finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, + SchemeBinding, +}; +use std::sync::Arc; +use fern_cli_sdk::formatter::OutputPipeline; +use fern_cli_sdk::http::HttpConfig; +use fern_cli_sdk::openapi::discovery::{ + RestDescription, RestMethod, RestResource, SecurityScheme, +}; +use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; +use serde_json::json; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const BEARER_TOKEN: &str = "bearer-secret"; +const API_KEY: &str = "apikey-secret"; + +/// Build a `RestDescription` with two declared security schemes and three +/// methods that exercise routing, anonymous, and fallback paths. +fn build_doc(server_url: &str) -> RestDescription { + let mut doc = RestDescription { + name: "auth-routing-fixture".to_string(), + version: "1.0".to_string(), + root_url: server_url.to_string(), + ..Default::default() + }; + doc.security_schemes + .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); + doc.security_schemes.insert( + "apiKey".to_string(), + SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + ); + + let mut things = RestResource::default(); + + // list — requires bearerAuth + let mut list_req = HashMap::new(); + list_req.insert("bearerAuth".to_string(), Vec::::new()); + things.methods.insert( + "list".to_string(), + RestMethod { + id: Some("things.list".to_string()), + http_method: "GET".to_string(), + path: "/things".to_string(), + root_url: server_url.to_string(), + security_requirements: Some(vec![list_req]), + ..Default::default() + }, + ); + + // update — requires apiKey only + let mut update_req = HashMap::new(); + update_req.insert("apiKey".to_string(), Vec::::new()); + things.methods.insert( + "update".to_string(), + RestMethod { + id: Some("things.update".to_string()), + http_method: "PUT".to_string(), + path: "/things".to_string(), + root_url: server_url.to_string(), + security_requirements: Some(vec![update_req]), + ..Default::default() + }, + ); + + // ping — no security requirements declared + things.methods.insert( + "ping".to_string(), + RestMethod { + id: Some("things.ping".to_string()), + http_method: "GET".to_string(), + path: "/ping".to_string(), + root_url: server_url.to_string(), + security_requirements: None, + ..Default::default() + }, + ); + + // health — explicit anonymous (`security: []`). Distinct from `ping` + // (which simply omits the security block): the empty array opts the + // endpoint *out* of every scheme, even when a default is bound. + things.methods.insert( + "health".to_string(), + RestMethod { + id: Some("things.health".to_string()), + http_method: "GET".to_string(), + path: "/health".to_string(), + root_url: server_url.to_string(), + security_requirements: Some(Vec::new()), + ..Default::default() + }, + ); + + doc.resources.insert("things".to_string(), things); + doc +} + +/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. +fn bindings() -> Vec<(String, SchemeBinding)> { + vec![ + ( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), + ), + ] +} + +fn http_config() -> HttpConfig { + HttpConfig::new("auth-routing-fixture").unwrap() +} + +fn pagination() -> PaginationConfig { + PaginationConfig::default() +} + +async fn run( + doc: &RestDescription, + method_name: &str, + provider: &DynAuthProvider, +) -> Result, fern_cli_sdk::error::CliError> { + let m = doc.resources["things"].methods[method_name].clone(); + executor::execute_method( + doc, + &m, + None, + None, + provider, + None, + None, + None, + false, + &pagination(), + &OutputPipeline::default(), + true, // capture_output (don't print to stdout) + None, + &http_config(), + false, // no_extract + false, // no_retry + false, // no_stream + &[], + ) + .await +} + +#[tokio::test] +async fn test_routing_endpoint_requires_bearer_only() { + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let provider = build_provider_from_doc(&doc, &bindings()); + + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + assert!( + result.is_ok(), + "list call failed: {:?}", + result.err() + ); +} + +#[tokio::test] +async fn test_routing_endpoint_requires_apikey_only() { + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let provider = build_provider_from_doc(&doc, &bindings()); + + Mock::given(method("PUT")) + .and(path("/things")) + .and(header("X-Api-Key", API_KEY)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "update", &provider).await; + assert!(result.is_ok(), "update call failed: {:?}", result.err()); + + // wiremock's header matchers only see headers that exist — they can't + // assert a header is *absent*. Inspect the actual recorded request to + // pin down that no Authorization leaked into the apiKey-only endpoint. + let recorded = server.received_requests().await.expect("requests recorded"); + assert_eq!(recorded.len(), 1, "exactly one request expected"); + let req = &recorded[0]; + assert_eq!( + req.headers + .get("X-Api-Key") + .and_then(|v| v.to_str().ok()), + Some(API_KEY), + "apiKey header value should match", + ); + assert!( + req.headers.get("Authorization").is_none(), + "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", + req.headers.get("Authorization"), + ); +} + +#[tokio::test] +async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { + // `ping` has no security requirements. The RoutingAuthProvider should + // fall through to its `default` (AnyAuthProvider), which tries the + // bindings in registration order — bearer first → Authorization wins. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let provider = build_provider_from_doc(&doc, &bindings()); + + Mock::given(method("GET")) + .and(path("/ping")) + .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "ping", &provider).await; + assert!(result.is_ok(), "ping failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { + // `health` declares `security: []` — the operation explicitly opts out + // of every scheme. Both bindings are present and have credentials, but + // neither header may land on the wire. The unit test at + // `compose.rs:399` pins the same behavior in isolation; this is the + // end-to-end version covering the executor + RoutingAuthProvider path. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let provider = build_provider_from_doc(&doc, &bindings()); + + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "health", &provider).await; + assert!(result.is_ok(), "health call failed: {:?}", result.err()); + + let recorded = server.received_requests().await.expect("requests recorded"); + assert_eq!(recorded.len(), 1); + let req = &recorded[0]; + assert!( + req.headers.get("Authorization").is_none(), + "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", + req.headers.get("Authorization"), + ); + assert!( + req.headers.get("X-Api-Key").is_none(), + "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", + req.headers.get("X-Api-Key"), + ); +} + +#[tokio::test] +async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { + // Only the apiKey scheme is bound. The bearer-required `list` endpoint + // can't satisfy any requirement → request goes out unauthed → server + // returns 401 → executor surfaces the friendly "no creds" Auth error, + // because `RoutingAuthProvider::has_credentials_for(endpoint)` + // recognizes that this specific endpoint's bearer requirement isn't + // satisfied (even though apiKey *is* bound elsewhere). + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let only_apikey = vec![( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), + )]; + let provider = build_provider_from_doc(&doc, &only_apikey); + + Mock::given(method("GET")) + .and(path("/things")) + .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) + .expect(1) + .mount(&server) + .await; + + let err = run(&doc, "list", &provider).await.unwrap_err(); + match err { + fern_cli_sdk::error::CliError::Auth(msg) => { + assert!( + msg.contains("Access denied"), + "expected friendly 'Access denied' message, got: {msg}", + ); + } + other => panic!("expected friendly CliError::Auth, got: {other:?}"), + } + + // Critical security guard: even though no requirement was satisfiable, + // the apiKey we have must NOT have been opportunistically attached. + let recorded = server.received_requests().await.expect("requests recorded"); + assert_eq!(recorded.len(), 1); + let req = &recorded[0]; + assert!(req.headers.get("Authorization").is_none()); + assert!(req.headers.get("X-Api-Key").is_none()); +} + +// -------- AuthStrategy::All (Phase 9) -------- + +#[tokio::test] +async fn test_strategy_all_attaches_every_scheme_to_every_request() { + // Generator-driven scenario: API requires bearer + apiKey on every + // request, regardless of what the spec says about per-endpoint + // security. `auth_strategy(All)` is how the generator expresses this. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let bindings = vec![ + ( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), + ), + ]; + let provider = build_provider_with_strategy( + &bindings, + &doc.security_schemes, + AuthStrategy::All, + true, // doc has per-endpoint security; All overrides anyway + ); + assert_eq!(provider.name(), "all"); + + // Even though `things.list` declares only bearerAuth in its + // security_requirements, the All strategy ignores that and attaches + // both schemes — that's the whole point. + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) + .and(header("X-Api-Key", API_KEY)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + assert!(result.is_ok(), "list call failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_strategy_all_friendly_error_when_any_scheme_missing() { + // All-auth means one missing scheme = no auth attempted. The friendly + // error should fire because we couldn't fully satisfy the requirement. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let bindings = vec![ + ( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), + ), + ( + "apiKey".to_string(), + // Missing — so all-auth can't be satisfied. + SchemeBinding::Token(AuthCredentialSource::Missing), + ), + ]; + let provider = build_provider_with_strategy( + &bindings, + &doc.security_schemes, + AuthStrategy::All, + true, + ); + assert!(!provider.has_credentials()); + + Mock::given(method("GET")) + .and(path("/things")) + .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) + .expect(1) + .mount(&server) + .await; + + let err = run(&doc, "list", &provider).await.unwrap_err(); + match err { + fern_cli_sdk::error::CliError::Auth(msg) => { + assert!(msg.contains("Access denied"), "got: {msg}"); + } + other => panic!("expected friendly Auth error, got: {other:?}"), + } + + // No auth must have been attached — partial all-auth would leak + // whichever scheme *is* bound (here the bearer token) without + // satisfying the API's actual requirement. `AllAuthProvider::apply` + // short-circuits when `has_credentials_for(endpoint)` is false so + // nothing reaches the wire. + let recorded = server.received_requests().await.expect("requests recorded"); + assert_eq!(recorded.len(), 1); + let req = &recorded[0]; + assert!( + req.headers.get("Authorization").is_none(), + "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", + req.headers.get("Authorization"), + ); + assert!( + req.headers.get("X-Api-Key").is_none(), + "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", + req.headers.get("X-Api-Key"), + ); +} + +// -------- Compositional credential sources (Phase 7) -------- + +/// Simulate `clap` parsing `--api-token ` and produce the matches +/// the SDK would normally hand to `finalize_bindings`. Test-only helper. +fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { + let cmd = clap::Command::new("auth-routing-test").arg( + clap::Arg::new(arg_name) + .long(arg_name) + .num_args(1), + ); + let argv: Vec = match value { + Some(v) => vec![ + "auth-routing-test".to_string(), + format!("--{arg_name}"), + v.to_string(), + ], + None => vec!["auth-routing-test".to_string()], + }; + Arc::new(cmd.try_get_matches_from(argv).unwrap()) +} + +#[tokio::test] +async fn test_credential_source_cli_finalizes_and_routes() { + // Bind bearer to a CLI flag, simulate the user passing + // `--api-token cli-supplied`, and confirm the value lands on the wire. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::cli("api-token")), + )]; + let matches = matches_with_arg("api-token", Some("cli-supplied")); + let finalized = finalize_bindings(bindings, &matches); + let provider = build_provider_from_doc(&doc, &finalized); + + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", "Bearer cli-supplied")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + assert!(result.is_ok(), "list call failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_credential_source_chain_falls_back_through_sources() { + // Chain: --api-token (not supplied) → env var (set). The env var should + // win because the CLI source resolves to None when the flag wasn't + // passed, and Chain takes the first non-empty. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; + std::env::set_var(env_key, "from-env-fallback"); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env(env_key), + ])), + )]; + let matches = matches_with_arg("api-token", None); + let finalized = finalize_bindings(bindings, &matches); + let provider = build_provider_from_doc(&doc, &finalized); + + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", "Bearer from-env-fallback")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + std::env::remove_var(env_key); + assert!(result.is_ok(), "list call failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_credential_source_chain_cli_wins_over_env() { + // Both CLI and env are set. CLI is registered first in the chain → CLI + // value wins. The standard "command-line overrides environment" + // precedence pattern. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; + std::env::set_var(env_key, "loser-from-env"); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env(env_key), + ])), + )]; + let matches = matches_with_arg("api-token", Some("winner-from-cli")); + let finalized = finalize_bindings(bindings, &matches); + let provider = build_provider_from_doc(&doc, &finalized); + + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", "Bearer winner-from-cli")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + std::env::remove_var(env_key); + assert!(result.is_ok(), "list call failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_credential_source_file_reads_from_disk() { + // Write a credential to a temp file, bind the bearer scheme to it, + // confirm the trimmed file contents land on the wire. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let dir = tempfile::tempdir().unwrap(); + let token_path = dir.path().join("api-token"); + std::fs::write(&token_path, " file-secret \n").unwrap(); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::file(&token_path)), + )]; + // No CLI args needed; finalize is a no-op for File. + let matches = matches_with_arg("ignored", None); + let finalized = finalize_bindings(bindings, &matches); + let provider = build_provider_from_doc(&doc, &finalized); + + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", "Bearer file-secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + assert!(result.is_ok(), "list call failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_credential_source_full_chain_cli_env_file() { + // Canonical "CLI > env > file" pattern. Only the file has a value, + // so the chain should resolve to the file's contents. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let dir = tempfile::tempdir().unwrap(); + let token_path = dir.path().join("token"); + std::fs::write(&token_path, "deepest-fallback").unwrap(); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), + AuthCredentialSource::file(&token_path), + ])), + )]; + let matches = matches_with_arg("api-token", None); + let finalized = finalize_bindings(bindings, &matches); + let provider = build_provider_from_doc(&doc, &finalized); + + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", "Bearer deepest-fallback")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + assert!(result.is_ok(), "list call failed: {:?}", result.err()); +} + +#[tokio::test] +async fn test_basic_auth_with_per_field_chains() { + // HTTP basic with chains on each field — username from CLI, password + // from a file. Closes the loop on the "decoupled sources" pitch. + let server = MockServer::start().await; + let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); + doc.security_schemes.insert( + "basic".to_string(), + fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, + ); + let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); + let mut req_map = HashMap::new(); + req_map.insert("basic".to_string(), Vec::::new()); + things.methods.insert( + "list".to_string(), + fern_cli_sdk::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + root_url: server.uri(), + security_requirements: Some(vec![req_map]), + ..Default::default() + }, + ); + doc.resources.insert("things".to_string(), things); + + let dir = tempfile::tempdir().unwrap(); + let pass_path = dir.path().join("pw"); + std::fs::write(&pass_path, "hunter2").unwrap(); + + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Basic { + username: AuthCredentialSource::cli("user"), + password: AuthCredentialSource::file(&pass_path), + }, + )]; + + let cmd = clap::Command::new("test").arg( + clap::Arg::new("user") + .long("user") + .num_args(1), + ); + let matches = Arc::new( + cmd.try_get_matches_from(["test", "--user", "alice"]) + .unwrap(), + ); + let finalized = finalize_bindings(bindings, &matches); + // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. + let provider = build_provider_from_bindings( + &finalized, + &doc.security_schemes, + true, + ); + + // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let m = doc.resources["things"].methods["list"].clone(); + let result = executor::execute_method( + &doc, + &m, + None, + None, + &provider, + None, + None, + None, + false, + &PaginationConfig::default(), + &fern_cli_sdk::formatter::OutputPipeline::default(), + true, + None, + &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), + false, // no_extract + false, // no_retry + false, // no_stream + &[], + ) + .await; + assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); + + // Pin that the unused EndpointAuthMetadata import compiles. + let _ = EndpointAuthMetadata::unspecified(); +} + +#[tokio::test] +async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { + // Symmetric guard for the bearer-only endpoint: even though the apiKey + // scheme is bound and has credentials, the operation's + // `security_requirements` pin bearer alone — X-Api-Key must not appear. + let server = MockServer::start().await; + let doc = build_doc(&server.uri()); + let provider = build_provider_from_doc(&doc, &bindings()); + + Mock::given(method("GET")) + .and(path("/things")) + .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + + let result = run(&doc, "list", &provider).await; + assert!(result.is_ok(), "list call failed: {:?}", result.err()); + + let recorded = server.received_requests().await.expect("requests recorded"); + assert_eq!(recorded.len(), 1); + let req = &recorded[0]; + assert!( + req.headers.get("X-Api-Key").is_none(), + "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", + req.headers.get("X-Api-Key"), + ); +} diff --git a/cli/tests/common/mod.rs b/cli/tests/common/mod.rs new file mode 100644 index 00000000..b269c5df --- /dev/null +++ b/cli/tests/common/mod.rs @@ -0,0 +1,260 @@ +// This module is shared across multiple `tests/*.rs` integration binaries +// via `mod common`. Each binary uses a different subset of these helpers, +// so per-binary dead-code lints fire on the unused leftovers. Suppress +// at the module level rather than peppering every item with attributes. +#![allow(dead_code)] + +use serde_json::Value; +use wiremock::matchers::{header_regex, method, path_regex}; +use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; + +/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. +pub struct OpenApiFixtures; + +impl OpenApiFixtures { + pub const FILE_ID: &'static str = "file-1"; + pub const FOLDER_ID: &'static str = "folder-1"; + pub const USER_ID: &'static str = "user-1"; + pub const TOKEN: &'static str = "test-token"; +} + +/// Canonical values for the graphql-fixture wire tests. +pub struct GraphqlFixtures; + +impl GraphqlFixtures { + pub const NODE_ID: &'static str = "node-1"; + pub const TOKEN: &'static str = "test-token"; +} + +/// Matches when the JSON body's `variables` object contains all specified key-value pairs +/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. +pub struct BodyVariablesContain(pub Value); + +impl Match for BodyVariablesContain { + fn matches(&self, request: &Request) -> bool { + let Ok(body) = serde_json::from_slice::(&request.body) else { + return false; + }; + let Some(vars) = body.get("variables") else { + return false; + }; + let Some(expected) = self.0.as_object() else { + return false; + }; + for (key, expected_val) in expected { + if vars.get(key) != Some(expected_val) { + return false; + } + } + true + } +} + +/// Matches when none of the named keys appear in the JSON body's `variables` object. +/// Use to assert that the CLI did not auto-emit a variable the user never supplied. +pub struct BodyVariablesAbsent(pub &'static [&'static str]); + +impl Match for BodyVariablesAbsent { + fn matches(&self, request: &Request) -> bool { + let Ok(body) = serde_json::from_slice::(&request.body) else { + return false; + }; + let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { + // No variables block at all — every key is trivially absent. + return true; + }; + self.0.iter().all(|k| !vars.contains_key(*k)) + } +} + +/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). +pub struct IsGraphqlRequest; + +impl Match for IsGraphqlRequest { + fn matches(&self, request: &Request) -> bool { + serde_json::from_slice::(&request.body) + .ok() + .and_then(|v| v.get("query").cloned()) + .is_some() + } +} + +/// Load all stubs from a WireMock mappings JSON string into an in-process +/// MockServer. This is the in-process equivalent of the Docker WireMock +/// approach, but with no external dependencies and per-test isolation. +/// +/// Loader rules: +/// - Method and path are always matched. +/// - `pathParameters` `equalTo` values are resolved into the path literal +/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. +/// - Remaining `{param}` placeholders become `[^/]+` wildcards. +/// - `Authorization: Bearer .+` is enforced when present in the mapping, +/// verifying the CLI sends auth on every real request. +/// - `queryParameters` and `bodyPatterns` are stripped — individual tests +/// that care about request shape add their own `expect(1)` mocks. +pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { + let doc: serde_json::Value = + serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); + + for mapping in doc["mappings"].as_array().expect("mappings must be array") { + let req = &mapping["request"]; + let resp = &mapping["response"]; + + let http_method = req["method"].as_str().unwrap_or("GET"); + let template = req + .get("urlPathTemplate") + .or_else(|| req.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("/"); + let status = resp["status"].as_u64().unwrap_or(200) as u16; + let body = resp["body"].as_str().unwrap_or(""); + + let resolved = resolve_path(template, req.get("pathParameters")); + let regex = template_to_path_regex(&resolved); + + let has_auth_check = req + .get("headers") + .and_then(|h| h.get("Authorization")) + .is_some(); + + // Propagate response headers so the CLI can correctly determine the + // response format. set_body_string() forces Content-Type: text/plain, + // so use set_body_json() for JSON responses — that way the CLI won't + // treat the body as a binary download. + let resp_content_type = resp + .get("headers") + .and_then(|h| h.get("Content-Type")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let mut response = + if resp_content_type.contains("application/json") { + if let Ok(json_body) = serde_json::from_str::(body) { + ResponseTemplate::new(status).set_body_json(json_body) + } else { + ResponseTemplate::new(status).set_body_string(body) + } + } else { + ResponseTemplate::new(status).set_body_string(body) + }; + if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { + for (name, value) in headers { + if name.to_lowercase() == "content-type" { + continue; // already handled by the body setter above + } + if let Some(v) = value.as_str() { + response = response.insert_header(name.as_str(), v); + } + } + } + + if has_auth_check { + Mock::given(method(http_method)) + .and(path_regex(regex)) + .and(header_regex("Authorization", "Bearer .+")) + .respond_with(response) + .mount(server) + .await; + } else { + Mock::given(method(http_method)) + .and(path_regex(regex)) + .respond_with(response) + .mount(server) + .await; + } + } +} + +/// Substitute `{param}` placeholders with their `equalTo` canonical values +/// from the mapping's `pathParameters` block. +fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { + let mut result = template.to_string(); + if let Some(obj) = path_params.and_then(|v| v.as_object()) { + for (param, matcher) in obj { + if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { + result = result.replace(&format!("{{{param}}}"), value); + } + } + } + result +} + +/// Convert a path template (possibly still containing `{param}` placeholders) +/// into a full anchored regex string suitable for `path_regex(...)`. +fn template_to_path_regex(template: &str) -> String { + let mut result = String::from("^"); + let mut chars = template.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '{' { + // consume the placeholder name up to and including '}' + for c in chars.by_ref() { + if c == '}' { + break; + } + } + result.push_str("[^/]+"); + } else { + // escape regex metacharacters in literal path segments + match ch { + '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { + result.push('\\'); + result.push(ch); + } + _ => result.push(ch), + } + } + } + result.push('$'); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_path_substitutes_known_params() { + let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); + assert_eq!( + resolve_path("/files/{file_id}", Some(¶ms)), + "/files/12345" + ); + } + + #[test] + fn resolve_path_leaves_unknown_params() { + let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); + assert_eq!( + resolve_path("/files/{file_id}", Some(¶ms)), + "/files/{file_id}" + ); + } + + #[test] + fn template_to_path_regex_exact() { + assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); + } + + #[test] + fn template_to_path_regex_single_param() { + assert_eq!( + template_to_path_regex("/files/{file_id}"), + "^/files/[^/]+$" + ); + } + + #[test] + fn template_to_path_regex_multi_param() { + assert_eq!( + template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), + "^/automations/[^/]+/nodes/[^/]+$" + ); + } + + #[test] + fn template_to_path_regex_escapes_dot() { + // e.g. /files/{file_id}/thumbnail.{extension} + let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); + assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); + assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); + } +} diff --git a/cli/tests/lib_api.rs b/cli/tests/lib_api.rs new file mode 100644 index 00000000..88873a63 --- /dev/null +++ b/cli/tests/lib_api.rs @@ -0,0 +1,40 @@ +//! Tests for the public library API surface. +//! +//! These verify that customers can use the library as documented. + +#[test] +fn test_cli_app_builder_chain() { + let app = fern_cli_sdk::openapi::CliApp::new("test") + .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) + .auth_scheme_env("bearer", "TEST_TOKEN") + .command( + clap::Command::new("custom").about("A custom command"), + |_args, _ctx| Ok(()), + ); + + // Builder chain completes without panic — the app is ready to run + // (We can't inspect private fields from integration tests, but the + // builder pattern itself is the test: if it compiles, the API works.) + drop(app); +} + +#[test] +fn test_building_blocks_accessible() { + // Verify all public modules are importable and types are usable + let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); + let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); + let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); + + assert!(cmd.find_subcommand("users").is_some()); + assert!(cmd.find_subcommand("files").is_some()); + + // Verify key types are accessible + let _format = fern_cli_sdk::formatter::OutputFormat::Json; + let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); +} + +#[test] +fn test_error_type_accessible() { + let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); + assert_eq!(err.exit_code(), 3); +} diff --git a/cli/tests/openapi_streaming_wire.rs b/cli/tests/openapi_streaming_wire.rs new file mode 100644 index 00000000..d009bdbf --- /dev/null +++ b/cli/tests/openapi_streaming_wire.rs @@ -0,0 +1,392 @@ +//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). +//! +//! Each test: +//! 1. Authors a minimal OpenAPI spec inline that declares one streaming +//! operation under `x-fern-streaming` (either SSE or NDJSON). +//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded +//! streamed body — `\n`-joined frames the executor must split. +//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against +//! the mock and asserts the request shape (path) and the events +//! captured into the buffered response value match expected ordering. +//! +//! The executor's *streaming* path (default — no `--no-stream`) writes +//! each event to stdout as it arrives, which is hard to capture from a +//! library test. The buffered branch (selected here via +//! `capture_output = true`) consumes the *same* `decode_stream_event` +//! pipeline and stores each event in order — so a regression in framing +//! or terminator handling fails this test before it reaches the CLI +//! surface. The CLI-binary end-to-end coverage of streaming output is +//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. + +use std::sync::Arc; + +use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; +use fern_cli_sdk::formatter::OutputPipeline; +use fern_cli_sdk::http::HttpConfig; +use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; +use fern_cli_sdk::openapi::load_openapi_spec; +use wiremock::matchers::{header_regex, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const TOKEN: &str = "wire-token"; + +fn bearer_provider() -> DynAuthProvider { + Arc::new(BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::literal(TOKEN), + )) +} + +fn default_pagination() -> PaginationConfig { + PaginationConfig::default() +} + +fn default_http_config() -> HttpConfig { + HttpConfig::new("openapi-streaming-wire").unwrap() +} + +/// Tiny OpenAPI document with one operation under `/stream` whose +/// `x-fern-streaming` payload is parameterized. Returning the YAML +/// from a single helper keeps each test focused on the body the +/// mock returns. +fn streaming_spec(extension: &str) -> String { + format!( + r#" +openapi: "3.0.0" +info: + title: Streaming Wire + version: "1.0" +servers: + - url: PLACEHOLDER +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +security: + - bearerAuth: [] +paths: + /stream: + post: + operationId: streamChat + x-fern-streaming: {extension} + responses: + "200": + description: stream +"# + ) +} + +/// Mount a single streaming mock. Wiremock's `set_body_string` +/// returns the entire body in one shot at the HTTP level — the +/// executor must still split it into discrete events using +/// `decode_stream_event`, which is the surface this test locks. +async fn mount_stream(server: &MockServer, body: &str) { + Mock::given(method("POST")) + .and(path("/stream")) + .and(header_regex( + "Authorization", + format!("^Bearer {TOKEN}$").as_str(), + )) + .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) + .expect(1) + .mount(server) + .await; +} + +/// Drive the streaming operation through the executor's *buffered* +/// branch (capture_output = true) so the test can assert against +/// the collected events. The executor still runs the full +/// `decode_stream_event` pipeline; only the final emit step differs +/// from the live `stream_response` path. +async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { + let spec = spec.replace("PLACEHOLDER", &server.uri()); + let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); + let method = doc.resources["stream"].methods["stream-chat"].clone(); + let result = executor::execute_method( + &doc, + &method, + None, + None, + &bearer_provider(), + None, + None, + None, + false, + &default_pagination(), + &OutputPipeline::default(), + true, // capture_output → collect events into a Value + None, // base_url_override + &default_http_config(), + false, // no_extract + false, // no_retry + false, // no_stream — irrelevant when capture_output is set + &[], // no x-fern-global-headers in this fixture + ) + .await + .expect("execute_method must succeed against the streaming mock"); + result.expect("streaming response must produce a value") +} + +#[tokio::test] +async fn streaming_sse_emits_events_in_order_and_honors_terminator() { + let server = MockServer::start().await; + // Mix `event:` framing and a comment line ahead of two real events, + // then the spec-declared `[DONE]` sentinel. The executor must skip + // the framing/comment lines and stop reading at the sentinel. + let body = "\ +: keepalive +event: message +data: {\"index\":0,\"delta\":\"hello\"} + +event: message +data: {\"index\":1,\"delta\":\"world\"} + +data: [DONE] + +data: {\"index\":2,\"delta\":\"AFTER\"} +"; + mount_stream(&server, body).await; + + // The terminator is part of the spec (no implicit default after + // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). + let value = drive_stream( + &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), + &server, + ) + .await; + let events = value + .as_array() + .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); + assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); + assert_eq!(events[0]["index"], 0); + assert_eq!(events[0]["delta"], "hello"); + assert_eq!(events[1]["index"], 1); + assert_eq!(events[1]["delta"], "world"); +} + +#[tokio::test] +async fn streaming_ndjson_emits_one_value_per_line() { + let server = MockServer::start().await; + let body = "\ +{\"id\":1,\"role\":\"user\"} +{\"id\":2,\"role\":\"assistant\"} +{\"id\":3,\"role\":\"assistant\"} +"; + mount_stream(&server, body).await; + + let value = drive_stream(&streaming_spec("true"), &server).await; + let events = value + .as_array() + .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); + assert_eq!(events.len(), 3); + assert_eq!(events[0]["id"], 1); + assert_eq!(events[1]["id"], 2); + assert_eq!(events[2]["id"], 3); +} + +#[tokio::test] +async fn streaming_sse_custom_terminator_replaces_default_sentinel() { + let server = MockServer::start().await; + // Custom terminator `[END]`: the executor must stop here, and + // `[DONE]` (which used to be the implicit default before this + // change landed) is now a regular event payload. + let body = "\ +data: {\"step\":1} + +data: [DONE] + +data: {\"step\":2} + +data: [END] + +data: {\"step\":\"unreachable\"} +"; + mount_stream(&server, body).await; + + let value = drive_stream( + &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), + &server, + ) + .await; + let events = value + .as_array() + .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); + assert_eq!(events.len(), 3); + assert_eq!(events[0]["step"], 1); + // `[DONE]` is now a regular event payload (string after stripping + // the `data:` prefix and one leading space). + assert_eq!(events[1].as_str(), Some("[DONE]")); + assert_eq!(events[2]["step"], 2); +} + +#[tokio::test] +async fn streaming_sse_concatenates_multiline_data_into_one_event() { + // A single event spanning three `data:` lines (e.g. a + // pretty-printed JSON payload) must join with `\n` and dispatch + // once on the blank-line separator — matches the WHATWG SSE + // spec and the TS runtime's `iterSseEvents` loop. Without this, + // Gemini-style multi-line streams would dispatch each line as + // its own corrupt JSON fragment. + let server = MockServer::start().await; + let body = "\ +data: { +data: \"foo\": 1 +data: } + +"; + mount_stream(&server, body).await; + + let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; + // Single buffered event → unwraps to the joined JSON object. + assert_eq!(value["foo"], 1); +} + +#[tokio::test] +async fn streaming_sse_separates_events_on_blank_line() { + // Two distinct events separated by a blank line dispatch as two + // payloads. Each block accumulates its own `data:` lines. + let server = MockServer::start().await; + let body = "\ +data: {\"index\":0} + +data: {\"index\":1} + +"; + mount_stream(&server, body).await; + + let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; + let events = value + .as_array() + .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["index"], 0); + assert_eq!(events[1]["index"], 1); +} + +#[tokio::test] +async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { + // Stream ends mid-event (no trailing blank line). The executor + // must still flush the buffered payload at EOF — mirrors the TS + // post-loop `if (dataValue != null)` dispatch. + let server = MockServer::start().await; + let body = "data: {\"final\":\"answer\"}"; + mount_stream(&server, body).await; + + let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; + assert_eq!(value["final"], "answer"); +} + +#[tokio::test] +async fn streaming_text_emits_each_non_empty_line_as_string_event() { + let server = MockServer::start().await; + // Three real lines plus a blank separator. The executor must + // emit each non-empty line verbatim as a plain string event — + // no JSON parse, no SSE prefix strip, no terminator check + // (mirrors the C# generator at + // `HttpEndpointGenerator.ts:815-825`). + let body = "\ +first line of output + +second line of output +third line of output +"; + mount_stream(&server, body).await; + + let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; + let events = value + .as_array() + .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); + assert_eq!(events.len(), 3); + assert_eq!(events[0].as_str(), Some("first line of output")); + assert_eq!(events[1].as_str(), Some("second line of output")); + assert_eq!(events[2].as_str(), Some("third line of output")); +} + +#[tokio::test] +async fn streaming_no_stream_flag_buffers_into_unary_value() { + // When `--no-stream` is set, the executor collapses the response + // into a single value. The buffered path is the same one + // `capture_output = true` uses; we exercise it here with + // `no_stream = true` and `capture_output = false` via the + // `--no-stream` plumbing on `execute_method` directly. + // + // The test asserts that a single-event body unwraps to that + // event's JSON value rather than a one-element array — the + // surface a JSON pipe (e.g. `… | jq`) expects. + let server = MockServer::start().await; + // No explicit terminator in the body — the executor must read + // until EOF when the spec doesn't declare a sentinel (matches the + // TS / C# typed-SDK runtimes). + let body = "data: {\"final\":\"answer\"}\n\n"; + mount_stream(&server, body).await; + + let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); + let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); + let method = doc.resources["stream"].methods["stream-chat"].clone(); + let result = executor::execute_method( + &doc, + &method, + None, + None, + &bearer_provider(), + None, + None, + None, + false, + &default_pagination(), + &OutputPipeline::default(), + true, // capture_output — verify the buffered Value shape + None, + &default_http_config(), + false, // no_extract + false, // no_retry + true, // no_stream — irrelevant under capture_output but the flag + // must not flip behavior into an error + &[], // no x-fern-global-headers in this fixture + ) + .await + .expect("execute_method must succeed against the streaming mock"); + let value = result.expect("streaming response must produce a value"); + // Single event → unwrap to the event's JSON value, not a 1-array. + assert_eq!(value["final"], "answer"); +} + +/// Regression guard: the cli-sdk runtime must NOT inject a +/// streaming-specific `Accept` header. The TypeScript and C# typed +/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, +/// and cli-sdk's parity rule for FER-9864 work is to mirror the +/// typed SDKs' behavior. wiremock matchers can only assert headers +/// that *exist*, so we inspect the recorded request directly — same +/// pattern as `tests/auth_routing_wire.rs` uses for asserting +/// Authorization absence. +#[tokio::test] +async fn streaming_endpoints_do_not_inject_accept_header() { + let server = MockServer::start().await; + let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; + mount_stream(&server, body).await; + + let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; + + let recorded = server + .received_requests() + .await + .expect("MockServer should record requests"); + assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); + let accept_values: Vec = recorded[0] + .headers + .get_all("accept") + .iter() + .map(|v| v.to_str().unwrap_or_default().to_string()) + .collect(); + for value in &accept_values { + assert!( + !value.contains("text/event-stream"), + "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" + ); + assert!( + !value.contains("x-ndjson") && !value.contains("jsonl"), + "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" + ); + } +} diff --git a/cli/tests/tls_env_vars.rs b/cli/tests/tls_env_vars.rs new file mode 100644 index 00000000..fe2167e3 --- /dev/null +++ b/cli/tests/tls_env_vars.rs @@ -0,0 +1,339 @@ +//! Integration test for the SDK's TLS env var contract. +//! +//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, +//! etc. actually change the TLS trust outcome of the HTTP client built +//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. +//! +//! Approach: spin up a local HTTPS server with a brand-new self-signed cert +//! that is never trusted by the system, then exercise the client against it +//! under different env-var configurations. This isolates the test from +//! whatever's in the developer's keychain (live tests against real APIs +//! can't be trusted to verify env-var behavior in isolation). +//! +//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI +//! machines). The test will skip itself with a printed warning if either is +//! missing. + +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use fern_cli_sdk::http::HttpConfig; + +const CLI_NAME: &str = "tls-test-cli"; +const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` + +/// Server fixture: a self-signed HTTPS server on a random localhost port, +/// with paths to the cert and a different (unsigned) "bogus" cert for negative +/// tests. Drops the server process and tempdir on Drop. +struct Fixture { + port: u16, + cert_path: std::path::PathBuf, + bogus_cert_path: std::path::PathBuf, + _tmp: tempfile::TempDir, + _child: ChildGuard, +} + +struct ChildGuard(Child); +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn deps_available() -> bool { + fn has(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + has("python3") && has("openssl") +} + +fn unused_port() -> u16 { + // Bind to :0, ask the kernel for a port, then immediately release it. + // There's a tiny race window before the test server binds, but in + // practice it's fine for an integration test. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + listener.local_addr().expect("local_addr").port() +} + +fn make_fixture() -> Fixture { + let tmp = tempfile::tempdir().expect("tmpdir"); + let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); + + // We generate a proper CA → leaf chain rather than a single self-signed + // CA-as-leaf cert. rustls (correctly) rejects the latter with + // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The + // proper structure is what real-world fixtures (e.g. Proxyman) produce. + + // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. + let ca_pem = p("ca.pem"); + let ca_key = p("ca.key"); + run_openssl(&[ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-subj", "/CN=test-ca", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-days", "1", + "-keyout", &ca_key, + "-out", &ca_pem, + ]); + + // 2. Leaf cert for the test server, signed by the CA above. + let leaf_pem = p("leaf.pem"); + let leaf_key = p("leaf.key"); + let leaf_csr = p("leaf.csr"); + let leaf_ext = p("leaf.ext"); + std::fs::write( + &leaf_ext, + "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", + ) + .unwrap(); + run_openssl(&[ + "req", "-newkey", "rsa:2048", "-nodes", + "-subj", "/CN=127.0.0.1", + "-keyout", &leaf_key, + "-out", &leaf_csr, + ]); + run_openssl(&[ + "x509", "-req", "-in", &leaf_csr, + "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", + "-out", &leaf_pem, + "-days", "1", + "-extfile", &leaf_ext, + ]); + + // 3. Bogus CA — a different self-signed CA whose private key never signs + // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make + // the leaf trusted (proves the bundle isn't a "trust everything" knob). + let bogus_pem = p("bogus.pem"); + let bogus_key = p("bogus.key"); + run_openssl(&[ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-subj", "/CN=bogus-ca", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-days", "1", + "-keyout", &bogus_key, + "-out", &bogus_pem, + ]); + + let port = unused_port(); + + // The Python server needs the leaf cert + leaf key. Cert/key paths and + // port are passed as argv to avoid mixing Rust's format! braces with + // Python's literal dict braces. + let server_script = r#" +import http.server, json, ssl, sys +cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = json.dumps({"ok": True}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *a, **kw): + pass +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain(certfile=cert, keyfile=key) +srv = http.server.HTTPServer(("127.0.0.1", port), H) +srv.socket = ctx.wrap_socket(srv.socket, server_side=True) +srv.serve_forever() +"#; + + let child = Command::new("python3") + .arg("-c") + .arg(server_script) + .arg(&leaf_pem) + .arg(&leaf_key) + .arg(port.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("python3 spawn"); + + // Give the server a moment to bind before the first request. + std::thread::sleep(Duration::from_millis(400)); + + Fixture { + port, + cert_path: ca_pem.into(), + bogus_cert_path: bogus_pem.into(), + _tmp: tmp, + _child: ChildGuard(child), + } +} + +/// Run `openssl ` and panic with stderr + the failing arg list if it +/// exits non-zero. Capturing stderr makes test failures self-explanatory +/// instead of "openssl exited with code 1, good luck." +fn run_openssl(args: &[&str]) { + let output = Command::new("openssl") + .args(args) + .output() + .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!( + "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", + output.status.code() + ); + } +} + +/// Wipe every env var that could leak into the test from the developer's +/// shell (Proxyman's auto-setup sets several of these). Must run *before* +/// HttpConfig::build_client() reads the environment. +fn clean_env() { + for k in [ + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "HTTPS_PROXY", + "HTTP_PROXY", + "https_proxy", + "http_proxy", + "NODE_EXTRA_CA_CERTS", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + "TLS_TEST_CLI_CA_BUNDLE", + "TLS_TEST_CLI_EXTRA_CA_CERTS", + "TLS_TEST_CLI_INSECURE", + "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", + "TLS_TEST_CLI_PROXY", + "TLS_TEST_CLI_NO_PROXY", + ] { + std::env::remove_var(k); + } +} + +async fn fetch(client: &reqwest::Client, port: u16) -> Result { + Ok(client + .get(format!("https://127.0.0.1:{port}/probe")) + .send() + .await? + .status()) +} + +/// Build a fresh client from the current env. Each test case mutates env +/// and then constructs a client to capture the new state — every test calls +/// this exactly once. +fn build_client() -> reqwest::Client { + try_build_client().expect("client build") +} + +/// Like [`build_client`] but doesn't unwrap the build error — useful for +/// cases that expect a malformed env var to surface as an error at +/// construction. +fn try_build_client() -> Result { + HttpConfig::new(CLI_NAME).unwrap().build_client() +} + +/// Cases run sequentially in a single test. Reqwest constructs new clients +/// fresh from the env each call, so we just mutate env between cases and +/// verify each. +/// +/// We use `serial_test::serial` so the env mutations don't race with other +/// tests in the binary. +#[tokio::test] +#[serial_test::serial] +async fn tls_env_vars_change_trust_outcome() { + if !deps_available() { + eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); + return; + } + + let fx = make_fixture(); + let port = fx.port; + let cert = fx.cert_path.to_str().unwrap().to_string(); + let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); + + // ---- A: no env vars → must fail --------------------------------------- + clean_env(); + let client = build_client(); + let err = fetch(&client, port).await.expect_err("A: must fail TLS"); + assert!( + err.is_connect() || err.to_string().to_lowercase().contains("certificate"), + "A: expected TLS / connect error, got: {err}" + ); + + // ---- B: _CA_BUNDLE → must succeed ----------------------------- + clean_env(); + std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); + let client = build_client(); + let status = fetch(&client, port).await.expect("B: must succeed"); + assert_eq!(status.as_u16(), 200, "B: expected 200"); + + // ---- C: _INSECURE=1 → must succeed ---------------------------- + clean_env(); + std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); + let client = build_client(); + let status = fetch(&client, port).await.expect("C: must succeed"); + assert_eq!(status.as_u16(), 200, "C: expected 200"); + + // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- + // Confirms the bundle isn't accidentally treated as "trust everything". + clean_env(); + std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); + let client = build_client(); + let err = fetch(&client, port).await.expect_err("D: must fail TLS"); + assert!( + err.is_connect() || err.to_string().to_lowercase().contains("certificate"), + "D: expected TLS error, got: {err}" + ); + + // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- + clean_env(); + std::env::set_var("SSL_CERT_FILE", &cert); + let client = build_client(); + let status = fetch(&client, port).await.expect("E: must succeed"); + assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); + + // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- + clean_env(); + std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); + let client = build_client(); + let status = fetch(&client, port).await.expect("F: must succeed"); + assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); + + // ---- G: missing _CA_BUNDLE path → must error at client build --------- + clean_env(); + std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); + let err = try_build_client().expect_err("G: must error"); + let msg = err.to_string(); + assert!( + msg.contains("/no/such/path.pem"), + "G: error should name the bad path; got: {msg}" + ); + + // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- + // Earlier the implementation called std::env::set_var("NO_PROXY", ...) + // as a side effect, leaking config to other code paths. Verify it doesn't. + clean_env(); + let original_no_proxy = std::env::var("NO_PROXY").ok(); + std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); + let _ = build_client(); + let after_no_proxy = std::env::var("NO_PROXY").ok(); + assert_eq!( + original_no_proxy, after_no_proxy, + "H: _NO_PROXY leaked into global NO_PROXY" + ); + + // ---- I: invalid _PROXY URL → must error at client build ------ + clean_env(); + std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); + let err = try_build_client().expect_err("I: must error"); + let msg = err.to_string(); + assert!( + msg.contains(&format!("{ENV_PREFIX}_PROXY")), + "I: error should name the env var; got: {msg}" + ); + + clean_env(); +} diff --git a/cli/tests/websocket_wire.rs b/cli/tests/websocket_wire.rs new file mode 100644 index 00000000..c49bde67 --- /dev/null +++ b/cli/tests/websocket_wire.rs @@ -0,0 +1,900 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Integration tests for `fern_cli_sdk::websocket`. +// +// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral +// port), drives a `WebSocketClient` against it, and asserts on the +// mock's view of what the client did + on the client's return value. +// +// Tests deliberately avoid asserting on stdout content. The transforms +// applied to each frame before emit (autoresponder elision, audio-key +// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; +// the wire tests cover the loop wiring and the failure-mode matrix. + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::{self, Message}; + +use fern_cli_sdk::auth::AuthCredentialSource; +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::http::HttpConfig; +use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; + +/// Test-local ping/pong autoresponder. +/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies +/// with `{"type":"pong","event_id":}`. +fn test_ping_pong_responder() -> AutoResponder { + std::sync::Arc::new(|frame: &Value| -> Option { + if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { + return None; + } + frame + .pointer("/ping_event/event_id") + .and_then(|v| v.as_i64()) + .map(|event_id| json!({"type": "pong", "event_id": event_id})) + }) +} + +// ----------------------------------------------------------------------------- +// Mock-server helpers +// ----------------------------------------------------------------------------- + +/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests +/// can build the `ws://127.0.0.1:/` URL without racing on a +/// hardcoded port. +async fn bind_ephemeral() -> (TcpListener, u16) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + (listener, port) +} + +/// Accept one upgrade and hand the connected server-side stream to +/// `handler`. Returns the handler's join handle so the test can await +/// the server-side side of the conversation. +fn spawn_one_shot_ws( + listener: TcpListener, + handler: F, +) -> tokio::task::JoinHandle<()> +where + F: FnOnce( + tokio_tungstenite::WebSocketStream, + ) -> Fut + + Send + + 'static, + Fut: std::future::Future + Send + 'static, +{ + tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let ws = tokio_tungstenite::accept_async(stream) + .await + .expect("ws handshake"); + handler(ws).await; + }) +} + +/// Standard HttpConfig for tests (no env-var overrides honored anyway). +fn test_http_config() -> HttpConfig { + HttpConfig::new("ws-wire-test").unwrap() +} + +// ----------------------------------------------------------------------------- +// 1. Handshake succeeds against a vanilla accept_async. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn handshake_succeeds() { + let (listener, port) = bind_ephemeral().await; + let server = spawn_one_shot_ws(listener, |mut ws| async move { + // Server side: send a normal Close(1000) immediately so the + // client returns Ok. Reading the eventual client-side Close + // keeps both sides in lockstep. + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "".into(), + }))) + .await + .ok(); + // Drain anything the client sends after seeing the close + while ws.next().await.is_some() {} + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .expect("handshake should succeed"); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let shutdown = Box::pin(async move { + let _ = shutdown_rx.await; + }); + let result = client.run_until_shutdown(shutdown).await; + drop(shutdown_tx); + server.await.ok(); + + // Server-side normal close → Ok per matrix. + assert!(result.is_ok(), "expected clean exit, got: {result:?}"); +} + +// ----------------------------------------------------------------------------- +// 2. Three inbound frames flow through the client without error. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn round_trips_three_frames() { + let (listener, port) = bind_ephemeral().await; + let server = spawn_one_shot_ws(listener, |mut ws| async move { + for i in 1..=3 { + ws.send(Message::Text(json!({"n": i}).to_string())) + .await + .ok(); + } + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + assert!(result.is_ok(), "expected clean exit, got: {result:?}"); +} + +// ----------------------------------------------------------------------------- +// 3. Server-initiated Close(1000) mid-stream → Ok(()). +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn close_1000_mid_stream_exits_zero() { + let (listener, port) = bind_ephemeral().await; + let server = spawn_one_shot_ws(listener, |mut ws| async move { + ws.send(Message::Text(json!({"hi": true}).to_string())) + .await + .ok(); + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "done".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); +} + +// ----------------------------------------------------------------------------- +// 4. Server-initiated abnormal close → CliError::Other with the hint. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn close_abnormal_maps_to_other_with_hint() { + let (listener, port) = bind_ephemeral().await; + let server = spawn_one_shot_ws(listener, |mut ws| async move { + // CloseCode::Error is the named variant for 1011 (Internal Error). + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Error, + reason: "server error".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + let err = match result { + Err(e) => e, + Ok(_) => panic!("abnormal close should be an error"), + }; + assert!(matches!(err, CliError::Other(_))); + let msg = err.to_string(); + assert!(msg.contains("1011"), "missing close code: {msg}"); + // Default `WsConfig::new` is API-neutral; per-API constructors weave + // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). + assert!( + msg.contains("keepalive") || msg.contains("auth"), + "default hint should mention auth or keepalive: {msg}", + ); + // Exit code per matrix: Other = 5. + assert_eq!(err.exit_code(), 5); +} + +// ----------------------------------------------------------------------------- +// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn shutdown_future_sends_close_and_exits_zero() { + let (listener, port) = bind_ephemeral().await; + // Channel from server back to test, to confirm the close frame arrived. + let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); + let server = spawn_one_shot_ws(listener, |mut ws| async move { + let mut close_seen: Option = None; + // Just listen; the test triggers shutdown on the client side. + while let Some(msg) = ws.next().await { + match msg { + Ok(Message::Close(frame)) => { + close_seen = frame.as_ref().map(|f| u16::from(f.code)); + break; + } + Ok(_) => continue, + Err(_) => break, + } + } + close_tx.send(close_seen.unwrap_or(0)).ok(); + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + + let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); + let shutdown = Box::pin(async move { + let _ = trigger_rx.await; + }); + let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); + + // Give the connection a moment to establish, then trigger. + tokio::time::sleep(Duration::from_millis(50)).await; + trigger_tx.send(()).unwrap(); + + let result = client_task.await.expect("join"); + server.await.ok(); + + assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); + let code = tokio::time::timeout(Duration::from_secs(2), close_rx) + .await + .expect("close-frame channel timeout") + .expect("close-frame channel closed"); + assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); +} + +// ----------------------------------------------------------------------------- +// 6. Bad URL → CliError::Validation, exit 3. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn bad_url_maps_to_validation_error() { + let cfg = WsConfig::new("not a url"); + // `WebSocketClient` doesn't implement Debug (it holds a stream that + // doesn't), so use match instead of expect_err. + let err = match WebSocketClient::connect(cfg, &test_http_config()).await { + Err(e) => e, + Ok(_) => panic!("invalid URL should error"), + }; + assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); + assert_eq!(err.exit_code(), 3); +} + +// ----------------------------------------------------------------------------- +// 7. Autoresponder elides ping + sends matching pong. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn auto_responder_elides_ping_and_sends_pong() { + let (listener, port) = bind_ephemeral().await; + let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); + let server = spawn_one_shot_ws(listener, |mut ws| async move { + // Send an app-level ping frame. + ws.send(Message::Text( + json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) + .to_string(), + )) + .await + .ok(); + + // Wait for the pong. + if let Some(Ok(Message::Text(reply))) = ws.next().await { + let v: Value = serde_json::from_str(&reply).unwrap(); + pong_tx.send(v).ok(); + } + + // Clean close. + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + cfg.auto_responder = Some(test_ping_pong_responder()); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) + .await + .expect("pong-channel timeout") + .expect("pong-channel closed"); + assert_eq!(pong, json!({"type": "pong", "event_id": 42})); +} + +// ----------------------------------------------------------------------------- +// 8. First-message auth: WsAuth::FirstMessage merges field into first send. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn first_message_auth_field_injected() { + let (listener, port) = bind_ephemeral().await; + let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); + let server = spawn_one_shot_ws(listener, |mut ws| async move { + if let Some(Ok(Message::Text(text))) = ws.next().await { + let v: Value = serde_json::from_str(&text).unwrap(); + first_msg_tx.send(v).ok(); + } + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + cfg.auth = WsAuth::FirstMessage( + "xi_api_key".into(), + AuthCredentialSource::literal("sk-test-merged"), + ); + let mut client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + client + .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + assert!(result.is_ok()); + let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) + .await + .expect("first-msg timeout") + .expect("first-msg channel closed"); + assert_eq!(first["xi_api_key"], "sk-test-merged"); + assert_eq!(first["text"], "hello"); + assert_eq!(first["voice_settings"]["stability"], 0.5); +} + +// ----------------------------------------------------------------------------- +// 9. Header auth: WsAuth::Header puts the value on the handshake. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn header_auth_sent_on_handshake() { + let (listener, port) = bind_ephemeral().await; + let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let captured: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let captured_clone = captured.clone(); + let cb = move |req: &tungstenite::handshake::server::Request, + resp: tungstenite::handshake::server::Response| { + if let Some(v) = req.headers().get("xi-api-key") { + *captured_clone.lock().unwrap() = + Some(v.to_str().unwrap_or("").to_string()); + } + Ok(resp) + }; + let ws = tokio_tungstenite::accept_hdr_async(stream, cb) + .await + .expect("ws handshake"); + // Send a clean close so the client returns Ok. + let mut ws = ws; + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + let final_val = captured.lock().unwrap().clone(); + hdr_tx.send(final_val).ok(); + }); + + let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + cfg.auth = WsAuth::Header( + "xi-api-key".into(), + AuthCredentialSource::literal("sk-header-test"), + ); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let _ = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) + .await + .expect("header-channel timeout") + .expect("header-channel closed"); + assert_eq!(observed.as_deref(), Some("sk-header-test")); +} + +// ----------------------------------------------------------------------------- +// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the +// autoresponder elides only the ping frames, the client emits the +// other frames, and pongs come back with matching event_ids. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn auto_responder_interleaved_with_data_frames() { + let (listener, port) = bind_ephemeral().await; + // Collect every pong from the client. We expect exactly two, with + // event_ids 100 and 200 in order. + let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); + let server = spawn_one_shot_ws(listener, |mut ws| async move { + // Frame 1: ping (should be auto-handled, NOT emitted). + ws.send(Message::Text( + json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) + .to_string(), + )) + .await + .ok(); + // Frame 2: data (should flow to OutputPipeline::emit). + ws.send(Message::Text( + json!({"type": "agent_response", "text": "hello world"}).to_string(), + )) + .await + .ok(); + // Wait for first pong, then send second ping. + if let Some(Ok(Message::Text(reply))) = ws.next().await { + let v: Value = serde_json::from_str(&reply).unwrap(); + pong_tx.send(v).await.ok(); + } + ws.send(Message::Text( + json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) + .to_string(), + )) + .await + .ok(); + if let Some(Ok(Message::Text(reply))) = ws.next().await { + let v: Value = serde_json::from_str(&reply).unwrap(); + pong_tx.send(v).await.ok(); + } + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + cfg.auto_responder = Some(test_ping_pong_responder()); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) + .await + .expect("first pong timeout") + .expect("first pong channel closed"); + let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) + .await + .expect("second pong timeout") + .expect("second pong channel closed"); + assert_eq!(first, json!({"type": "pong", "event_id": 100})); + assert_eq!(second, json!({"type": "pong", "event_id": 200})); +} + +// ----------------------------------------------------------------------------- +// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request +// (until we see the blank-line terminator) and write a fixed HTTP response. +// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving +// `accept_async` (which would force a real WS handshake). +// ----------------------------------------------------------------------------- + +async fn answer_with_http_status( + listener: TcpListener, + status_line: &'static str, + body: &'static str, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + // Read until we see the blank line that terminates the request headers. + let mut buf = Vec::with_capacity(1024); + let mut chunk = [0u8; 256]; + loop { + match stream.read(&mut chunk).await { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + Err(_) => break, + } + } + let response = format!( + "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", + len = body.len(), + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + }) +} + +// ----------------------------------------------------------------------------- +// 11. Handshake 401 → CliError::Auth (exit 2). +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn handshake_401_maps_to_auth_error() { + let (listener, port) = bind_ephemeral().await; + let server = answer_with_http_status( + listener, + "HTTP/1.1 401 Unauthorized", + "missing api key", + ) + .await; + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let err = match WebSocketClient::connect(cfg, &test_http_config()).await { + Err(e) => e, + Ok(_) => panic!("401 upgrade should fail handshake"), + }; + server.await.ok(); + assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); + assert_eq!(err.exit_code(), 2); +} + +// ----------------------------------------------------------------------------- +// 12. Handshake 404 → CliError::Discovery (exit 4). +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn handshake_404_maps_to_discovery_error() { + let (listener, port) = bind_ephemeral().await; + let server = answer_with_http_status( + listener, + "HTTP/1.1 404 Not Found", + "no such endpoint", + ) + .await; + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let err = match WebSocketClient::connect(cfg, &test_http_config()).await { + Err(e) => e, + Ok(_) => panic!("404 upgrade should fail handshake"), + }; + server.await.ok(); + assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); + assert_eq!(err.exit_code(), 4); +} + +// ----------------------------------------------------------------------------- +// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn handshake_5xx_maps_to_api_error() { + let (listener, port) = bind_ephemeral().await; + let server = answer_with_http_status( + listener, + "HTTP/1.1 503 Service Unavailable", + "upstream down", + ) + .await; + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let err = match WebSocketClient::connect(cfg, &test_http_config()).await { + Err(e) => e, + Ok(_) => panic!("503 upgrade should fail handshake"), + }; + server.await.ok(); + match err { + CliError::Api { code, .. } => { + assert_eq!(code, 503); + } + other => panic!("expected Api, got: {other:?}"), + } +} + +// ----------------------------------------------------------------------------- +// 14. Two-header auth: e.g. Authorization + an API-version header. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn two_header_auth_emits_both_pairs() { + let (listener, port) = bind_ephemeral().await; + let captured: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let captured_clone = captured.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let cb = move |req: &tungstenite::handshake::server::Request, + resp: tungstenite::handshake::server::Response| { + for header in &["Authorization", "X-Api-Version"] { + if let Some(v) = req.headers().get(*header) { + captured_clone.lock().unwrap().push(( + (*header).to_string(), + v.to_str().unwrap_or("").to_string(), + )); + } + } + Ok(resp) + }; + let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) + .await + .expect("ws handshake"); + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Normal, + reason: "".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + cfg.auth = WsAuth::Headers(vec![ + ( + "Authorization".into(), + AuthCredentialSource::literal("Bearer sk-test"), + ), + ( + "X-Api-Version".into(), + AuthCredentialSource::literal("v1"), + ), + ]); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let _ = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + let observed = captured.lock().unwrap().clone(); + assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); + assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); + assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); +} + +// ----------------------------------------------------------------------------- +// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn close_1001_going_away_is_clean_exit() { + let (listener, port) = bind_ephemeral().await; + let server = spawn_one_shot_ws(listener, |mut ws| async move { + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Away, + reason: "session cap exceeded".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + assert!(matches!(result, Ok(())), + "1001 Going Away should be a clean exit, got: {result:?}"); +} + +// ----------------------------------------------------------------------------- +// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio +// streaming). Mock asserts the bytes round-trip intact. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn send_binary_emits_binary_frames() { + let (listener, port) = bind_ephemeral().await; + let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); + let server = spawn_one_shot_ws(listener, move |mut ws| async move { + while let Some(msg) = ws.next().await { + match msg { + Ok(Message::Binary(bytes)) => { + if rx_tx.send(bytes).await.is_err() { + break; + } + } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} + } + } + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let mut client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + // 16-bit PCM frames are typical; mimic with a small payload. + client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); + client.send_binary(vec![10, 20, 30]).await.unwrap(); + let shutdown = Box::pin(async { + // Give the server time to drain. + tokio::time::sleep(Duration::from_millis(100)).await; + }); + let _ = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) + .await + .expect("first binary frame timeout") + .expect("rx closed"); + let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) + .await + .expect("second binary frame timeout") + .expect("rx closed"); + assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); + assert_eq!(frame2, vec![10u8, 20, 30]); +} + +// ----------------------------------------------------------------------------- +// 17. Custom abnormal_close_hint overrides the default in error messages. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn custom_abnormal_close_hint_appears_in_error() { + let (listener, port) = bind_ephemeral().await; + let server = spawn_one_shot_ws(listener, |mut ws| async move { + ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { + code: tungstenite::protocol::frame::coding::CloseCode::Error, + reason: "internal".into(), + }))) + .await + .ok(); + while ws.next().await.is_some() {} + }); + + let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + cfg.abnormal_close_hint = + "custom hint: KeepAlive cadence + encoding".to_string(); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + let err = match result { + Err(e) => e, + Ok(_) => panic!("abnormal close should error"), + }; + let msg = err.to_string(); + assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); + assert!(!msg.contains("ping/pong"), + "default hint should NOT appear: {msg}"); +} + +// ----------------------------------------------------------------------------- +// 18. Regression: if a caller invokes `client.send(&...)` before +// `run_until_shutdown`, the `first_send_done` flag must propagate +// into the loop so the loop doesn't re-merge or double-process +// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured +// away on entry to the loop. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn first_send_done_propagates_into_recv_loop() { + let (listener, port) = bind_ephemeral().await; + let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); + let server = spawn_one_shot_ws(listener, |mut ws| async move { + while let Some(msg) = ws.next().await { + match msg { + Ok(Message::Text(s)) => { + let v: Value = serde_json::from_str(&s).unwrap(); + if frames_tx.send(v).await.is_err() { + break; + } + } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} + } + } + }); + + let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + cfg.auth = WsAuth::FirstMessage( + "xi_api_key".into(), + AuthCredentialSource::literal("sk-once"), + ); + let mut client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + client.send(&json!({"text": "first"})).await.unwrap(); + let shutdown = Box::pin(async { + tokio::time::sleep(Duration::from_millis(100)).await; + }); + let _ = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) + .await + .expect("first frame timeout") + .expect("rx closed"); + assert_eq!(first["xi_api_key"], "sk-once"); + assert_eq!(first["text"], "first"); + // No additional *text* frames should appear — the loop must not + // produce a second "first" send after the destructuring. The + // channel closes when the server task ends (after seeing the + // Close frame the client sends on graceful shutdown), so a `None` + // recv is also fine; only `Some(value)` would mean the loop + // synthesised an unexpected text frame. + match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { + Err(_) => {} // timeout: no extra frame within the window. + Ok(None) => {} // channel closed by server (Close ack path). + Ok(Some(extra)) => { + panic!("loop synthesised an unexpected extra frame: {extra}"); + } + } +} + +// ----------------------------------------------------------------------------- +// 19. Stream ending without a close frame → CliError::Other. +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn abrupt_disconnect_maps_to_other_error() { + let (listener, port) = bind_ephemeral().await; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); + // Drop the WS without sending a close frame. tungstenite will + // surface this as an abnormal close to the client. + drop(ws); + }); + + let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); + let client = WebSocketClient::connect(cfg, &test_http_config()) + .await + .unwrap(); + let shutdown = Box::pin(std::future::pending::<()>()); + let result = client.run_until_shutdown(shutdown).await; + server.await.ok(); + + let err = result.expect_err("abrupt drop should error"); + assert!(matches!(err, CliError::Other(_))); + assert_eq!(err.exit_code(), 5); +} diff --git a/cli/tests/x_name_server_alias_wire.rs b/cli/tests/x_name_server_alias_wire.rs new file mode 100644 index 00000000..1f97c3c8 --- /dev/null +++ b/cli/tests/x_name_server_alias_wire.rs @@ -0,0 +1,140 @@ +//! Wire test for the legacy v1 server-name alias `x-name`. +//! +//! Confirms that an OpenAPI spec using the legacy spelling alone (no +//! `x-fern-server-name` anywhere) parses end-to-end and the resulting +//! command tree dispatches a real request through the executor against +//! a wiremock server. Mirrors fern's behavior in +//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, +//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either +//! key with v1-wins precedence on the rare spec that carries both. +//! +//! Pairs with the in-source unit tests covering the four shape +//! permutations (only v2 / only v1 / both / neither); this file pins +//! the end-to-end command-tree path so a future regression in the +//! parser → discovery → executor chain that drops legacy specs surfaces +//! as a wire failure rather than a silent miss. + +use std::sync::Arc; + +use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; +use fern_cli_sdk::formatter::OutputPipeline; +use fern_cli_sdk::http::HttpConfig; +use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; +use fern_cli_sdk::openapi::load_openapi_spec; +use serde_json::json; +use wiremock::matchers::{header_regex, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const TOKEN: &str = "x-name-wire-token"; + +fn bearer_provider() -> DynAuthProvider { + Arc::new(BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::literal(TOKEN), + )) +} + +fn default_pagination() -> PaginationConfig { + PaginationConfig::default() +} + +fn default_http_config() -> HttpConfig { + HttpConfig::new("x-name-server-alias-wire").unwrap() +} + +/// Spec carrying only the legacy v1 alias `x-name`. No +/// `x-fern-server-name` anywhere — exercises the fallback read. +fn legacy_alias_spec(server_url: &str) -> String { + format!( + r#" +openapi: "3.0.0" +info: + title: Legacy Alias Wire + version: "1.0" +servers: + - url: {server_url} + x-name: LegacyProd + description: Legacy v1-named production server. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +security: + - bearerAuth: [] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"# + ) +} + +#[tokio::test] +async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/things")) + .and(header_regex( + "Authorization", + format!("^Bearer {TOKEN}$").as_str(), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "things": [{"id": "thing-1"}], + }))) + .expect(1) + .mount(&server) + .await; + + let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); + + // Pre-flight: the parser surfaced the legacy spelling as a resolved + // server name (mirroring fern's importer) and exposes it via the + // `named_servers` helper that drives the help surface. + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); + assert_eq!( + doc.servers[0].description.as_deref(), + Some("Legacy v1-named production server."), + ); + let named: Vec<_> = doc.named_servers().collect(); + assert_eq!(named.len(), 1); + assert_eq!(named[0].0, "LegacyProd"); + + // End-to-end wire: the executor dispatches against the spec's + // server URL and the mock observes exactly one matching request. + // If the parser had ignored `x-name`, the named-server data would + // still be empty here — but the operation still dispatches against + // the spec's `servers:` block, so the wire mock would still match. + // The pre-flight assertions above are what lock the legacy alias. + let method = doc.resources["things"].methods["list"].clone(); + let result = executor::execute_method( + &doc, + &method, + None, + None, + &bearer_provider(), + None, + None, + None, + false, + &default_pagination(), + &OutputPipeline::default(), + true, // capture_output → return the response body + None, // no base-url override + &default_http_config(), + false, // no_extract + false, // no_retry + false, // no_stream + &[], // no x-fern-global-headers + ) + .await + .expect("execute_method must succeed against the wire mock"); + + let body = result.expect("response body must be captured"); + assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); +} diff --git a/fern/generators.yml b/fern/generators.yml index fc5fe01f..239bdec5 100644 --- a/fern/generators.yml +++ b/fern/generators.yml @@ -2,7 +2,6 @@ api: specs: - openapi: openapi.json - overrides: overlay.yaml default-group: local groups: local: @@ -98,7 +97,7 @@ groups: version: 0.4.0 output: location: local-file-system - path: generated/cli + path: ../cli config: binaryName: agentmail smart-casing: false diff --git a/fern/openapi.json b/fern/openapi.json index cf92634b..c2934a9f 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -56,7 +56,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "inboxes_create", @@ -110,7 +114,11 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/inboxes/{inbox_id}": { @@ -164,7 +172,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes" + ], + "x-fern-sdk-method-name": "get" }, "patch": { "operationId": "inboxes_update", @@ -226,7 +238,11 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes" + ], + "x-fern-sdk-method-name": "update" }, "delete": { "operationId": "inboxes_delete", @@ -271,7 +287,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/pods": { @@ -325,7 +345,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "pods_create", @@ -378,7 +402,11 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "pods" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/pods/{pod_id}": { @@ -432,7 +460,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods" + ], + "x-fern-sdk-method-name": "get" }, "delete": { "operationId": "pods_delete", @@ -477,7 +509,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/webhooks": { @@ -531,7 +567,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "webhooks" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "webhooks_create", @@ -584,7 +624,11 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "webhooks" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/webhooks/{webhook_id}": { @@ -638,7 +682,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "webhooks" + ], + "x-fern-sdk-method-name": "get" }, "patch": { "operationId": "webhooks_update", @@ -710,7 +758,11 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "webhooks" + ], + "x-fern-sdk-method-name": "update" }, "delete": { "operationId": "webhooks_delete", @@ -755,7 +807,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "webhooks" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/api-keys": { @@ -809,7 +865,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "apiKeys" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "apiKeys_create", @@ -862,7 +922,11 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "apiKeys" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/api-keys/{api_key}": { @@ -909,7 +973,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "apiKeys" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/domains": { @@ -963,7 +1031,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "domains" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "domains_create", @@ -1016,7 +1088,11 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "domains" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/domains/{domain_id}": { @@ -1070,7 +1146,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "domains" + ], + "x-fern-sdk-method-name": "get" }, "delete": { "operationId": "domains_delete", @@ -1115,7 +1195,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "domains" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/domains/{domain_id}/zone-file": { @@ -1162,7 +1246,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "domains" + ], + "x-fern-sdk-method-name": "getZoneFile" } }, "/v0/domains/{domain_id}/verify": { @@ -1209,7 +1297,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "domains" + ], + "x-fern-sdk-method-name": "verify" } }, "/v0/drafts": { @@ -1309,7 +1401,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "drafts" + ], + "x-fern-sdk-method-name": "list" } }, "/v0/drafts/{draft_id}": { @@ -1363,7 +1459,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "drafts" + ], + "x-fern-sdk-method-name": "get" } }, "/v0/inboxes/{inbox_id}/drafts": { @@ -1471,7 +1571,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "drafts" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "inboxes_drafts_create", @@ -1533,7 +1638,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "drafts" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/inboxes/{inbox_id}/drafts/{draft_id}": { @@ -1595,7 +1705,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "drafts" + ], + "x-fern-sdk-method-name": "get" }, "patch": { "operationId": "inboxes_drafts_update", @@ -1665,7 +1780,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "drafts" + ], + "x-fern-sdk-method-name": "update" }, "delete": { "operationId": "inboxes_drafts_delete", @@ -1718,7 +1838,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "drafts" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/inboxes/{inbox_id}/drafts/{draft_id}/send": { @@ -1810,7 +1935,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "drafts" + ], + "x-fern-sdk-method-name": "send" } }, "/v0/inboxes/{inbox_id}/messages": { @@ -1927,7 +2057,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "list" } }, "/v0/inboxes/{inbox_id}/messages/{message_id}": { @@ -1989,7 +2124,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "get" }, "patch": { "operationId": "inboxes_messages_update", @@ -2069,7 +2209,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "update" } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}": { @@ -2139,7 +2284,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "getAttachment" } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/raw": { @@ -2201,7 +2351,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "getRaw" } }, "/v0/inboxes/{inbox_id}/messages/send": { @@ -2285,7 +2440,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "send" } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/reply": { @@ -2377,7 +2537,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "reply" } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all": { @@ -2469,7 +2634,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "reply-all" } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/forward": { @@ -2561,7 +2731,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "inboxes", + "messages" + ], + "x-fern-sdk-method-name": "forward" } }, "/v0/inboxes/{inbox_id}/metrics": { @@ -2650,7 +2825,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "metrics" + ], + "x-fern-sdk-method-name": "get" } }, "/v0/inboxes/{inbox_id}/threads": { @@ -2767,7 +2947,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "threads" + ], + "x-fern-sdk-method-name": "list" } }, "/v0/inboxes/{inbox_id}/threads/{thread_id}": { @@ -2829,7 +3014,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "threads" + ], + "x-fern-sdk-method-name": "get" }, "delete": { "operationId": "inboxes_threads_delete", @@ -2882,7 +3072,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "threads" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}": { @@ -2952,7 +3147,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "inboxes", + "threads" + ], + "x-fern-sdk-method-name": "getAttachment" } }, "/v0/metrics": { @@ -3023,7 +3223,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "metrics" + ], + "x-fern-sdk-method-name": "list" } }, "/v0/organizations": { @@ -3059,7 +3263,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "organizations" + ], + "x-fern-sdk-method-name": "get" } }, "/v0/pods/{pod_id}/domains": { @@ -3131,7 +3339,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "domains" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "pods_domains_create", @@ -3193,7 +3406,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "pods", + "domains" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/pods/{pod_id}/domains/{domain_id}": { @@ -3248,7 +3466,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "domains" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/pods/{pod_id}/drafts": { @@ -3356,7 +3579,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "drafts" + ], + "x-fern-sdk-method-name": "list" } }, "/v0/pods/{pod_id}/drafts/{draft_id}": { @@ -3418,7 +3646,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "drafts" + ], + "x-fern-sdk-method-name": "get" } }, "/v0/pods/{pod_id}/inboxes": { @@ -3490,7 +3723,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "inboxes" + ], + "x-fern-sdk-method-name": "list" }, "post": { "operationId": "pods_inboxes_create", @@ -3552,7 +3790,12 @@ } } } - } + }, + "x-fern-sdk-group-name": [ + "pods", + "inboxes" + ], + "x-fern-sdk-method-name": "create" } }, "/v0/pods/{pod_id}/inboxes/{inbox_id}": { @@ -3614,7 +3857,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "inboxes" + ], + "x-fern-sdk-method-name": "get" }, "delete": { "operationId": "pods_inboxes_delete", @@ -3667,7 +3915,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "inboxes" + ], + "x-fern-sdk-method-name": "delete" } }, "/v0/pods/{pod_id}/threads": { @@ -3784,7 +4037,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "threads" + ], + "x-fern-sdk-method-name": "list" } }, "/v0/pods/{pod_id}/threads/{thread_id}": { @@ -3846,7 +4104,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "threads" + ], + "x-fern-sdk-method-name": "get" } }, "/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}": { @@ -3916,7 +4179,12 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "pods", + "threads" + ], + "x-fern-sdk-method-name": "getAttachment" } }, "/v0/threads": { @@ -4025,7 +4293,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "threads" + ], + "x-fern-sdk-method-name": "list" } }, "/v0/threads/{thread_id}": { @@ -4079,7 +4351,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "threads" + ], + "x-fern-sdk-method-name": "get" } }, "/v0/threads/{thread_id}/attachments/{attachment_id}": { @@ -4141,7 +4417,11 @@ { "BearerAuth": [] } - ] + ], + "x-fern-sdk-group-name": [ + "threads" + ], + "x-fern-sdk-method-name": "getAttachment" } } }, diff --git a/fern/overlay.yaml b/fern/overlay.yaml deleted file mode 100644 index 4150a900..00000000 --- a/fern/overlay.yaml +++ /dev/null @@ -1,354 +0,0 @@ -# Auto-generated overlay that maps AgentMail operationIds → nested CLI -# group + method names. Without this, the CLI surface ends up stuttered -# (e.g. `agentmail inboxes-messages inboxes-messages-list`) because the -# upstream spec uses flat tag names like `InboxesMessages` instead of -# x-fern-sdk-group-name. Regenerate from the spec by running the inline -# script in this directory's README. -# -overlay: 1.0.0 -info: - title: AgentMail CLI group/method overlay - version: 1.0.0 -actions: -- target: $.paths['/v0/inboxes'].get - update: - x-fern-sdk-group-name: - - inboxes - x-fern-sdk-method-name: list -- target: $.paths['/v0/inboxes'].post - update: - x-fern-sdk-group-name: - - inboxes - x-fern-sdk-method-name: create -- target: $.paths['/v0/inboxes/{inbox_id}'].get - update: - x-fern-sdk-group-name: - - inboxes - x-fern-sdk-method-name: get -- target: $.paths['/v0/inboxes/{inbox_id}'].patch - update: - x-fern-sdk-group-name: - - inboxes - x-fern-sdk-method-name: update -- target: $.paths['/v0/inboxes/{inbox_id}'].delete - update: - x-fern-sdk-group-name: - - inboxes - x-fern-sdk-method-name: delete -- target: $.paths['/v0/pods'].get - update: - x-fern-sdk-group-name: - - pods - x-fern-sdk-method-name: list -- target: $.paths['/v0/pods'].post - update: - x-fern-sdk-group-name: - - pods - x-fern-sdk-method-name: create -- target: $.paths['/v0/pods/{pod_id}'].get - update: - x-fern-sdk-group-name: - - pods - x-fern-sdk-method-name: get -- target: $.paths['/v0/pods/{pod_id}'].delete - update: - x-fern-sdk-group-name: - - pods - x-fern-sdk-method-name: delete -- target: $.paths['/v0/webhooks'].get - update: - x-fern-sdk-group-name: - - webhooks - x-fern-sdk-method-name: list -- target: $.paths['/v0/webhooks'].post - update: - x-fern-sdk-group-name: - - webhooks - x-fern-sdk-method-name: create -- target: $.paths['/v0/webhooks/{webhook_id}'].get - update: - x-fern-sdk-group-name: - - webhooks - x-fern-sdk-method-name: get -- target: $.paths['/v0/webhooks/{webhook_id}'].patch - update: - x-fern-sdk-group-name: - - webhooks - x-fern-sdk-method-name: update -- target: $.paths['/v0/webhooks/{webhook_id}'].delete - update: - x-fern-sdk-group-name: - - webhooks - x-fern-sdk-method-name: delete -- target: $.paths['/v0/api-keys'].get - update: - x-fern-sdk-group-name: - - apiKeys - x-fern-sdk-method-name: list -- target: $.paths['/v0/api-keys'].post - update: - x-fern-sdk-group-name: - - apiKeys - x-fern-sdk-method-name: create -- target: $.paths['/v0/api-keys/{api_key}'].delete - update: - x-fern-sdk-group-name: - - apiKeys - x-fern-sdk-method-name: delete -- target: $.paths['/v0/domains'].get - update: - x-fern-sdk-group-name: - - domains - x-fern-sdk-method-name: list -- target: $.paths['/v0/domains'].post - update: - x-fern-sdk-group-name: - - domains - x-fern-sdk-method-name: create -- target: $.paths['/v0/domains/{domain_id}'].get - update: - x-fern-sdk-group-name: - - domains - x-fern-sdk-method-name: get -- target: $.paths['/v0/domains/{domain_id}'].delete - update: - x-fern-sdk-group-name: - - domains - x-fern-sdk-method-name: delete -- target: $.paths['/v0/domains/{domain_id}/zone-file'].get - update: - x-fern-sdk-group-name: - - domains - x-fern-sdk-method-name: getZoneFile -- target: $.paths['/v0/domains/{domain_id}/verify'].post - update: - x-fern-sdk-group-name: - - domains - x-fern-sdk-method-name: verify -- target: $.paths['/v0/drafts'].get - update: - x-fern-sdk-group-name: - - drafts - x-fern-sdk-method-name: list -- target: $.paths['/v0/drafts/{draft_id}'].get - update: - x-fern-sdk-group-name: - - drafts - x-fern-sdk-method-name: get -- target: $.paths['/v0/inboxes/{inbox_id}/drafts'].get - update: - x-fern-sdk-group-name: - - inboxes - - drafts - x-fern-sdk-method-name: list -- target: $.paths['/v0/inboxes/{inbox_id}/drafts'].post - update: - x-fern-sdk-group-name: - - inboxes - - drafts - x-fern-sdk-method-name: create -- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].get - update: - x-fern-sdk-group-name: - - inboxes - - drafts - x-fern-sdk-method-name: get -- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].patch - update: - x-fern-sdk-group-name: - - inboxes - - drafts - x-fern-sdk-method-name: update -- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].delete - update: - x-fern-sdk-group-name: - - inboxes - - drafts - x-fern-sdk-method-name: delete -- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}/send'].post - update: - x-fern-sdk-group-name: - - inboxes - - drafts - x-fern-sdk-method-name: send -- target: $.paths['/v0/inboxes/{inbox_id}/messages'].get - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: list -- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}'].get - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: get -- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}'].patch - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: update -- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}'].get - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: getAttachment -- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/raw'].get - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: getRaw -- target: $.paths['/v0/inboxes/{inbox_id}/messages/send'].post - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: send -- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/reply'].post - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: reply -- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all'].post - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: reply-all -- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/forward'].post - update: - x-fern-sdk-group-name: - - inboxes - - messages - x-fern-sdk-method-name: forward -- target: $.paths['/v0/inboxes/{inbox_id}/metrics'].get - update: - x-fern-sdk-group-name: - - inboxes - - metrics - x-fern-sdk-method-name: get -- target: $.paths['/v0/inboxes/{inbox_id}/threads'].get - update: - x-fern-sdk-group-name: - - inboxes - - threads - x-fern-sdk-method-name: list -- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}'].get - update: - x-fern-sdk-group-name: - - inboxes - - threads - x-fern-sdk-method-name: get -- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}'].delete - update: - x-fern-sdk-group-name: - - inboxes - - threads - x-fern-sdk-method-name: delete -- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}'].get - update: - x-fern-sdk-group-name: - - inboxes - - threads - x-fern-sdk-method-name: getAttachment -- target: $.paths['/v0/metrics'].get - update: - x-fern-sdk-group-name: - - metrics - x-fern-sdk-method-name: list -- target: $.paths['/v0/organizations'].get - update: - x-fern-sdk-group-name: - - organizations - x-fern-sdk-method-name: get -- target: $.paths['/v0/pods/{pod_id}/domains'].get - update: - x-fern-sdk-group-name: - - pods - - domains - x-fern-sdk-method-name: list -- target: $.paths['/v0/pods/{pod_id}/domains'].post - update: - x-fern-sdk-group-name: - - pods - - domains - x-fern-sdk-method-name: create -- target: $.paths['/v0/pods/{pod_id}/domains/{domain_id}'].delete - update: - x-fern-sdk-group-name: - - pods - - domains - x-fern-sdk-method-name: delete -- target: $.paths['/v0/pods/{pod_id}/drafts'].get - update: - x-fern-sdk-group-name: - - pods - - drafts - x-fern-sdk-method-name: list -- target: $.paths['/v0/pods/{pod_id}/drafts/{draft_id}'].get - update: - x-fern-sdk-group-name: - - pods - - drafts - x-fern-sdk-method-name: get -- target: $.paths['/v0/pods/{pod_id}/inboxes'].get - update: - x-fern-sdk-group-name: - - pods - - inboxes - x-fern-sdk-method-name: list -- target: $.paths['/v0/pods/{pod_id}/inboxes'].post - update: - x-fern-sdk-group-name: - - pods - - inboxes - x-fern-sdk-method-name: create -- target: $.paths['/v0/pods/{pod_id}/inboxes/{inbox_id}'].get - update: - x-fern-sdk-group-name: - - pods - - inboxes - x-fern-sdk-method-name: get -- target: $.paths['/v0/pods/{pod_id}/inboxes/{inbox_id}'].delete - update: - x-fern-sdk-group-name: - - pods - - inboxes - x-fern-sdk-method-name: delete -- target: $.paths['/v0/pods/{pod_id}/threads'].get - update: - x-fern-sdk-group-name: - - pods - - threads - x-fern-sdk-method-name: list -- target: $.paths['/v0/pods/{pod_id}/threads/{thread_id}'].get - update: - x-fern-sdk-group-name: - - pods - - threads - x-fern-sdk-method-name: get -- target: $.paths['/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}'].get - update: - x-fern-sdk-group-name: - - pods - - threads - x-fern-sdk-method-name: getAttachment -- target: $.paths['/v0/threads'].get - update: - x-fern-sdk-group-name: - - threads - x-fern-sdk-method-name: list -- target: $.paths['/v0/threads/{thread_id}'].get - update: - x-fern-sdk-group-name: - - threads - x-fern-sdk-method-name: get -- target: $.paths['/v0/threads/{thread_id}/attachments/{attachment_id}'].get - update: - x-fern-sdk-group-name: - - threads - x-fern-sdk-method-name: getAttachment From 06ef89b46db63aaef2cb401f1d12c28b1958a2c4 Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 16:40:06 -0400 Subject: [PATCH 4/8] demo: wire overlay via fern native 'overlays' key (keep spec + overlay separate) --- fern/generators.yml | 1 + fern/openapi.json | 404 +++++++------------------------------------- fern/overlay.yaml | 354 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+), 342 deletions(-) create mode 100644 fern/overlay.yaml diff --git a/fern/generators.yml b/fern/generators.yml index 239bdec5..900ecd45 100644 --- a/fern/generators.yml +++ b/fern/generators.yml @@ -2,6 +2,7 @@ api: specs: - openapi: openapi.json + overlays: overlay.yaml default-group: local groups: local: diff --git a/fern/openapi.json b/fern/openapi.json index c2934a9f..cf92634b 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -56,11 +56,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "inboxes_create", @@ -114,11 +110,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/inboxes/{inbox_id}": { @@ -172,11 +164,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes" - ], - "x-fern-sdk-method-name": "get" + ] }, "patch": { "operationId": "inboxes_update", @@ -238,11 +226,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes" - ], - "x-fern-sdk-method-name": "update" + } }, "delete": { "operationId": "inboxes_delete", @@ -287,11 +271,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/pods": { @@ -345,11 +325,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "pods_create", @@ -402,11 +378,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "pods" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/pods/{pod_id}": { @@ -460,11 +432,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods" - ], - "x-fern-sdk-method-name": "get" + ] }, "delete": { "operationId": "pods_delete", @@ -509,11 +477,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/webhooks": { @@ -567,11 +531,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "webhooks" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "webhooks_create", @@ -624,11 +584,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "webhooks" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/webhooks/{webhook_id}": { @@ -682,11 +638,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "webhooks" - ], - "x-fern-sdk-method-name": "get" + ] }, "patch": { "operationId": "webhooks_update", @@ -758,11 +710,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "webhooks" - ], - "x-fern-sdk-method-name": "update" + } }, "delete": { "operationId": "webhooks_delete", @@ -807,11 +755,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "webhooks" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/api-keys": { @@ -865,11 +809,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "apiKeys" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "apiKeys_create", @@ -922,11 +862,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "apiKeys" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/api-keys/{api_key}": { @@ -973,11 +909,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "apiKeys" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/domains": { @@ -1031,11 +963,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "domains" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "domains_create", @@ -1088,11 +1016,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "domains" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/domains/{domain_id}": { @@ -1146,11 +1070,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "domains" - ], - "x-fern-sdk-method-name": "get" + ] }, "delete": { "operationId": "domains_delete", @@ -1195,11 +1115,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "domains" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/domains/{domain_id}/zone-file": { @@ -1246,11 +1162,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "domains" - ], - "x-fern-sdk-method-name": "getZoneFile" + ] } }, "/v0/domains/{domain_id}/verify": { @@ -1297,11 +1209,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "domains" - ], - "x-fern-sdk-method-name": "verify" + ] } }, "/v0/drafts": { @@ -1401,11 +1309,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "drafts" - ], - "x-fern-sdk-method-name": "list" + ] } }, "/v0/drafts/{draft_id}": { @@ -1459,11 +1363,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "drafts" - ], - "x-fern-sdk-method-name": "get" + ] } }, "/v0/inboxes/{inbox_id}/drafts": { @@ -1571,12 +1471,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "drafts" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "inboxes_drafts_create", @@ -1638,12 +1533,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "drafts" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/inboxes/{inbox_id}/drafts/{draft_id}": { @@ -1705,12 +1595,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "drafts" - ], - "x-fern-sdk-method-name": "get" + ] }, "patch": { "operationId": "inboxes_drafts_update", @@ -1780,12 +1665,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "drafts" - ], - "x-fern-sdk-method-name": "update" + } }, "delete": { "operationId": "inboxes_drafts_delete", @@ -1838,12 +1718,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "drafts" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/inboxes/{inbox_id}/drafts/{draft_id}/send": { @@ -1935,12 +1810,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "drafts" - ], - "x-fern-sdk-method-name": "send" + } } }, "/v0/inboxes/{inbox_id}/messages": { @@ -2057,12 +1927,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "list" + ] } }, "/v0/inboxes/{inbox_id}/messages/{message_id}": { @@ -2124,12 +1989,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "get" + ] }, "patch": { "operationId": "inboxes_messages_update", @@ -2209,12 +2069,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "update" + } } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}": { @@ -2284,12 +2139,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "getAttachment" + ] } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/raw": { @@ -2351,12 +2201,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "getRaw" + ] } }, "/v0/inboxes/{inbox_id}/messages/send": { @@ -2440,12 +2285,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "send" + } } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/reply": { @@ -2537,12 +2377,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "reply" + } } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all": { @@ -2634,12 +2469,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "reply-all" + } } }, "/v0/inboxes/{inbox_id}/messages/{message_id}/forward": { @@ -2731,12 +2561,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "inboxes", - "messages" - ], - "x-fern-sdk-method-name": "forward" + } } }, "/v0/inboxes/{inbox_id}/metrics": { @@ -2825,12 +2650,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "metrics" - ], - "x-fern-sdk-method-name": "get" + ] } }, "/v0/inboxes/{inbox_id}/threads": { @@ -2947,12 +2767,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "threads" - ], - "x-fern-sdk-method-name": "list" + ] } }, "/v0/inboxes/{inbox_id}/threads/{thread_id}": { @@ -3014,12 +2829,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "threads" - ], - "x-fern-sdk-method-name": "get" + ] }, "delete": { "operationId": "inboxes_threads_delete", @@ -3072,12 +2882,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "threads" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}": { @@ -3147,12 +2952,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "inboxes", - "threads" - ], - "x-fern-sdk-method-name": "getAttachment" + ] } }, "/v0/metrics": { @@ -3223,11 +3023,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "metrics" - ], - "x-fern-sdk-method-name": "list" + ] } }, "/v0/organizations": { @@ -3263,11 +3059,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "organizations" - ], - "x-fern-sdk-method-name": "get" + ] } }, "/v0/pods/{pod_id}/domains": { @@ -3339,12 +3131,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "domains" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "pods_domains_create", @@ -3406,12 +3193,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "pods", - "domains" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/pods/{pod_id}/domains/{domain_id}": { @@ -3466,12 +3248,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "domains" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/pods/{pod_id}/drafts": { @@ -3579,12 +3356,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "drafts" - ], - "x-fern-sdk-method-name": "list" + ] } }, "/v0/pods/{pod_id}/drafts/{draft_id}": { @@ -3646,12 +3418,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "drafts" - ], - "x-fern-sdk-method-name": "get" + ] } }, "/v0/pods/{pod_id}/inboxes": { @@ -3723,12 +3490,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "inboxes" - ], - "x-fern-sdk-method-name": "list" + ] }, "post": { "operationId": "pods_inboxes_create", @@ -3790,12 +3552,7 @@ } } } - }, - "x-fern-sdk-group-name": [ - "pods", - "inboxes" - ], - "x-fern-sdk-method-name": "create" + } } }, "/v0/pods/{pod_id}/inboxes/{inbox_id}": { @@ -3857,12 +3614,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "inboxes" - ], - "x-fern-sdk-method-name": "get" + ] }, "delete": { "operationId": "pods_inboxes_delete", @@ -3915,12 +3667,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "inboxes" - ], - "x-fern-sdk-method-name": "delete" + ] } }, "/v0/pods/{pod_id}/threads": { @@ -4037,12 +3784,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "threads" - ], - "x-fern-sdk-method-name": "list" + ] } }, "/v0/pods/{pod_id}/threads/{thread_id}": { @@ -4104,12 +3846,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "threads" - ], - "x-fern-sdk-method-name": "get" + ] } }, "/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}": { @@ -4179,12 +3916,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "pods", - "threads" - ], - "x-fern-sdk-method-name": "getAttachment" + ] } }, "/v0/threads": { @@ -4293,11 +4025,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "threads" - ], - "x-fern-sdk-method-name": "list" + ] } }, "/v0/threads/{thread_id}": { @@ -4351,11 +4079,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "threads" - ], - "x-fern-sdk-method-name": "get" + ] } }, "/v0/threads/{thread_id}/attachments/{attachment_id}": { @@ -4417,11 +4141,7 @@ { "BearerAuth": [] } - ], - "x-fern-sdk-group-name": [ - "threads" - ], - "x-fern-sdk-method-name": "getAttachment" + ] } } }, diff --git a/fern/overlay.yaml b/fern/overlay.yaml new file mode 100644 index 00000000..4150a900 --- /dev/null +++ b/fern/overlay.yaml @@ -0,0 +1,354 @@ +# Auto-generated overlay that maps AgentMail operationIds → nested CLI +# group + method names. Without this, the CLI surface ends up stuttered +# (e.g. `agentmail inboxes-messages inboxes-messages-list`) because the +# upstream spec uses flat tag names like `InboxesMessages` instead of +# x-fern-sdk-group-name. Regenerate from the spec by running the inline +# script in this directory's README. +# +overlay: 1.0.0 +info: + title: AgentMail CLI group/method overlay + version: 1.0.0 +actions: +- target: $.paths['/v0/inboxes'].get + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes'].post + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: create +- target: $.paths['/v0/inboxes/{inbox_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}'].patch + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: update +- target: $.paths['/v0/inboxes/{inbox_id}'].delete + update: + x-fern-sdk-group-name: + - inboxes + x-fern-sdk-method-name: delete +- target: $.paths['/v0/pods'].get + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods'].post + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: create +- target: $.paths['/v0/pods/{pod_id}'].get + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}'].delete + update: + x-fern-sdk-group-name: + - pods + x-fern-sdk-method-name: delete +- target: $.paths['/v0/webhooks'].get + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: list +- target: $.paths['/v0/webhooks'].post + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: create +- target: $.paths['/v0/webhooks/{webhook_id}'].get + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: get +- target: $.paths['/v0/webhooks/{webhook_id}'].patch + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: update +- target: $.paths['/v0/webhooks/{webhook_id}'].delete + update: + x-fern-sdk-group-name: + - webhooks + x-fern-sdk-method-name: delete +- target: $.paths['/v0/api-keys'].get + update: + x-fern-sdk-group-name: + - apiKeys + x-fern-sdk-method-name: list +- target: $.paths['/v0/api-keys'].post + update: + x-fern-sdk-group-name: + - apiKeys + x-fern-sdk-method-name: create +- target: $.paths['/v0/api-keys/{api_key}'].delete + update: + x-fern-sdk-group-name: + - apiKeys + x-fern-sdk-method-name: delete +- target: $.paths['/v0/domains'].get + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: list +- target: $.paths['/v0/domains'].post + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: create +- target: $.paths['/v0/domains/{domain_id}'].get + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: get +- target: $.paths['/v0/domains/{domain_id}'].delete + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: delete +- target: $.paths['/v0/domains/{domain_id}/zone-file'].get + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: getZoneFile +- target: $.paths['/v0/domains/{domain_id}/verify'].post + update: + x-fern-sdk-group-name: + - domains + x-fern-sdk-method-name: verify +- target: $.paths['/v0/drafts'].get + update: + x-fern-sdk-group-name: + - drafts + x-fern-sdk-method-name: list +- target: $.paths['/v0/drafts/{draft_id}'].get + update: + x-fern-sdk-group-name: + - drafts + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/drafts'].get + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes/{inbox_id}/drafts'].post + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: create +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].patch + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: update +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}'].delete + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: delete +- target: $.paths['/v0/inboxes/{inbox_id}/drafts/{draft_id}/send'].post + update: + x-fern-sdk-group-name: + - inboxes + - drafts + x-fern-sdk-method-name: send +- target: $.paths['/v0/inboxes/{inbox_id}/messages'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}'].patch + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: update +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: getAttachment +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/raw'].get + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: getRaw +- target: $.paths['/v0/inboxes/{inbox_id}/messages/send'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: send +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/reply'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: reply +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: reply-all +- target: $.paths['/v0/inboxes/{inbox_id}/messages/{message_id}/forward'].post + update: + x-fern-sdk-group-name: + - inboxes + - messages + x-fern-sdk-method-name: forward +- target: $.paths['/v0/inboxes/{inbox_id}/metrics'].get + update: + x-fern-sdk-group-name: + - inboxes + - metrics + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/threads'].get + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: list +- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: get +- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}'].delete + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: delete +- target: $.paths['/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - inboxes + - threads + x-fern-sdk-method-name: getAttachment +- target: $.paths['/v0/metrics'].get + update: + x-fern-sdk-group-name: + - metrics + x-fern-sdk-method-name: list +- target: $.paths['/v0/organizations'].get + update: + x-fern-sdk-group-name: + - organizations + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/domains'].get + update: + x-fern-sdk-group-name: + - pods + - domains + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/domains'].post + update: + x-fern-sdk-group-name: + - pods + - domains + x-fern-sdk-method-name: create +- target: $.paths['/v0/pods/{pod_id}/domains/{domain_id}'].delete + update: + x-fern-sdk-group-name: + - pods + - domains + x-fern-sdk-method-name: delete +- target: $.paths['/v0/pods/{pod_id}/drafts'].get + update: + x-fern-sdk-group-name: + - pods + - drafts + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/drafts/{draft_id}'].get + update: + x-fern-sdk-group-name: + - pods + - drafts + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/inboxes'].get + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/inboxes'].post + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: create +- target: $.paths['/v0/pods/{pod_id}/inboxes/{inbox_id}'].get + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/inboxes/{inbox_id}'].delete + update: + x-fern-sdk-group-name: + - pods + - inboxes + x-fern-sdk-method-name: delete +- target: $.paths['/v0/pods/{pod_id}/threads'].get + update: + x-fern-sdk-group-name: + - pods + - threads + x-fern-sdk-method-name: list +- target: $.paths['/v0/pods/{pod_id}/threads/{thread_id}'].get + update: + x-fern-sdk-group-name: + - pods + - threads + x-fern-sdk-method-name: get +- target: $.paths['/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - pods + - threads + x-fern-sdk-method-name: getAttachment +- target: $.paths['/v0/threads'].get + update: + x-fern-sdk-group-name: + - threads + x-fern-sdk-method-name: list +- target: $.paths['/v0/threads/{thread_id}'].get + update: + x-fern-sdk-group-name: + - threads + x-fern-sdk-method-name: get +- target: $.paths['/v0/threads/{thread_id}/attachments/{attachment_id}'].get + update: + x-fern-sdk-group-name: + - threads + x-fern-sdk-method-name: getAttachment From d59c9b0a5b9e265fb8ede53f8727ef26fe395a9a Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 17:08:30 -0400 Subject: [PATCH 5/8] demo: bump fern-cli generator to 0.4.1 --- fern/generators.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fern/generators.yml b/fern/generators.yml index 900ecd45..346201a5 100644 --- a/fern/generators.yml +++ b/fern/generators.yml @@ -95,7 +95,7 @@ groups: cli: generators: - name: fernapi/fern-cli - version: 0.4.0 + version: 0.4.1 output: location: local-file-system path: ../cli From fbccaf7d827d22de4ba75492ecff419707de234a Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 17:26:46 -0400 Subject: [PATCH 6/8] refactor: split into multi-API layout (api: Fern Definition for SDKs/docs; cli: OpenAPI for CLI) - move fern/definition -> fern/apis/api/definition - move openapi.json + overlay.yaml -> fern/apis/cli/ - split root generators.yml into per-API generators.yml (paths remapped) - SDK generation stays on the Fern Definition (websockets preserved); CLI on OpenAPI - update sync-stainless + api-changelog workflows for new paths and --api api --- .github/workflows/api-changelog.yml | 2 +- .github/workflows/sync-stainless.yml | 7 +++--- AGENTS.md | 6 ++--- .../{ => apis/api}/definition/__package__.yml | 0 fern/{ => apis/api}/definition/agent.yml | 0 fern/{ => apis/api}/definition/api-keys.yml | 0 fern/{ => apis/api}/definition/api.yml | 0 .../{ => apis/api}/definition/attachments.yml | 0 fern/{ => apis/api}/definition/domains.yml | 0 fern/{ => apis/api}/definition/drafts.yml | 0 fern/{ => apis/api}/definition/events.yml | 0 .../api}/definition/inbox-events.yml | 0 .../api}/definition/inboxes/__package__.yml | 0 .../api}/definition/inboxes/api-keys.yml | 0 .../api}/definition/inboxes/drafts.yml | 0 .../api}/definition/inboxes/events.yml | 0 .../api}/definition/inboxes/lists.yml | 0 .../api}/definition/inboxes/messages.yml | 0 .../api}/definition/inboxes/metrics.yml | 0 .../api}/definition/inboxes/threads.yml | 0 fern/{ => apis/api}/definition/lists.yml | 0 fern/{ => apis/api}/definition/messages.yml | 0 fern/{ => apis/api}/definition/metrics.yml | 0 .../api}/definition/organizations.yml | 0 .../api}/definition/pods/__package__.yml | 0 .../api}/definition/pods/api-keys.yml | 0 .../api}/definition/pods/domains.yml | 0 .../{ => apis/api}/definition/pods/drafts.yml | 0 .../api}/definition/pods/inboxes.yml | 0 fern/{ => apis/api}/definition/pods/lists.yml | 0 .../api}/definition/pods/metrics.yml | 0 .../api}/definition/pods/threads.yml | 0 fern/{ => apis/api}/definition/threads.yml | 0 .../api}/definition/webhooks/__package__.yml | 0 .../api}/definition/webhooks/events.yml | 0 fern/{ => apis/api}/definition/websockets.yml | 0 fern/{ => apis/api}/generators.yml | 24 ++++++------------- fern/apis/cli/generators.yml | 21 ++++++++++++++++ fern/{ => apis/cli}/openapi.json | 0 fern/{ => apis/cli}/overlay.yaml | 0 40 files changed, 35 insertions(+), 25 deletions(-) rename fern/{ => apis/api}/definition/__package__.yml (100%) rename fern/{ => apis/api}/definition/agent.yml (100%) rename fern/{ => apis/api}/definition/api-keys.yml (100%) rename fern/{ => apis/api}/definition/api.yml (100%) rename fern/{ => apis/api}/definition/attachments.yml (100%) rename fern/{ => apis/api}/definition/domains.yml (100%) rename fern/{ => apis/api}/definition/drafts.yml (100%) rename fern/{ => apis/api}/definition/events.yml (100%) rename fern/{ => apis/api}/definition/inbox-events.yml (100%) rename fern/{ => apis/api}/definition/inboxes/__package__.yml (100%) rename fern/{ => apis/api}/definition/inboxes/api-keys.yml (100%) rename fern/{ => apis/api}/definition/inboxes/drafts.yml (100%) rename fern/{ => apis/api}/definition/inboxes/events.yml (100%) rename fern/{ => apis/api}/definition/inboxes/lists.yml (100%) rename fern/{ => apis/api}/definition/inboxes/messages.yml (100%) rename fern/{ => apis/api}/definition/inboxes/metrics.yml (100%) rename fern/{ => apis/api}/definition/inboxes/threads.yml (100%) rename fern/{ => apis/api}/definition/lists.yml (100%) rename fern/{ => apis/api}/definition/messages.yml (100%) rename fern/{ => apis/api}/definition/metrics.yml (100%) rename fern/{ => apis/api}/definition/organizations.yml (100%) rename fern/{ => apis/api}/definition/pods/__package__.yml (100%) rename fern/{ => apis/api}/definition/pods/api-keys.yml (100%) rename fern/{ => apis/api}/definition/pods/domains.yml (100%) rename fern/{ => apis/api}/definition/pods/drafts.yml (100%) rename fern/{ => apis/api}/definition/pods/inboxes.yml (100%) rename fern/{ => apis/api}/definition/pods/lists.yml (100%) rename fern/{ => apis/api}/definition/pods/metrics.yml (100%) rename fern/{ => apis/api}/definition/pods/threads.yml (100%) rename fern/{ => apis/api}/definition/threads.yml (100%) rename fern/{ => apis/api}/definition/webhooks/__package__.yml (100%) rename fern/{ => apis/api}/definition/webhooks/events.yml (100%) rename fern/{ => apis/api}/definition/websockets.yml (100%) rename fern/{ => apis/api}/generators.yml (90%) create mode 100644 fern/apis/cli/generators.yml rename fern/{ => apis/cli}/openapi.json (100%) rename fern/{ => apis/cli}/overlay.yaml (100%) diff --git a/.github/workflows/api-changelog.yml b/.github/workflows/api-changelog.yml index 2955d556..9da7f7b1 100644 --- a/.github/workflows/api-changelog.yml +++ b/.github/workflows/api-changelog.yml @@ -3,7 +3,7 @@ name: API Changelog Automation on: pull_request: paths: - - 'fern/definition/**/*.yml' + - 'fern/apis/api/definition/**/*.yml' workflow_dispatch: jobs: diff --git a/.github/workflows/sync-stainless.yml b/.github/workflows/sync-stainless.yml index 4a84ebd3..9cdd2627 100644 --- a/.github/workflows/sync-stainless.yml +++ b/.github/workflows/sync-stainless.yml @@ -6,9 +6,8 @@ on: branches: - main paths: - - "fern/definition/**" - - "fern/generators.yml" - - "fern/api.yml" + - "fern/apis/api/definition/**" + - "fern/apis/api/generators.yml" jobs: sync: @@ -22,7 +21,7 @@ jobs: - name: Generate OpenAPI spec working-directory: fern - run: fern generate --group openapi --local + run: fern generate --api api --group openapi --local env: FERN_TOKEN: ${{ secrets.FERN_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index aa6520f0..f0620b46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Instructions for creating changelog entries (e.g. when asked in #github-prs with ## When you're invoked -- **API changes:** A PR that touches `fern/definition/**` triggers a GitHub Action. It posts an oasdiff (technical diff) on the PR and uploads it as the `api-changelog-diff` artifact. Someone may tag you with that PR or paste the diff. +- **API changes:** A PR that touches `fern/apis/api/definition/**` triggers a GitHub Action. It posts an oasdiff (technical diff) on the PR and uploads it as the `api-changelog-diff` artifact. Someone may tag you with that PR or paste the diff. - **Manual:** For non-API changes, create `fern/changelog/YYYY-MM-DD.mdx` and follow the structure below. Use `fern/changelog/TEMPLATE.mdx` as reference. - **Output:** Create or edit files in `fern/changelog/`. File name: `YYYY-MM-DD.mdx` (same day = add suffix e.g. `2026-01-30-metrics.mdx`). @@ -79,14 +79,14 @@ slug: knowledge-base/article-slug Match Resend KB quality. Articles should include: - **Tables** for DNS record fields, comparison matrices, etc. -- **Code examples** using the actual AgentMail SDK (Python primary, verify API signatures against `fern/definition/` or existing docs) +- **Code examples** using the actual AgentMail SDK (Python primary, verify API signatures against `fern/apis/api/definition/` or existing docs) - **Warnings/Notes** using `` and `` Fern components for provider-specific gotchas - **Troubleshooting section** ("Common Issues") for DNS guides and similar - **Verification section** for setup guides (propagation times, how to confirm success) ## Code accuracy -- Always verify SDK method signatures against `fern/pages/core-concepts/` docs or `fern/definition/` YAML +- Always verify SDK method signatures against `fern/pages/core-concepts/` docs or `fern/apis/api/definition/` YAML - `to`, `cc`, `bcc` are `list` on messages - Webhook payloads use `payload["message"]` for `message.received` events (not `payload["data"]`) - WebSocket SDK uses `client.websockets.connect()` with typed events (`Subscribe`, `Subscribed`, `MessageReceivedEvent`) diff --git a/fern/definition/__package__.yml b/fern/apis/api/definition/__package__.yml similarity index 100% rename from fern/definition/__package__.yml rename to fern/apis/api/definition/__package__.yml diff --git a/fern/definition/agent.yml b/fern/apis/api/definition/agent.yml similarity index 100% rename from fern/definition/agent.yml rename to fern/apis/api/definition/agent.yml diff --git a/fern/definition/api-keys.yml b/fern/apis/api/definition/api-keys.yml similarity index 100% rename from fern/definition/api-keys.yml rename to fern/apis/api/definition/api-keys.yml diff --git a/fern/definition/api.yml b/fern/apis/api/definition/api.yml similarity index 100% rename from fern/definition/api.yml rename to fern/apis/api/definition/api.yml diff --git a/fern/definition/attachments.yml b/fern/apis/api/definition/attachments.yml similarity index 100% rename from fern/definition/attachments.yml rename to fern/apis/api/definition/attachments.yml diff --git a/fern/definition/domains.yml b/fern/apis/api/definition/domains.yml similarity index 100% rename from fern/definition/domains.yml rename to fern/apis/api/definition/domains.yml diff --git a/fern/definition/drafts.yml b/fern/apis/api/definition/drafts.yml similarity index 100% rename from fern/definition/drafts.yml rename to fern/apis/api/definition/drafts.yml diff --git a/fern/definition/events.yml b/fern/apis/api/definition/events.yml similarity index 100% rename from fern/definition/events.yml rename to fern/apis/api/definition/events.yml diff --git a/fern/definition/inbox-events.yml b/fern/apis/api/definition/inbox-events.yml similarity index 100% rename from fern/definition/inbox-events.yml rename to fern/apis/api/definition/inbox-events.yml diff --git a/fern/definition/inboxes/__package__.yml b/fern/apis/api/definition/inboxes/__package__.yml similarity index 100% rename from fern/definition/inboxes/__package__.yml rename to fern/apis/api/definition/inboxes/__package__.yml diff --git a/fern/definition/inboxes/api-keys.yml b/fern/apis/api/definition/inboxes/api-keys.yml similarity index 100% rename from fern/definition/inboxes/api-keys.yml rename to fern/apis/api/definition/inboxes/api-keys.yml diff --git a/fern/definition/inboxes/drafts.yml b/fern/apis/api/definition/inboxes/drafts.yml similarity index 100% rename from fern/definition/inboxes/drafts.yml rename to fern/apis/api/definition/inboxes/drafts.yml diff --git a/fern/definition/inboxes/events.yml b/fern/apis/api/definition/inboxes/events.yml similarity index 100% rename from fern/definition/inboxes/events.yml rename to fern/apis/api/definition/inboxes/events.yml diff --git a/fern/definition/inboxes/lists.yml b/fern/apis/api/definition/inboxes/lists.yml similarity index 100% rename from fern/definition/inboxes/lists.yml rename to fern/apis/api/definition/inboxes/lists.yml diff --git a/fern/definition/inboxes/messages.yml b/fern/apis/api/definition/inboxes/messages.yml similarity index 100% rename from fern/definition/inboxes/messages.yml rename to fern/apis/api/definition/inboxes/messages.yml diff --git a/fern/definition/inboxes/metrics.yml b/fern/apis/api/definition/inboxes/metrics.yml similarity index 100% rename from fern/definition/inboxes/metrics.yml rename to fern/apis/api/definition/inboxes/metrics.yml diff --git a/fern/definition/inboxes/threads.yml b/fern/apis/api/definition/inboxes/threads.yml similarity index 100% rename from fern/definition/inboxes/threads.yml rename to fern/apis/api/definition/inboxes/threads.yml diff --git a/fern/definition/lists.yml b/fern/apis/api/definition/lists.yml similarity index 100% rename from fern/definition/lists.yml rename to fern/apis/api/definition/lists.yml diff --git a/fern/definition/messages.yml b/fern/apis/api/definition/messages.yml similarity index 100% rename from fern/definition/messages.yml rename to fern/apis/api/definition/messages.yml diff --git a/fern/definition/metrics.yml b/fern/apis/api/definition/metrics.yml similarity index 100% rename from fern/definition/metrics.yml rename to fern/apis/api/definition/metrics.yml diff --git a/fern/definition/organizations.yml b/fern/apis/api/definition/organizations.yml similarity index 100% rename from fern/definition/organizations.yml rename to fern/apis/api/definition/organizations.yml diff --git a/fern/definition/pods/__package__.yml b/fern/apis/api/definition/pods/__package__.yml similarity index 100% rename from fern/definition/pods/__package__.yml rename to fern/apis/api/definition/pods/__package__.yml diff --git a/fern/definition/pods/api-keys.yml b/fern/apis/api/definition/pods/api-keys.yml similarity index 100% rename from fern/definition/pods/api-keys.yml rename to fern/apis/api/definition/pods/api-keys.yml diff --git a/fern/definition/pods/domains.yml b/fern/apis/api/definition/pods/domains.yml similarity index 100% rename from fern/definition/pods/domains.yml rename to fern/apis/api/definition/pods/domains.yml diff --git a/fern/definition/pods/drafts.yml b/fern/apis/api/definition/pods/drafts.yml similarity index 100% rename from fern/definition/pods/drafts.yml rename to fern/apis/api/definition/pods/drafts.yml diff --git a/fern/definition/pods/inboxes.yml b/fern/apis/api/definition/pods/inboxes.yml similarity index 100% rename from fern/definition/pods/inboxes.yml rename to fern/apis/api/definition/pods/inboxes.yml diff --git a/fern/definition/pods/lists.yml b/fern/apis/api/definition/pods/lists.yml similarity index 100% rename from fern/definition/pods/lists.yml rename to fern/apis/api/definition/pods/lists.yml diff --git a/fern/definition/pods/metrics.yml b/fern/apis/api/definition/pods/metrics.yml similarity index 100% rename from fern/definition/pods/metrics.yml rename to fern/apis/api/definition/pods/metrics.yml diff --git a/fern/definition/pods/threads.yml b/fern/apis/api/definition/pods/threads.yml similarity index 100% rename from fern/definition/pods/threads.yml rename to fern/apis/api/definition/pods/threads.yml diff --git a/fern/definition/threads.yml b/fern/apis/api/definition/threads.yml similarity index 100% rename from fern/definition/threads.yml rename to fern/apis/api/definition/threads.yml diff --git a/fern/definition/webhooks/__package__.yml b/fern/apis/api/definition/webhooks/__package__.yml similarity index 100% rename from fern/definition/webhooks/__package__.yml rename to fern/apis/api/definition/webhooks/__package__.yml diff --git a/fern/definition/webhooks/events.yml b/fern/apis/api/definition/webhooks/events.yml similarity index 100% rename from fern/definition/webhooks/events.yml rename to fern/apis/api/definition/webhooks/events.yml diff --git a/fern/definition/websockets.yml b/fern/apis/api/definition/websockets.yml similarity index 100% rename from fern/definition/websockets.yml rename to fern/apis/api/definition/websockets.yml diff --git a/fern/generators.yml b/fern/apis/api/generators.yml similarity index 90% rename from fern/generators.yml rename to fern/apis/api/generators.yml index 346201a5..1670c816 100644 --- a/fern/generators.yml +++ b/fern/apis/api/generators.yml @@ -1,8 +1,8 @@ # yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json -api: - specs: - - openapi: openapi.json - overlays: overlay.yaml +# SDK / docs / OpenAPI-export / Postman API — sourced from the Fern Definition +# in ./definition. Output paths are relative to this file; ../../ reaches the +# fern/ root and ../../../ reaches the repo root so artifacts land where they +# did before the multi-API split. default-group: local groups: local: @@ -11,7 +11,7 @@ groups: version: 3.60.0 output: location: local-file-system - path: generated/typescript + path: ../../generated/typescript config: namespaceExport: AgentMail noSerdeLayer: false @@ -22,7 +22,7 @@ groups: version: 4.64.1 output: location: local-file-system - path: generated/python + path: ../../generated/python config: client_class_name: AgentMail pydantic_config: @@ -92,23 +92,13 @@ groups: license = "MIT" homepage = "https://agentmail.to" smart-casing: false - cli: - generators: - - name: fernapi/fern-cli - version: 0.4.1 - output: - location: local-file-system - path: ../cli - config: - binaryName: agentmail - smart-casing: false openapi: generators: - name: fernapi/fern-openapi version: 0.3.0 output: location: local-file-system - path: ../openapi + path: ../../../openapi smart-casing: false postman: generators: diff --git a/fern/apis/cli/generators.yml b/fern/apis/cli/generators.yml new file mode 100644 index 00000000..60869a38 --- /dev/null +++ b/fern/apis/cli/generators.yml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +# CLI API — sourced from the OpenAPI spec in this folder (with an OpenAPI +# overlay that maps operations into nested x-fern-sdk-group-name groups). +# Kept separate from the Fern-Definition-backed SDK API so the two evolve +# independently. Generate with: fern generate --api cli --group cli --local +api: + specs: + - openapi: openapi.json + overlays: overlay.yaml +default-group: cli +groups: + cli: + generators: + - name: fernapi/fern-cli + version: 0.4.1 + output: + location: local-file-system + path: ../../../cli + config: + binaryName: agentmail + smart-casing: false diff --git a/fern/openapi.json b/fern/apis/cli/openapi.json similarity index 100% rename from fern/openapi.json rename to fern/apis/cli/openapi.json diff --git a/fern/overlay.yaml b/fern/apis/cli/overlay.yaml similarity index 100% rename from fern/overlay.yaml rename to fern/apis/cli/overlay.yaml From 83400a1c62712b1559b955a9e452330673dd64ee Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 17:33:59 -0400 Subject: [PATCH 7/8] feat: source CLI from definition-derived OpenAPI export + add regenerate-cli workflow - replace hand-copied openapi.json with openapi.yml (the fern-openapi export of the Fern Definition, same spec sync-stainless pushes) as the CLI input - keep overlay.yaml for nested-group mapping (targets /v0 paths, ports cleanly) - add workflow_dispatch-only regenerate-cli workflow: export spec -> regenerate CLI, no publish (no-op until cli output targets a GitHub repo) - gitignore the transient /openapi/ export --- .github/workflows/regenerate-cli.yml | 37 + .gitignore | 3 + cli/cli/agentmail/openapi0.json | 2 +- fern/apis/cli/generators.yml | 12 +- fern/apis/cli/openapi.json | 6961 ----------------------- fern/apis/cli/openapi.yml | 7776 ++++++++++++++++++++++++++ 6 files changed, 7824 insertions(+), 6967 deletions(-) create mode 100644 .github/workflows/regenerate-cli.yml delete mode 100644 fern/apis/cli/openapi.json create mode 100644 fern/apis/cli/openapi.yml diff --git a/.github/workflows/regenerate-cli.yml b/.github/workflows/regenerate-cli.yml new file mode 100644 index 00000000..147ab3c5 --- /dev/null +++ b/.github/workflows/regenerate-cli.yml @@ -0,0 +1,37 @@ +name: Regenerate CLI + +# Regenerates the CLI from the Fern Definition: exports the OpenAPI spec, +# refreshes fern/apis/cli/openapi.yml, then runs the fern-cli generator. +# +# NOTE: today the `cli` group outputs to the local file system, so a CI run +# regenerates into the runner and the artifacts are discarded — this workflow +# is intentionally a no-op end-to-end for now. Once the `cli` group's output is +# switched to a GitHub repo target (like the SDK release workflows), this will +# publish the CLI. Manual-only until then. +on: + workflow_dispatch: + +jobs: + regenerate-cli: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Fern + run: npm install -g fern-api + + - name: Export OpenAPI spec from the Fern Definition + working-directory: fern + run: fern generate --api api --group openapi --local + env: + FERN_TOKEN: ${{ secrets.FERN_TOKEN }} + + - name: Refresh the CLI input spec + run: cp openapi/openapi.yml fern/apis/cli/openapi.yml + + - name: Generate the CLI + working-directory: fern + run: fern generate --api cli --group cli --local + env: + FERN_TOKEN: ${{ secrets.FERN_TOKEN }} diff --git a/.gitignore b/.gitignore index 3e794c47..b5116021 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ node_modules/ # Generated CLI build artifacts /cli/target/ + +# Transient OpenAPI export (regenerated in CI, pushed to Stainless) +/openapi/ diff --git a/cli/cli/agentmail/openapi0.json b/cli/cli/agentmail/openapi0.json index 7bd0d7d6..5e948e7c 100644 --- a/cli/cli/agentmail/openapi0.json +++ b/cli/cli/agentmail/openapi0.json @@ -1 +1 @@ -{"openapi":"3.0.1","info":{"title":"api","version":""},"paths":{"/v0/inboxes":{"get":{"operationId":"inboxes_list","tags":["Inboxes"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"inboxes_create","tags":["Inboxes"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest","nullable":true}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}":{"get":{"operationId":"inboxes_get","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"inboxes_update","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesUpdateInboxRequest"}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"update"},"delete":{"operationId":"inboxes_delete","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods":{"get":{"operationId":"pods_list","tags":["Pods"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsListPodsResponse"}}}}},"summary":"List Pods","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"pods_create","tags":["Pods"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsCreatePodRequest"}}}},"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}":{"get":{"operationId":"pods_get","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"pods_delete","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"delete"}},"/v0/webhooks":{"get":{"operationId":"webhooks_list","tags":["Webhooks"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksListWebhooksResponse"}}}}},"summary":"List Webhooks","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"webhooks_create","tags":["Webhooks"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksCreateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"create"}},"/v0/webhooks/{webhook_id}":{"get":{"operationId":"webhooks_get","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"webhooks_update","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"update"},"delete":{"operationId":"webhooks_delete","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"delete"}},"/v0/api-keys":{"get":{"operationId":"apiKeys_list","tags":["ApiKeys"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"apiKeys_create","tags":["ApiKeys"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"create"}},"/v0/api-keys/{api_key}":{"delete":{"operationId":"apiKeys_delete","tags":["ApiKeys"],"parameters":[{"name":"api_key","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"delete"}},"/v0/domains":{"get":{"operationId":"domains_list","tags":["Domains"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"domains_create","tags":["Domains"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"create"}},"/v0/domains/{domain_id}":{"get":{"operationId":"domains_get","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"domains_delete","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"delete"}},"/v0/domains/{domain_id}/zone-file":{"get":{"operationId":"domains_getZoneFile","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Zone File","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"getZoneFile"}},"/v0/domains/{domain_id}/verify":{"post":{"operationId":"domains_verify","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Verify Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"verify"}},"/v0/drafts":{"get":{"operationId":"drafts_list","tags":["Drafts"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"list"}},"/v0/drafts/{draft_id}":{"get":{"operationId":"drafts_get","tags":["Drafts"],"parameters":[{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"get"}},"/v0/inboxes/{inbox_id}/drafts":{"get":{"operationId":"inboxes_drafts_list","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"inboxes_drafts_create","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}":{"get":{"operationId":"inboxes_drafts_get","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"inboxes_drafts_update","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"update"},"delete":{"operationId":"inboxes_drafts_delete","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}/send":{"post":{"operationId":"inboxes_drafts_send","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/messages":{"get":{"operationId":"inboxes_messages_list","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMessagesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/messages/{message_id}":{"get":{"operationId":"inboxes_messages_get","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"get"},"patch":{"operationId":"inboxes_messages_update","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"update"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}":{"get":{"operationId":"inboxes_messages_getAttachment","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/raw":{"get":{"operationId":"inboxes_messages_getRaw","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RawMessageResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Raw Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"getRaw"}},"/v0/inboxes/{inbox_id}/messages/send":{"post":{"operationId":"inboxes_messages_send","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply":{"post":{"operationId":"inboxes_messages_reply","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply To Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyToMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all":{"post":{"operationId":"inboxes_messages_reply-all","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply All Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyAllMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply-all"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/forward":{"post":{"operationId":"inboxes_messages_forward","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Forward Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"forward"}},"/v0/inboxes/{inbox_id}/metrics":{"get":{"operationId":"inboxes_metrics_get","tags":["InboxesMetrics"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricStartTimestamp"}},{"name":"end_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricEndTimestamp"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Metrics","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","metrics"],"x-fern-sdk-method-name":"get"}},"/v0/inboxes/{inbox_id}/threads":{"get":{"operationId":"inboxes_threads_list","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}":{"get":{"operationId":"inboxes_threads_get","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"inboxes_threads_delete","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"operationId":"inboxes_threads_getAttachment","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/metrics":{"get":{"operationId":"metrics_list","tags":["Metrics"],"parameters":[{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricStartTimestamp"}},{"name":"end_timestamp","in":"query","required":true,"schema":{"$ref":"#/components/schemas/MetricEndTimestamp"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMetricsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Metrics","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["metrics"],"x-fern-sdk-method-name":"list"}},"/v0/organizations":{"get":{"description":"Get the current organization.","operationId":"organizations_get","tags":["Organizations"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}}},"summary":"Get Organization","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["organizations"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/domains":{"get":{"operationId":"pods_domains_list","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"pods_domains_create","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/domains/{domain_id}":{"delete":{"operationId":"pods_domains_delete","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/drafts":{"get":{"operationId":"pods_drafts_list","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/drafts/{draft_id}":{"get":{"operationId":"pods_drafts_get","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/inboxes":{"get":{"operationId":"pods_inboxes_list","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"list"},"post":{"operationId":"pods_inboxes_create","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest"}}}},"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/inboxes/{inbox_id}":{"get":{"operationId":"pods_inboxes_get","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"get"},"delete":{"operationId":"pods_inboxes_delete","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/threads":{"get":{"operationId":"pods_threads_list","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/threads/{thread_id}":{"get":{"operationId":"pods_threads_get","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"operationId":"pods_threads_getAttachment","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/threads":{"get":{"operationId":"threads_list","tags":["Threads"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"list"}},"/v0/threads/{thread_id}":{"get":{"operationId":"threads_get","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"get"}},"/v0/threads/{thread_id}/attachments/{attachment_id}":{"get":{"operationId":"threads_getAttachment","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://api.agentmail.dev"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"getAttachment"}}},"components":{"schemas":{"Limit":{"title":"Limit","type":"integer","description":"Limit of number of items returned."},"Count":{"title":"Count","type":"integer","description":"Number of items returned."},"PageToken":{"title":"PageToken","type":"string","description":"Page token for pagination."},"Labels":{"title":"Labels","type":"array","items":{"type":"string"},"description":"Labels to filter by."},"Before":{"title":"Before","type":"string","format":"date-time","description":"Timestamp before which to filter by."},"After":{"title":"After","type":"string","format":"date-time","description":"Timestamp after which to filter by."},"Ascending":{"title":"Ascending","type":"boolean","description":"Sort in ascending temporal order."},"IncludeSpam":{"title":"IncludeSpam","type":"boolean","description":"Include spam in results."},"OrganizationId":{"title":"OrganizationId","type":"string","description":"ID of organization."},"ErrorName":{"title":"ErrorName","type":"string","description":"Name of error."},"ErrorResponse":{"title":"ErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"message":{"type":"string","description":"Error message."}},"required":["name","message"]},"ValidationErrorResponse":{"title":"ValidationErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"errors":{"description":"Validation errors."}},"required":["name","errors"]},"inboxesInboxId":{"title":"inboxesInboxId","type":"string","description":"ID of inbox."},"inboxesDisplayName":{"title":"inboxesDisplayName","type":"string","description":"Display name: `Display Name `."},"inboxesClientId":{"title":"inboxesClientId","type":"string","description":"Client ID of inbox."},"inboxesInbox":{"title":"inboxesInbox","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which inbox was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which inbox was created."}},"required":["pod_id","inbox_id","updated_at","created_at"]},"inboxesListInboxesResponse":{"title":"inboxesListInboxesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"inboxes":{"type":"array","items":{"$ref":"#/components/schemas/inboxesInbox"},"description":"Ordered by `created_at` descending."}},"required":["count","inboxes"]},"inboxesCreateInboxRequest":{"title":"inboxesCreateInboxRequest","type":"object","properties":{"username":{"type":"string","nullable":true,"description":"Username of address. Randomly generated if not specified."},"domain":{"type":"string","nullable":true,"description":"Domain of address. Must be verified domain. Defaults to `agentmail.to`."},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true}}},"inboxesUpdateInboxRequest":{"title":"inboxesUpdateInboxRequest","type":"object","properties":{"display_name":{"$ref":"#/components/schemas/inboxesDisplayName"}},"required":["display_name"]},"podsPodId":{"title":"podsPodId","type":"string","description":"ID of pod."},"podsName":{"title":"podsName","type":"string","description":"Name of pod."},"podsClientId":{"title":"podsClientId","type":"string","description":"Client ID of pod."},"podsPod":{"title":"podsPod","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"name":{"$ref":"#/components/schemas/podsName"},"updated_at":{"type":"string","format":"date-time","description":"Time at which pod was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which pod was created."},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}},"required":["pod_id","name","updated_at","created_at"]},"podsListPodsResponse":{"title":"podsListPodsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"pods":{"type":"array","items":{"$ref":"#/components/schemas/podsPod"},"description":"Ordered by `created_at` descending."}},"required":["count","pods"]},"podsCreatePodRequest":{"title":"podsCreatePodRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/podsName","nullable":true},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}}},"webhooksWebhookId":{"title":"webhooksWebhookId","type":"string","description":"ID of webhook."},"webhooksClientId":{"title":"webhooksClientId","type":"string","description":"Client ID of webhook."},"webhooksUrl":{"title":"webhooksUrl","type":"string","description":"URL of webhook endpoint."},"webhooksWebhook":{"title":"webhooksWebhook","type":"object","properties":{"webhook_id":{"$ref":"#/components/schemas/webhooksWebhookId"},"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"secret":{"type":"string","description":"Secret for webhook signature verification."},"enabled":{"type":"boolean","description":"Webhook is enabled."},"updated_at":{"type":"string","format":"date-time","description":"Time at which webhook was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which webhook was created."},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true}},"required":["webhook_id","url","secret","enabled","updated_at","created_at"]},"webhooksListWebhooksResponse":{"title":"webhooksListWebhooksResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/webhooksWebhook"},"description":"Ordered by `created_at` descending."}},"required":["count","webhooks"]},"webhooksCreateWebhookRequest":{"title":"webhooksCreateWebhookRequest","type":"object","properties":{"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/EventTypes"},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true}},"required":["url","event_types"]},"webhooksUpdateWebhookRequest":{"title":"webhooksUpdateWebhookRequest","type":"object","properties":{"add_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to subscribe to the webhook."},"remove_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to unsubscribe from the webhook."},"add_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to subscribe to the webhook."},"remove_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to unsubscribe from the webhook."}}},"ApiKeyId":{"title":"ApiKeyId","type":"string","description":"ID of api key."},"Prefix":{"title":"Prefix","type":"string","description":"Prefix of api key."},"Name":{"title":"Name","type":"string","description":"Name of api key."},"CreatedAt":{"title":"CreatedAt","type":"string","format":"date-time","description":"Time at which api key was created."},"ApiKey":{"title":"ApiKey","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"used_at":{"type":"string","format":"date-time","nullable":true,"description":"Time at which api key was last used."},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","prefix","name","created_at"]},"CreateApiKeyResponse":{"title":"CreateApiKeyResponse","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"api_key":{"type":"string","description":"API key."},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","api_key","prefix","name","created_at"]},"ListApiKeysResponse":{"title":"ListApiKeysResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"api_keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"},"description":"Ordered by `created_at` descending."}},"required":["count","api_keys"]},"CreateApiKeyRequest":{"title":"CreateApiKeyRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/Name"}},"required":["name"]},"AttachmentId":{"title":"AttachmentId","type":"string","description":"ID of attachment."},"AttachmentFilename":{"title":"AttachmentFilename","type":"string","description":"Filename of attachment."},"AttachmentSize":{"title":"AttachmentSize","type":"integer","description":"Size of attachment in bytes."},"AttachmentContentType":{"title":"AttachmentContentType","type":"string","description":"Content type of attachment."},"AttachmentContentDisposition":{"title":"AttachmentContentDisposition","type":"string","enum":["inline","attachment"],"description":"Content disposition of attachment."},"AttachmentContentId":{"title":"AttachmentContentId","type":"string","description":"Content ID of attachment."},"Attachment":{"title":"Attachment","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true}},"required":["attachment_id","size"]},"AttachmentResponse":{"title":"AttachmentResponse","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"download_url":{"type":"string","description":"URL to download the attachment."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["attachment_id","size","download_url","expires_at"]},"SendAttachment":{"title":"SendAttachment","type":"object","properties":{"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"content":{"type":"string","nullable":true,"description":"Base64 encoded content of attachment."},"url":{"type":"string","nullable":true,"description":"URL to the attachment."}}},"DomainId":{"title":"DomainId","type":"string","description":"The name of the domain. (e.g., \" your-domain.com\")"},"DomainName":{"title":"DomainName","type":"string","description":"The name of the domain. (e.g., \"example.com\")"},"RecordType":{"title":"RecordType","type":"string","enum":["TXT","CNAME","MX"]},"VerificationStatus":{"title":"VerificationStatus","type":"string","enum":["NOT_STARTED","PENDING","INVALID","FAILED","VERIFYING","VERIFIED"]},"RecordStatus":{"title":"RecordStatus","type":"string","enum":["MISSING","INVALID","VALID"]},"VerificationRecord":{"title":"VerificationRecord","type":"object","properties":{"type":{"$ref":"#/components/schemas/RecordType","description":"The type of the DNS record."},"name":{"type":"string","description":"The name or host of the record."},"value":{"type":"string","description":"The value of the record."},"status":{"$ref":"#/components/schemas/RecordStatus","description":"The verification status of this specific record."},"priority":{"type":"integer","nullable":true,"description":"The priority of the MX record."}},"required":["type","name","value","status"]},"FeedbackEnabled":{"title":"FeedbackEnabled","type":"boolean","description":"Bounce and complaint notifications are sent to your inboxes."},"ClientId":{"title":"ClientId","type":"string","description":"Client ID of domain."},"Domain":{"title":"Domain","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"status":{"$ref":"#/components/schemas/VerificationStatus","description":"The verification status of the domain."},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"records":{"type":"array","items":{"$ref":"#/components/schemas/VerificationRecord"},"description":"A list of DNS records required to verify the domain."},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","status","feedback_enabled","records","updated_at","created_at"]},"DomainItem":{"title":"DomainItem","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","feedback_enabled","updated_at","created_at"]},"ListDomainsResponse":{"title":"ListDomainsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"domains":{"type":"array","items":{"$ref":"#/components/schemas/DomainItem"},"description":"Ordered by `created_at` descending."}},"required":["count","domains"]},"CreateDomainRequest":{"title":"CreateDomainRequest","type":"object","properties":{"domain":{"$ref":"#/components/schemas/DomainName"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"}},"required":["domain","feedback_enabled"]},"DraftId":{"title":"DraftId","type":"string","description":"ID of draft."},"DraftClientId":{"title":"DraftClientId","type":"string","description":"Client ID of draft."},"DraftLabels":{"title":"DraftLabels","type":"array","items":{"type":"string"},"description":"Labels of draft."},"DraftReplyTo":{"title":"DraftReplyTo","type":"array","items":{"type":"string"},"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"DraftTo":{"title":"DraftTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"DraftCc":{"title":"DraftCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"DraftBcc":{"title":"DraftBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"DraftSubject":{"title":"DraftSubject","type":"string","description":"Subject of draft."},"DraftPreview":{"title":"DraftPreview","type":"string","description":"Text preview of draft."},"DraftText":{"title":"DraftText","type":"string","description":"Plain text body of draft."},"DraftHtml":{"title":"DraftHtml","type":"string","description":"HTML body of draft."},"DraftAttachments":{"title":"DraftAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in draft."},"DraftInReplyTo":{"title":"DraftInReplyTo","type":"string","description":"ID of message being replied to."},"DraftSendStatus":{"title":"DraftSendStatus","type":"string","enum":["scheduled","sending","failed"],"description":"Schedule send status of draft."},"DraftSendAt":{"title":"DraftSendAt","type":"string","format":"date-time","description":"Time at which to schedule send draft."},"DraftUpdatedAt":{"title":"DraftUpdatedAt","type":"string","format":"date-time","description":"Time at which draft was last updated."},"DraftItem":{"title":"DraftItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"labels":{"$ref":"#/components/schemas/DraftLabels"},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"}},"required":["inbox_id","thread_id","draft_id","labels","updated_at"]},"Draft":{"title":"Draft","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true},"labels":{"$ref":"#/components/schemas/DraftLabels"},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"references":{"type":"array","items":{"type":"string"},"nullable":true,"description":"IDs of previous messages in thread."},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"},"created_at":{"type":"string","format":"date-time","description":"Time at which draft was created."}},"required":["inbox_id","thread_id","draft_id","labels","updated_at","created_at"]},"ListDraftsResponse":{"title":"ListDraftsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"drafts":{"type":"array","items":{"$ref":"#/components/schemas/DraftItem"},"description":"Ordered by `updated_at` descending."}},"required":["count","drafts"]},"CreateDraftRequest":{"title":"CreateDraftRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/DraftLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true}}},"UpdateDraftRequest":{"title":"UpdateDraftRequest","type":"object","properties":{"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true}}},"EventType":{"title":"EventType","type":"string","enum":["message.received","message.sent","message.delivered","message.bounced","message.complained","message.rejected","domain.verified"]},"EventTypes":{"title":"EventTypes","type":"array","items":{"$ref":"#/components/schemas/EventType"},"description":"Event types for which to send events."},"PodIds":{"title":"PodIds","type":"array","items":{"type":"string"},"description":"Pods for which to send events. Maximum 10 per webhook."},"InboxIds":{"title":"InboxIds","type":"array","items":{"type":"string"},"description":"Inboxes for which to send events. Maximum 10 per webhook."},"EventId":{"title":"EventId","type":"string","description":"ID of event."},"Timestamp":{"title":"Timestamp","type":"string","format":"date-time","description":"Timestamp of event."},"Recipient":{"title":"Recipient","type":"object","properties":{"address":{"type":"string","description":"Recipient address."},"status":{"type":"string","description":"Recipient status."}},"required":["address","status"]},"Send":{"title":"Send","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Sent recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"]},"Delivery":{"title":"Delivery","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Delivered recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"]},"Bounce":{"title":"Bounce","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Bounce type."},"sub_type":{"type":"string","description":"Bounce sub-type."},"recipients":{"type":"array","items":{"$ref":"#/components/schemas/Recipient"},"description":"Bounced recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Complaint":{"title":"Complaint","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Complaint type."},"sub_type":{"type":"string","description":"Complaint sub-type."},"recipients":{"type":"array","items":{"type":"string"},"description":"Complained recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Reject":{"title":"Reject","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"reason":{"type":"string","description":"Reject reason."}},"required":["inbox_id","thread_id","message_id","timestamp","reason"]},"MessageReceivedEvent":{"title":"MessageReceivedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.received"},"event_id":{"$ref":"#/components/schemas/EventId"},"message":{"$ref":"#/components/schemas/Message"},"thread":{"$ref":"#/components/schemas/ThreadItem"}},"required":["type","event_type","event_id","message","thread"]},"MessageSentEvent":{"title":"MessageSentEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.sent"},"event_id":{"$ref":"#/components/schemas/EventId"},"send":{"$ref":"#/components/schemas/Send"}},"required":["type","event_type","event_id","send"]},"MessageDeliveredEvent":{"title":"MessageDeliveredEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.delivered"},"event_id":{"$ref":"#/components/schemas/EventId"},"delivery":{"$ref":"#/components/schemas/Delivery"}},"required":["type","event_type","event_id","delivery"]},"MessageBouncedEvent":{"title":"MessageBouncedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.bounced"},"event_id":{"$ref":"#/components/schemas/EventId"},"bounce":{"$ref":"#/components/schemas/Bounce"}},"required":["type","event_type","event_id","bounce"]},"MessageComplainedEvent":{"title":"MessageComplainedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.complained"},"event_id":{"$ref":"#/components/schemas/EventId"},"complaint":{"$ref":"#/components/schemas/Complaint"}},"required":["type","event_type","event_id","complaint"]},"MessageRejectedEvent":{"title":"MessageRejectedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.rejected"},"event_id":{"$ref":"#/components/schemas/EventId"},"reject":{"$ref":"#/components/schemas/Reject"}},"required":["type","event_type","event_id","reject"]},"DomainVerifiedEvent":{"title":"DomainVerifiedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"domain.verified"},"event_id":{"$ref":"#/components/schemas/EventId"},"domain":{"$ref":"#/components/schemas/Domain"}},"required":["type","event_type","event_id","domain"]},"MessageId":{"title":"MessageId","type":"string","description":"ID of message."},"MessageLabels":{"title":"MessageLabels","type":"array","items":{"type":"string"},"description":"Labels of message."},"MessageTimestamp":{"title":"MessageTimestamp","type":"string","format":"date-time","description":"Time at which message was sent or drafted."},"MessageFrom":{"title":"MessageFrom","type":"string","description":"Address of sender. In format `username@domain.com` or `Display Name `."},"MessageReplyTo":{"title":"MessageReplyTo","type":"array","items":{"type":"string"},"description":"Addresses of reply-to recipients. In format `username@domain.com` or `Display Name `."},"MessageTo":{"title":"MessageTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"MessageCc":{"title":"MessageCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"MessageBcc":{"title":"MessageBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"MessageSubject":{"title":"MessageSubject","type":"string","description":"Subject of message."},"MessagePreview":{"title":"MessagePreview","type":"string","description":"Text preview of message."},"MessageText":{"title":"MessageText","type":"string","description":"Plain text body of message."},"MessageHtml":{"title":"MessageHtml","type":"string","description":"HTML body of message."},"MessageAttachments":{"title":"MessageAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in message."},"MessageInReplyTo":{"title":"MessageInReplyTo","type":"string","description":"ID of message being replied to."},"MessageReferences":{"title":"MessageReferences","type":"array","items":{"type":"string"},"description":"IDs of previous messages in thread."},"MessageHeaders":{"title":"MessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers in message."},"MessageSize":{"title":"MessageSize","type":"integer","description":"Size of message in bytes."},"MessageUpdatedAt":{"title":"MessageUpdatedAt","type":"string","format":"date-time","description":"Time at which message was last updated."},"MessageCreatedAt":{"title":"MessageCreatedAt","type":"string","format":"date-time","description":"Time at which message was created."},"MessageItem":{"title":"MessageItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"Message":{"title":"Message","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"reply_to":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"extracted_text":{"type":"string","nullable":true,"description":"Extracted new text content."},"extracted_html":{"type":"string","nullable":true,"description":"Extracted new HTML content."},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"ListMessagesResponse":{"title":"ListMessagesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MessageItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","messages"]},"Addresses":{"title":"Addresses","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"SendMessageReplyTo":{"title":"SendMessageReplyTo","$ref":"#/components/schemas/Addresses","description":"Reply-to address or addresses."},"SendMessageTo":{"title":"SendMessageTo","$ref":"#/components/schemas/Addresses","description":"Recipient address or addresses."},"SendMessageCc":{"title":"SendMessageCc","$ref":"#/components/schemas/Addresses","description":"CC recipient address or addresses."},"SendMessageBcc":{"title":"SendMessageBcc","$ref":"#/components/schemas/Addresses","description":"BCC recipient address or addresses."},"SendMessageAttachments":{"title":"SendMessageAttachments","type":"array","items":{"$ref":"#/components/schemas/SendAttachment"},"description":"Attachments to include in message."},"SendMessageHeaders":{"title":"SendMessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers to include in message."},"SendMessageRequest":{"title":"SendMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"SendMessageResponse":{"title":"SendMessageResponse","type":"object","properties":{"message_id":{"$ref":"#/components/schemas/MessageId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"}},"required":["message_id","thread_id"]},"RawMessageResponse":{"title":"RawMessageResponse","type":"object","description":"S3 presigned URL to download the raw .eml file.","properties":{"message_id":{"$ref":"#/components/schemas/MessageId","description":"ID of the message."},"size":{"$ref":"#/components/schemas/MessageSize","description":"Size of the raw message in bytes."},"download_url":{"type":"string","description":"S3 presigned URL to download the raw message. Expires at expires_at."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["message_id","size","download_url","expires_at"]},"ReplyAll":{"title":"ReplyAll","type":"boolean","description":"Reply to all recipients of the original message."},"ReplyToMessageRequest":{"title":"ReplyToMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"reply_all":{"$ref":"#/components/schemas/ReplyAll","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"ReplyAllMessageRequest":{"title":"ReplyAllMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"UpdateMessageRequest":{"title":"UpdateMessageRequest","type":"object","properties":{"add_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to add to message."},"remove_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to remove from message."}}},"MetricEventType":{"title":"MetricEventType","type":"string","enum":["message.sent","message.delivered","message.bounced","message.delayed","message.rejected","message.complained","message.received"],"description":"Type of metric event."},"MetricTimestamp":{"title":"MetricTimestamp","type":"string","format":"date-time","description":"Timestamp when the metric event occurred."},"MetricEventTypes":{"title":"MetricEventTypes","type":"array","items":{"$ref":"#/components/schemas/MetricEventType"},"description":"List of metric event types to filter by."},"MetricStartTimestamp":{"title":"MetricStartTimestamp","type":"string","format":"date-time","description":"Start timestamp for the metrics query range."},"MetricEndTimestamp":{"title":"MetricEndTimestamp","type":"string","format":"date-time","description":"End timestamp for the metrics query range."},"MessageMetrics":{"title":"MessageMetrics","type":"object","properties":{"sent":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were sent."},"delivered":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were delivered."},"bounced":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages bounced."},"delayed":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were delayed."},"rejected":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were rejected."},"complained":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages received complaints."},"received":{"type":"array","items":{"$ref":"#/components/schemas/MetricTimestamp"},"nullable":true,"description":"Timestamps when messages were received."}}},"ListMetricsResponse":{"title":"ListMetricsResponse","type":"object","properties":{"message":{"$ref":"#/components/schemas/MessageMetrics","nullable":true,"description":"Message metrics grouped by event type."}}},"Organization":{"title":"Organization","type":"object","description":"Organization details with usage limits and counts.","properties":{"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"inbox_count":{"type":"integer","description":"Current number of inboxes."},"domain_count":{"type":"integer","description":"Current number of domains."},"inbox_limit":{"type":"integer","nullable":true,"description":"Maximum number of inboxes allowed."},"domain_limit":{"type":"integer","nullable":true,"description":"Maximum number of domains allowed."},"updated_at":{"type":"string","format":"date-time","description":"Time at which organization was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which organization was created."}},"required":["organization_id","inbox_count","domain_count","updated_at","created_at"]},"ThreadId":{"title":"ThreadId","type":"string","description":"ID of thread."},"ThreadLabels":{"title":"ThreadLabels","type":"array","items":{"type":"string"},"description":"Labels of thread."},"ThreadTimestamp":{"title":"ThreadTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent or received message."},"ThreadReceivedTimestamp":{"title":"ThreadReceivedTimestamp","type":"string","format":"date-time","description":"Timestamp of last received message."},"ThreadSentTimestamp":{"title":"ThreadSentTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent message."},"ThreadSenders":{"title":"ThreadSenders","type":"array","items":{"type":"string"},"description":"Senders in thread. In format `username@domain.com` or `Display Name `."},"ThreadRecipients":{"title":"ThreadRecipients","type":"array","items":{"type":"string"},"description":"Recipients in thread. In format `username@domain.com` or `Display Name `."},"ThreadSubject":{"title":"ThreadSubject","type":"string","description":"Subject of thread."},"ThreadPreview":{"title":"ThreadPreview","type":"string","description":"Text preview of last message in thread."},"ThreadAttachments":{"title":"ThreadAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in thread."},"ThreadLastMessageId":{"title":"ThreadLastMessageId","type":"string","description":"ID of last message in thread."},"ThreadMessageCount":{"title":"ThreadMessageCount","type":"integer","description":"Number of messages in thread."},"ThreadSize":{"title":"ThreadSize","type":"integer","description":"Size of thread in bytes."},"ThreadUpdatedAt":{"title":"ThreadUpdatedAt","type":"string","format":"date-time","description":"Time at which thread was last updated."},"ThreadCreatedAt":{"title":"ThreadCreatedAt","type":"string","format":"date-time","description":"Time at which thread was created."},"ThreadItem":{"title":"ThreadItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at"]},"Thread":{"title":"Thread","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"},"description":"Messages in thread. Ordered by `timestamp` ascending."}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at","messages"]},"ListThreadsResponse":{"title":"ListThreadsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"threads":{"type":"array","items":{"$ref":"#/components/schemas/ThreadItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","threads"]},"webhooksSvixId":{"title":"webhooksSvixId","type":"string","description":"ID of webhook message."},"webhooksSvixTimestamp":{"title":"webhooksSvixTimestamp","type":"string","format":"date-time","description":"Timestamp of webhook message."},"webhooksSvixSignature":{"title":"webhooksSvixSignature","type":"string","description":"Signature of webhook message."},"Subscribe":{"title":"Subscribe","type":"object","properties":{"type":{"type":"string","const":"subscribe"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]},"Subscribed":{"title":"Subscribed","type":"object","properties":{"type":{"type":"string","const":"subscribed"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]}},"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer"}}},"servers":[{"url":"https://api.agentmail.to","description":"Prod (US)"},{"url":"https://api.agentmail.eu","description":"Prod (EU)"}]} \ No newline at end of file +{"openapi":"3.0.1","info":{"title":"api","version":""},"paths":{"/v0/inboxes":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes list\n```","operationId":"inboxes_list","tags":["Inboxes"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail inboxes create --display-name \"My Agent\" --username myagent --domain agentmail.to\n```","operationId":"inboxes_create","tags":["Inboxes"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest","nullable":true}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes get --inbox-id \n```","operationId":"inboxes_get","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail inboxes update --inbox-id --display-name \"Updated Name\"\n```","operationId":"inboxes_update","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesUpdateInboxRequest"}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes delete --inbox-id \n```","operationId":"inboxes_delete","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods":{"get":{"description":"**CLI:**\n```bash\nagentmail pods list\n```","operationId":"pods_list","tags":["Pods"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsListPodsResponse"}}}}},"summary":"List Pods","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods create --client-id my-pod\n```","operationId":"pods_create","tags":["Pods"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsCreatePodRequest"}}}},"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods get --pod-id \n```","operationId":"pods_get","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"get"},"delete":{"description":"**CLI:**\n```bash\nagentmail pods delete --pod-id \n```","operationId":"pods_delete","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"delete"}},"/v0/webhooks":{"get":{"description":"**CLI:**\n```bash\nagentmail webhooks list\n```","operationId":"webhooks_list","tags":["Webhooks"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksListWebhooksResponse"}}}}},"summary":"List Webhooks","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail webhooks create --url https://example.com/webhook --event-type message.received\n```","operationId":"webhooks_create","tags":["Webhooks"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksCreateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"create"}},"/v0/webhooks/{webhook_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail webhooks get --webhook-id \n```","operationId":"webhooks_get","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Update inbox or pod subscriptions, or replace the webhook's `event_types` in full when you pass a\nnon-empty `event_types` array (see request field docs). Inbox and pod changes use add/remove lists.\n\n**CLI:**\n```bash\nagentmail webhooks update --webhook-id --add-inbox-id \n```","operationId":"webhooks_update","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail webhooks delete --webhook-id \n```","operationId":"webhooks_delete","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"delete"}},"/v0/agent/sign-up":{"post":{"description":"Create a new agent organization with an inbox and API key. This endpoint is for signing up for the first time. If you've already signed up, you're all set — just use your existing API key.\n\nA 6-digit OTP is sent to the human's email for verification.\n\nThis endpoint is idempotent. Calling it again with the same `human_email` will rotate the API key and resend the OTP if expired.\n\nThe returned API key has limited permissions until the organization is verified via the verify endpoint.\n\n**CLI:**\n```bash\nagentmail agent sign-up --human-email user@example.com --username my-agent\n```","operationId":"agent_signUp","tags":["Agent"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentSignupResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Sign Up","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentSignupRequest"}}}}}},"/v0/agent/verify":{"post":{"description":"Verify an agent organization using the 6-digit OTP sent to the human's email during sign-up.\n\nOn success, the organization is upgraded from `agent_unverified` to `agent_verified`, the send allowlist is removed, and free plan entitlements are applied.\n\nThe OTP expires after 24 hours and allows a maximum of 10 attempts. If you run into any difficulties receiving the OTP code, you can also create an account on [console.agentmail.to](https://console.agentmail.to) using the human email address you provided to verify your account.\n\n**CLI:**\n```bash\nagentmail agent verify --otp-code 123456\n```","operationId":"agent_verify","tags":["Agent"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVerifyResponse"}}}}},"summary":"Verify","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVerifyRequest"}}}}}},"/v0/api-keys":{"get":{"description":"**CLI:**\n```bash\nagentmail api-keys list\n```","operationId":"apiKeys_list","tags":["ApiKeys"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail api-keys create --name \"My Key\"\n```","operationId":"apiKeys_create","tags":["ApiKeys"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"create"}},"/v0/api-keys/{api_key_id}":{"delete":{"description":"**CLI:**\n```bash\nagentmail api-keys delete --api-key-id \n```","operationId":"apiKeys_delete","tags":["ApiKeys"],"parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/domains":{"get":{"description":"**CLI:**\n```bash\nagentmail domains list\n```","operationId":"domains_list","tags":["Domains"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail domains create --domain example.com\n```","operationId":"domains_create","tags":["Domains"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"create"}},"/v0/domains/{domain_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail domains get --domain-id \n```","operationId":"domains_get","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail domains update --domain-id \n```","operationId":"domains_update","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDomainRequest"}}}}},"delete":{"description":"**CLI:**\n```bash\nagentmail domains delete --domain-id \n```","operationId":"domains_delete","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"delete"}},"/v0/domains/{domain_id}/zone-file":{"get":{"description":"**CLI:**\n```bash\nagentmail domains get-zone-file --domain-id \n```","operationId":"domains_getZoneFile","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Zone File","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"getZoneFile"}},"/v0/domains/{domain_id}/verify":{"post":{"description":"**CLI:**\n```bash\nagentmail domains verify --domain-id \n```","operationId":"domains_verify","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Verify Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"verify"}},"/v0/drafts":{"get":{"description":"**CLI:**\n```bash\nagentmail drafts list\n```","operationId":"drafts_list","tags":["Drafts"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"list"}},"/v0/drafts/{draft_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail drafts get --draft-id \n```","operationId":"drafts_get","tags":["Drafts"],"parameters":[{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"get"}},"/v0/drafts/{draft_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail drafts get-attachment --draft-id --attachment-id \n```","operationId":"drafts_getAttachment","tags":["Drafts"],"parameters":[{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/inboxes/{inbox_id}/api-keys":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:api-keys list --inbox-id \n```","operationId":"inboxes_apiKeys_list","tags":["InboxesApiKeys"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:api-keys create --inbox-id --name \"My Key\"\n```","operationId":"inboxes_apiKeys_create","tags":["InboxesApiKeys"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}}}},"/v0/inboxes/{inbox_id}/api-keys/{api_key_id}":{"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes:api-keys delete --inbox-id --api-key-id \n```","operationId":"inboxes_apiKeys_delete","tags":["InboxesApiKeys"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"api_key_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/inboxes/{inbox_id}/drafts":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:drafts list --inbox-id \n```","operationId":"inboxes_drafts_list","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:drafts create --inbox-id --to recipient@example.com --subject \"Draft subject\" --text \"Draft body\"\n```","operationId":"inboxes_drafts_create","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:drafts get --inbox-id --draft-id \n```","operationId":"inboxes_drafts_get","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail inboxes:drafts update --inbox-id --draft-id --subject \"Updated subject\"\n```","operationId":"inboxes_drafts_update","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes:drafts delete --inbox-id --draft-id \n```","operationId":"inboxes_drafts_delete","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:drafts get-attachment --inbox-id --draft-id --attachment-id \n```","operationId":"inboxes_drafts_getAttachment","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}/send":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:drafts send --inbox-id --draft-id \n```","operationId":"inboxes_drafts_send","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/events":{"get":{"description":"List label change events for an inbox. Returns events in reverse chronological order by default. Use for IMAP UID projection or audit logging.\n\n**CLI:**\n```bash\nagentmail inboxes:events list --inbox-id \n```","operationId":"inboxes_events_list","tags":["InboxesEvents"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListInboxEventsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Inbox Events","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/inboxes/{inbox_id}/lists/{direction}/{type}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:lists list --inbox-id --direction --type \n```","operationId":"inboxes_lists_list","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListListEntriesResponse"}}}}},"summary":"List Entries","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:lists create --inbox-id --direction --type --entry user@example.com\n```","operationId":"inboxes_lists_create","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateListEntryRequest"}}}}}},"/v0/inboxes/{inbox_id}/lists/{direction}/{type}/{entry}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:lists get --inbox-id --direction --type --entry \n```","operationId":"inboxes_lists_get","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes:lists delete --inbox-id --direction --type --entry \n```","operationId":"inboxes_lists_delete","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/inboxes/{inbox_id}/messages":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages list --inbox-id \n```","operationId":"inboxes_messages_list","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMessagesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/messages/{message_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages get --inbox-id --message-id \n```","operationId":"inboxes_messages_get","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages update --inbox-id --message-id --add-label read --remove-label unread\n```","operationId":"inboxes_messages_update","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"update"},"delete":{"description":"Permanently deletes a message.\n\n**CLI:**\n```bash\nagentmail inboxes:messages delete --inbox-id --message-id \n```","operationId":"inboxes_messages_delete","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/inboxes/{inbox_id}/messages/batch-get":{"post":{"description":"Fetch metadata for up to 500 messages in one request. Missing or\nrestricted IDs are silently omitted; compare `count` against `limit`\nto detect misses.\n\n**CLI:**\n```bash\nagentmail inboxes:messages batch-get --inbox-id --message-id --message-id \n```","operationId":"inboxes_messages_batchGet","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchGetMessagesResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Batch Get Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchGetMessagesRequest"}}}}}},"/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages get-attachment --inbox-id --message-id --attachment-id \n```","operationId":"inboxes_messages_getAttachment","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/raw":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages get-raw --inbox-id --message-id \n```","operationId":"inboxes_messages_getRaw","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RawMessageResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Raw Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"getRaw"}},"/v0/inboxes/{inbox_id}/messages/send":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages send --inbox-id --to recipient@example.com --subject \"Hello\" --text \"Body\"\n```","operationId":"inboxes_messages_send","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages reply --inbox-id --message-id --text \"Reply text\"\n```","operationId":"inboxes_messages_reply","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply To Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyToMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages reply-all --inbox-id --message-id --text \"Reply text\"\n```","operationId":"inboxes_messages_reply-all","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply All Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyAllMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply-all"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/forward":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes:messages forward --inbox-id --message-id --to recipient@example.com\n```","operationId":"inboxes_messages_forward","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Forward Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"forward"}},"/v0/inboxes/{inbox_id}/metrics":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:metrics query --inbox-id \n```","operationId":"inboxes_metrics_query","tags":["InboxesMetrics"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Metrics","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","metrics"],"x-fern-sdk-method-name":"get"}},"/v0/inboxes/{inbox_id}/threads":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:threads list --inbox-id \n```","operationId":"inboxes_threads_list","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:threads get --inbox-id --thread-id \n```","operationId":"inboxes_threads_get","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages.","operationId":"inboxes_threads_update","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadRequest"}}}}},"delete":{"description":"Moves the thread to trash by adding a trash label to all messages. If the thread is already in trash, it will be permanently deleted. Use `permanent=true` to force permanent deletion.\n\n**CLI:**\n```bash\nagentmail inboxes:threads delete --inbox-id --thread-id \n```","operationId":"inboxes_threads_delete","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"permanent","in":"query","description":"If true, permanently delete the thread instead of moving to trash.","required":false,"schema":{"type":"boolean","nullable":true}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes:threads get-attachment --inbox-id --thread-id --attachment-id \n```","operationId":"inboxes_threads_getAttachment","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/lists/{direction}/{type}":{"get":{"description":"**CLI:**\n```bash\nagentmail lists list --direction --type \n```","operationId":"lists_list","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListListEntriesResponse"}}}}},"summary":"List Entries","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"post":{"description":"**CLI:**\n```bash\nagentmail lists create --direction --type --entry user@example.com\n```","operationId":"lists_create","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListEntry"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateListEntryRequest"}}}}}},"/v0/lists/{direction}/{type}/{entry}":{"get":{"description":"**CLI:**\n```bash\nagentmail lists get --direction --type --entry \n```","operationId":"lists_get","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListEntry"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"delete":{"description":"**CLI:**\n```bash\nagentmail lists delete --direction --type --entry \n```","operationId":"lists_delete","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/metrics":{"get":{"description":"**CLI:**\n```bash\nagentmail metrics list\n```","operationId":"metrics_query","tags":["Metrics"],"parameters":[{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Metrics","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["metrics"],"x-fern-sdk-method-name":"list"}},"/v0/organizations":{"get":{"description":"Returns the organization for the authenticated API key (usage limits, counts, and billing metadata).\n\n**CLI:**\n```bash\nagentmail organizations get\n```","operationId":"organizations_get","tags":["Organizations"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}}},"summary":"Get Organization","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["organizations"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/api-keys":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:api-keys list --pod-id \n```","operationId":"pods_apiKeys_list","tags":["PodsApiKeys"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"post":{"description":"**CLI:**\n```bash\nagentmail pods:api-keys create --pod-id --name \"My Key\"\n```","operationId":"pods_apiKeys_create","tags":["PodsApiKeys"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}}}},"/v0/pods/{pod_id}/api-keys/{api_key_id}":{"delete":{"description":"**CLI:**\n```bash\nagentmail pods:api-keys delete --pod-id --api-key-id \n```","operationId":"pods_apiKeys_delete","tags":["PodsApiKeys"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"api_key_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/pods/{pod_id}/domains":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:domains list --pod-id \n```","operationId":"pods_domains_list","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods:domains create --pod-id --domain example.com\n```","operationId":"pods_domains_create","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/domains/{domain_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:domains get --pod-id --domain-id \n```","operationId":"pods_domains_get","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"patch":{"description":"**CLI:**\n```bash\nagentmail pods:domains update --pod-id --domain-id \n```","operationId":"pods_domains_update","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDomainRequest"}}}}},"delete":{"description":"**CLI:**\n```bash\nagentmail pods:domains delete --pod-id --domain-id \n```","operationId":"pods_domains_delete","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/domains/{domain_id}/zone-file":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:domains get-zone-file --pod-id --domain-id \n```","operationId":"pods_domains_getZoneFile","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Zone File","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/pods/{pod_id}/domains/{domain_id}/verify":{"post":{"description":"**CLI:**\n```bash\nagentmail pods:domains verify --pod-id --domain-id \n```","operationId":"pods_domains_verify","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Verify Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/pods/{pod_id}/drafts":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:drafts list --pod-id \n```","operationId":"pods_drafts_list","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/drafts/{draft_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:drafts get --pod-id --draft-id \n```","operationId":"pods_drafts_get","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/drafts/{draft_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:drafts get-attachment --pod-id --draft-id --attachment-id \n```","operationId":"pods_drafts_getAttachment","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/pods/{pod_id}/inboxes":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:inboxes list --pod-id \n```","operationId":"pods_inboxes_list","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods:inboxes create --pod-id --username myagent --domain example.com\n```","operationId":"pods_inboxes_create","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest"}}}},"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/inboxes/{inbox_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:inboxes get --pod-id --inbox-id \n```","operationId":"pods_inboxes_get","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail pods:inboxes update --pod-id --inbox-id \n```","operationId":"pods_inboxes_update","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesUpdateInboxRequest"}}}}},"delete":{"description":"**CLI:**\n```bash\nagentmail pods:inboxes delete --pod-id --inbox-id \n```","operationId":"pods_inboxes_delete","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/lists/{direction}/{type}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:lists list --pod-id --direction --type \n```","operationId":"pods_lists_list","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListListEntriesResponse"}}}}},"summary":"List Entries","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"post":{"description":"**CLI:**\n```bash\nagentmail pods:lists create --pod-id --direction --type --entry user@example.com\n```","operationId":"pods_lists_create","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateListEntryRequest"}}}}}},"/v0/pods/{pod_id}/lists/{direction}/{type}/{entry}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:lists get --pod-id --direction --type --entry \n```","operationId":"pods_lists_get","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]},"delete":{"description":"**CLI:**\n```bash\nagentmail pods:lists delete --pod-id --direction --type --entry \n```","operationId":"pods_lists_delete","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/pods/{pod_id}/metrics":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:metrics query --pod-id \n```","operationId":"pods_metrics_query","tags":["PodsMetrics"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Metrics","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/pods/{pod_id}/threads":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:threads list --pod-id \n```","operationId":"pods_threads_list","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/threads/{thread_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:threads get --pod-id --thread-id \n```","operationId":"pods_threads_get","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages.","operationId":"pods_threads_update","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadRequest"}}}}},"delete":{"description":"Moves the thread to trash by adding a trash label to all messages. If the thread is already in trash, it will be permanently deleted. Use `permanent=true` to force permanent deletion.\n\n**CLI:**\n```bash\nagentmail pods:threads delete --pod-id --thread-id \n```","operationId":"pods_threads_delete","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"permanent","in":"query","description":"If true, permanently delete the thread instead of moving to trash.","required":false,"schema":{"type":"boolean","nullable":true}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods:threads get-attachment --pod-id --thread-id --attachment-id \n```","operationId":"pods_threads_getAttachment","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"getAttachment"}},"/v0/threads":{"get":{"description":"**CLI:**\n```bash\nagentmail threads list\n```","operationId":"threads_list","tags":["Threads"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"list"}},"/v0/threads/{thread_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail threads get --thread-id \n```","operationId":"threads_get","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages.","operationId":"threads_update","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadRequest"}}}}},"delete":{"description":"Moves the thread to trash by adding a trash label to all messages. If the thread is already in trash, it will be permanently deleted. Use `permanent=true` to force permanent deletion.\n\n**CLI:**\n```bash\nagentmail threads delete --thread-id \n```","operationId":"threads_delete","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"permanent","in":"query","description":"If true, permanently delete the thread instead of moving to trash.","required":false,"schema":{"type":"boolean","nullable":true}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}]}},"/v0/threads/{thread_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail threads get-attachment --thread-id --attachment-id \n```","operationId":"threads_getAttachment","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"getAttachment"}}},"components":{"schemas":{"Limit":{"title":"Limit","type":"integer","description":"Limit of number of items returned."},"Count":{"title":"Count","type":"integer","description":"Number of items returned."},"PageToken":{"title":"PageToken","type":"string","description":"Page token for pagination."},"Labels":{"title":"Labels","type":"array","items":{"type":"string"},"description":"Labels to filter by."},"Before":{"title":"Before","type":"string","format":"date-time","description":"Timestamp before which to filter by."},"After":{"title":"After","type":"string","format":"date-time","description":"Timestamp after which to filter by."},"Ascending":{"title":"Ascending","type":"boolean","description":"Sort in ascending temporal order."},"IncludeSpam":{"title":"IncludeSpam","type":"boolean","description":"Include spam in results."},"IncludeBlocked":{"title":"IncludeBlocked","type":"boolean","description":"Include blocked in results."},"IncludeUnauthenticated":{"title":"IncludeUnauthenticated","type":"boolean","description":"Include unauthenticated in results."},"IncludeTrash":{"title":"IncludeTrash","type":"boolean","description":"Include trash in results."},"OrganizationId":{"title":"OrganizationId","type":"string","description":"ID of organization."},"ErrorName":{"title":"ErrorName","type":"string","description":"Name of error."},"ErrorMessage":{"title":"ErrorMessage","type":"string","description":"Error message."},"ErrorResponse":{"title":"ErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"message":{"$ref":"#/components/schemas/ErrorMessage"}},"required":["name","message"]},"ValidationErrorResponse":{"title":"ValidationErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"errors":{"description":"Validation errors."}},"required":["name","errors"]},"inboxesInboxId":{"title":"inboxesInboxId","type":"string","description":"The ID of the inbox."},"inboxesEmail":{"title":"inboxesEmail","type":"string","description":"Email address of the inbox."},"inboxesDisplayName":{"title":"inboxesDisplayName","type":"string","description":"Display name: `Display Name `."},"inboxesClientId":{"title":"inboxesClientId","type":"string","description":"Client ID of inbox."},"inboxesInbox":{"title":"inboxesInbox","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"email":{"$ref":"#/components/schemas/inboxesEmail"},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which inbox was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which inbox was created."}},"required":["pod_id","inbox_id","email","updated_at","created_at"]},"inboxesListInboxesResponse":{"title":"inboxesListInboxesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"inboxes":{"type":"array","items":{"$ref":"#/components/schemas/inboxesInbox"},"description":"Ordered by `created_at` descending."}},"required":["count","inboxes"]},"inboxesCreateInboxRequest":{"title":"inboxesCreateInboxRequest","type":"object","properties":{"username":{"type":"string","nullable":true,"description":"Username of address. Randomly generated if not specified."},"domain":{"type":"string","nullable":true,"description":"Domain of address. Must be verified domain. Defaults to `agentmail.to`."},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true}}},"inboxesUpdateInboxRequest":{"title":"inboxesUpdateInboxRequest","type":"object","properties":{"display_name":{"$ref":"#/components/schemas/inboxesDisplayName"}},"required":["display_name"]},"podsPodId":{"title":"podsPodId","type":"string","description":"ID of pod."},"podsName":{"title":"podsName","type":"string","description":"Name of pod."},"podsClientId":{"title":"podsClientId","type":"string","description":"Client ID of pod."},"podsPod":{"title":"podsPod","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"name":{"$ref":"#/components/schemas/podsName"},"updated_at":{"type":"string","format":"date-time","description":"Time at which pod was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which pod was created."},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}},"required":["pod_id","name","updated_at","created_at"]},"podsListPodsResponse":{"title":"podsListPodsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"pods":{"type":"array","items":{"$ref":"#/components/schemas/podsPod"},"description":"Ordered by `created_at` descending."}},"required":["count","pods"]},"podsCreatePodRequest":{"title":"podsCreatePodRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/podsName","nullable":true},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}}},"webhooksWebhookId":{"title":"webhooksWebhookId","type":"string","description":"ID of webhook."},"webhooksClientId":{"title":"webhooksClientId","type":"string","description":"Client ID of webhook."},"webhooksUrl":{"title":"webhooksUrl","type":"string","description":"URL of webhook endpoint."},"webhooksWebhook":{"title":"webhooksWebhook","type":"object","properties":{"webhook_id":{"$ref":"#/components/schemas/webhooksWebhookId"},"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"secret":{"type":"string","description":"Secret for webhook signature verification."},"enabled":{"type":"boolean","description":"Webhook is enabled."},"updated_at":{"type":"string","format":"date-time","description":"Time at which webhook was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which webhook was created."},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true}},"required":["webhook_id","url","secret","enabled","updated_at","created_at"]},"webhooksListWebhooksResponse":{"title":"webhooksListWebhooksResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/webhooksWebhook"},"description":"Ordered by `created_at` descending."}},"required":["count","webhooks"]},"webhooksCreateWebhookRequest":{"title":"webhooksCreateWebhookRequest","type":"object","properties":{"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/EventTypes","description":"Full list of event types this webhook should receive. At least one type is required. Send every type you\nwant in this array (not incremental). See [Webhooks overview](https://docs.agentmail.to/webhooks-overview)\nfor spam, blocked, and unauthenticated events and required permissions."},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true}},"required":["url","event_types"]},"webhooksUpdateWebhookRequest":{"title":"webhooksUpdateWebhookRequest","type":"object","properties":{"add_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to subscribe to the webhook."},"remove_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to unsubscribe from the webhook."},"add_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to subscribe to the webhook."},"remove_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to unsubscribe from the webhook."},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true,"description":"When you send a non-empty list, it replaces the webhook's subscribed event types in full (the same\n\"set the list\" behavior as create). It is not a merge or diff: include every event type you want after\nthe update. Sending a one-element array means the webhook will only receive that one type afterward.\nOmit this field or send an empty array to leave event types unchanged. Clearing all types with an empty\nlist is not supported. Subscribing to `message.received.spam`, `message.received.blocked`, or\n`message.received.unauthenticated` requires the matching label permission on the API key."}}},"AgentSignupRequest":{"title":"AgentSignupRequest","type":"object","description":"Request body to sign up an agent.","properties":{"human_email":{"type":"string","description":"Email address of the human who owns the agent. A 6-digit OTP will be sent to this address."},"username":{"type":"string","description":"Username for the auto-created inbox (e.g. \"my-agent\" creates my-agent@agentmail.to)."},"source":{"type":"string","nullable":true,"description":"The SDK, framework, or platform issuing this sign-up (e.g. `agentmail-python`, `agentmail-cli`, `agentmail-mcp`).\nIdentifies the caller — answers \"who is signing up\".\nMax 2048 characters."},"referrer":{"type":"string","nullable":true,"description":"The channel that drove this sign-up — where the agent or its developer discovered AgentMail\n(e.g. `agent.email`, a partner URL, a campaign tag). Answers \"where did this sign-up come from\".\nMax 2048 characters."}},"required":["human_email","username"]},"AgentSignupResponse":{"title":"AgentSignupResponse","type":"object","description":"Response after successful agent sign-up.","properties":{"organization_id":{"type":"string","description":"ID of the created organization."},"inbox_id":{"type":"string","description":"ID of the auto-created inbox."},"api_key":{"type":"string","description":"API key for authenticating subsequent requests. Store this securely, it cannot be retrieved again."}},"required":["organization_id","inbox_id","api_key"]},"AgentVerifyRequest":{"title":"AgentVerifyRequest","type":"object","description":"Request body to verify an agent with an OTP code.","properties":{"otp_code":{"type":"string","description":"6-digit verification code sent to the human's email address."}},"required":["otp_code"]},"AgentVerifyResponse":{"title":"AgentVerifyResponse","type":"object","description":"Response after successful agent verification.","properties":{"verified":{"type":"boolean","description":"Whether the organization was verified."}},"required":["verified"]},"ApiKeyId":{"title":"ApiKeyId","type":"string","description":"ID of api key."},"Prefix":{"title":"Prefix","type":"string","description":"Prefix of api key."},"Name":{"title":"Name","type":"string","description":"Name of api key."},"CreatedAt":{"title":"CreatedAt","type":"string","format":"date-time","description":"Time at which api key was created."},"ApiKeyPermissions":{"title":"ApiKeyPermissions","type":"object","description":"Granular permissions for the API key. When ommitted all permissions are granted. Otherwise, only permissions set to true are granted.","properties":{"inbox_read":{"type":"boolean","nullable":true,"description":"Read inbox details."},"inbox_create":{"type":"boolean","nullable":true,"description":"Create new inboxes."},"inbox_update":{"type":"boolean","nullable":true,"description":"Update inbox settings."},"inbox_delete":{"type":"boolean","nullable":true,"description":"Delete inboxes."},"thread_read":{"type":"boolean","nullable":true,"description":"Read threads."},"thread_delete":{"type":"boolean","nullable":true,"description":"Delete threads."},"message_read":{"type":"boolean","nullable":true,"description":"Read messages."},"message_send":{"type":"boolean","nullable":true,"description":"Send messages."},"message_update":{"type":"boolean","nullable":true,"description":"Update message labels."},"label_spam_read":{"type":"boolean","nullable":true,"description":"Access messages labeled spam."},"label_blocked_read":{"type":"boolean","nullable":true,"description":"Access messages labeled blocked."},"label_trash_read":{"type":"boolean","nullable":true,"description":"Access messages labeled trash."},"draft_read":{"type":"boolean","nullable":true,"description":"Read drafts."},"draft_create":{"type":"boolean","nullable":true,"description":"Create drafts."},"draft_update":{"type":"boolean","nullable":true,"description":"Update drafts."},"draft_delete":{"type":"boolean","nullable":true,"description":"Delete drafts."},"draft_send":{"type":"boolean","nullable":true,"description":"Send drafts."},"webhook_read":{"type":"boolean","nullable":true,"description":"Read webhook configurations."},"webhook_create":{"type":"boolean","nullable":true,"description":"Create webhooks."},"webhook_update":{"type":"boolean","nullable":true,"description":"Update webhooks."},"webhook_delete":{"type":"boolean","nullable":true,"description":"Delete webhooks."},"domain_read":{"type":"boolean","nullable":true,"description":"Read domain details."},"domain_create":{"type":"boolean","nullable":true,"description":"Create domains."},"domain_update":{"type":"boolean","nullable":true,"description":"Update domains."},"domain_delete":{"type":"boolean","nullable":true,"description":"Delete domains."},"list_entry_read":{"type":"boolean","nullable":true,"description":"Read list entries."},"list_entry_create":{"type":"boolean","nullable":true,"description":"Create list entries."},"list_entry_delete":{"type":"boolean","nullable":true,"description":"Delete list entries."},"metrics_read":{"type":"boolean","nullable":true,"description":"Read metrics."},"api_key_read":{"type":"boolean","nullable":true,"description":"Read API keys."},"api_key_create":{"type":"boolean","nullable":true,"description":"Create API keys."},"api_key_delete":{"type":"boolean","nullable":true,"description":"Delete API keys."},"pod_read":{"type":"boolean","nullable":true,"description":"Read pods."},"pod_create":{"type":"boolean","nullable":true,"description":"Create pods."},"pod_delete":{"type":"boolean","nullable":true,"description":"Delete pods."}}},"ApiKey":{"title":"ApiKey","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"pod_id":{"type":"string","nullable":true,"description":"Pod ID the api key is scoped to. If set, the key can only access resources within this pod."},"inbox_id":{"type":"string","nullable":true,"description":"Inbox ID the api key is scoped to. If set, the key can only access resources within this inbox."},"used_at":{"type":"string","format":"date-time","nullable":true,"description":"Time at which api key was last used."},"permissions":{"$ref":"#/components/schemas/ApiKeyPermissions","nullable":true},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","prefix","name","created_at"]},"CreateApiKeyResponse":{"title":"CreateApiKeyResponse","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"api_key":{"type":"string","description":"API key."},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"pod_id":{"type":"string","nullable":true,"description":"Pod ID the api key is scoped to."},"inbox_id":{"type":"string","nullable":true,"description":"Inbox ID the api key is scoped to."},"permissions":{"$ref":"#/components/schemas/ApiKeyPermissions","nullable":true},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","api_key","prefix","name","created_at"]},"ListApiKeysResponse":{"title":"ListApiKeysResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"api_keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"},"description":"Ordered by `created_at` descending."}},"required":["count","api_keys"]},"CreateApiKeyRequest":{"title":"CreateApiKeyRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/Name","nullable":true},"permissions":{"$ref":"#/components/schemas/ApiKeyPermissions","nullable":true}}},"AttachmentId":{"title":"AttachmentId","type":"string","description":"ID of attachment."},"AttachmentFilename":{"title":"AttachmentFilename","type":"string","description":"Filename of attachment."},"AttachmentSize":{"title":"AttachmentSize","type":"integer","description":"Size of attachment in bytes."},"AttachmentContentType":{"title":"AttachmentContentType","type":"string","description":"Content type of attachment."},"AttachmentContentDisposition":{"title":"AttachmentContentDisposition","type":"string","enum":["inline","attachment"],"description":"Content disposition of attachment."},"AttachmentContentId":{"title":"AttachmentContentId","type":"string","description":"Content ID of attachment."},"Attachment":{"title":"Attachment","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true}},"required":["attachment_id","size"]},"AttachmentResponse":{"title":"AttachmentResponse","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"download_url":{"type":"string","description":"URL to download the attachment."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["attachment_id","size","download_url","expires_at"]},"SendAttachment":{"title":"SendAttachment","type":"object","properties":{"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"content":{"type":"string","nullable":true,"description":"Base64 encoded content of attachment."},"url":{"type":"string","nullable":true,"description":"URL to the attachment."}}},"DomainId":{"title":"DomainId","type":"string","description":"The ID of the domain."},"DomainName":{"title":"DomainName","type":"string","description":"The name of the domain (e.g., `example.com`)."},"RecordType":{"title":"RecordType","type":"string","enum":["TXT","CNAME","MX"]},"VerificationStatus":{"title":"VerificationStatus","type":"string","enum":["NOT_STARTED","PENDING","INVALID","FAILED","VERIFYING","VERIFIED"]},"RecordStatus":{"title":"RecordStatus","type":"string","enum":["MISSING","INVALID","VALID"]},"VerificationRecord":{"title":"VerificationRecord","type":"object","properties":{"type":{"$ref":"#/components/schemas/RecordType","description":"The type of the DNS record."},"name":{"type":"string","description":"The name or host of the record."},"value":{"type":"string","description":"The value of the record."},"status":{"$ref":"#/components/schemas/RecordStatus","description":"The verification status of this specific record."},"priority":{"type":"integer","nullable":true,"description":"The priority of the MX record."}},"required":["type","name","value","status"]},"Status":{"title":"Status","$ref":"#/components/schemas/VerificationStatus","description":"The verification status of the domain."},"FeedbackEnabled":{"title":"FeedbackEnabled","type":"boolean","description":"Bounce and complaint notifications are sent to your inboxes."},"ClientId":{"title":"ClientId","type":"string","description":"Client ID of domain."},"Domain":{"title":"Domain","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"domain":{"$ref":"#/components/schemas/DomainName"},"status":{"$ref":"#/components/schemas/Status"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"records":{"type":"array","items":{"$ref":"#/components/schemas/VerificationRecord"},"description":"A list of DNS records required to verify the domain."},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","domain","status","feedback_enabled","records","updated_at","created_at"]},"DomainItem":{"title":"DomainItem","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"domain":{"$ref":"#/components/schemas/DomainName"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","domain","feedback_enabled","updated_at","created_at"]},"ListDomainsResponse":{"title":"ListDomainsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"domains":{"type":"array","items":{"$ref":"#/components/schemas/DomainItem"},"description":"Ordered by `created_at` descending."}},"required":["count","domains"]},"CreateDomainRequest":{"title":"CreateDomainRequest","type":"object","properties":{"domain":{"$ref":"#/components/schemas/DomainName"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"}},"required":["domain","feedback_enabled"]},"UpdateDomainRequest":{"title":"UpdateDomainRequest","type":"object","properties":{"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled","nullable":true}}},"DraftId":{"title":"DraftId","type":"string","description":"ID of draft."},"DraftClientId":{"title":"DraftClientId","type":"string","description":"Client ID of draft."},"DraftLabels":{"title":"DraftLabels","type":"array","items":{"type":"string"},"description":"Labels of draft."},"DraftReplyTo":{"title":"DraftReplyTo","type":"array","items":{"type":"string"},"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"DraftTo":{"title":"DraftTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"DraftCc":{"title":"DraftCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"DraftBcc":{"title":"DraftBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"DraftSubject":{"title":"DraftSubject","type":"string","description":"Subject of draft."},"DraftPreview":{"title":"DraftPreview","type":"string","description":"Text preview of draft."},"DraftText":{"title":"DraftText","type":"string","description":"Plain text body of draft."},"DraftHtml":{"title":"DraftHtml","type":"string","description":"HTML body of draft."},"DraftAttachments":{"title":"DraftAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in draft."},"DraftInReplyTo":{"title":"DraftInReplyTo","type":"string","description":"ID of message being replied to."},"DraftSendStatus":{"title":"DraftSendStatus","type":"string","enum":["scheduled","sending","failed"],"description":"Schedule send status of draft."},"DraftSendAt":{"title":"DraftSendAt","type":"string","format":"date-time","description":"Time at which to schedule send draft."},"DraftUpdatedAt":{"title":"DraftUpdatedAt","type":"string","format":"date-time","description":"Time at which draft was last updated."},"DraftItem":{"title":"DraftItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"labels":{"$ref":"#/components/schemas/DraftLabels"},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"}},"required":["inbox_id","draft_id","labels","updated_at"]},"Draft":{"title":"Draft","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true},"labels":{"$ref":"#/components/schemas/DraftLabels"},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"references":{"type":"array","items":{"type":"string"},"nullable":true,"description":"IDs of previous messages in thread."},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"},"created_at":{"type":"string","format":"date-time","description":"Time at which draft was created."}},"required":["inbox_id","draft_id","labels","updated_at","created_at"]},"ListDraftsResponse":{"title":"ListDraftsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"drafts":{"type":"array","items":{"$ref":"#/components/schemas/DraftItem"},"description":"Ordered by `updated_at` descending."}},"required":["count","drafts"]},"CreateDraftRequest":{"title":"CreateDraftRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/DraftLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/SendAttachment"},"nullable":true,"description":"Attachments to include in draft."},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true}}},"UpdateDraftRequest":{"title":"UpdateDraftRequest","type":"object","properties":{"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true}}},"EventType":{"title":"EventType","type":"string","enum":["message.received","message.received.spam","message.received.blocked","message.received.unauthenticated","message.sent","message.delivered","message.bounced","message.complained","message.rejected","domain.verified"]},"EventTypes":{"title":"EventTypes","type":"array","items":{"$ref":"#/components/schemas/EventType"},"description":"Event types for which to send events."},"MessageReceivedEventType":{"title":"MessageReceivedEventType","type":"string","enum":["message.received","message.received.spam","message.received.blocked","message.received.unauthenticated"]},"PodIds":{"title":"PodIds","type":"array","items":{"type":"string"},"description":"Pods for which to send events. Maximum 10 per webhook."},"InboxIds":{"title":"InboxIds","type":"array","items":{"type":"string"},"description":"Inboxes for which to send events. Maximum 10 per webhook."},"EventId":{"title":"EventId","type":"string","description":"ID of event."},"Timestamp":{"title":"Timestamp","type":"string","format":"date-time","description":"Timestamp of event."},"Recipient":{"title":"Recipient","type":"object","properties":{"address":{"type":"string","description":"Recipient address."},"status":{"type":"string","description":"Recipient status."}},"required":["address","status"]},"Send":{"title":"Send","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Sent recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"]},"Delivery":{"title":"Delivery","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Delivered recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"]},"Bounce":{"title":"Bounce","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Bounce type."},"sub_type":{"type":"string","description":"Bounce sub-type."},"recipients":{"type":"array","items":{"$ref":"#/components/schemas/Recipient"},"description":"Bounced recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Complaint":{"title":"Complaint","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Complaint type."},"sub_type":{"type":"string","description":"Complaint sub-type."},"recipients":{"type":"array","items":{"type":"string"},"description":"Complained recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Reject":{"title":"Reject","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"reason":{"type":"string","description":"Reject reason."}},"required":["inbox_id","thread_id","message_id","timestamp","reason"]},"MessageReceivedEvent":{"title":"MessageReceivedEvent","type":"object","description":"A message was received. Spam, blocked, and unauthenticated received-message events use the same payload shape with different `event_type` values.","properties":{"type":{"type":"string","const":"event"},"event_type":{"$ref":"#/components/schemas/MessageReceivedEventType"},"event_id":{"$ref":"#/components/schemas/EventId"},"message":{"$ref":"#/components/schemas/Message"},"thread":{"$ref":"#/components/schemas/ThreadItem"}},"required":["type","event_type","event_id","message","thread"]},"MessageSentEvent":{"title":"MessageSentEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.sent"},"event_id":{"$ref":"#/components/schemas/EventId"},"send":{"$ref":"#/components/schemas/Send"}},"required":["type","event_type","event_id","send"]},"MessageDeliveredEvent":{"title":"MessageDeliveredEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.delivered"},"event_id":{"$ref":"#/components/schemas/EventId"},"delivery":{"$ref":"#/components/schemas/Delivery"}},"required":["type","event_type","event_id","delivery"]},"MessageBouncedEvent":{"title":"MessageBouncedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.bounced"},"event_id":{"$ref":"#/components/schemas/EventId"},"bounce":{"$ref":"#/components/schemas/Bounce"}},"required":["type","event_type","event_id","bounce"]},"MessageComplainedEvent":{"title":"MessageComplainedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.complained"},"event_id":{"$ref":"#/components/schemas/EventId"},"complaint":{"$ref":"#/components/schemas/Complaint"}},"required":["type","event_type","event_id","complaint"]},"MessageRejectedEvent":{"title":"MessageRejectedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.rejected"},"event_id":{"$ref":"#/components/schemas/EventId"},"reject":{"$ref":"#/components/schemas/Reject"}},"required":["type","event_type","event_id","reject"]},"DomainVerifiedEvent":{"title":"DomainVerifiedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"domain.verified"},"event_id":{"$ref":"#/components/schemas/EventId"},"domain":{"$ref":"#/components/schemas/Domain"}},"required":["type","event_type","event_id","domain"]},"InboxEventId":{"title":"InboxEventId","type":"string","description":"ID of event."},"InboxEventType":{"title":"InboxEventType","type":"string","enum":["label.added","label.removed"],"description":"Type of inbox event. Wire format is dot.case to match the\nconvention used by webhook events (`message.received`,\n`domain.verified`, etc. in events.yml). Pre-2026-04 these were\n`label_added`/`label_removed` (snake_case). The Fern enum's `name`\nfield stays uppercase-snake (Fern convention); only the wire\n`value` changed."},"InboxEvent":{"title":"InboxEvent","type":"object","properties":{"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"pod_id":{"type":"string","description":"ID of pod."},"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"event_id":{"$ref":"#/components/schemas/InboxEventId"},"event_type":{"$ref":"#/components/schemas/InboxEventType"},"message_id":{"type":"string","description":"ID of message."},"label":{"type":"string","description":"Label added or removed."},"event_at":{"type":"string","format":"date-time","description":"Time at which the event occurred."},"created_at":{"type":"string","format":"date-time","description":"Time at which the event was recorded."}},"required":["organization_id","pod_id","inbox_id","event_id","event_type","message_id","label","event_at","created_at"]},"ListInboxEventsResponse":{"title":"ListInboxEventsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"events":{"type":"array","items":{"$ref":"#/components/schemas/InboxEvent"},"description":"Ordered by `event_id` descending."}},"required":["count","events"]},"Direction":{"title":"Direction","type":"string","enum":["send","receive","reply"],"description":"Direction of list entry."},"ListType":{"title":"ListType","type":"string","enum":["allow","block"],"description":"Type of list entry."},"EntryType":{"title":"EntryType","type":"string","enum":["email","domain"],"description":"Whether the entry is an email address or domain."},"ListEntryBase":{"title":"ListEntryBase","type":"object","properties":{"entry":{"type":"string","description":"Email address or domain of list entry."},"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"reason":{"type":"string","nullable":true,"description":"Reason for adding the entry."},"direction":{"$ref":"#/components/schemas/Direction"},"list_type":{"$ref":"#/components/schemas/ListType"},"entry_type":{"$ref":"#/components/schemas/EntryType"},"created_at":{"type":"string","format":"date-time","description":"Time at which entry was created."},"read_only":{"type":"boolean","nullable":true,"description":"Whether the entry is read-only and cannot be deleted via the API."}},"required":["entry","organization_id","direction","list_type","entry_type","created_at"]},"ListEntry":{"title":"ListEntry","type":"object","properties":{},"allOf":[{"$ref":"#/components/schemas/ListEntryBase"}]},"PodListEntry":{"title":"PodListEntry","type":"object","properties":{"pod_id":{"type":"string","description":"ID of pod."},"inbox_id":{"type":"string","nullable":true,"description":"ID of inbox, if entry is inbox-scoped."}},"required":["pod_id"],"allOf":[{"$ref":"#/components/schemas/ListEntryBase"}]},"PodListListEntriesResponse":{"title":"PodListListEntriesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"entries":{"type":"array","items":{"$ref":"#/components/schemas/PodListEntry"},"description":"Ordered by entry ascending."}},"required":["count","entries"]},"ListListEntriesResponse":{"title":"ListListEntriesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"entries":{"type":"array","items":{"$ref":"#/components/schemas/ListEntry"},"description":"Ordered by entry ascending."}},"required":["count","entries"]},"CreateListEntryRequest":{"title":"CreateListEntryRequest","type":"object","properties":{"entry":{"type":"string","description":"Email address or domain to add."},"reason":{"type":"string","nullable":true,"description":"Reason for adding the entry."}},"required":["entry"]},"MessageId":{"title":"MessageId","type":"string","description":"ID of message."},"MessageLabels":{"title":"MessageLabels","type":"array","items":{"type":"string"},"description":"Labels of message."},"MessageTimestamp":{"title":"MessageTimestamp","type":"string","format":"date-time","description":"Time at which message was sent or drafted."},"MessageFrom":{"title":"MessageFrom","type":"string","description":"Address of sender. In format `username@domain.com` or `Display Name `."},"MessageReplyTo":{"title":"MessageReplyTo","type":"array","items":{"type":"string"},"description":"Addresses of reply-to recipients. In format `username@domain.com` or `Display Name `."},"MessageTo":{"title":"MessageTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"MessageCc":{"title":"MessageCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"MessageBcc":{"title":"MessageBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"MessageSubject":{"title":"MessageSubject","type":"string","description":"Subject of message."},"MessagePreview":{"title":"MessagePreview","type":"string","description":"Text preview of message."},"MessageText":{"title":"MessageText","type":"string","description":"Plain text body of message."},"MessageHtml":{"title":"MessageHtml","type":"string","description":"HTML body of message."},"MessageAttachments":{"title":"MessageAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in message."},"MessageInReplyTo":{"title":"MessageInReplyTo","type":"string","description":"ID of message being replied to."},"MessageReferences":{"title":"MessageReferences","type":"array","items":{"type":"string"},"description":"IDs of previous messages in thread."},"MessageHeaders":{"title":"MessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers in message."},"MessageSize":{"title":"MessageSize","type":"integer","description":"Size of message in bytes."},"MessageUpdatedAt":{"title":"MessageUpdatedAt","type":"string","format":"date-time","description":"Time at which message was last updated."},"MessageCreatedAt":{"title":"MessageCreatedAt","type":"string","format":"date-time","description":"Time at which message was created."},"MessageItem":{"title":"MessageItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"Message":{"title":"Message","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"reply_to":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"extracted_text":{"type":"string","nullable":true,"description":"Extracted new text content."},"extracted_html":{"type":"string","nullable":true,"description":"Extracted new HTML content."},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"ListMessagesResponse":{"title":"ListMessagesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MessageItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","messages"]},"BatchGetMessagesMessageIds":{"title":"BatchGetMessagesMessageIds","type":"array","items":{"$ref":"#/components/schemas/MessageId"},"description":"IDs of messages to fetch. Maximum 500 ids per request. Duplicates are\nrejected with a validation error. IDs not found in the inbox (including\ncross-inbox or permission-restricted) are silently omitted from the\nresponse; callers detect misses by comparing `count` against `limit`."},"BatchGetMessagesRequest":{"title":"BatchGetMessagesRequest","type":"object","properties":{"message_ids":{"$ref":"#/components/schemas/BatchGetMessagesMessageIds"}},"required":["message_ids"]},"BatchGetMessagesResponse":{"title":"BatchGetMessagesResponse","type":"object","properties":{"limit":{"$ref":"#/components/schemas/Limit"},"count":{"$ref":"#/components/schemas/Count"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"},"description":"Found messages. Order matches `message_ids` in the request. Body\nfields (`text`, `html`, `extracted_text`, `extracted_html`) are\nnever populated; use the single-message endpoint to retrieve bodies."}},"required":["limit","count","messages"]},"RawMessageResponse":{"title":"RawMessageResponse","type":"object","description":"S3 presigned URL to download the raw .eml file.","properties":{"message_id":{"$ref":"#/components/schemas/MessageId","description":"ID of the message."},"size":{"$ref":"#/components/schemas/MessageSize","description":"Size of the raw message in bytes."},"download_url":{"type":"string","description":"S3 presigned URL to download the raw message. Expires at expires_at."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["message_id","size","download_url","expires_at"]},"Addresses":{"title":"Addresses","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"SendMessageReplyTo":{"title":"SendMessageReplyTo","$ref":"#/components/schemas/Addresses","description":"Reply-to address or addresses."},"SendMessageTo":{"title":"SendMessageTo","$ref":"#/components/schemas/Addresses","description":"Recipient address or addresses."},"SendMessageCc":{"title":"SendMessageCc","$ref":"#/components/schemas/Addresses","description":"CC recipient address or addresses."},"SendMessageBcc":{"title":"SendMessageBcc","$ref":"#/components/schemas/Addresses","description":"BCC recipient address or addresses."},"SendMessageAttachments":{"title":"SendMessageAttachments","type":"array","items":{"$ref":"#/components/schemas/SendAttachment"},"description":"Attachments to include in message."},"SendMessageHeaders":{"title":"SendMessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers to include in message."},"SendMessageRequest":{"title":"SendMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"SendMessageResponse":{"title":"SendMessageResponse","type":"object","properties":{"message_id":{"$ref":"#/components/schemas/MessageId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"}},"required":["message_id","thread_id"]},"UpdateMessageResponse":{"title":"UpdateMessageResponse","type":"object","properties":{"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"}},"required":["message_id","labels"]},"ReplyAll":{"title":"ReplyAll","type":"boolean","description":"Reply to all recipients of the original message."},"ReplyToMessageRequest":{"title":"ReplyToMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"reply_all":{"$ref":"#/components/schemas/ReplyAll","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"ReplyAllMessageRequest":{"title":"ReplyAllMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true}}},"UpdateMessageLabels":{"title":"UpdateMessageLabels","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Label or list of labels."},"UpdateMessageRequest":{"title":"UpdateMessageRequest","type":"object","properties":{"add_labels":{"$ref":"#/components/schemas/UpdateMessageLabels","nullable":true,"description":"Label or labels to add to message."},"remove_labels":{"$ref":"#/components/schemas/UpdateMessageLabels","nullable":true,"description":"Label or labels to remove from message."}}},"MetricEventType":{"title":"MetricEventType","type":"string","enum":["message.sent","message.delivered","message.bounced","message.delayed","message.rejected","message.complained","message.received"],"description":"Type of metric event."},"MetricEventTypes":{"title":"MetricEventTypes","type":"array","items":{"$ref":"#/components/schemas/MetricEventType"},"description":"List of metric event types to query."},"Start":{"title":"Start","type":"string","format":"date-time","description":"Start timestamp for the query."},"End":{"title":"End","type":"string","format":"date-time","description":"End timestamp for the query."},"Period":{"title":"Period","type":"string","description":"Period in number of seconds for the query."},"MetricLimit":{"title":"MetricLimit","type":"integer","description":"Limit on number of buckets to return."},"Descending":{"title":"Descending","type":"boolean","description":"Sort in descending order."},"MetricBucket":{"title":"MetricBucket","type":"object","properties":{"timestamp":{"type":"string","format":"date-time","description":"Timestamp of the bucket."},"count":{"type":"integer","description":"Count of events in the bucket."}},"required":["timestamp","count"]},"QueryMetricsResponse":{"title":"QueryMetricsResponse","type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/MetricBucket"}},"description":"Metrics grouped by event type."},"Organization":{"title":"Organization","type":"object","description":"Organization details with usage limits and counts.","properties":{"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"inbox_count":{"type":"integer","description":"Current number of inboxes."},"domain_count":{"type":"integer","description":"Current number of domains."},"inbox_limit":{"type":"integer","nullable":true,"description":"Maximum number of inboxes allowed."},"domain_limit":{"type":"integer","nullable":true,"description":"Maximum number of domains allowed."},"billing_id":{"type":"string","nullable":true,"description":"Provider-agnostic billing customer ID."},"billing_type":{"type":"string","nullable":true,"description":"Billing provider type (e.g. \"stripe\")."},"billing_subscription_id":{"type":"string","nullable":true,"description":"Active billing subscription ID."},"authentication_id":{"type":"string","nullable":true,"description":"Provider-agnostic authentication ID."},"authentication_type":{"type":"string","nullable":true,"description":"Authentication provider type."},"updated_at":{"type":"string","format":"date-time","description":"Time at which organization was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which organization was created."}},"required":["organization_id","inbox_count","domain_count","updated_at","created_at"]},"ThreadId":{"title":"ThreadId","type":"string","description":"ID of thread."},"ThreadLabels":{"title":"ThreadLabels","type":"array","items":{"type":"string"},"description":"Labels of thread."},"ThreadTimestamp":{"title":"ThreadTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent or received message."},"ThreadReceivedTimestamp":{"title":"ThreadReceivedTimestamp","type":"string","format":"date-time","description":"Timestamp of last received message."},"ThreadSentTimestamp":{"title":"ThreadSentTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent message."},"ThreadSenders":{"title":"ThreadSenders","type":"array","items":{"type":"string"},"description":"Senders in thread. In format `username@domain.com` or `Display Name `."},"ThreadRecipients":{"title":"ThreadRecipients","type":"array","items":{"type":"string"},"description":"Recipients in thread. In format `username@domain.com` or `Display Name `."},"ThreadSubject":{"title":"ThreadSubject","type":"string","description":"Subject of thread."},"ThreadPreview":{"title":"ThreadPreview","type":"string","description":"Text preview of last message in thread."},"ThreadAttachments":{"title":"ThreadAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in thread."},"ThreadLastMessageId":{"title":"ThreadLastMessageId","type":"string","description":"ID of last message in thread."},"ThreadMessageCount":{"title":"ThreadMessageCount","type":"integer","description":"Number of messages in thread."},"ThreadSize":{"title":"ThreadSize","type":"integer","description":"Size of thread in bytes."},"ThreadUpdatedAt":{"title":"ThreadUpdatedAt","type":"string","format":"date-time","description":"Time at which thread was last updated."},"ThreadCreatedAt":{"title":"ThreadCreatedAt","type":"string","format":"date-time","description":"Time at which thread was created."},"ThreadItem":{"title":"ThreadItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at"]},"Thread":{"title":"Thread","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"},"description":"Messages in thread. Ordered by `timestamp` ascending."}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at","messages"]},"UpdateThreadRequest":{"title":"UpdateThreadRequest","type":"object","properties":{"add_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to add to thread. Cannot be system labels."},"remove_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to remove from thread. Cannot be system labels. Takes priority over `add_labels` (in the event of duplicate labels passed in)."}}},"UpdateThreadResponse":{"title":"UpdateThreadResponse","type":"object","properties":{"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"}},"required":["thread_id","labels"]},"ListThreadsResponse":{"title":"ListThreadsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"threads":{"type":"array","items":{"$ref":"#/components/schemas/ThreadItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","threads"]},"webhooksSvixId":{"title":"webhooksSvixId","type":"string","description":"ID of webhook message."},"webhooksSvixTimestamp":{"title":"webhooksSvixTimestamp","type":"string","format":"date-time","description":"Timestamp of webhook message."},"webhooksSvixSignature":{"title":"webhooksSvixSignature","type":"string","description":"Signature of webhook message."},"Subscribe":{"title":"Subscribe","type":"object","properties":{"type":{"type":"string","const":"subscribe"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]},"Subscribed":{"title":"Subscribed","type":"object","properties":{"type":{"type":"string","const":"subscribed"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]},"Error":{"title":"Error","type":"object","properties":{"type":{"type":"string","const":"error"},"name":{"$ref":"#/components/schemas/ErrorName"},"message":{"$ref":"#/components/schemas/ErrorMessage"}},"required":["type","name","message"]}},"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer"}}}} \ No newline at end of file diff --git a/fern/apis/cli/generators.yml b/fern/apis/cli/generators.yml index 60869a38..9c29073b 100644 --- a/fern/apis/cli/generators.yml +++ b/fern/apis/cli/generators.yml @@ -1,11 +1,13 @@ # yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json -# CLI API — sourced from the OpenAPI spec in this folder (with an OpenAPI -# overlay that maps operations into nested x-fern-sdk-group-name groups). -# Kept separate from the Fern-Definition-backed SDK API so the two evolve -# independently. Generate with: fern generate --api cli --group cli --local +# CLI API — sourced from openapi.yml, which is the OpenAPI export of the Fern +# Definition (the same spec the `api` group's openapi generator produces and +# sync-stainless pushes). Regenerate it via the regenerate-cli workflow or: +# fern generate --api api --group openapi --local && cp ../../../openapi/openapi.yml openapi.yml +# The overlay maps operations into nested x-fern-sdk-group-name groups. +# Generate the CLI with: fern generate --api cli --group cli --local api: specs: - - openapi: openapi.json + - openapi: openapi.yml overlays: overlay.yaml default-group: cli groups: diff --git a/fern/apis/cli/openapi.json b/fern/apis/cli/openapi.json deleted file mode 100644 index cf92634b..00000000 --- a/fern/apis/cli/openapi.json +++ /dev/null @@ -1,6961 +0,0 @@ -{ - "openapi": "3.0.1", - "info": { - "title": "api", - "version": "" - }, - "paths": { - "/v0/inboxes": { - "get": { - "operationId": "inboxes_list", - "tags": [ - "Inboxes" - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesListInboxesResponse" - } - } - } - } - }, - "summary": "List Inboxes", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "inboxes_create", - "tags": [ - "Inboxes" - ], - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesInbox" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - } - }, - "summary": "Create Inbox", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesCreateInboxRequest", - "nullable": true - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}": { - "get": { - "operationId": "inboxes_get", - "tags": [ - "Inboxes" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesInbox" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Inbox", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "patch": { - "operationId": "inboxes_update", - "tags": [ - "Inboxes" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesInbox" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Update Inbox", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesUpdateInboxRequest" - } - } - } - } - }, - "delete": { - "operationId": "inboxes_delete", - "tags": [ - "Inboxes" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Inbox", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods": { - "get": { - "operationId": "pods_list", - "tags": [ - "Pods" - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/podsListPodsResponse" - } - } - } - } - }, - "summary": "List Pods", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "pods_create", - "tags": [ - "Pods" - ], - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/podsPod" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - } - }, - "summary": "Create Pod", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/podsCreatePodRequest" - } - } - } - } - } - }, - "/v0/pods/{pod_id}": { - "get": { - "operationId": "pods_get", - "tags": [ - "Pods" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/podsPod" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Pod", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "delete": { - "operationId": "pods_delete", - "tags": [ - "Pods" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Pod", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/webhooks": { - "get": { - "operationId": "webhooks_list", - "tags": [ - "Webhooks" - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/webhooksListWebhooksResponse" - } - } - } - } - }, - "summary": "List Webhooks", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "webhooks_create", - "tags": [ - "Webhooks" - ], - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/webhooksWebhook" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - } - }, - "summary": "Create Webhook", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/webhooksCreateWebhookRequest" - } - } - } - } - } - }, - "/v0/webhooks/{webhook_id}": { - "get": { - "operationId": "webhooks_get", - "tags": [ - "Webhooks" - ], - "parameters": [ - { - "name": "webhook_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/webhooksWebhookId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/webhooksWebhook" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Webhook", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "patch": { - "operationId": "webhooks_update", - "tags": [ - "Webhooks" - ], - "parameters": [ - { - "name": "webhook_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/webhooksWebhookId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/webhooksWebhook" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Update Webhook", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/webhooksUpdateWebhookRequest" - } - } - } - } - }, - "delete": { - "operationId": "webhooks_delete", - "tags": [ - "Webhooks" - ], - "parameters": [ - { - "name": "webhook_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/webhooksWebhookId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Webhook", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/api-keys": { - "get": { - "operationId": "apiKeys_list", - "tags": [ - "ApiKeys" - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListApiKeysResponse" - } - } - } - } - }, - "summary": "List API Keys", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "apiKeys_create", - "tags": [ - "ApiKeys" - ], - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateApiKeyResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - } - }, - "summary": "Create API Key", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateApiKeyRequest" - } - } - } - } - } - }, - "/v0/api-keys/{api_key}": { - "delete": { - "operationId": "apiKeys_delete", - "tags": [ - "ApiKeys" - ], - "parameters": [ - { - "name": "api_key", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ApiKeyId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete API Key", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/domains": { - "get": { - "operationId": "domains_list", - "tags": [ - "Domains" - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListDomainsResponse" - } - } - } - } - }, - "summary": "List Domains", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "domains_create", - "tags": [ - "Domains" - ], - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Domain" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - } - }, - "summary": "Create Domain", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDomainRequest" - } - } - } - } - } - }, - "/v0/domains/{domain_id}": { - "get": { - "operationId": "domains_get", - "tags": [ - "Domains" - ], - "parameters": [ - { - "name": "domain_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DomainId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Domain" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Domain", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "delete": { - "operationId": "domains_delete", - "tags": [ - "Domains" - ], - "parameters": [ - { - "name": "domain_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DomainId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Domain", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/domains/{domain_id}/zone-file": { - "get": { - "operationId": "domains_getZoneFile", - "tags": [ - "Domains" - ], - "parameters": [ - { - "name": "domain_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DomainId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Zone File", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/domains/{domain_id}/verify": { - "post": { - "operationId": "domains_verify", - "tags": [ - "Domains" - ], - "parameters": [ - { - "name": "domain_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DomainId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Verify Domain", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/drafts": { - "get": { - "operationId": "drafts_list", - "tags": [ - "Drafts" - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - }, - { - "name": "labels", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Labels", - "nullable": true - } - }, - { - "name": "before", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Before", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/After", - "nullable": true - } - }, - { - "name": "ascending", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Ascending", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListDraftsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Drafts", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/drafts/{draft_id}": { - "get": { - "operationId": "drafts_get", - "tags": [ - "Drafts" - ], - "parameters": [ - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DraftId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Draft" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Draft", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/drafts": { - "get": { - "operationId": "inboxes_drafts_list", - "tags": [ - "InboxesDrafts" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - }, - { - "name": "labels", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Labels", - "nullable": true - } - }, - { - "name": "before", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Before", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/After", - "nullable": true - } - }, - { - "name": "ascending", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Ascending", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListDraftsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Drafts", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "inboxes_drafts_create", - "tags": [ - "InboxesDrafts" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Draft" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Create Draft", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDraftRequest" - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}/drafts/{draft_id}": { - "get": { - "operationId": "inboxes_drafts_get", - "tags": [ - "InboxesDrafts" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DraftId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Draft" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Draft", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "patch": { - "operationId": "inboxes_drafts_update", - "tags": [ - "InboxesDrafts" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DraftId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Draft" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Update Draft", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateDraftRequest" - } - } - } - } - }, - "delete": { - "operationId": "inboxes_drafts_delete", - "tags": [ - "InboxesDrafts" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DraftId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Draft", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/drafts/{draft_id}/send": { - "post": { - "operationId": "inboxes_drafts_send", - "tags": [ - "InboxesDrafts" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DraftId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendMessageResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Send Draft", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMessageRequest" - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}/messages": { - "get": { - "operationId": "inboxes_messages_list", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - }, - { - "name": "labels", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Labels", - "nullable": true - } - }, - { - "name": "before", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Before", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/After", - "nullable": true - } - }, - { - "name": "ascending", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Ascending", - "nullable": true - } - }, - { - "name": "include_spam", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/IncludeSpam", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListMessagesResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Messages", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/messages/{message_id}": { - "get": { - "operationId": "inboxes_messages_get", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "message_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/MessageId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Message" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Message", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "patch": { - "operationId": "inboxes_messages_update", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "message_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/MessageId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Message" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Update Message", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMessageRequest" - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}": { - "get": { - "operationId": "inboxes_messages_getAttachment", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "message_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/MessageId" - } - }, - { - "name": "attachment_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/AttachmentId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AttachmentResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Attachment", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/messages/{message_id}/raw": { - "get": { - "operationId": "inboxes_messages_getRaw", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "message_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/MessageId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RawMessageResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Raw Message", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/messages/send": { - "post": { - "operationId": "inboxes_messages_send", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendMessageResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Send Message", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendMessageRequest" - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}/messages/{message_id}/reply": { - "post": { - "operationId": "inboxes_messages_reply", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "message_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/MessageId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendMessageResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Reply To Message", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplyToMessageRequest" - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all": { - "post": { - "operationId": "inboxes_messages_reply-all", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "message_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/MessageId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendMessageResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Reply All Message", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplyAllMessageRequest" - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}/messages/{message_id}/forward": { - "post": { - "operationId": "inboxes_messages_forward", - "tags": [ - "InboxesMessages" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "message_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/MessageId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendMessageResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "403": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Forward Message", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendMessageRequest" - } - } - } - } - } - }, - "/v0/inboxes/{inbox_id}/metrics": { - "get": { - "operationId": "inboxes_metrics_get", - "tags": [ - "InboxesMetrics" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "event_types", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/MetricEventTypes", - "nullable": true - } - }, - { - "name": "start_timestamp", - "in": "query", - "required": true, - "schema": { - "$ref": "#/components/schemas/MetricStartTimestamp" - } - }, - { - "name": "end_timestamp", - "in": "query", - "required": true, - "schema": { - "$ref": "#/components/schemas/MetricEndTimestamp" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListMetricsResponse" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Metrics", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/threads": { - "get": { - "operationId": "inboxes_threads_list", - "tags": [ - "InboxesThreads" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - }, - { - "name": "labels", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Labels", - "nullable": true - } - }, - { - "name": "before", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Before", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/After", - "nullable": true - } - }, - { - "name": "ascending", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Ascending", - "nullable": true - } - }, - { - "name": "include_spam", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/IncludeSpam", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListThreadsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Threads", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/threads/{thread_id}": { - "get": { - "operationId": "inboxes_threads_get", - "tags": [ - "InboxesThreads" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "thread_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ThreadId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Thread" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Thread", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "delete": { - "operationId": "inboxes_threads_delete", - "tags": [ - "InboxesThreads" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "thread_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ThreadId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Thread", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}": { - "get": { - "operationId": "inboxes_threads_getAttachment", - "tags": [ - "InboxesThreads" - ], - "parameters": [ - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - }, - { - "name": "thread_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ThreadId" - } - }, - { - "name": "attachment_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/AttachmentId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AttachmentResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Attachment", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/metrics": { - "get": { - "operationId": "metrics_list", - "tags": [ - "Metrics" - ], - "parameters": [ - { - "name": "event_types", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/MetricEventTypes", - "nullable": true - } - }, - { - "name": "start_timestamp", - "in": "query", - "required": true, - "schema": { - "$ref": "#/components/schemas/MetricStartTimestamp" - } - }, - { - "name": "end_timestamp", - "in": "query", - "required": true, - "schema": { - "$ref": "#/components/schemas/MetricEndTimestamp" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListMetricsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Metrics", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/organizations": { - "get": { - "description": "Get the current organization.", - "operationId": "organizations_get", - "tags": [ - "Organizations" - ], - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Organization" - } - } - } - } - }, - "summary": "Get Organization", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods/{pod_id}/domains": { - "get": { - "operationId": "pods_domains_list", - "tags": [ - "PodsDomains" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListDomainsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Domains", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "pods_domains_create", - "tags": [ - "PodsDomains" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Domain" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - } - }, - "summary": "Create Domain", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDomainRequest" - } - } - } - } - } - }, - "/v0/pods/{pod_id}/domains/{domain_id}": { - "delete": { - "operationId": "pods_domains_delete", - "tags": [ - "PodsDomains" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "domain_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DomainId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Domain", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods/{pod_id}/drafts": { - "get": { - "operationId": "pods_drafts_list", - "tags": [ - "PodsDrafts" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - }, - { - "name": "labels", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Labels", - "nullable": true - } - }, - { - "name": "before", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Before", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/After", - "nullable": true - } - }, - { - "name": "ascending", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Ascending", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListDraftsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Drafts", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods/{pod_id}/drafts/{draft_id}": { - "get": { - "operationId": "pods_drafts_get", - "tags": [ - "PodsDrafts" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/DraftId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Draft" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Draft", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods/{pod_id}/inboxes": { - "get": { - "operationId": "pods_inboxes_list", - "tags": [ - "PodsInboxes" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesListInboxesResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Inboxes", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "post": { - "operationId": "pods_inboxes_create", - "tags": [ - "PodsInboxes" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesInbox" - } - } - } - }, - "400": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" - } - } - } - } - }, - "summary": "Create Inbox", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesCreateInboxRequest" - } - } - } - } - } - }, - "/v0/pods/{pod_id}/inboxes/{inbox_id}": { - "get": { - "operationId": "pods_inboxes_get", - "tags": [ - "PodsInboxes" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/inboxesInbox" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Inbox", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - }, - "delete": { - "operationId": "pods_inboxes_delete", - "tags": [ - "PodsInboxes" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "inbox_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/inboxesInboxId" - } - } - ], - "responses": { - "204": { - "description": "" - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Delete Inbox", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods/{pod_id}/threads": { - "get": { - "operationId": "pods_threads_list", - "tags": [ - "PodsThreads" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - }, - { - "name": "labels", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Labels", - "nullable": true - } - }, - { - "name": "before", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Before", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/After", - "nullable": true - } - }, - { - "name": "ascending", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Ascending", - "nullable": true - } - }, - { - "name": "include_spam", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/IncludeSpam", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListThreadsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Threads", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods/{pod_id}/threads/{thread_id}": { - "get": { - "operationId": "pods_threads_get", - "tags": [ - "PodsThreads" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "thread_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ThreadId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Thread" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Thread", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}": { - "get": { - "operationId": "pods_threads_getAttachment", - "tags": [ - "PodsThreads" - ], - "parameters": [ - { - "name": "pod_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/podsPodId" - } - }, - { - "name": "thread_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ThreadId" - } - }, - { - "name": "attachment_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/AttachmentId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AttachmentResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Attachment", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/threads": { - "get": { - "operationId": "threads_list", - "tags": [ - "Threads" - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Limit", - "nullable": true - } - }, - { - "name": "page_token", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - } - }, - { - "name": "labels", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Labels", - "nullable": true - } - }, - { - "name": "before", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Before", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/After", - "nullable": true - } - }, - { - "name": "ascending", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/Ascending", - "nullable": true - } - }, - { - "name": "include_spam", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/IncludeSpam", - "nullable": true - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListThreadsResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "List Threads", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/threads/{thread_id}": { - "get": { - "operationId": "threads_get", - "tags": [ - "Threads" - ], - "parameters": [ - { - "name": "thread_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ThreadId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Thread" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Thread", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - }, - "/v0/threads/{thread_id}/attachments/{attachment_id}": { - "get": { - "operationId": "threads_getAttachment", - "tags": [ - "Threads" - ], - "parameters": [ - { - "name": "thread_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/ThreadId" - } - }, - { - "name": "attachment_id", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/AttachmentId" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AttachmentResponse" - } - } - } - }, - "404": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - } - }, - "summary": "Get Attachment", - "servers": [ - { - "url": "https://api.agentmail.to" - }, - { - "url": "https://api.agentmail.dev" - } - ], - "security": [ - { - "BearerAuth": [] - } - ] - } - } - }, - "components": { - "schemas": { - "Limit": { - "title": "Limit", - "type": "integer", - "description": "Limit of number of items returned." - }, - "Count": { - "title": "Count", - "type": "integer", - "description": "Number of items returned." - }, - "PageToken": { - "title": "PageToken", - "type": "string", - "description": "Page token for pagination." - }, - "Labels": { - "title": "Labels", - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels to filter by." - }, - "Before": { - "title": "Before", - "type": "string", - "format": "date-time", - "description": "Timestamp before which to filter by." - }, - "After": { - "title": "After", - "type": "string", - "format": "date-time", - "description": "Timestamp after which to filter by." - }, - "Ascending": { - "title": "Ascending", - "type": "boolean", - "description": "Sort in ascending temporal order." - }, - "IncludeSpam": { - "title": "IncludeSpam", - "type": "boolean", - "description": "Include spam in results." - }, - "OrganizationId": { - "title": "OrganizationId", - "type": "string", - "description": "ID of organization." - }, - "ErrorName": { - "title": "ErrorName", - "type": "string", - "description": "Name of error." - }, - "ErrorResponse": { - "title": "ErrorResponse", - "type": "object", - "properties": { - "name": { - "$ref": "#/components/schemas/ErrorName" - }, - "message": { - "type": "string", - "description": "Error message." - } - }, - "required": [ - "name", - "message" - ] - }, - "ValidationErrorResponse": { - "title": "ValidationErrorResponse", - "type": "object", - "properties": { - "name": { - "$ref": "#/components/schemas/ErrorName" - }, - "errors": { - "description": "Validation errors." - } - }, - "required": [ - "name", - "errors" - ] - }, - "inboxesInboxId": { - "title": "inboxesInboxId", - "type": "string", - "description": "ID of inbox." - }, - "inboxesDisplayName": { - "title": "inboxesDisplayName", - "type": "string", - "description": "Display name: `Display Name `." - }, - "inboxesClientId": { - "title": "inboxesClientId", - "type": "string", - "description": "Client ID of inbox." - }, - "inboxesInbox": { - "title": "inboxesInbox", - "type": "object", - "properties": { - "pod_id": { - "$ref": "#/components/schemas/podsPodId" - }, - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "display_name": { - "$ref": "#/components/schemas/inboxesDisplayName", - "nullable": true - }, - "client_id": { - "$ref": "#/components/schemas/inboxesClientId", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Time at which inbox was last updated." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Time at which inbox was created." - } - }, - "required": [ - "pod_id", - "inbox_id", - "updated_at", - "created_at" - ] - }, - "inboxesListInboxesResponse": { - "title": "inboxesListInboxesResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "limit": { - "$ref": "#/components/schemas/Limit", - "nullable": true - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "inboxes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/inboxesInbox" - }, - "description": "Ordered by `created_at` descending." - } - }, - "required": [ - "count", - "inboxes" - ] - }, - "inboxesCreateInboxRequest": { - "title": "inboxesCreateInboxRequest", - "type": "object", - "properties": { - "username": { - "type": "string", - "nullable": true, - "description": "Username of address. Randomly generated if not specified." - }, - "domain": { - "type": "string", - "nullable": true, - "description": "Domain of address. Must be verified domain. Defaults to `agentmail.to`." - }, - "display_name": { - "$ref": "#/components/schemas/inboxesDisplayName", - "nullable": true - }, - "client_id": { - "$ref": "#/components/schemas/inboxesClientId", - "nullable": true - } - } - }, - "inboxesUpdateInboxRequest": { - "title": "inboxesUpdateInboxRequest", - "type": "object", - "properties": { - "display_name": { - "$ref": "#/components/schemas/inboxesDisplayName" - } - }, - "required": [ - "display_name" - ] - }, - "podsPodId": { - "title": "podsPodId", - "type": "string", - "description": "ID of pod." - }, - "podsName": { - "title": "podsName", - "type": "string", - "description": "Name of pod." - }, - "podsClientId": { - "title": "podsClientId", - "type": "string", - "description": "Client ID of pod." - }, - "podsPod": { - "title": "podsPod", - "type": "object", - "properties": { - "pod_id": { - "$ref": "#/components/schemas/podsPodId" - }, - "name": { - "$ref": "#/components/schemas/podsName" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Time at which pod was last updated." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Time at which pod was created." - }, - "client_id": { - "$ref": "#/components/schemas/podsClientId", - "nullable": true - } - }, - "required": [ - "pod_id", - "name", - "updated_at", - "created_at" - ] - }, - "podsListPodsResponse": { - "title": "podsListPodsResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "limit": { - "$ref": "#/components/schemas/Limit", - "nullable": true - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "pods": { - "type": "array", - "items": { - "$ref": "#/components/schemas/podsPod" - }, - "description": "Ordered by `created_at` descending." - } - }, - "required": [ - "count", - "pods" - ] - }, - "podsCreatePodRequest": { - "title": "podsCreatePodRequest", - "type": "object", - "properties": { - "name": { - "$ref": "#/components/schemas/podsName", - "nullable": true - }, - "client_id": { - "$ref": "#/components/schemas/podsClientId", - "nullable": true - } - } - }, - "webhooksWebhookId": { - "title": "webhooksWebhookId", - "type": "string", - "description": "ID of webhook." - }, - "webhooksClientId": { - "title": "webhooksClientId", - "type": "string", - "description": "Client ID of webhook." - }, - "webhooksUrl": { - "title": "webhooksUrl", - "type": "string", - "description": "URL of webhook endpoint." - }, - "webhooksWebhook": { - "title": "webhooksWebhook", - "type": "object", - "properties": { - "webhook_id": { - "$ref": "#/components/schemas/webhooksWebhookId" - }, - "url": { - "$ref": "#/components/schemas/webhooksUrl" - }, - "event_types": { - "$ref": "#/components/schemas/EventTypes", - "nullable": true - }, - "pod_ids": { - "$ref": "#/components/schemas/PodIds", - "nullable": true - }, - "inbox_ids": { - "$ref": "#/components/schemas/InboxIds", - "nullable": true - }, - "secret": { - "type": "string", - "description": "Secret for webhook signature verification." - }, - "enabled": { - "type": "boolean", - "description": "Webhook is enabled." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Time at which webhook was last updated." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Time at which webhook was created." - }, - "client_id": { - "$ref": "#/components/schemas/webhooksClientId", - "nullable": true - } - }, - "required": [ - "webhook_id", - "url", - "secret", - "enabled", - "updated_at", - "created_at" - ] - }, - "webhooksListWebhooksResponse": { - "title": "webhooksListWebhooksResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "limit": { - "$ref": "#/components/schemas/Limit", - "nullable": true - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "webhooks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/webhooksWebhook" - }, - "description": "Ordered by `created_at` descending." - } - }, - "required": [ - "count", - "webhooks" - ] - }, - "webhooksCreateWebhookRequest": { - "title": "webhooksCreateWebhookRequest", - "type": "object", - "properties": { - "url": { - "$ref": "#/components/schemas/webhooksUrl" - }, - "event_types": { - "$ref": "#/components/schemas/EventTypes" - }, - "pod_ids": { - "$ref": "#/components/schemas/PodIds", - "nullable": true - }, - "inbox_ids": { - "$ref": "#/components/schemas/InboxIds", - "nullable": true - }, - "client_id": { - "$ref": "#/components/schemas/webhooksClientId", - "nullable": true - } - }, - "required": [ - "url", - "event_types" - ] - }, - "webhooksUpdateWebhookRequest": { - "title": "webhooksUpdateWebhookRequest", - "type": "object", - "properties": { - "add_inbox_ids": { - "$ref": "#/components/schemas/InboxIds", - "nullable": true, - "description": "Inbox IDs to subscribe to the webhook." - }, - "remove_inbox_ids": { - "$ref": "#/components/schemas/InboxIds", - "nullable": true, - "description": "Inbox IDs to unsubscribe from the webhook." - }, - "add_pod_ids": { - "$ref": "#/components/schemas/PodIds", - "nullable": true, - "description": "Pod IDs to subscribe to the webhook." - }, - "remove_pod_ids": { - "$ref": "#/components/schemas/PodIds", - "nullable": true, - "description": "Pod IDs to unsubscribe from the webhook." - } - } - }, - "ApiKeyId": { - "title": "ApiKeyId", - "type": "string", - "description": "ID of api key." - }, - "Prefix": { - "title": "Prefix", - "type": "string", - "description": "Prefix of api key." - }, - "Name": { - "title": "Name", - "type": "string", - "description": "Name of api key." - }, - "CreatedAt": { - "title": "CreatedAt", - "type": "string", - "format": "date-time", - "description": "Time at which api key was created." - }, - "ApiKey": { - "title": "ApiKey", - "type": "object", - "properties": { - "api_key_id": { - "$ref": "#/components/schemas/ApiKeyId" - }, - "prefix": { - "$ref": "#/components/schemas/Prefix" - }, - "name": { - "$ref": "#/components/schemas/Name" - }, - "used_at": { - "type": "string", - "format": "date-time", - "nullable": true, - "description": "Time at which api key was last used." - }, - "created_at": { - "$ref": "#/components/schemas/CreatedAt" - } - }, - "required": [ - "api_key_id", - "prefix", - "name", - "created_at" - ] - }, - "CreateApiKeyResponse": { - "title": "CreateApiKeyResponse", - "type": "object", - "properties": { - "api_key_id": { - "$ref": "#/components/schemas/ApiKeyId" - }, - "api_key": { - "type": "string", - "description": "API key." - }, - "prefix": { - "$ref": "#/components/schemas/Prefix" - }, - "name": { - "$ref": "#/components/schemas/Name" - }, - "created_at": { - "$ref": "#/components/schemas/CreatedAt" - } - }, - "required": [ - "api_key_id", - "api_key", - "prefix", - "name", - "created_at" - ] - }, - "ListApiKeysResponse": { - "title": "ListApiKeysResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "api_keys": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ApiKey" - }, - "description": "Ordered by `created_at` descending." - } - }, - "required": [ - "count", - "api_keys" - ] - }, - "CreateApiKeyRequest": { - "title": "CreateApiKeyRequest", - "type": "object", - "properties": { - "name": { - "$ref": "#/components/schemas/Name" - } - }, - "required": [ - "name" - ] - }, - "AttachmentId": { - "title": "AttachmentId", - "type": "string", - "description": "ID of attachment." - }, - "AttachmentFilename": { - "title": "AttachmentFilename", - "type": "string", - "description": "Filename of attachment." - }, - "AttachmentSize": { - "title": "AttachmentSize", - "type": "integer", - "description": "Size of attachment in bytes." - }, - "AttachmentContentType": { - "title": "AttachmentContentType", - "type": "string", - "description": "Content type of attachment." - }, - "AttachmentContentDisposition": { - "title": "AttachmentContentDisposition", - "type": "string", - "enum": [ - "inline", - "attachment" - ], - "description": "Content disposition of attachment." - }, - "AttachmentContentId": { - "title": "AttachmentContentId", - "type": "string", - "description": "Content ID of attachment." - }, - "Attachment": { - "title": "Attachment", - "type": "object", - "properties": { - "attachment_id": { - "$ref": "#/components/schemas/AttachmentId" - }, - "filename": { - "$ref": "#/components/schemas/AttachmentFilename", - "nullable": true - }, - "size": { - "$ref": "#/components/schemas/AttachmentSize" - }, - "content_type": { - "$ref": "#/components/schemas/AttachmentContentType", - "nullable": true - }, - "content_disposition": { - "$ref": "#/components/schemas/AttachmentContentDisposition", - "nullable": true - }, - "content_id": { - "$ref": "#/components/schemas/AttachmentContentId", - "nullable": true - } - }, - "required": [ - "attachment_id", - "size" - ] - }, - "AttachmentResponse": { - "title": "AttachmentResponse", - "type": "object", - "properties": { - "attachment_id": { - "$ref": "#/components/schemas/AttachmentId" - }, - "filename": { - "$ref": "#/components/schemas/AttachmentFilename", - "nullable": true - }, - "size": { - "$ref": "#/components/schemas/AttachmentSize" - }, - "content_type": { - "$ref": "#/components/schemas/AttachmentContentType", - "nullable": true - }, - "content_disposition": { - "$ref": "#/components/schemas/AttachmentContentDisposition", - "nullable": true - }, - "content_id": { - "$ref": "#/components/schemas/AttachmentContentId", - "nullable": true - }, - "download_url": { - "type": "string", - "description": "URL to download the attachment." - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "Time at which the download URL expires." - } - }, - "required": [ - "attachment_id", - "size", - "download_url", - "expires_at" - ] - }, - "SendAttachment": { - "title": "SendAttachment", - "type": "object", - "properties": { - "filename": { - "$ref": "#/components/schemas/AttachmentFilename", - "nullable": true - }, - "content_type": { - "$ref": "#/components/schemas/AttachmentContentType", - "nullable": true - }, - "content_disposition": { - "$ref": "#/components/schemas/AttachmentContentDisposition", - "nullable": true - }, - "content_id": { - "$ref": "#/components/schemas/AttachmentContentId", - "nullable": true - }, - "content": { - "type": "string", - "nullable": true, - "description": "Base64 encoded content of attachment." - }, - "url": { - "type": "string", - "nullable": true, - "description": "URL to the attachment." - } - } - }, - "DomainId": { - "title": "DomainId", - "type": "string", - "description": "The name of the domain. (e.g., \" your-domain.com\")" - }, - "DomainName": { - "title": "DomainName", - "type": "string", - "description": "The name of the domain. (e.g., \"example.com\")" - }, - "RecordType": { - "title": "RecordType", - "type": "string", - "enum": [ - "TXT", - "CNAME", - "MX" - ] - }, - "VerificationStatus": { - "title": "VerificationStatus", - "type": "string", - "enum": [ - "NOT_STARTED", - "PENDING", - "INVALID", - "FAILED", - "VERIFYING", - "VERIFIED" - ] - }, - "RecordStatus": { - "title": "RecordStatus", - "type": "string", - "enum": [ - "MISSING", - "INVALID", - "VALID" - ] - }, - "VerificationRecord": { - "title": "VerificationRecord", - "type": "object", - "properties": { - "type": { - "$ref": "#/components/schemas/RecordType", - "description": "The type of the DNS record." - }, - "name": { - "type": "string", - "description": "The name or host of the record." - }, - "value": { - "type": "string", - "description": "The value of the record." - }, - "status": { - "$ref": "#/components/schemas/RecordStatus", - "description": "The verification status of this specific record." - }, - "priority": { - "type": "integer", - "nullable": true, - "description": "The priority of the MX record." - } - }, - "required": [ - "type", - "name", - "value", - "status" - ] - }, - "FeedbackEnabled": { - "title": "FeedbackEnabled", - "type": "boolean", - "description": "Bounce and complaint notifications are sent to your inboxes." - }, - "ClientId": { - "title": "ClientId", - "type": "string", - "description": "Client ID of domain." - }, - "Domain": { - "title": "Domain", - "type": "object", - "properties": { - "pod_id": { - "$ref": "#/components/schemas/podsPodId", - "nullable": true - }, - "domain_id": { - "$ref": "#/components/schemas/DomainId" - }, - "status": { - "$ref": "#/components/schemas/VerificationStatus", - "description": "The verification status of the domain." - }, - "feedback_enabled": { - "$ref": "#/components/schemas/FeedbackEnabled" - }, - "records": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VerificationRecord" - }, - "description": "A list of DNS records required to verify the domain." - }, - "client_id": { - "$ref": "#/components/schemas/ClientId", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Time at which the domain was last updated." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Time at which the domain was created." - } - }, - "required": [ - "domain_id", - "status", - "feedback_enabled", - "records", - "updated_at", - "created_at" - ] - }, - "DomainItem": { - "title": "DomainItem", - "type": "object", - "properties": { - "pod_id": { - "$ref": "#/components/schemas/podsPodId", - "nullable": true - }, - "domain_id": { - "$ref": "#/components/schemas/DomainId" - }, - "feedback_enabled": { - "$ref": "#/components/schemas/FeedbackEnabled" - }, - "client_id": { - "$ref": "#/components/schemas/ClientId", - "nullable": true - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Time at which the domain was last updated." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Time at which the domain was created." - } - }, - "required": [ - "domain_id", - "feedback_enabled", - "updated_at", - "created_at" - ] - }, - "ListDomainsResponse": { - "title": "ListDomainsResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "limit": { - "$ref": "#/components/schemas/Limit", - "nullable": true - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "domains": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DomainItem" - }, - "description": "Ordered by `created_at` descending." - } - }, - "required": [ - "count", - "domains" - ] - }, - "CreateDomainRequest": { - "title": "CreateDomainRequest", - "type": "object", - "properties": { - "domain": { - "$ref": "#/components/schemas/DomainName" - }, - "feedback_enabled": { - "$ref": "#/components/schemas/FeedbackEnabled" - } - }, - "required": [ - "domain", - "feedback_enabled" - ] - }, - "DraftId": { - "title": "DraftId", - "type": "string", - "description": "ID of draft." - }, - "DraftClientId": { - "title": "DraftClientId", - "type": "string", - "description": "Client ID of draft." - }, - "DraftLabels": { - "title": "DraftLabels", - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels of draft." - }, - "DraftReplyTo": { - "title": "DraftReplyTo", - "type": "array", - "items": { - "type": "string" - }, - "description": "Reply-to addresses. In format `username@domain.com` or `Display Name `." - }, - "DraftTo": { - "title": "DraftTo", - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses of recipients. In format `username@domain.com` or `Display Name `." - }, - "DraftCc": { - "title": "DraftCc", - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses of CC recipients. In format `username@domain.com` or `Display Name `." - }, - "DraftBcc": { - "title": "DraftBcc", - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `." - }, - "DraftSubject": { - "title": "DraftSubject", - "type": "string", - "description": "Subject of draft." - }, - "DraftPreview": { - "title": "DraftPreview", - "type": "string", - "description": "Text preview of draft." - }, - "DraftText": { - "title": "DraftText", - "type": "string", - "description": "Plain text body of draft." - }, - "DraftHtml": { - "title": "DraftHtml", - "type": "string", - "description": "HTML body of draft." - }, - "DraftAttachments": { - "title": "DraftAttachments", - "type": "array", - "items": { - "$ref": "#/components/schemas/Attachment" - }, - "description": "Attachments in draft." - }, - "DraftInReplyTo": { - "title": "DraftInReplyTo", - "type": "string", - "description": "ID of message being replied to." - }, - "DraftSendStatus": { - "title": "DraftSendStatus", - "type": "string", - "enum": [ - "scheduled", - "sending", - "failed" - ], - "description": "Schedule send status of draft." - }, - "DraftSendAt": { - "title": "DraftSendAt", - "type": "string", - "format": "date-time", - "description": "Time at which to schedule send draft." - }, - "DraftUpdatedAt": { - "title": "DraftUpdatedAt", - "type": "string", - "format": "date-time", - "description": "Time at which draft was last updated." - }, - "DraftItem": { - "title": "DraftItem", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "draft_id": { - "$ref": "#/components/schemas/DraftId" - }, - "labels": { - "$ref": "#/components/schemas/DraftLabels" - }, - "to": { - "$ref": "#/components/schemas/DraftTo", - "nullable": true - }, - "cc": { - "$ref": "#/components/schemas/DraftCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/DraftBcc", - "nullable": true - }, - "subject": { - "$ref": "#/components/schemas/DraftSubject", - "nullable": true - }, - "preview": { - "$ref": "#/components/schemas/DraftPreview", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/DraftAttachments", - "nullable": true - }, - "send_status": { - "$ref": "#/components/schemas/DraftSendStatus", - "nullable": true - }, - "send_at": { - "$ref": "#/components/schemas/DraftSendAt", - "nullable": true - }, - "updated_at": { - "$ref": "#/components/schemas/DraftUpdatedAt" - } - }, - "required": [ - "inbox_id", - "thread_id", - "draft_id", - "labels", - "updated_at" - ] - }, - "Draft": { - "title": "Draft", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "draft_id": { - "$ref": "#/components/schemas/DraftId" - }, - "client_id": { - "$ref": "#/components/schemas/DraftClientId", - "nullable": true - }, - "labels": { - "$ref": "#/components/schemas/DraftLabels" - }, - "reply_to": { - "$ref": "#/components/schemas/DraftReplyTo", - "nullable": true - }, - "to": { - "$ref": "#/components/schemas/DraftTo", - "nullable": true - }, - "cc": { - "$ref": "#/components/schemas/DraftCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/DraftBcc", - "nullable": true - }, - "subject": { - "$ref": "#/components/schemas/DraftSubject", - "nullable": true - }, - "preview": { - "$ref": "#/components/schemas/DraftPreview", - "nullable": true - }, - "text": { - "$ref": "#/components/schemas/DraftText", - "nullable": true - }, - "html": { - "$ref": "#/components/schemas/DraftHtml", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/DraftAttachments", - "nullable": true - }, - "in_reply_to": { - "$ref": "#/components/schemas/DraftInReplyTo", - "nullable": true - }, - "references": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "description": "IDs of previous messages in thread." - }, - "send_status": { - "$ref": "#/components/schemas/DraftSendStatus", - "nullable": true - }, - "send_at": { - "$ref": "#/components/schemas/DraftSendAt", - "nullable": true - }, - "updated_at": { - "$ref": "#/components/schemas/DraftUpdatedAt" - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Time at which draft was created." - } - }, - "required": [ - "inbox_id", - "thread_id", - "draft_id", - "labels", - "updated_at", - "created_at" - ] - }, - "ListDraftsResponse": { - "title": "ListDraftsResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "limit": { - "$ref": "#/components/schemas/Limit", - "nullable": true - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "drafts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DraftItem" - }, - "description": "Ordered by `updated_at` descending." - } - }, - "required": [ - "count", - "drafts" - ] - }, - "CreateDraftRequest": { - "title": "CreateDraftRequest", - "type": "object", - "properties": { - "labels": { - "$ref": "#/components/schemas/DraftLabels", - "nullable": true - }, - "reply_to": { - "$ref": "#/components/schemas/DraftReplyTo", - "nullable": true - }, - "to": { - "$ref": "#/components/schemas/DraftTo", - "nullable": true - }, - "cc": { - "$ref": "#/components/schemas/DraftCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/DraftBcc", - "nullable": true - }, - "subject": { - "$ref": "#/components/schemas/DraftSubject", - "nullable": true - }, - "text": { - "$ref": "#/components/schemas/DraftText", - "nullable": true - }, - "html": { - "$ref": "#/components/schemas/DraftHtml", - "nullable": true - }, - "in_reply_to": { - "$ref": "#/components/schemas/DraftInReplyTo", - "nullable": true - }, - "send_at": { - "$ref": "#/components/schemas/DraftSendAt", - "nullable": true - }, - "client_id": { - "$ref": "#/components/schemas/DraftClientId", - "nullable": true - } - } - }, - "UpdateDraftRequest": { - "title": "UpdateDraftRequest", - "type": "object", - "properties": { - "reply_to": { - "$ref": "#/components/schemas/DraftReplyTo", - "nullable": true - }, - "to": { - "$ref": "#/components/schemas/DraftTo", - "nullable": true - }, - "cc": { - "$ref": "#/components/schemas/DraftCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/DraftBcc", - "nullable": true - }, - "subject": { - "$ref": "#/components/schemas/DraftSubject", - "nullable": true - }, - "text": { - "$ref": "#/components/schemas/DraftText", - "nullable": true - }, - "html": { - "$ref": "#/components/schemas/DraftHtml", - "nullable": true - }, - "send_at": { - "$ref": "#/components/schemas/DraftSendAt", - "nullable": true - } - } - }, - "EventType": { - "title": "EventType", - "type": "string", - "enum": [ - "message.received", - "message.sent", - "message.delivered", - "message.bounced", - "message.complained", - "message.rejected", - "domain.verified" - ] - }, - "EventTypes": { - "title": "EventTypes", - "type": "array", - "items": { - "$ref": "#/components/schemas/EventType" - }, - "description": "Event types for which to send events." - }, - "PodIds": { - "title": "PodIds", - "type": "array", - "items": { - "type": "string" - }, - "description": "Pods for which to send events. Maximum 10 per webhook." - }, - "InboxIds": { - "title": "InboxIds", - "type": "array", - "items": { - "type": "string" - }, - "description": "Inboxes for which to send events. Maximum 10 per webhook." - }, - "EventId": { - "title": "EventId", - "type": "string", - "description": "ID of event." - }, - "Timestamp": { - "title": "Timestamp", - "type": "string", - "format": "date-time", - "description": "Timestamp of event." - }, - "Recipient": { - "title": "Recipient", - "type": "object", - "properties": { - "address": { - "type": "string", - "description": "Recipient address." - }, - "status": { - "type": "string", - "description": "Recipient status." - } - }, - "required": [ - "address", - "status" - ] - }, - "Send": { - "title": "Send", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "timestamp": { - "$ref": "#/components/schemas/Timestamp" - }, - "recipients": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Sent recipients." - } - }, - "required": [ - "inbox_id", - "thread_id", - "message_id", - "timestamp", - "recipients" - ] - }, - "Delivery": { - "title": "Delivery", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "timestamp": { - "$ref": "#/components/schemas/Timestamp" - }, - "recipients": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Delivered recipients." - } - }, - "required": [ - "inbox_id", - "thread_id", - "message_id", - "timestamp", - "recipients" - ] - }, - "Bounce": { - "title": "Bounce", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "timestamp": { - "$ref": "#/components/schemas/Timestamp" - }, - "type": { - "type": "string", - "description": "Bounce type." - }, - "sub_type": { - "type": "string", - "description": "Bounce sub-type." - }, - "recipients": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Recipient" - }, - "description": "Bounced recipients." - } - }, - "required": [ - "inbox_id", - "thread_id", - "message_id", - "timestamp", - "type", - "sub_type", - "recipients" - ] - }, - "Complaint": { - "title": "Complaint", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "timestamp": { - "$ref": "#/components/schemas/Timestamp" - }, - "type": { - "type": "string", - "description": "Complaint type." - }, - "sub_type": { - "type": "string", - "description": "Complaint sub-type." - }, - "recipients": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Complained recipients." - } - }, - "required": [ - "inbox_id", - "thread_id", - "message_id", - "timestamp", - "type", - "sub_type", - "recipients" - ] - }, - "Reject": { - "title": "Reject", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "timestamp": { - "$ref": "#/components/schemas/Timestamp" - }, - "reason": { - "type": "string", - "description": "Reject reason." - } - }, - "required": [ - "inbox_id", - "thread_id", - "message_id", - "timestamp", - "reason" - ] - }, - "MessageReceivedEvent": { - "title": "MessageReceivedEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "event" - }, - "event_type": { - "type": "string", - "const": "message.received" - }, - "event_id": { - "$ref": "#/components/schemas/EventId" - }, - "message": { - "$ref": "#/components/schemas/Message" - }, - "thread": { - "$ref": "#/components/schemas/ThreadItem" - } - }, - "required": [ - "type", - "event_type", - "event_id", - "message", - "thread" - ] - }, - "MessageSentEvent": { - "title": "MessageSentEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "event" - }, - "event_type": { - "type": "string", - "const": "message.sent" - }, - "event_id": { - "$ref": "#/components/schemas/EventId" - }, - "send": { - "$ref": "#/components/schemas/Send" - } - }, - "required": [ - "type", - "event_type", - "event_id", - "send" - ] - }, - "MessageDeliveredEvent": { - "title": "MessageDeliveredEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "event" - }, - "event_type": { - "type": "string", - "const": "message.delivered" - }, - "event_id": { - "$ref": "#/components/schemas/EventId" - }, - "delivery": { - "$ref": "#/components/schemas/Delivery" - } - }, - "required": [ - "type", - "event_type", - "event_id", - "delivery" - ] - }, - "MessageBouncedEvent": { - "title": "MessageBouncedEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "event" - }, - "event_type": { - "type": "string", - "const": "message.bounced" - }, - "event_id": { - "$ref": "#/components/schemas/EventId" - }, - "bounce": { - "$ref": "#/components/schemas/Bounce" - } - }, - "required": [ - "type", - "event_type", - "event_id", - "bounce" - ] - }, - "MessageComplainedEvent": { - "title": "MessageComplainedEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "event" - }, - "event_type": { - "type": "string", - "const": "message.complained" - }, - "event_id": { - "$ref": "#/components/schemas/EventId" - }, - "complaint": { - "$ref": "#/components/schemas/Complaint" - } - }, - "required": [ - "type", - "event_type", - "event_id", - "complaint" - ] - }, - "MessageRejectedEvent": { - "title": "MessageRejectedEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "event" - }, - "event_type": { - "type": "string", - "const": "message.rejected" - }, - "event_id": { - "$ref": "#/components/schemas/EventId" - }, - "reject": { - "$ref": "#/components/schemas/Reject" - } - }, - "required": [ - "type", - "event_type", - "event_id", - "reject" - ] - }, - "DomainVerifiedEvent": { - "title": "DomainVerifiedEvent", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "event" - }, - "event_type": { - "type": "string", - "const": "domain.verified" - }, - "event_id": { - "$ref": "#/components/schemas/EventId" - }, - "domain": { - "$ref": "#/components/schemas/Domain" - } - }, - "required": [ - "type", - "event_type", - "event_id", - "domain" - ] - }, - "MessageId": { - "title": "MessageId", - "type": "string", - "description": "ID of message." - }, - "MessageLabels": { - "title": "MessageLabels", - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels of message." - }, - "MessageTimestamp": { - "title": "MessageTimestamp", - "type": "string", - "format": "date-time", - "description": "Time at which message was sent or drafted." - }, - "MessageFrom": { - "title": "MessageFrom", - "type": "string", - "description": "Address of sender. In format `username@domain.com` or `Display Name `." - }, - "MessageReplyTo": { - "title": "MessageReplyTo", - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses of reply-to recipients. In format `username@domain.com` or `Display Name `." - }, - "MessageTo": { - "title": "MessageTo", - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses of recipients. In format `username@domain.com` or `Display Name `." - }, - "MessageCc": { - "title": "MessageCc", - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses of CC recipients. In format `username@domain.com` or `Display Name `." - }, - "MessageBcc": { - "title": "MessageBcc", - "type": "array", - "items": { - "type": "string" - }, - "description": "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `." - }, - "MessageSubject": { - "title": "MessageSubject", - "type": "string", - "description": "Subject of message." - }, - "MessagePreview": { - "title": "MessagePreview", - "type": "string", - "description": "Text preview of message." - }, - "MessageText": { - "title": "MessageText", - "type": "string", - "description": "Plain text body of message." - }, - "MessageHtml": { - "title": "MessageHtml", - "type": "string", - "description": "HTML body of message." - }, - "MessageAttachments": { - "title": "MessageAttachments", - "type": "array", - "items": { - "$ref": "#/components/schemas/Attachment" - }, - "description": "Attachments in message." - }, - "MessageInReplyTo": { - "title": "MessageInReplyTo", - "type": "string", - "description": "ID of message being replied to." - }, - "MessageReferences": { - "title": "MessageReferences", - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of previous messages in thread." - }, - "MessageHeaders": { - "title": "MessageHeaders", - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Headers in message." - }, - "MessageSize": { - "title": "MessageSize", - "type": "integer", - "description": "Size of message in bytes." - }, - "MessageUpdatedAt": { - "title": "MessageUpdatedAt", - "type": "string", - "format": "date-time", - "description": "Time at which message was last updated." - }, - "MessageCreatedAt": { - "title": "MessageCreatedAt", - "type": "string", - "format": "date-time", - "description": "Time at which message was created." - }, - "MessageItem": { - "title": "MessageItem", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "labels": { - "$ref": "#/components/schemas/MessageLabels" - }, - "timestamp": { - "$ref": "#/components/schemas/MessageTimestamp" - }, - "from": { - "$ref": "#/components/schemas/MessageFrom" - }, - "to": { - "$ref": "#/components/schemas/MessageTo" - }, - "cc": { - "$ref": "#/components/schemas/MessageCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/MessageBcc", - "nullable": true - }, - "subject": { - "$ref": "#/components/schemas/MessageSubject", - "nullable": true - }, - "preview": { - "$ref": "#/components/schemas/MessagePreview", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/MessageAttachments", - "nullable": true - }, - "in_reply_to": { - "$ref": "#/components/schemas/MessageInReplyTo", - "nullable": true - }, - "references": { - "$ref": "#/components/schemas/MessageReferences", - "nullable": true - }, - "headers": { - "$ref": "#/components/schemas/MessageHeaders", - "nullable": true - }, - "size": { - "$ref": "#/components/schemas/MessageSize" - }, - "updated_at": { - "$ref": "#/components/schemas/MessageUpdatedAt" - }, - "created_at": { - "$ref": "#/components/schemas/MessageCreatedAt" - } - }, - "required": [ - "inbox_id", - "thread_id", - "message_id", - "labels", - "timestamp", - "from", - "to", - "size", - "updated_at", - "created_at" - ] - }, - "Message": { - "title": "Message", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "labels": { - "$ref": "#/components/schemas/MessageLabels" - }, - "timestamp": { - "$ref": "#/components/schemas/MessageTimestamp" - }, - "from": { - "$ref": "#/components/schemas/MessageFrom" - }, - "reply_to": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "description": "Reply-to addresses. In format `username@domain.com` or `Display Name `." - }, - "to": { - "$ref": "#/components/schemas/MessageTo" - }, - "cc": { - "$ref": "#/components/schemas/MessageCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/MessageBcc", - "nullable": true - }, - "subject": { - "$ref": "#/components/schemas/MessageSubject", - "nullable": true - }, - "preview": { - "$ref": "#/components/schemas/MessagePreview", - "nullable": true - }, - "text": { - "$ref": "#/components/schemas/MessageText", - "nullable": true - }, - "html": { - "$ref": "#/components/schemas/MessageHtml", - "nullable": true - }, - "extracted_text": { - "type": "string", - "nullable": true, - "description": "Extracted new text content." - }, - "extracted_html": { - "type": "string", - "nullable": true, - "description": "Extracted new HTML content." - }, - "attachments": { - "$ref": "#/components/schemas/MessageAttachments", - "nullable": true - }, - "in_reply_to": { - "$ref": "#/components/schemas/MessageInReplyTo", - "nullable": true - }, - "references": { - "$ref": "#/components/schemas/MessageReferences", - "nullable": true - }, - "headers": { - "$ref": "#/components/schemas/MessageHeaders", - "nullable": true - }, - "size": { - "$ref": "#/components/schemas/MessageSize" - }, - "updated_at": { - "$ref": "#/components/schemas/MessageUpdatedAt" - }, - "created_at": { - "$ref": "#/components/schemas/MessageCreatedAt" - } - }, - "required": [ - "inbox_id", - "thread_id", - "message_id", - "labels", - "timestamp", - "from", - "to", - "size", - "updated_at", - "created_at" - ] - }, - "ListMessagesResponse": { - "title": "ListMessagesResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "limit": { - "$ref": "#/components/schemas/Limit", - "nullable": true - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessageItem" - }, - "description": "Ordered by `timestamp` descending." - } - }, - "required": [ - "count", - "messages" - ] - }, - "Addresses": { - "title": "Addresses", - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "SendMessageReplyTo": { - "title": "SendMessageReplyTo", - "$ref": "#/components/schemas/Addresses", - "description": "Reply-to address or addresses." - }, - "SendMessageTo": { - "title": "SendMessageTo", - "$ref": "#/components/schemas/Addresses", - "description": "Recipient address or addresses." - }, - "SendMessageCc": { - "title": "SendMessageCc", - "$ref": "#/components/schemas/Addresses", - "description": "CC recipient address or addresses." - }, - "SendMessageBcc": { - "title": "SendMessageBcc", - "$ref": "#/components/schemas/Addresses", - "description": "BCC recipient address or addresses." - }, - "SendMessageAttachments": { - "title": "SendMessageAttachments", - "type": "array", - "items": { - "$ref": "#/components/schemas/SendAttachment" - }, - "description": "Attachments to include in message." - }, - "SendMessageHeaders": { - "title": "SendMessageHeaders", - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Headers to include in message." - }, - "SendMessageRequest": { - "title": "SendMessageRequest", - "type": "object", - "properties": { - "labels": { - "$ref": "#/components/schemas/MessageLabels", - "nullable": true - }, - "reply_to": { - "$ref": "#/components/schemas/SendMessageReplyTo", - "nullable": true - }, - "to": { - "$ref": "#/components/schemas/SendMessageTo", - "nullable": true - }, - "cc": { - "$ref": "#/components/schemas/SendMessageCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/SendMessageBcc", - "nullable": true - }, - "subject": { - "$ref": "#/components/schemas/MessageSubject", - "nullable": true - }, - "text": { - "$ref": "#/components/schemas/MessageText", - "nullable": true - }, - "html": { - "$ref": "#/components/schemas/MessageHtml", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/SendMessageAttachments", - "nullable": true - }, - "headers": { - "$ref": "#/components/schemas/SendMessageHeaders", - "nullable": true - } - } - }, - "SendMessageResponse": { - "title": "SendMessageResponse", - "type": "object", - "properties": { - "message_id": { - "$ref": "#/components/schemas/MessageId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - } - }, - "required": [ - "message_id", - "thread_id" - ] - }, - "RawMessageResponse": { - "title": "RawMessageResponse", - "type": "object", - "description": "S3 presigned URL to download the raw .eml file.", - "properties": { - "message_id": { - "$ref": "#/components/schemas/MessageId", - "description": "ID of the message." - }, - "size": { - "$ref": "#/components/schemas/MessageSize", - "description": "Size of the raw message in bytes." - }, - "download_url": { - "type": "string", - "description": "S3 presigned URL to download the raw message. Expires at expires_at." - }, - "expires_at": { - "type": "string", - "format": "date-time", - "description": "Time at which the download URL expires." - } - }, - "required": [ - "message_id", - "size", - "download_url", - "expires_at" - ] - }, - "ReplyAll": { - "title": "ReplyAll", - "type": "boolean", - "description": "Reply to all recipients of the original message." - }, - "ReplyToMessageRequest": { - "title": "ReplyToMessageRequest", - "type": "object", - "properties": { - "labels": { - "$ref": "#/components/schemas/MessageLabels", - "nullable": true - }, - "reply_to": { - "$ref": "#/components/schemas/SendMessageReplyTo", - "nullable": true - }, - "to": { - "$ref": "#/components/schemas/SendMessageTo", - "nullable": true - }, - "cc": { - "$ref": "#/components/schemas/SendMessageCc", - "nullable": true - }, - "bcc": { - "$ref": "#/components/schemas/SendMessageBcc", - "nullable": true - }, - "reply_all": { - "$ref": "#/components/schemas/ReplyAll", - "nullable": true - }, - "text": { - "$ref": "#/components/schemas/MessageText", - "nullable": true - }, - "html": { - "$ref": "#/components/schemas/MessageHtml", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/SendMessageAttachments", - "nullable": true - }, - "headers": { - "$ref": "#/components/schemas/SendMessageHeaders", - "nullable": true - } - } - }, - "ReplyAllMessageRequest": { - "title": "ReplyAllMessageRequest", - "type": "object", - "properties": { - "labels": { - "$ref": "#/components/schemas/MessageLabels", - "nullable": true - }, - "reply_to": { - "$ref": "#/components/schemas/SendMessageReplyTo", - "nullable": true - }, - "text": { - "$ref": "#/components/schemas/MessageText", - "nullable": true - }, - "html": { - "$ref": "#/components/schemas/MessageHtml", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/SendMessageAttachments", - "nullable": true - }, - "headers": { - "$ref": "#/components/schemas/SendMessageHeaders", - "nullable": true - } - } - }, - "UpdateMessageRequest": { - "title": "UpdateMessageRequest", - "type": "object", - "properties": { - "add_labels": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "description": "Labels to add to message." - }, - "remove_labels": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true, - "description": "Labels to remove from message." - } - } - }, - "MetricEventType": { - "title": "MetricEventType", - "type": "string", - "enum": [ - "message.sent", - "message.delivered", - "message.bounced", - "message.delayed", - "message.rejected", - "message.complained", - "message.received" - ], - "description": "Type of metric event." - }, - "MetricTimestamp": { - "title": "MetricTimestamp", - "type": "string", - "format": "date-time", - "description": "Timestamp when the metric event occurred." - }, - "MetricEventTypes": { - "title": "MetricEventTypes", - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricEventType" - }, - "description": "List of metric event types to filter by." - }, - "MetricStartTimestamp": { - "title": "MetricStartTimestamp", - "type": "string", - "format": "date-time", - "description": "Start timestamp for the metrics query range." - }, - "MetricEndTimestamp": { - "title": "MetricEndTimestamp", - "type": "string", - "format": "date-time", - "description": "End timestamp for the metrics query range." - }, - "MessageMetrics": { - "title": "MessageMetrics", - "type": "object", - "properties": { - "sent": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricTimestamp" - }, - "nullable": true, - "description": "Timestamps when messages were sent." - }, - "delivered": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricTimestamp" - }, - "nullable": true, - "description": "Timestamps when messages were delivered." - }, - "bounced": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricTimestamp" - }, - "nullable": true, - "description": "Timestamps when messages bounced." - }, - "delayed": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricTimestamp" - }, - "nullable": true, - "description": "Timestamps when messages were delayed." - }, - "rejected": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricTimestamp" - }, - "nullable": true, - "description": "Timestamps when messages were rejected." - }, - "complained": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricTimestamp" - }, - "nullable": true, - "description": "Timestamps when messages received complaints." - }, - "received": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetricTimestamp" - }, - "nullable": true, - "description": "Timestamps when messages were received." - } - } - }, - "ListMetricsResponse": { - "title": "ListMetricsResponse", - "type": "object", - "properties": { - "message": { - "$ref": "#/components/schemas/MessageMetrics", - "nullable": true, - "description": "Message metrics grouped by event type." - } - } - }, - "Organization": { - "title": "Organization", - "type": "object", - "description": "Organization details with usage limits and counts.", - "properties": { - "organization_id": { - "$ref": "#/components/schemas/OrganizationId" - }, - "inbox_count": { - "type": "integer", - "description": "Current number of inboxes." - }, - "domain_count": { - "type": "integer", - "description": "Current number of domains." - }, - "inbox_limit": { - "type": "integer", - "nullable": true, - "description": "Maximum number of inboxes allowed." - }, - "domain_limit": { - "type": "integer", - "nullable": true, - "description": "Maximum number of domains allowed." - }, - "updated_at": { - "type": "string", - "format": "date-time", - "description": "Time at which organization was last updated." - }, - "created_at": { - "type": "string", - "format": "date-time", - "description": "Time at which organization was created." - } - }, - "required": [ - "organization_id", - "inbox_count", - "domain_count", - "updated_at", - "created_at" - ] - }, - "ThreadId": { - "title": "ThreadId", - "type": "string", - "description": "ID of thread." - }, - "ThreadLabels": { - "title": "ThreadLabels", - "type": "array", - "items": { - "type": "string" - }, - "description": "Labels of thread." - }, - "ThreadTimestamp": { - "title": "ThreadTimestamp", - "type": "string", - "format": "date-time", - "description": "Timestamp of last sent or received message." - }, - "ThreadReceivedTimestamp": { - "title": "ThreadReceivedTimestamp", - "type": "string", - "format": "date-time", - "description": "Timestamp of last received message." - }, - "ThreadSentTimestamp": { - "title": "ThreadSentTimestamp", - "type": "string", - "format": "date-time", - "description": "Timestamp of last sent message." - }, - "ThreadSenders": { - "title": "ThreadSenders", - "type": "array", - "items": { - "type": "string" - }, - "description": "Senders in thread. In format `username@domain.com` or `Display Name `." - }, - "ThreadRecipients": { - "title": "ThreadRecipients", - "type": "array", - "items": { - "type": "string" - }, - "description": "Recipients in thread. In format `username@domain.com` or `Display Name `." - }, - "ThreadSubject": { - "title": "ThreadSubject", - "type": "string", - "description": "Subject of thread." - }, - "ThreadPreview": { - "title": "ThreadPreview", - "type": "string", - "description": "Text preview of last message in thread." - }, - "ThreadAttachments": { - "title": "ThreadAttachments", - "type": "array", - "items": { - "$ref": "#/components/schemas/Attachment" - }, - "description": "Attachments in thread." - }, - "ThreadLastMessageId": { - "title": "ThreadLastMessageId", - "type": "string", - "description": "ID of last message in thread." - }, - "ThreadMessageCount": { - "title": "ThreadMessageCount", - "type": "integer", - "description": "Number of messages in thread." - }, - "ThreadSize": { - "title": "ThreadSize", - "type": "integer", - "description": "Size of thread in bytes." - }, - "ThreadUpdatedAt": { - "title": "ThreadUpdatedAt", - "type": "string", - "format": "date-time", - "description": "Time at which thread was last updated." - }, - "ThreadCreatedAt": { - "title": "ThreadCreatedAt", - "type": "string", - "format": "date-time", - "description": "Time at which thread was created." - }, - "ThreadItem": { - "title": "ThreadItem", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "labels": { - "$ref": "#/components/schemas/ThreadLabels" - }, - "timestamp": { - "$ref": "#/components/schemas/ThreadTimestamp" - }, - "received_timestamp": { - "$ref": "#/components/schemas/ThreadReceivedTimestamp", - "nullable": true - }, - "sent_timestamp": { - "$ref": "#/components/schemas/ThreadSentTimestamp", - "nullable": true - }, - "senders": { - "$ref": "#/components/schemas/ThreadSenders" - }, - "recipients": { - "$ref": "#/components/schemas/ThreadRecipients" - }, - "subject": { - "$ref": "#/components/schemas/ThreadSubject", - "nullable": true - }, - "preview": { - "$ref": "#/components/schemas/ThreadPreview", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/ThreadAttachments", - "nullable": true - }, - "last_message_id": { - "$ref": "#/components/schemas/ThreadLastMessageId" - }, - "message_count": { - "$ref": "#/components/schemas/ThreadMessageCount" - }, - "size": { - "$ref": "#/components/schemas/ThreadSize" - }, - "updated_at": { - "$ref": "#/components/schemas/ThreadUpdatedAt" - }, - "created_at": { - "$ref": "#/components/schemas/ThreadCreatedAt" - } - }, - "required": [ - "inbox_id", - "thread_id", - "labels", - "timestamp", - "senders", - "recipients", - "last_message_id", - "message_count", - "size", - "updated_at", - "created_at" - ] - }, - "Thread": { - "title": "Thread", - "type": "object", - "properties": { - "inbox_id": { - "$ref": "#/components/schemas/inboxesInboxId" - }, - "thread_id": { - "$ref": "#/components/schemas/ThreadId" - }, - "labels": { - "$ref": "#/components/schemas/ThreadLabels" - }, - "timestamp": { - "$ref": "#/components/schemas/ThreadTimestamp" - }, - "received_timestamp": { - "$ref": "#/components/schemas/ThreadReceivedTimestamp", - "nullable": true - }, - "sent_timestamp": { - "$ref": "#/components/schemas/ThreadSentTimestamp", - "nullable": true - }, - "senders": { - "$ref": "#/components/schemas/ThreadSenders" - }, - "recipients": { - "$ref": "#/components/schemas/ThreadRecipients" - }, - "subject": { - "$ref": "#/components/schemas/ThreadSubject", - "nullable": true - }, - "preview": { - "$ref": "#/components/schemas/ThreadPreview", - "nullable": true - }, - "attachments": { - "$ref": "#/components/schemas/ThreadAttachments", - "nullable": true - }, - "last_message_id": { - "$ref": "#/components/schemas/ThreadLastMessageId" - }, - "message_count": { - "$ref": "#/components/schemas/ThreadMessageCount" - }, - "size": { - "$ref": "#/components/schemas/ThreadSize" - }, - "updated_at": { - "$ref": "#/components/schemas/ThreadUpdatedAt" - }, - "created_at": { - "$ref": "#/components/schemas/ThreadCreatedAt" - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message" - }, - "description": "Messages in thread. Ordered by `timestamp` ascending." - } - }, - "required": [ - "inbox_id", - "thread_id", - "labels", - "timestamp", - "senders", - "recipients", - "last_message_id", - "message_count", - "size", - "updated_at", - "created_at", - "messages" - ] - }, - "ListThreadsResponse": { - "title": "ListThreadsResponse", - "type": "object", - "properties": { - "count": { - "$ref": "#/components/schemas/Count" - }, - "limit": { - "$ref": "#/components/schemas/Limit", - "nullable": true - }, - "next_page_token": { - "$ref": "#/components/schemas/PageToken", - "nullable": true - }, - "threads": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ThreadItem" - }, - "description": "Ordered by `timestamp` descending." - } - }, - "required": [ - "count", - "threads" - ] - }, - "webhooksSvixId": { - "title": "webhooksSvixId", - "type": "string", - "description": "ID of webhook message." - }, - "webhooksSvixTimestamp": { - "title": "webhooksSvixTimestamp", - "type": "string", - "format": "date-time", - "description": "Timestamp of webhook message." - }, - "webhooksSvixSignature": { - "title": "webhooksSvixSignature", - "type": "string", - "description": "Signature of webhook message." - }, - "Subscribe": { - "title": "Subscribe", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "subscribe" - }, - "event_types": { - "$ref": "#/components/schemas/EventTypes", - "nullable": true - }, - "inbox_ids": { - "$ref": "#/components/schemas/InboxIds", - "nullable": true - }, - "pod_ids": { - "$ref": "#/components/schemas/PodIds", - "nullable": true - } - }, - "required": [ - "type" - ] - }, - "Subscribed": { - "title": "Subscribed", - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "subscribed" - }, - "event_types": { - "$ref": "#/components/schemas/EventTypes", - "nullable": true - }, - "inbox_ids": { - "$ref": "#/components/schemas/InboxIds", - "nullable": true - }, - "pod_ids": { - "$ref": "#/components/schemas/PodIds", - "nullable": true - } - }, - "required": [ - "type" - ] - } - }, - "securitySchemes": { - "BearerAuth": { - "type": "http", - "scheme": "bearer" - } - } - }, - "servers": [ - { - "url": "https://api.agentmail.to", - "description": "Prod (US)" - }, - { - "url": "https://api.agentmail.eu", - "description": "Prod (EU)" - } - ] -} \ No newline at end of file diff --git a/fern/apis/cli/openapi.yml b/fern/apis/cli/openapi.yml new file mode 100644 index 00000000..3696b33d --- /dev/null +++ b/fern/apis/cli/openapi.yml @@ -0,0 +1,7776 @@ +openapi: 3.0.1 +info: + title: api + version: '' +paths: + /v0/inboxes: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes list + ``` + operationId: inboxes_list + tags: + - Inboxes + parameters: + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesListInboxesResponse' + summary: List Inboxes + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes create --display-name "My Agent" --username myagent + --domain agentmail.to + + ``` + operationId: inboxes_create + tags: + - Inboxes + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesInbox' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesCreateInboxRequest' + nullable: true + /v0/inboxes/{inbox_id}: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes get --inbox-id + ``` + operationId: inboxes_get + tags: + - Inboxes + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesInbox' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: >- + **CLI:** + + ```bash + + agentmail inboxes update --inbox-id --display-name "Updated + Name" + + ``` + operationId: inboxes_update + tags: + - Inboxes + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesInbox' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesUpdateInboxRequest' + delete: + description: |- + **CLI:** + ```bash + agentmail inboxes delete --inbox-id + ``` + operationId: inboxes_delete + tags: + - Inboxes + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods: + get: + description: |- + **CLI:** + ```bash + agentmail pods list + ``` + operationId: pods_list + tags: + - Pods + parameters: + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/podsListPodsResponse' + summary: List Pods + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: |- + **CLI:** + ```bash + agentmail pods create --client-id my-pod + ``` + operationId: pods_create + tags: + - Pods + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/podsPod' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create Pod + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/podsCreatePodRequest' + /v0/pods/{pod_id}: + get: + description: |- + **CLI:** + ```bash + agentmail pods get --pod-id + ``` + operationId: pods_get + tags: + - Pods + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/podsPod' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Pod + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + delete: + description: |- + **CLI:** + ```bash + agentmail pods delete --pod-id + ``` + operationId: pods_delete + tags: + - Pods + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Pod + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/webhooks: + get: + description: |- + **CLI:** + ```bash + agentmail webhooks list + ``` + operationId: webhooks_list + tags: + - Webhooks + parameters: + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/webhooksListWebhooksResponse' + summary: List Webhooks + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: >- + **CLI:** + + ```bash + + agentmail webhooks create --url https://example.com/webhook --event-type + message.received + + ``` + operationId: webhooks_create + tags: + - Webhooks + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/webhooksWebhook' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create Webhook + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/webhooksCreateWebhookRequest' + /v0/webhooks/{webhook_id}: + get: + description: |- + **CLI:** + ```bash + agentmail webhooks get --webhook-id + ``` + operationId: webhooks_get + tags: + - Webhooks + parameters: + - name: webhook_id + in: path + required: true + schema: + $ref: '#/components/schemas/webhooksWebhookId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/webhooksWebhook' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Webhook + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: >- + Update inbox or pod subscriptions, or replace the webhook's + `event_types` in full when you pass a + + non-empty `event_types` array (see request field docs). Inbox and pod + changes use add/remove lists. + + + **CLI:** + + ```bash + + agentmail webhooks update --webhook-id --add-inbox-id + + + ``` + operationId: webhooks_update + tags: + - Webhooks + parameters: + - name: webhook_id + in: path + required: true + schema: + $ref: '#/components/schemas/webhooksWebhookId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/webhooksWebhook' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Webhook + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/webhooksUpdateWebhookRequest' + delete: + description: |- + **CLI:** + ```bash + agentmail webhooks delete --webhook-id + ``` + operationId: webhooks_delete + tags: + - Webhooks + parameters: + - name: webhook_id + in: path + required: true + schema: + $ref: '#/components/schemas/webhooksWebhookId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Webhook + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/agent/sign-up: + post: + description: >- + Create a new agent organization with an inbox and API key. This endpoint + is for signing up for the first time. If you've already signed up, + you're all set — just use your existing API key. + + + A 6-digit OTP is sent to the human's email for verification. + + + This endpoint is idempotent. Calling it again with the same + `human_email` will rotate the API key and resend the OTP if expired. + + + The returned API key has limited permissions until the organization is + verified via the verify endpoint. + + + **CLI:** + + ```bash + + agentmail agent sign-up --human-email user@example.com --username + my-agent + + ``` + operationId: agent_signUp + tags: + - Agent + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AgentSignupResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Sign Up + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentSignupRequest' + /v0/agent/verify: + post: + description: >- + Verify an agent organization using the 6-digit OTP sent to the human's + email during sign-up. + + + On success, the organization is upgraded from `agent_unverified` to + `agent_verified`, the send allowlist is removed, and free plan + entitlements are applied. + + + The OTP expires after 24 hours and allows a maximum of 10 attempts. If + you run into any difficulties receiving the OTP code, you can also + create an account on + [console.agentmail.to](https://console.agentmail.to) using the human + email address you provided to verify your account. + + + **CLI:** + + ```bash + + agentmail agent verify --otp-code 123456 + + ``` + operationId: agent_verify + tags: + - Agent + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVerifyResponse' + summary: Verify + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVerifyRequest' + /v0/api-keys: + get: + description: |- + **CLI:** + ```bash + agentmail api-keys list + ``` + operationId: apiKeys_list + tags: + - ApiKeys + parameters: + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListApiKeysResponse' + summary: List API Keys + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: |- + **CLI:** + ```bash + agentmail api-keys create --name "My Key" + ``` + operationId: apiKeys_create + tags: + - ApiKeys + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiKeyResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create API Key + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiKeyRequest' + /v0/api-keys/{api_key_id}: + delete: + description: |- + **CLI:** + ```bash + agentmail api-keys delete --api-key-id + ``` + operationId: apiKeys_delete + tags: + - ApiKeys + parameters: + - name: api_key_id + in: path + required: true + schema: + $ref: '#/components/schemas/ApiKeyId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete API Key + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/domains: + get: + description: |- + **CLI:** + ```bash + agentmail domains list + ``` + operationId: domains_list + tags: + - Domains + parameters: + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListDomainsResponse' + summary: List Domains + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: |- + **CLI:** + ```bash + agentmail domains create --domain example.com + ``` + operationId: domains_create + tags: + - Domains + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDomainRequest' + /v0/domains/{domain_id}: + get: + description: |- + **CLI:** + ```bash + agentmail domains get --domain-id + ``` + operationId: domains_get + tags: + - Domains + parameters: + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: |- + **CLI:** + ```bash + agentmail domains update --domain-id + ``` + operationId: domains_update + tags: + - Domains + parameters: + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDomainRequest' + delete: + description: |- + **CLI:** + ```bash + agentmail domains delete --domain-id + ``` + operationId: domains_delete + tags: + - Domains + parameters: + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/domains/{domain_id}/zone-file: + get: + description: |- + **CLI:** + ```bash + agentmail domains get-zone-file --domain-id + ``` + operationId: domains_getZoneFile + tags: + - Domains + parameters: + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Zone File + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/domains/{domain_id}/verify: + post: + description: |- + **CLI:** + ```bash + agentmail domains verify --domain-id + ``` + operationId: domains_verify + tags: + - Domains + parameters: + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Verify Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/drafts: + get: + description: |- + **CLI:** + ```bash + agentmail drafts list + ``` + operationId: drafts_list + tags: + - Drafts + parameters: + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: labels + in: query + required: false + schema: + $ref: '#/components/schemas/Labels' + nullable: true + - name: before + in: query + required: false + schema: + $ref: '#/components/schemas/Before' + nullable: true + - name: after + in: query + required: false + schema: + $ref: '#/components/schemas/After' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListDraftsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Drafts + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/drafts/{draft_id}: + get: + description: |- + **CLI:** + ```bash + agentmail drafts get --draft-id + ``` + operationId: drafts_get + tags: + - Drafts + parameters: + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Draft' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Draft + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/drafts/{draft_id}/attachments/{attachment_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail drafts get-attachment --draft-id --attachment-id + + + ``` + operationId: drafts_getAttachment + tags: + - Drafts + parameters: + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + - name: attachment_id + in: path + required: true + schema: + $ref: '#/components/schemas/AttachmentId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Attachment + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/api-keys: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes:api-keys list --inbox-id + ``` + operationId: inboxes_apiKeys_list + tags: + - InboxesApiKeys + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListApiKeysResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List API Keys + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: |- + **CLI:** + ```bash + agentmail inboxes:api-keys create --inbox-id --name "My Key" + ``` + operationId: inboxes_apiKeys_create + tags: + - InboxesApiKeys + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiKeyResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Create API Key + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiKeyRequest' + /v0/inboxes/{inbox_id}/api-keys/{api_key_id}: + delete: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:api-keys delete --inbox-id --api-key-id + + + ``` + operationId: inboxes_apiKeys_delete + tags: + - InboxesApiKeys + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: api_key_id + in: path + required: true + schema: + $ref: '#/components/schemas/ApiKeyId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete API Key + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/drafts: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes:drafts list --inbox-id + ``` + operationId: inboxes_drafts_list + tags: + - InboxesDrafts + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: labels + in: query + required: false + schema: + $ref: '#/components/schemas/Labels' + nullable: true + - name: before + in: query + required: false + schema: + $ref: '#/components/schemas/Before' + nullable: true + - name: after + in: query + required: false + schema: + $ref: '#/components/schemas/After' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListDraftsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Drafts + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:drafts create --inbox-id --to + recipient@example.com --subject "Draft subject" --text "Draft body" + + ``` + operationId: inboxes_drafts_create + tags: + - InboxesDrafts + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Draft' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Create Draft + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDraftRequest' + /v0/inboxes/{inbox_id}/drafts/{draft_id}: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes:drafts get --inbox-id --draft-id + ``` + operationId: inboxes_drafts_get + tags: + - InboxesDrafts + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Draft' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Draft + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:drafts update --inbox-id --draft-id + --subject "Updated subject" + + ``` + operationId: inboxes_drafts_update + tags: + - InboxesDrafts + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Draft' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Draft + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDraftRequest' + delete: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:drafts delete --inbox-id --draft-id + + + ``` + operationId: inboxes_drafts_delete + tags: + - InboxesDrafts + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Draft + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/drafts/{draft_id}/attachments/{attachment_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:drafts get-attachment --inbox-id --draft-id + --attachment-id + + ``` + operationId: inboxes_drafts_getAttachment + tags: + - InboxesDrafts + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + - name: attachment_id + in: path + required: true + schema: + $ref: '#/components/schemas/AttachmentId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Attachment + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/drafts/{draft_id}/send: + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:drafts send --inbox-id --draft-id + + + ``` + operationId: inboxes_drafts_send + tags: + - InboxesDrafts + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '403': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Send Draft + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMessageRequest' + /v0/inboxes/{inbox_id}/events: + get: + description: >- + List label change events for an inbox. Returns events in reverse + chronological order by default. Use for IMAP UID projection or audit + logging. + + + **CLI:** + + ```bash + + agentmail inboxes:events list --inbox-id + + ``` + operationId: inboxes_events_list + tags: + - InboxesEvents + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListInboxEventsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Inbox Events + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/lists/{direction}/{type}: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:lists list --inbox-id --direction + --type + + ``` + operationId: inboxes_lists_list + tags: + - InboxesLists + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PodListListEntriesResponse' + summary: List Entries + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:lists create --inbox-id --direction + --type --entry user@example.com + + ``` + operationId: inboxes_lists_create + tags: + - InboxesLists + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PodListEntry' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateListEntryRequest' + /v0/inboxes/{inbox_id}/lists/{direction}/{type}/{entry}: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:lists get --inbox-id --direction + --type --entry + + ``` + operationId: inboxes_lists_get + tags: + - InboxesLists + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: entry + in: path + description: Email address or domain. + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PodListEntry' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + delete: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:lists delete --inbox-id --direction + --type --entry + + ``` + operationId: inboxes_lists_delete + tags: + - InboxesLists + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: entry + in: path + description: Email address or domain. + required: true + schema: + type: string + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/messages: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes:messages list --inbox-id + ``` + operationId: inboxes_messages_list + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: labels + in: query + required: false + schema: + $ref: '#/components/schemas/Labels' + nullable: true + - name: before + in: query + required: false + schema: + $ref: '#/components/schemas/Before' + nullable: true + - name: after + in: query + required: false + schema: + $ref: '#/components/schemas/After' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + - name: include_spam + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeSpam' + nullable: true + - name: include_blocked + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeBlocked' + nullable: true + - name: include_unauthenticated + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeUnauthenticated' + nullable: true + - name: include_trash + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeTrash' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListMessagesResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Messages + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/messages/{message_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages get --inbox-id --message-id + + + ``` + operationId: inboxes_messages_get + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages update --inbox-id --message-id + --add-label read --remove-label unread + + ``` + operationId: inboxes_messages_update + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMessageResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMessageRequest' + delete: + description: >- + Permanently deletes a message. + + + **CLI:** + + ```bash + + agentmail inboxes:messages delete --inbox-id --message-id + + + ``` + operationId: inboxes_messages_delete + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/messages/batch-get: + post: + description: >- + Fetch metadata for up to 500 messages in one request. Missing or + + restricted IDs are silently omitted; compare `count` against `limit` + + to detect misses. + + + **CLI:** + + ```bash + + agentmail inboxes:messages batch-get --inbox-id --message-id + --message-id + + ``` + operationId: inboxes_messages_batchGet + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/BatchGetMessagesResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Batch Get Messages + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BatchGetMessagesRequest' + /v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages get-attachment --inbox-id + --message-id --attachment-id + + ``` + operationId: inboxes_messages_getAttachment + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + - name: attachment_id + in: path + required: true + schema: + $ref: '#/components/schemas/AttachmentId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Attachment + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/messages/{message_id}/raw: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages get-raw --inbox-id --message-id + + + ``` + operationId: inboxes_messages_getRaw + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/RawMessageResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Raw Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/messages/send: + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages send --inbox-id --to + recipient@example.com --subject "Hello" --text "Body" + + ``` + operationId: inboxes_messages_send + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '403': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Send Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageRequest' + /v0/inboxes/{inbox_id}/messages/{message_id}/reply: + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages reply --inbox-id --message-id + --text "Reply text" + + ``` + operationId: inboxes_messages_reply + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '403': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Reply To Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReplyToMessageRequest' + /v0/inboxes/{inbox_id}/messages/{message_id}/reply-all: + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages reply-all --inbox-id --message-id + --text "Reply text" + + ``` + operationId: inboxes_messages_reply-all + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '403': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Reply All Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReplyAllMessageRequest' + /v0/inboxes/{inbox_id}/messages/{message_id}/forward: + post: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:messages forward --inbox-id --message-id + --to recipient@example.com + + ``` + operationId: inboxes_messages_forward + tags: + - InboxesMessages + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: message_id + in: path + required: true + schema: + $ref: '#/components/schemas/MessageId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '403': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Forward Message + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageRequest' + /v0/inboxes/{inbox_id}/metrics: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes:metrics query --inbox-id + ``` + operationId: inboxes_metrics_query + tags: + - InboxesMetrics + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: event_types + in: query + required: false + schema: + $ref: '#/components/schemas/MetricEventTypes' + nullable: true + - name: start + in: query + required: false + schema: + $ref: '#/components/schemas/Start' + nullable: true + - name: end + in: query + required: false + schema: + $ref: '#/components/schemas/End' + nullable: true + - name: period + in: query + required: false + schema: + $ref: '#/components/schemas/Period' + nullable: true + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/MetricLimit' + nullable: true + - name: descending + in: query + required: false + schema: + $ref: '#/components/schemas/Descending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/QueryMetricsResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Query Metrics + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/threads: + get: + description: |- + **CLI:** + ```bash + agentmail inboxes:threads list --inbox-id + ``` + operationId: inboxes_threads_list + tags: + - InboxesThreads + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: labels + in: query + required: false + schema: + $ref: '#/components/schemas/Labels' + nullable: true + - name: before + in: query + required: false + schema: + $ref: '#/components/schemas/Before' + nullable: true + - name: after + in: query + required: false + schema: + $ref: '#/components/schemas/After' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + - name: include_spam + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeSpam' + nullable: true + - name: include_blocked + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeBlocked' + nullable: true + - name: include_unauthenticated + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeUnauthenticated' + nullable: true + - name: include_trash + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeTrash' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListThreadsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Threads + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/threads/{thread_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:threads get --inbox-id --thread-id + + + ``` + operationId: inboxes_threads_get + tags: + - InboxesThreads + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Thread' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: >- + Updates thread labels. Cannot add or remove system labels (sent, + received, bounced, etc.). Rejects requests with a `422` for threads with + 100 or more messages. + operationId: inboxes_threads_update + tags: + - InboxesThreads + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateThreadResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateThreadRequest' + delete: + description: >- + Moves the thread to trash by adding a trash label to all messages. If + the thread is already in trash, it will be permanently deleted. Use + `permanent=true` to force permanent deletion. + + + **CLI:** + + ```bash + + agentmail inboxes:threads delete --inbox-id --thread-id + + + ``` + operationId: inboxes_threads_delete + tags: + - InboxesThreads + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + - name: permanent + in: query + description: If true, permanently delete the thread instead of moving to trash. + required: false + schema: + type: boolean + nullable: true + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail inboxes:threads get-attachment --inbox-id + --thread-id --attachment-id + + ``` + operationId: inboxes_threads_getAttachment + tags: + - InboxesThreads + parameters: + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + - name: attachment_id + in: path + required: true + schema: + $ref: '#/components/schemas/AttachmentId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Attachment + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/lists/{direction}/{type}: + get: + description: |- + **CLI:** + ```bash + agentmail lists list --direction --type + ``` + operationId: lists_list + tags: + - Lists + parameters: + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListListEntriesResponse' + summary: List Entries + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: >- + **CLI:** + + ```bash + + agentmail lists create --direction --type --entry + user@example.com + + ``` + operationId: lists_create + tags: + - Lists + parameters: + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListEntry' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateListEntryRequest' + /v0/lists/{direction}/{type}/{entry}: + get: + description: >- + **CLI:** + + ```bash + + agentmail lists get --direction --type --entry + + + ``` + operationId: lists_get + tags: + - Lists + parameters: + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: entry + in: path + description: Email address or domain. + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListEntry' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + delete: + description: >- + **CLI:** + + ```bash + + agentmail lists delete --direction --type --entry + + + ``` + operationId: lists_delete + tags: + - Lists + parameters: + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: entry + in: path + description: Email address or domain. + required: true + schema: + type: string + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/metrics: + get: + description: |- + **CLI:** + ```bash + agentmail metrics list + ``` + operationId: metrics_query + tags: + - Metrics + parameters: + - name: event_types + in: query + required: false + schema: + $ref: '#/components/schemas/MetricEventTypes' + nullable: true + - name: start + in: query + required: false + schema: + $ref: '#/components/schemas/Start' + nullable: true + - name: end + in: query + required: false + schema: + $ref: '#/components/schemas/End' + nullable: true + - name: period + in: query + required: false + schema: + $ref: '#/components/schemas/Period' + nullable: true + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/MetricLimit' + nullable: true + - name: descending + in: query + required: false + schema: + $ref: '#/components/schemas/Descending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/QueryMetricsResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Query Metrics + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/organizations: + get: + description: >- + Returns the organization for the authenticated API key (usage limits, + counts, and billing metadata). + + + **CLI:** + + ```bash + + agentmail organizations get + + ``` + operationId: organizations_get + tags: + - Organizations + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Organization' + summary: Get Organization + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/api-keys: + get: + description: |- + **CLI:** + ```bash + agentmail pods:api-keys list --pod-id + ``` + operationId: pods_apiKeys_list + tags: + - PodsApiKeys + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListApiKeysResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List API Keys + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: |- + **CLI:** + ```bash + agentmail pods:api-keys create --pod-id --name "My Key" + ``` + operationId: pods_apiKeys_create + tags: + - PodsApiKeys + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiKeyResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Create API Key + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateApiKeyRequest' + /v0/pods/{pod_id}/api-keys/{api_key_id}: + delete: + description: >- + **CLI:** + + ```bash + + agentmail pods:api-keys delete --pod-id --api-key-id + + + ``` + operationId: pods_apiKeys_delete + tags: + - PodsApiKeys + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: api_key_id + in: path + required: true + schema: + $ref: '#/components/schemas/ApiKeyId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete API Key + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/domains: + get: + description: |- + **CLI:** + ```bash + agentmail pods:domains list --pod-id + ``` + operationId: pods_domains_list + tags: + - PodsDomains + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListDomainsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Domains + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: |- + **CLI:** + ```bash + agentmail pods:domains create --pod-id --domain example.com + ``` + operationId: pods_domains_create + tags: + - PodsDomains + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDomainRequest' + /v0/pods/{pod_id}/domains/{domain_id}: + get: + description: |- + **CLI:** + ```bash + agentmail pods:domains get --pod-id --domain-id + ``` + operationId: pods_domains_get + tags: + - PodsDomains + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: |- + **CLI:** + ```bash + agentmail pods:domains update --pod-id --domain-id + ``` + operationId: pods_domains_update + tags: + - PodsDomains + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDomainRequest' + delete: + description: |- + **CLI:** + ```bash + agentmail pods:domains delete --pod-id --domain-id + ``` + operationId: pods_domains_delete + tags: + - PodsDomains + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/domains/{domain_id}/zone-file: + get: + description: >- + **CLI:** + + ```bash + + agentmail pods:domains get-zone-file --pod-id --domain-id + + + ``` + operationId: pods_domains_getZoneFile + tags: + - PodsDomains + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Zone File + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/domains/{domain_id}/verify: + post: + description: |- + **CLI:** + ```bash + agentmail pods:domains verify --pod-id --domain-id + ``` + operationId: pods_domains_verify + tags: + - PodsDomains + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: domain_id + in: path + required: true + schema: + $ref: '#/components/schemas/DomainId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Verify Domain + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/drafts: + get: + description: |- + **CLI:** + ```bash + agentmail pods:drafts list --pod-id + ``` + operationId: pods_drafts_list + tags: + - PodsDrafts + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: labels + in: query + required: false + schema: + $ref: '#/components/schemas/Labels' + nullable: true + - name: before + in: query + required: false + schema: + $ref: '#/components/schemas/Before' + nullable: true + - name: after + in: query + required: false + schema: + $ref: '#/components/schemas/After' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListDraftsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Drafts + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/drafts/{draft_id}: + get: + description: |- + **CLI:** + ```bash + agentmail pods:drafts get --pod-id --draft-id + ``` + operationId: pods_drafts_get + tags: + - PodsDrafts + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Draft' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Draft + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/drafts/{draft_id}/attachments/{attachment_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail pods:drafts get-attachment --pod-id --draft-id + --attachment-id + + ``` + operationId: pods_drafts_getAttachment + tags: + - PodsDrafts + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: draft_id + in: path + required: true + schema: + $ref: '#/components/schemas/DraftId' + - name: attachment_id + in: path + required: true + schema: + $ref: '#/components/schemas/AttachmentId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Attachment + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/inboxes: + get: + description: |- + **CLI:** + ```bash + agentmail pods:inboxes list --pod-id + ``` + operationId: pods_inboxes_list + tags: + - PodsInboxes + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesListInboxesResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Inboxes + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: >- + **CLI:** + + ```bash + + agentmail pods:inboxes create --pod-id --username myagent + --domain example.com + + ``` + operationId: pods_inboxes_create + tags: + - PodsInboxes + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesInbox' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesCreateInboxRequest' + /v0/pods/{pod_id}/inboxes/{inbox_id}: + get: + description: |- + **CLI:** + ```bash + agentmail pods:inboxes get --pod-id --inbox-id + ``` + operationId: pods_inboxes_get + tags: + - PodsInboxes + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesInbox' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: |- + **CLI:** + ```bash + agentmail pods:inboxes update --pod-id --inbox-id + ``` + operationId: pods_inboxes_update + tags: + - PodsInboxes + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesInbox' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/inboxesUpdateInboxRequest' + delete: + description: |- + **CLI:** + ```bash + agentmail pods:inboxes delete --pod-id --inbox-id + ``` + operationId: pods_inboxes_delete + tags: + - PodsInboxes + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: inbox_id + in: path + required: true + schema: + $ref: '#/components/schemas/inboxesInboxId' + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Inbox + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/lists/{direction}/{type}: + get: + description: >- + **CLI:** + + ```bash + + agentmail pods:lists list --pod-id --direction + --type + + ``` + operationId: pods_lists_list + tags: + - PodsLists + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PodListListEntriesResponse' + summary: List Entries + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + post: + description: >- + **CLI:** + + ```bash + + agentmail pods:lists create --pod-id --direction + --type --entry user@example.com + + ``` + operationId: pods_lists_create + tags: + - PodsLists + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PodListEntry' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Create List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateListEntryRequest' + /v0/pods/{pod_id}/lists/{direction}/{type}/{entry}: + get: + description: >- + **CLI:** + + ```bash + + agentmail pods:lists get --pod-id --direction + --type --entry + + ``` + operationId: pods_lists_get + tags: + - PodsLists + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: entry + in: path + description: Email address or domain. + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PodListEntry' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + delete: + description: >- + **CLI:** + + ```bash + + agentmail pods:lists delete --pod-id --direction + --type --entry + + ``` + operationId: pods_lists_delete + tags: + - PodsLists + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: direction + in: path + required: true + schema: + $ref: '#/components/schemas/Direction' + - name: type + in: path + required: true + schema: + $ref: '#/components/schemas/ListType' + - name: entry + in: path + description: Email address or domain. + required: true + schema: + type: string + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete List Entry + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/metrics: + get: + description: |- + **CLI:** + ```bash + agentmail pods:metrics query --pod-id + ``` + operationId: pods_metrics_query + tags: + - PodsMetrics + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: event_types + in: query + required: false + schema: + $ref: '#/components/schemas/MetricEventTypes' + nullable: true + - name: start + in: query + required: false + schema: + $ref: '#/components/schemas/Start' + nullable: true + - name: end + in: query + required: false + schema: + $ref: '#/components/schemas/End' + nullable: true + - name: period + in: query + required: false + schema: + $ref: '#/components/schemas/Period' + nullable: true + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/MetricLimit' + nullable: true + - name: descending + in: query + required: false + schema: + $ref: '#/components/schemas/Descending' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/QueryMetricsResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + summary: Query Metrics + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/threads: + get: + description: |- + **CLI:** + ```bash + agentmail pods:threads list --pod-id + ``` + operationId: pods_threads_list + tags: + - PodsThreads + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: labels + in: query + required: false + schema: + $ref: '#/components/schemas/Labels' + nullable: true + - name: before + in: query + required: false + schema: + $ref: '#/components/schemas/Before' + nullable: true + - name: after + in: query + required: false + schema: + $ref: '#/components/schemas/After' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + - name: include_spam + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeSpam' + nullable: true + - name: include_blocked + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeBlocked' + nullable: true + - name: include_unauthenticated + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeUnauthenticated' + nullable: true + - name: include_trash + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeTrash' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListThreadsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Threads + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/threads/{thread_id}: + get: + description: |- + **CLI:** + ```bash + agentmail pods:threads get --pod-id --thread-id + ``` + operationId: pods_threads_get + tags: + - PodsThreads + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Thread' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: >- + Updates thread labels. Cannot add or remove system labels (sent, + received, bounced, etc.). Rejects requests with a `422` for threads with + 100 or more messages. + operationId: pods_threads_update + tags: + - PodsThreads + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateThreadResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateThreadRequest' + delete: + description: >- + Moves the thread to trash by adding a trash label to all messages. If + the thread is already in trash, it will be permanently deleted. Use + `permanent=true` to force permanent deletion. + + + **CLI:** + + ```bash + + agentmail pods:threads delete --pod-id --thread-id + + ``` + operationId: pods_threads_delete + tags: + - PodsThreads + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + - name: permanent + in: query + description: If true, permanently delete the thread instead of moving to trash. + required: false + schema: + type: boolean + nullable: true + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail pods:threads get-attachment --pod-id --thread-id + --attachment-id + + ``` + operationId: pods_threads_getAttachment + tags: + - PodsThreads + parameters: + - name: pod_id + in: path + required: true + schema: + $ref: '#/components/schemas/podsPodId' + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + - name: attachment_id + in: path + required: true + schema: + $ref: '#/components/schemas/AttachmentId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Attachment + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/threads: + get: + description: |- + **CLI:** + ```bash + agentmail threads list + ``` + operationId: threads_list + tags: + - Threads + parameters: + - name: limit + in: query + required: false + schema: + $ref: '#/components/schemas/Limit' + nullable: true + - name: page_token + in: query + required: false + schema: + $ref: '#/components/schemas/PageToken' + nullable: true + - name: labels + in: query + required: false + schema: + $ref: '#/components/schemas/Labels' + nullable: true + - name: before + in: query + required: false + schema: + $ref: '#/components/schemas/Before' + nullable: true + - name: after + in: query + required: false + schema: + $ref: '#/components/schemas/After' + nullable: true + - name: ascending + in: query + required: false + schema: + $ref: '#/components/schemas/Ascending' + nullable: true + - name: include_spam + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeSpam' + nullable: true + - name: include_blocked + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeBlocked' + nullable: true + - name: include_unauthenticated + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeUnauthenticated' + nullable: true + - name: include_trash + in: query + required: false + schema: + $ref: '#/components/schemas/IncludeTrash' + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ListThreadsResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: List Threads + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/threads/{thread_id}: + get: + description: |- + **CLI:** + ```bash + agentmail threads get --thread-id + ``` + operationId: threads_get + tags: + - Threads + parameters: + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Thread' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + patch: + description: >- + Updates thread labels. Cannot add or remove system labels (sent, + received, bounced, etc.). Rejects requests with a `422` for threads with + 100 or more messages. + operationId: threads_update + tags: + - Threads + parameters: + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateThreadResponse' + '400': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Update Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateThreadRequest' + delete: + description: >- + Moves the thread to trash by adding a trash label to all messages. If + the thread is already in trash, it will be permanently deleted. Use + `permanent=true` to force permanent deletion. + + + **CLI:** + + ```bash + + agentmail threads delete --thread-id + + ``` + operationId: threads_delete + tags: + - Threads + parameters: + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + - name: permanent + in: query + description: If true, permanently delete the thread instead of moving to trash. + required: false + schema: + type: boolean + nullable: true + responses: + '204': + description: '' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Delete Thread + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] + /v0/threads/{thread_id}/attachments/{attachment_id}: + get: + description: >- + **CLI:** + + ```bash + + agentmail threads get-attachment --thread-id --attachment-id + + + ``` + operationId: threads_getAttachment + tags: + - Threads + parameters: + - name: thread_id + in: path + required: true + schema: + $ref: '#/components/schemas/ThreadId' + - name: attachment_id + in: path + required: true + schema: + $ref: '#/components/schemas/AttachmentId' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AttachmentResponse' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + summary: Get Attachment + servers: + - url: https://api.agentmail.to + - url: https://x402.api.agentmail.to + - url: https://mpp.api.agentmail.to + - url: https://api.agentmail.eu + security: + - BearerAuth: [] +components: + schemas: + Limit: + title: Limit + type: integer + description: Limit of number of items returned. + Count: + title: Count + type: integer + description: Number of items returned. + PageToken: + title: PageToken + type: string + description: Page token for pagination. + Labels: + title: Labels + type: array + items: + type: string + description: Labels to filter by. + Before: + title: Before + type: string + format: date-time + description: Timestamp before which to filter by. + After: + title: After + type: string + format: date-time + description: Timestamp after which to filter by. + Ascending: + title: Ascending + type: boolean + description: Sort in ascending temporal order. + IncludeSpam: + title: IncludeSpam + type: boolean + description: Include spam in results. + IncludeBlocked: + title: IncludeBlocked + type: boolean + description: Include blocked in results. + IncludeUnauthenticated: + title: IncludeUnauthenticated + type: boolean + description: Include unauthenticated in results. + IncludeTrash: + title: IncludeTrash + type: boolean + description: Include trash in results. + OrganizationId: + title: OrganizationId + type: string + description: ID of organization. + ErrorName: + title: ErrorName + type: string + description: Name of error. + ErrorMessage: + title: ErrorMessage + type: string + description: Error message. + ErrorResponse: + title: ErrorResponse + type: object + properties: + name: + $ref: '#/components/schemas/ErrorName' + message: + $ref: '#/components/schemas/ErrorMessage' + required: + - name + - message + ValidationErrorResponse: + title: ValidationErrorResponse + type: object + properties: + name: + $ref: '#/components/schemas/ErrorName' + errors: + description: Validation errors. + required: + - name + - errors + inboxesInboxId: + title: inboxesInboxId + type: string + description: The ID of the inbox. + inboxesEmail: + title: inboxesEmail + type: string + description: Email address of the inbox. + inboxesDisplayName: + title: inboxesDisplayName + type: string + description: 'Display name: `Display Name `.' + inboxesClientId: + title: inboxesClientId + type: string + description: Client ID of inbox. + inboxesInbox: + title: inboxesInbox + type: object + properties: + pod_id: + $ref: '#/components/schemas/podsPodId' + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + email: + $ref: '#/components/schemas/inboxesEmail' + display_name: + $ref: '#/components/schemas/inboxesDisplayName' + nullable: true + client_id: + $ref: '#/components/schemas/inboxesClientId' + nullable: true + updated_at: + type: string + format: date-time + description: Time at which inbox was last updated. + created_at: + type: string + format: date-time + description: Time at which inbox was created. + required: + - pod_id + - inbox_id + - email + - updated_at + - created_at + inboxesListInboxesResponse: + title: inboxesListInboxesResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + inboxes: + type: array + items: + $ref: '#/components/schemas/inboxesInbox' + description: Ordered by `created_at` descending. + required: + - count + - inboxes + inboxesCreateInboxRequest: + title: inboxesCreateInboxRequest + type: object + properties: + username: + type: string + nullable: true + description: Username of address. Randomly generated if not specified. + domain: + type: string + nullable: true + description: >- + Domain of address. Must be verified domain. Defaults to + `agentmail.to`. + display_name: + $ref: '#/components/schemas/inboxesDisplayName' + nullable: true + client_id: + $ref: '#/components/schemas/inboxesClientId' + nullable: true + inboxesUpdateInboxRequest: + title: inboxesUpdateInboxRequest + type: object + properties: + display_name: + $ref: '#/components/schemas/inboxesDisplayName' + required: + - display_name + podsPodId: + title: podsPodId + type: string + description: ID of pod. + podsName: + title: podsName + type: string + description: Name of pod. + podsClientId: + title: podsClientId + type: string + description: Client ID of pod. + podsPod: + title: podsPod + type: object + properties: + pod_id: + $ref: '#/components/schemas/podsPodId' + name: + $ref: '#/components/schemas/podsName' + updated_at: + type: string + format: date-time + description: Time at which pod was last updated. + created_at: + type: string + format: date-time + description: Time at which pod was created. + client_id: + $ref: '#/components/schemas/podsClientId' + nullable: true + required: + - pod_id + - name + - updated_at + - created_at + podsListPodsResponse: + title: podsListPodsResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + pods: + type: array + items: + $ref: '#/components/schemas/podsPod' + description: Ordered by `created_at` descending. + required: + - count + - pods + podsCreatePodRequest: + title: podsCreatePodRequest + type: object + properties: + name: + $ref: '#/components/schemas/podsName' + nullable: true + client_id: + $ref: '#/components/schemas/podsClientId' + nullable: true + webhooksWebhookId: + title: webhooksWebhookId + type: string + description: ID of webhook. + webhooksClientId: + title: webhooksClientId + type: string + description: Client ID of webhook. + webhooksUrl: + title: webhooksUrl + type: string + description: URL of webhook endpoint. + webhooksWebhook: + title: webhooksWebhook + type: object + properties: + webhook_id: + $ref: '#/components/schemas/webhooksWebhookId' + url: + $ref: '#/components/schemas/webhooksUrl' + event_types: + $ref: '#/components/schemas/EventTypes' + nullable: true + pod_ids: + $ref: '#/components/schemas/PodIds' + nullable: true + inbox_ids: + $ref: '#/components/schemas/InboxIds' + nullable: true + secret: + type: string + description: Secret for webhook signature verification. + enabled: + type: boolean + description: Webhook is enabled. + updated_at: + type: string + format: date-time + description: Time at which webhook was last updated. + created_at: + type: string + format: date-time + description: Time at which webhook was created. + client_id: + $ref: '#/components/schemas/webhooksClientId' + nullable: true + required: + - webhook_id + - url + - secret + - enabled + - updated_at + - created_at + webhooksListWebhooksResponse: + title: webhooksListWebhooksResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + webhooks: + type: array + items: + $ref: '#/components/schemas/webhooksWebhook' + description: Ordered by `created_at` descending. + required: + - count + - webhooks + webhooksCreateWebhookRequest: + title: webhooksCreateWebhookRequest + type: object + properties: + url: + $ref: '#/components/schemas/webhooksUrl' + event_types: + $ref: '#/components/schemas/EventTypes' + description: >- + Full list of event types this webhook should receive. At least one + type is required. Send every type you + + want in this array (not incremental). See [Webhooks + overview](https://docs.agentmail.to/webhooks-overview) + + for spam, blocked, and unauthenticated events and required + permissions. + pod_ids: + $ref: '#/components/schemas/PodIds' + nullable: true + inbox_ids: + $ref: '#/components/schemas/InboxIds' + nullable: true + client_id: + $ref: '#/components/schemas/webhooksClientId' + nullable: true + required: + - url + - event_types + webhooksUpdateWebhookRequest: + title: webhooksUpdateWebhookRequest + type: object + properties: + add_inbox_ids: + $ref: '#/components/schemas/InboxIds' + nullable: true + description: Inbox IDs to subscribe to the webhook. + remove_inbox_ids: + $ref: '#/components/schemas/InboxIds' + nullable: true + description: Inbox IDs to unsubscribe from the webhook. + add_pod_ids: + $ref: '#/components/schemas/PodIds' + nullable: true + description: Pod IDs to subscribe to the webhook. + remove_pod_ids: + $ref: '#/components/schemas/PodIds' + nullable: true + description: Pod IDs to unsubscribe from the webhook. + event_types: + $ref: '#/components/schemas/EventTypes' + nullable: true + description: >- + When you send a non-empty list, it replaces the webhook's subscribed + event types in full (the same + + "set the list" behavior as create). It is not a merge or diff: + include every event type you want after + + the update. Sending a one-element array means the webhook will only + receive that one type afterward. + + Omit this field or send an empty array to leave event types + unchanged. Clearing all types with an empty + + list is not supported. Subscribing to `message.received.spam`, + `message.received.blocked`, or + + `message.received.unauthenticated` requires the matching label + permission on the API key. + AgentSignupRequest: + title: AgentSignupRequest + type: object + description: Request body to sign up an agent. + properties: + human_email: + type: string + description: >- + Email address of the human who owns the agent. A 6-digit OTP will be + sent to this address. + username: + type: string + description: >- + Username for the auto-created inbox (e.g. "my-agent" creates + my-agent@agentmail.to). + source: + type: string + nullable: true + description: >- + The SDK, framework, or platform issuing this sign-up (e.g. + `agentmail-python`, `agentmail-cli`, `agentmail-mcp`). + + Identifies the caller — answers "who is signing up". + + Max 2048 characters. + referrer: + type: string + nullable: true + description: >- + The channel that drove this sign-up — where the agent or its + developer discovered AgentMail + + (e.g. `agent.email`, a partner URL, a campaign tag). Answers "where + did this sign-up come from". + + Max 2048 characters. + required: + - human_email + - username + AgentSignupResponse: + title: AgentSignupResponse + type: object + description: Response after successful agent sign-up. + properties: + organization_id: + type: string + description: ID of the created organization. + inbox_id: + type: string + description: ID of the auto-created inbox. + api_key: + type: string + description: >- + API key for authenticating subsequent requests. Store this securely, + it cannot be retrieved again. + required: + - organization_id + - inbox_id + - api_key + AgentVerifyRequest: + title: AgentVerifyRequest + type: object + description: Request body to verify an agent with an OTP code. + properties: + otp_code: + type: string + description: 6-digit verification code sent to the human's email address. + required: + - otp_code + AgentVerifyResponse: + title: AgentVerifyResponse + type: object + description: Response after successful agent verification. + properties: + verified: + type: boolean + description: Whether the organization was verified. + required: + - verified + ApiKeyId: + title: ApiKeyId + type: string + description: ID of api key. + Prefix: + title: Prefix + type: string + description: Prefix of api key. + Name: + title: Name + type: string + description: Name of api key. + CreatedAt: + title: CreatedAt + type: string + format: date-time + description: Time at which api key was created. + ApiKeyPermissions: + title: ApiKeyPermissions + type: object + description: >- + Granular permissions for the API key. When ommitted all permissions are + granted. Otherwise, only permissions set to true are granted. + properties: + inbox_read: + type: boolean + nullable: true + description: Read inbox details. + inbox_create: + type: boolean + nullable: true + description: Create new inboxes. + inbox_update: + type: boolean + nullable: true + description: Update inbox settings. + inbox_delete: + type: boolean + nullable: true + description: Delete inboxes. + thread_read: + type: boolean + nullable: true + description: Read threads. + thread_delete: + type: boolean + nullable: true + description: Delete threads. + message_read: + type: boolean + nullable: true + description: Read messages. + message_send: + type: boolean + nullable: true + description: Send messages. + message_update: + type: boolean + nullable: true + description: Update message labels. + label_spam_read: + type: boolean + nullable: true + description: Access messages labeled spam. + label_blocked_read: + type: boolean + nullable: true + description: Access messages labeled blocked. + label_trash_read: + type: boolean + nullable: true + description: Access messages labeled trash. + draft_read: + type: boolean + nullable: true + description: Read drafts. + draft_create: + type: boolean + nullable: true + description: Create drafts. + draft_update: + type: boolean + nullable: true + description: Update drafts. + draft_delete: + type: boolean + nullable: true + description: Delete drafts. + draft_send: + type: boolean + nullable: true + description: Send drafts. + webhook_read: + type: boolean + nullable: true + description: Read webhook configurations. + webhook_create: + type: boolean + nullable: true + description: Create webhooks. + webhook_update: + type: boolean + nullable: true + description: Update webhooks. + webhook_delete: + type: boolean + nullable: true + description: Delete webhooks. + domain_read: + type: boolean + nullable: true + description: Read domain details. + domain_create: + type: boolean + nullable: true + description: Create domains. + domain_update: + type: boolean + nullable: true + description: Update domains. + domain_delete: + type: boolean + nullable: true + description: Delete domains. + list_entry_read: + type: boolean + nullable: true + description: Read list entries. + list_entry_create: + type: boolean + nullable: true + description: Create list entries. + list_entry_delete: + type: boolean + nullable: true + description: Delete list entries. + metrics_read: + type: boolean + nullable: true + description: Read metrics. + api_key_read: + type: boolean + nullable: true + description: Read API keys. + api_key_create: + type: boolean + nullable: true + description: Create API keys. + api_key_delete: + type: boolean + nullable: true + description: Delete API keys. + pod_read: + type: boolean + nullable: true + description: Read pods. + pod_create: + type: boolean + nullable: true + description: Create pods. + pod_delete: + type: boolean + nullable: true + description: Delete pods. + ApiKey: + title: ApiKey + type: object + properties: + api_key_id: + $ref: '#/components/schemas/ApiKeyId' + prefix: + $ref: '#/components/schemas/Prefix' + name: + $ref: '#/components/schemas/Name' + pod_id: + type: string + nullable: true + description: >- + Pod ID the api key is scoped to. If set, the key can only access + resources within this pod. + inbox_id: + type: string + nullable: true + description: >- + Inbox ID the api key is scoped to. If set, the key can only access + resources within this inbox. + used_at: + type: string + format: date-time + nullable: true + description: Time at which api key was last used. + permissions: + $ref: '#/components/schemas/ApiKeyPermissions' + nullable: true + created_at: + $ref: '#/components/schemas/CreatedAt' + required: + - api_key_id + - prefix + - name + - created_at + CreateApiKeyResponse: + title: CreateApiKeyResponse + type: object + properties: + api_key_id: + $ref: '#/components/schemas/ApiKeyId' + api_key: + type: string + description: API key. + prefix: + $ref: '#/components/schemas/Prefix' + name: + $ref: '#/components/schemas/Name' + pod_id: + type: string + nullable: true + description: Pod ID the api key is scoped to. + inbox_id: + type: string + nullable: true + description: Inbox ID the api key is scoped to. + permissions: + $ref: '#/components/schemas/ApiKeyPermissions' + nullable: true + created_at: + $ref: '#/components/schemas/CreatedAt' + required: + - api_key_id + - api_key + - prefix + - name + - created_at + ListApiKeysResponse: + title: ListApiKeysResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + api_keys: + type: array + items: + $ref: '#/components/schemas/ApiKey' + description: Ordered by `created_at` descending. + required: + - count + - api_keys + CreateApiKeyRequest: + title: CreateApiKeyRequest + type: object + properties: + name: + $ref: '#/components/schemas/Name' + nullable: true + permissions: + $ref: '#/components/schemas/ApiKeyPermissions' + nullable: true + AttachmentId: + title: AttachmentId + type: string + description: ID of attachment. + AttachmentFilename: + title: AttachmentFilename + type: string + description: Filename of attachment. + AttachmentSize: + title: AttachmentSize + type: integer + description: Size of attachment in bytes. + AttachmentContentType: + title: AttachmentContentType + type: string + description: Content type of attachment. + AttachmentContentDisposition: + title: AttachmentContentDisposition + type: string + enum: + - inline + - attachment + description: Content disposition of attachment. + AttachmentContentId: + title: AttachmentContentId + type: string + description: Content ID of attachment. + Attachment: + title: Attachment + type: object + properties: + attachment_id: + $ref: '#/components/schemas/AttachmentId' + filename: + $ref: '#/components/schemas/AttachmentFilename' + nullable: true + size: + $ref: '#/components/schemas/AttachmentSize' + content_type: + $ref: '#/components/schemas/AttachmentContentType' + nullable: true + content_disposition: + $ref: '#/components/schemas/AttachmentContentDisposition' + nullable: true + content_id: + $ref: '#/components/schemas/AttachmentContentId' + nullable: true + required: + - attachment_id + - size + AttachmentResponse: + title: AttachmentResponse + type: object + properties: + attachment_id: + $ref: '#/components/schemas/AttachmentId' + filename: + $ref: '#/components/schemas/AttachmentFilename' + nullable: true + size: + $ref: '#/components/schemas/AttachmentSize' + content_type: + $ref: '#/components/schemas/AttachmentContentType' + nullable: true + content_disposition: + $ref: '#/components/schemas/AttachmentContentDisposition' + nullable: true + content_id: + $ref: '#/components/schemas/AttachmentContentId' + nullable: true + download_url: + type: string + description: URL to download the attachment. + expires_at: + type: string + format: date-time + description: Time at which the download URL expires. + required: + - attachment_id + - size + - download_url + - expires_at + SendAttachment: + title: SendAttachment + type: object + properties: + filename: + $ref: '#/components/schemas/AttachmentFilename' + nullable: true + content_type: + $ref: '#/components/schemas/AttachmentContentType' + nullable: true + content_disposition: + $ref: '#/components/schemas/AttachmentContentDisposition' + nullable: true + content_id: + $ref: '#/components/schemas/AttachmentContentId' + nullable: true + content: + type: string + nullable: true + description: Base64 encoded content of attachment. + url: + type: string + nullable: true + description: URL to the attachment. + DomainId: + title: DomainId + type: string + description: The ID of the domain. + DomainName: + title: DomainName + type: string + description: The name of the domain (e.g., `example.com`). + RecordType: + title: RecordType + type: string + enum: + - TXT + - CNAME + - MX + VerificationStatus: + title: VerificationStatus + type: string + enum: + - NOT_STARTED + - PENDING + - INVALID + - FAILED + - VERIFYING + - VERIFIED + RecordStatus: + title: RecordStatus + type: string + enum: + - MISSING + - INVALID + - VALID + VerificationRecord: + title: VerificationRecord + type: object + properties: + type: + $ref: '#/components/schemas/RecordType' + description: The type of the DNS record. + name: + type: string + description: The name or host of the record. + value: + type: string + description: The value of the record. + status: + $ref: '#/components/schemas/RecordStatus' + description: The verification status of this specific record. + priority: + type: integer + nullable: true + description: The priority of the MX record. + required: + - type + - name + - value + - status + Status: + title: Status + $ref: '#/components/schemas/VerificationStatus' + description: The verification status of the domain. + FeedbackEnabled: + title: FeedbackEnabled + type: boolean + description: Bounce and complaint notifications are sent to your inboxes. + ClientId: + title: ClientId + type: string + description: Client ID of domain. + Domain: + title: Domain + type: object + properties: + pod_id: + $ref: '#/components/schemas/podsPodId' + nullable: true + domain_id: + $ref: '#/components/schemas/DomainId' + domain: + $ref: '#/components/schemas/DomainName' + status: + $ref: '#/components/schemas/Status' + feedback_enabled: + $ref: '#/components/schemas/FeedbackEnabled' + records: + type: array + items: + $ref: '#/components/schemas/VerificationRecord' + description: A list of DNS records required to verify the domain. + client_id: + $ref: '#/components/schemas/ClientId' + nullable: true + updated_at: + type: string + format: date-time + description: Time at which the domain was last updated. + created_at: + type: string + format: date-time + description: Time at which the domain was created. + required: + - domain_id + - domain + - status + - feedback_enabled + - records + - updated_at + - created_at + DomainItem: + title: DomainItem + type: object + properties: + pod_id: + $ref: '#/components/schemas/podsPodId' + nullable: true + domain_id: + $ref: '#/components/schemas/DomainId' + domain: + $ref: '#/components/schemas/DomainName' + feedback_enabled: + $ref: '#/components/schemas/FeedbackEnabled' + client_id: + $ref: '#/components/schemas/ClientId' + nullable: true + updated_at: + type: string + format: date-time + description: Time at which the domain was last updated. + created_at: + type: string + format: date-time + description: Time at which the domain was created. + required: + - domain_id + - domain + - feedback_enabled + - updated_at + - created_at + ListDomainsResponse: + title: ListDomainsResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + domains: + type: array + items: + $ref: '#/components/schemas/DomainItem' + description: Ordered by `created_at` descending. + required: + - count + - domains + CreateDomainRequest: + title: CreateDomainRequest + type: object + properties: + domain: + $ref: '#/components/schemas/DomainName' + feedback_enabled: + $ref: '#/components/schemas/FeedbackEnabled' + required: + - domain + - feedback_enabled + UpdateDomainRequest: + title: UpdateDomainRequest + type: object + properties: + feedback_enabled: + $ref: '#/components/schemas/FeedbackEnabled' + nullable: true + DraftId: + title: DraftId + type: string + description: ID of draft. + DraftClientId: + title: DraftClientId + type: string + description: Client ID of draft. + DraftLabels: + title: DraftLabels + type: array + items: + type: string + description: Labels of draft. + DraftReplyTo: + title: DraftReplyTo + type: array + items: + type: string + description: >- + Reply-to addresses. In format `username@domain.com` or `Display Name + `. + DraftTo: + title: DraftTo + type: array + items: + type: string + description: >- + Addresses of recipients. In format `username@domain.com` or `Display + Name `. + DraftCc: + title: DraftCc + type: array + items: + type: string + description: >- + Addresses of CC recipients. In format `username@domain.com` or `Display + Name `. + DraftBcc: + title: DraftBcc + type: array + items: + type: string + description: >- + Addresses of BCC recipients. In format `username@domain.com` or `Display + Name `. + DraftSubject: + title: DraftSubject + type: string + description: Subject of draft. + DraftPreview: + title: DraftPreview + type: string + description: Text preview of draft. + DraftText: + title: DraftText + type: string + description: Plain text body of draft. + DraftHtml: + title: DraftHtml + type: string + description: HTML body of draft. + DraftAttachments: + title: DraftAttachments + type: array + items: + $ref: '#/components/schemas/Attachment' + description: Attachments in draft. + DraftInReplyTo: + title: DraftInReplyTo + type: string + description: ID of message being replied to. + DraftSendStatus: + title: DraftSendStatus + type: string + enum: + - scheduled + - sending + - failed + description: Schedule send status of draft. + DraftSendAt: + title: DraftSendAt + type: string + format: date-time + description: Time at which to schedule send draft. + DraftUpdatedAt: + title: DraftUpdatedAt + type: string + format: date-time + description: Time at which draft was last updated. + DraftItem: + title: DraftItem + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + draft_id: + $ref: '#/components/schemas/DraftId' + labels: + $ref: '#/components/schemas/DraftLabels' + to: + $ref: '#/components/schemas/DraftTo' + nullable: true + cc: + $ref: '#/components/schemas/DraftCc' + nullable: true + bcc: + $ref: '#/components/schemas/DraftBcc' + nullable: true + subject: + $ref: '#/components/schemas/DraftSubject' + nullable: true + preview: + $ref: '#/components/schemas/DraftPreview' + nullable: true + attachments: + $ref: '#/components/schemas/DraftAttachments' + nullable: true + in_reply_to: + $ref: '#/components/schemas/DraftInReplyTo' + nullable: true + send_status: + $ref: '#/components/schemas/DraftSendStatus' + nullable: true + send_at: + $ref: '#/components/schemas/DraftSendAt' + nullable: true + updated_at: + $ref: '#/components/schemas/DraftUpdatedAt' + required: + - inbox_id + - draft_id + - labels + - updated_at + Draft: + title: Draft + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + draft_id: + $ref: '#/components/schemas/DraftId' + client_id: + $ref: '#/components/schemas/DraftClientId' + nullable: true + labels: + $ref: '#/components/schemas/DraftLabels' + reply_to: + $ref: '#/components/schemas/DraftReplyTo' + nullable: true + to: + $ref: '#/components/schemas/DraftTo' + nullable: true + cc: + $ref: '#/components/schemas/DraftCc' + nullable: true + bcc: + $ref: '#/components/schemas/DraftBcc' + nullable: true + subject: + $ref: '#/components/schemas/DraftSubject' + nullable: true + preview: + $ref: '#/components/schemas/DraftPreview' + nullable: true + text: + $ref: '#/components/schemas/DraftText' + nullable: true + html: + $ref: '#/components/schemas/DraftHtml' + nullable: true + attachments: + $ref: '#/components/schemas/DraftAttachments' + nullable: true + in_reply_to: + $ref: '#/components/schemas/DraftInReplyTo' + nullable: true + references: + type: array + items: + type: string + nullable: true + description: IDs of previous messages in thread. + send_status: + $ref: '#/components/schemas/DraftSendStatus' + nullable: true + send_at: + $ref: '#/components/schemas/DraftSendAt' + nullable: true + updated_at: + $ref: '#/components/schemas/DraftUpdatedAt' + created_at: + type: string + format: date-time + description: Time at which draft was created. + required: + - inbox_id + - draft_id + - labels + - updated_at + - created_at + ListDraftsResponse: + title: ListDraftsResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + drafts: + type: array + items: + $ref: '#/components/schemas/DraftItem' + description: Ordered by `updated_at` descending. + required: + - count + - drafts + CreateDraftRequest: + title: CreateDraftRequest + type: object + properties: + labels: + $ref: '#/components/schemas/DraftLabels' + nullable: true + reply_to: + $ref: '#/components/schemas/DraftReplyTo' + nullable: true + to: + $ref: '#/components/schemas/DraftTo' + nullable: true + cc: + $ref: '#/components/schemas/DraftCc' + nullable: true + bcc: + $ref: '#/components/schemas/DraftBcc' + nullable: true + subject: + $ref: '#/components/schemas/DraftSubject' + nullable: true + text: + $ref: '#/components/schemas/DraftText' + nullable: true + html: + $ref: '#/components/schemas/DraftHtml' + nullable: true + attachments: + type: array + items: + $ref: '#/components/schemas/SendAttachment' + nullable: true + description: Attachments to include in draft. + in_reply_to: + $ref: '#/components/schemas/DraftInReplyTo' + nullable: true + send_at: + $ref: '#/components/schemas/DraftSendAt' + nullable: true + client_id: + $ref: '#/components/schemas/DraftClientId' + nullable: true + UpdateDraftRequest: + title: UpdateDraftRequest + type: object + properties: + reply_to: + $ref: '#/components/schemas/DraftReplyTo' + nullable: true + to: + $ref: '#/components/schemas/DraftTo' + nullable: true + cc: + $ref: '#/components/schemas/DraftCc' + nullable: true + bcc: + $ref: '#/components/schemas/DraftBcc' + nullable: true + subject: + $ref: '#/components/schemas/DraftSubject' + nullable: true + text: + $ref: '#/components/schemas/DraftText' + nullable: true + html: + $ref: '#/components/schemas/DraftHtml' + nullable: true + send_at: + $ref: '#/components/schemas/DraftSendAt' + nullable: true + EventType: + title: EventType + type: string + enum: + - message.received + - message.received.spam + - message.received.blocked + - message.received.unauthenticated + - message.sent + - message.delivered + - message.bounced + - message.complained + - message.rejected + - domain.verified + EventTypes: + title: EventTypes + type: array + items: + $ref: '#/components/schemas/EventType' + description: Event types for which to send events. + MessageReceivedEventType: + title: MessageReceivedEventType + type: string + enum: + - message.received + - message.received.spam + - message.received.blocked + - message.received.unauthenticated + PodIds: + title: PodIds + type: array + items: + type: string + description: Pods for which to send events. Maximum 10 per webhook. + InboxIds: + title: InboxIds + type: array + items: + type: string + description: Inboxes for which to send events. Maximum 10 per webhook. + EventId: + title: EventId + type: string + description: ID of event. + Timestamp: + title: Timestamp + type: string + format: date-time + description: Timestamp of event. + Recipient: + title: Recipient + type: object + properties: + address: + type: string + description: Recipient address. + status: + type: string + description: Recipient status. + required: + - address + - status + Send: + title: Send + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + message_id: + $ref: '#/components/schemas/MessageId' + timestamp: + $ref: '#/components/schemas/Timestamp' + recipients: + type: array + items: + type: string + description: Sent recipients. + required: + - inbox_id + - thread_id + - message_id + - timestamp + - recipients + Delivery: + title: Delivery + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + message_id: + $ref: '#/components/schemas/MessageId' + timestamp: + $ref: '#/components/schemas/Timestamp' + recipients: + type: array + items: + type: string + description: Delivered recipients. + required: + - inbox_id + - thread_id + - message_id + - timestamp + - recipients + Bounce: + title: Bounce + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + message_id: + $ref: '#/components/schemas/MessageId' + timestamp: + $ref: '#/components/schemas/Timestamp' + type: + type: string + description: Bounce type. + sub_type: + type: string + description: Bounce sub-type. + recipients: + type: array + items: + $ref: '#/components/schemas/Recipient' + description: Bounced recipients. + required: + - inbox_id + - thread_id + - message_id + - timestamp + - type + - sub_type + - recipients + Complaint: + title: Complaint + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + message_id: + $ref: '#/components/schemas/MessageId' + timestamp: + $ref: '#/components/schemas/Timestamp' + type: + type: string + description: Complaint type. + sub_type: + type: string + description: Complaint sub-type. + recipients: + type: array + items: + type: string + description: Complained recipients. + required: + - inbox_id + - thread_id + - message_id + - timestamp + - type + - sub_type + - recipients + Reject: + title: Reject + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + message_id: + $ref: '#/components/schemas/MessageId' + timestamp: + $ref: '#/components/schemas/Timestamp' + reason: + type: string + description: Reject reason. + required: + - inbox_id + - thread_id + - message_id + - timestamp + - reason + MessageReceivedEvent: + title: MessageReceivedEvent + type: object + description: >- + A message was received. Spam, blocked, and unauthenticated + received-message events use the same payload shape with different + `event_type` values. + properties: + type: + type: string + const: event + event_type: + $ref: '#/components/schemas/MessageReceivedEventType' + event_id: + $ref: '#/components/schemas/EventId' + message: + $ref: '#/components/schemas/Message' + thread: + $ref: '#/components/schemas/ThreadItem' + required: + - type + - event_type + - event_id + - message + - thread + MessageSentEvent: + title: MessageSentEvent + type: object + properties: + type: + type: string + const: event + event_type: + type: string + const: message.sent + event_id: + $ref: '#/components/schemas/EventId' + send: + $ref: '#/components/schemas/Send' + required: + - type + - event_type + - event_id + - send + MessageDeliveredEvent: + title: MessageDeliveredEvent + type: object + properties: + type: + type: string + const: event + event_type: + type: string + const: message.delivered + event_id: + $ref: '#/components/schemas/EventId' + delivery: + $ref: '#/components/schemas/Delivery' + required: + - type + - event_type + - event_id + - delivery + MessageBouncedEvent: + title: MessageBouncedEvent + type: object + properties: + type: + type: string + const: event + event_type: + type: string + const: message.bounced + event_id: + $ref: '#/components/schemas/EventId' + bounce: + $ref: '#/components/schemas/Bounce' + required: + - type + - event_type + - event_id + - bounce + MessageComplainedEvent: + title: MessageComplainedEvent + type: object + properties: + type: + type: string + const: event + event_type: + type: string + const: message.complained + event_id: + $ref: '#/components/schemas/EventId' + complaint: + $ref: '#/components/schemas/Complaint' + required: + - type + - event_type + - event_id + - complaint + MessageRejectedEvent: + title: MessageRejectedEvent + type: object + properties: + type: + type: string + const: event + event_type: + type: string + const: message.rejected + event_id: + $ref: '#/components/schemas/EventId' + reject: + $ref: '#/components/schemas/Reject' + required: + - type + - event_type + - event_id + - reject + DomainVerifiedEvent: + title: DomainVerifiedEvent + type: object + properties: + type: + type: string + const: event + event_type: + type: string + const: domain.verified + event_id: + $ref: '#/components/schemas/EventId' + domain: + $ref: '#/components/schemas/Domain' + required: + - type + - event_type + - event_id + - domain + InboxEventId: + title: InboxEventId + type: string + description: ID of event. + InboxEventType: + title: InboxEventType + type: string + enum: + - label.added + - label.removed + description: |- + Type of inbox event. Wire format is dot.case to match the + convention used by webhook events (`message.received`, + `domain.verified`, etc. in events.yml). Pre-2026-04 these were + `label_added`/`label_removed` (snake_case). The Fern enum's `name` + field stays uppercase-snake (Fern convention); only the wire + `value` changed. + InboxEvent: + title: InboxEvent + type: object + properties: + organization_id: + $ref: '#/components/schemas/OrganizationId' + pod_id: + type: string + description: ID of pod. + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + event_id: + $ref: '#/components/schemas/InboxEventId' + event_type: + $ref: '#/components/schemas/InboxEventType' + message_id: + type: string + description: ID of message. + label: + type: string + description: Label added or removed. + event_at: + type: string + format: date-time + description: Time at which the event occurred. + created_at: + type: string + format: date-time + description: Time at which the event was recorded. + required: + - organization_id + - pod_id + - inbox_id + - event_id + - event_type + - message_id + - label + - event_at + - created_at + ListInboxEventsResponse: + title: ListInboxEventsResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + events: + type: array + items: + $ref: '#/components/schemas/InboxEvent' + description: Ordered by `event_id` descending. + required: + - count + - events + Direction: + title: Direction + type: string + enum: + - send + - receive + - reply + description: Direction of list entry. + ListType: + title: ListType + type: string + enum: + - allow + - block + description: Type of list entry. + EntryType: + title: EntryType + type: string + enum: + - email + - domain + description: Whether the entry is an email address or domain. + ListEntryBase: + title: ListEntryBase + type: object + properties: + entry: + type: string + description: Email address or domain of list entry. + organization_id: + $ref: '#/components/schemas/OrganizationId' + reason: + type: string + nullable: true + description: Reason for adding the entry. + direction: + $ref: '#/components/schemas/Direction' + list_type: + $ref: '#/components/schemas/ListType' + entry_type: + $ref: '#/components/schemas/EntryType' + created_at: + type: string + format: date-time + description: Time at which entry was created. + read_only: + type: boolean + nullable: true + description: Whether the entry is read-only and cannot be deleted via the API. + required: + - entry + - organization_id + - direction + - list_type + - entry_type + - created_at + ListEntry: + title: ListEntry + type: object + properties: {} + allOf: + - $ref: '#/components/schemas/ListEntryBase' + PodListEntry: + title: PodListEntry + type: object + properties: + pod_id: + type: string + description: ID of pod. + inbox_id: + type: string + nullable: true + description: ID of inbox, if entry is inbox-scoped. + required: + - pod_id + allOf: + - $ref: '#/components/schemas/ListEntryBase' + PodListListEntriesResponse: + title: PodListListEntriesResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + entries: + type: array + items: + $ref: '#/components/schemas/PodListEntry' + description: Ordered by entry ascending. + required: + - count + - entries + ListListEntriesResponse: + title: ListListEntriesResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + entries: + type: array + items: + $ref: '#/components/schemas/ListEntry' + description: Ordered by entry ascending. + required: + - count + - entries + CreateListEntryRequest: + title: CreateListEntryRequest + type: object + properties: + entry: + type: string + description: Email address or domain to add. + reason: + type: string + nullable: true + description: Reason for adding the entry. + required: + - entry + MessageId: + title: MessageId + type: string + description: ID of message. + MessageLabels: + title: MessageLabels + type: array + items: + type: string + description: Labels of message. + MessageTimestamp: + title: MessageTimestamp + type: string + format: date-time + description: Time at which message was sent or drafted. + MessageFrom: + title: MessageFrom + type: string + description: >- + Address of sender. In format `username@domain.com` or `Display Name + `. + MessageReplyTo: + title: MessageReplyTo + type: array + items: + type: string + description: >- + Addresses of reply-to recipients. In format `username@domain.com` or + `Display Name `. + MessageTo: + title: MessageTo + type: array + items: + type: string + description: >- + Addresses of recipients. In format `username@domain.com` or `Display + Name `. + MessageCc: + title: MessageCc + type: array + items: + type: string + description: >- + Addresses of CC recipients. In format `username@domain.com` or `Display + Name `. + MessageBcc: + title: MessageBcc + type: array + items: + type: string + description: >- + Addresses of BCC recipients. In format `username@domain.com` or `Display + Name `. + MessageSubject: + title: MessageSubject + type: string + description: Subject of message. + MessagePreview: + title: MessagePreview + type: string + description: Text preview of message. + MessageText: + title: MessageText + type: string + description: Plain text body of message. + MessageHtml: + title: MessageHtml + type: string + description: HTML body of message. + MessageAttachments: + title: MessageAttachments + type: array + items: + $ref: '#/components/schemas/Attachment' + description: Attachments in message. + MessageInReplyTo: + title: MessageInReplyTo + type: string + description: ID of message being replied to. + MessageReferences: + title: MessageReferences + type: array + items: + type: string + description: IDs of previous messages in thread. + MessageHeaders: + title: MessageHeaders + type: object + additionalProperties: + type: string + description: Headers in message. + MessageSize: + title: MessageSize + type: integer + description: Size of message in bytes. + MessageUpdatedAt: + title: MessageUpdatedAt + type: string + format: date-time + description: Time at which message was last updated. + MessageCreatedAt: + title: MessageCreatedAt + type: string + format: date-time + description: Time at which message was created. + MessageItem: + title: MessageItem + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + message_id: + $ref: '#/components/schemas/MessageId' + labels: + $ref: '#/components/schemas/MessageLabels' + timestamp: + $ref: '#/components/schemas/MessageTimestamp' + from: + $ref: '#/components/schemas/MessageFrom' + to: + $ref: '#/components/schemas/MessageTo' + cc: + $ref: '#/components/schemas/MessageCc' + nullable: true + bcc: + $ref: '#/components/schemas/MessageBcc' + nullable: true + subject: + $ref: '#/components/schemas/MessageSubject' + nullable: true + preview: + $ref: '#/components/schemas/MessagePreview' + nullable: true + attachments: + $ref: '#/components/schemas/MessageAttachments' + nullable: true + in_reply_to: + $ref: '#/components/schemas/MessageInReplyTo' + nullable: true + references: + $ref: '#/components/schemas/MessageReferences' + nullable: true + headers: + $ref: '#/components/schemas/MessageHeaders' + nullable: true + size: + $ref: '#/components/schemas/MessageSize' + updated_at: + $ref: '#/components/schemas/MessageUpdatedAt' + created_at: + $ref: '#/components/schemas/MessageCreatedAt' + required: + - inbox_id + - thread_id + - message_id + - labels + - timestamp + - from + - to + - size + - updated_at + - created_at + Message: + title: Message + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + message_id: + $ref: '#/components/schemas/MessageId' + labels: + $ref: '#/components/schemas/MessageLabels' + timestamp: + $ref: '#/components/schemas/MessageTimestamp' + from: + $ref: '#/components/schemas/MessageFrom' + reply_to: + type: array + items: + type: string + nullable: true + description: >- + Reply-to addresses. In format `username@domain.com` or `Display Name + `. + to: + $ref: '#/components/schemas/MessageTo' + cc: + $ref: '#/components/schemas/MessageCc' + nullable: true + bcc: + $ref: '#/components/schemas/MessageBcc' + nullable: true + subject: + $ref: '#/components/schemas/MessageSubject' + nullable: true + preview: + $ref: '#/components/schemas/MessagePreview' + nullable: true + text: + $ref: '#/components/schemas/MessageText' + nullable: true + html: + $ref: '#/components/schemas/MessageHtml' + nullable: true + extracted_text: + type: string + nullable: true + description: Extracted new text content. + extracted_html: + type: string + nullable: true + description: Extracted new HTML content. + attachments: + $ref: '#/components/schemas/MessageAttachments' + nullable: true + in_reply_to: + $ref: '#/components/schemas/MessageInReplyTo' + nullable: true + references: + $ref: '#/components/schemas/MessageReferences' + nullable: true + headers: + $ref: '#/components/schemas/MessageHeaders' + nullable: true + size: + $ref: '#/components/schemas/MessageSize' + updated_at: + $ref: '#/components/schemas/MessageUpdatedAt' + created_at: + $ref: '#/components/schemas/MessageCreatedAt' + required: + - inbox_id + - thread_id + - message_id + - labels + - timestamp + - from + - to + - size + - updated_at + - created_at + ListMessagesResponse: + title: ListMessagesResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + messages: + type: array + items: + $ref: '#/components/schemas/MessageItem' + description: Ordered by `timestamp` descending. + required: + - count + - messages + BatchGetMessagesMessageIds: + title: BatchGetMessagesMessageIds + type: array + items: + $ref: '#/components/schemas/MessageId' + description: |- + IDs of messages to fetch. Maximum 500 ids per request. Duplicates are + rejected with a validation error. IDs not found in the inbox (including + cross-inbox or permission-restricted) are silently omitted from the + response; callers detect misses by comparing `count` against `limit`. + BatchGetMessagesRequest: + title: BatchGetMessagesRequest + type: object + properties: + message_ids: + $ref: '#/components/schemas/BatchGetMessagesMessageIds' + required: + - message_ids + BatchGetMessagesResponse: + title: BatchGetMessagesResponse + type: object + properties: + limit: + $ref: '#/components/schemas/Limit' + count: + $ref: '#/components/schemas/Count' + messages: + type: array + items: + $ref: '#/components/schemas/Message' + description: |- + Found messages. Order matches `message_ids` in the request. Body + fields (`text`, `html`, `extracted_text`, `extracted_html`) are + never populated; use the single-message endpoint to retrieve bodies. + required: + - limit + - count + - messages + RawMessageResponse: + title: RawMessageResponse + type: object + description: S3 presigned URL to download the raw .eml file. + properties: + message_id: + $ref: '#/components/schemas/MessageId' + description: ID of the message. + size: + $ref: '#/components/schemas/MessageSize' + description: Size of the raw message in bytes. + download_url: + type: string + description: S3 presigned URL to download the raw message. Expires at expires_at. + expires_at: + type: string + format: date-time + description: Time at which the download URL expires. + required: + - message_id + - size + - download_url + - expires_at + Addresses: + title: Addresses + oneOf: + - type: string + - type: array + items: + type: string + SendMessageReplyTo: + title: SendMessageReplyTo + $ref: '#/components/schemas/Addresses' + description: Reply-to address or addresses. + SendMessageTo: + title: SendMessageTo + $ref: '#/components/schemas/Addresses' + description: Recipient address or addresses. + SendMessageCc: + title: SendMessageCc + $ref: '#/components/schemas/Addresses' + description: CC recipient address or addresses. + SendMessageBcc: + title: SendMessageBcc + $ref: '#/components/schemas/Addresses' + description: BCC recipient address or addresses. + SendMessageAttachments: + title: SendMessageAttachments + type: array + items: + $ref: '#/components/schemas/SendAttachment' + description: Attachments to include in message. + SendMessageHeaders: + title: SendMessageHeaders + type: object + additionalProperties: + type: string + description: Headers to include in message. + SendMessageRequest: + title: SendMessageRequest + type: object + properties: + labels: + $ref: '#/components/schemas/MessageLabels' + nullable: true + reply_to: + $ref: '#/components/schemas/SendMessageReplyTo' + nullable: true + to: + $ref: '#/components/schemas/SendMessageTo' + nullable: true + cc: + $ref: '#/components/schemas/SendMessageCc' + nullable: true + bcc: + $ref: '#/components/schemas/SendMessageBcc' + nullable: true + subject: + $ref: '#/components/schemas/MessageSubject' + nullable: true + text: + $ref: '#/components/schemas/MessageText' + nullable: true + html: + $ref: '#/components/schemas/MessageHtml' + nullable: true + attachments: + $ref: '#/components/schemas/SendMessageAttachments' + nullable: true + headers: + $ref: '#/components/schemas/SendMessageHeaders' + nullable: true + SendMessageResponse: + title: SendMessageResponse + type: object + properties: + message_id: + $ref: '#/components/schemas/MessageId' + thread_id: + $ref: '#/components/schemas/ThreadId' + required: + - message_id + - thread_id + UpdateMessageResponse: + title: UpdateMessageResponse + type: object + properties: + message_id: + $ref: '#/components/schemas/MessageId' + labels: + $ref: '#/components/schemas/MessageLabels' + required: + - message_id + - labels + ReplyAll: + title: ReplyAll + type: boolean + description: Reply to all recipients of the original message. + ReplyToMessageRequest: + title: ReplyToMessageRequest + type: object + properties: + labels: + $ref: '#/components/schemas/MessageLabels' + nullable: true + reply_to: + $ref: '#/components/schemas/SendMessageReplyTo' + nullable: true + to: + $ref: '#/components/schemas/SendMessageTo' + nullable: true + cc: + $ref: '#/components/schemas/SendMessageCc' + nullable: true + bcc: + $ref: '#/components/schemas/SendMessageBcc' + nullable: true + reply_all: + $ref: '#/components/schemas/ReplyAll' + nullable: true + text: + $ref: '#/components/schemas/MessageText' + nullable: true + html: + $ref: '#/components/schemas/MessageHtml' + nullable: true + attachments: + $ref: '#/components/schemas/SendMessageAttachments' + nullable: true + headers: + $ref: '#/components/schemas/SendMessageHeaders' + nullable: true + ReplyAllMessageRequest: + title: ReplyAllMessageRequest + type: object + properties: + labels: + $ref: '#/components/schemas/MessageLabels' + nullable: true + reply_to: + $ref: '#/components/schemas/SendMessageReplyTo' + nullable: true + text: + $ref: '#/components/schemas/MessageText' + nullable: true + html: + $ref: '#/components/schemas/MessageHtml' + nullable: true + attachments: + $ref: '#/components/schemas/SendMessageAttachments' + nullable: true + headers: + $ref: '#/components/schemas/SendMessageHeaders' + nullable: true + UpdateMessageLabels: + title: UpdateMessageLabels + oneOf: + - type: string + - type: array + items: + type: string + description: Label or list of labels. + UpdateMessageRequest: + title: UpdateMessageRequest + type: object + properties: + add_labels: + $ref: '#/components/schemas/UpdateMessageLabels' + nullable: true + description: Label or labels to add to message. + remove_labels: + $ref: '#/components/schemas/UpdateMessageLabels' + nullable: true + description: Label or labels to remove from message. + MetricEventType: + title: MetricEventType + type: string + enum: + - message.sent + - message.delivered + - message.bounced + - message.delayed + - message.rejected + - message.complained + - message.received + description: Type of metric event. + MetricEventTypes: + title: MetricEventTypes + type: array + items: + $ref: '#/components/schemas/MetricEventType' + description: List of metric event types to query. + Start: + title: Start + type: string + format: date-time + description: Start timestamp for the query. + End: + title: End + type: string + format: date-time + description: End timestamp for the query. + Period: + title: Period + type: string + description: Period in number of seconds for the query. + MetricLimit: + title: MetricLimit + type: integer + description: Limit on number of buckets to return. + Descending: + title: Descending + type: boolean + description: Sort in descending order. + MetricBucket: + title: MetricBucket + type: object + properties: + timestamp: + type: string + format: date-time + description: Timestamp of the bucket. + count: + type: integer + description: Count of events in the bucket. + required: + - timestamp + - count + QueryMetricsResponse: + title: QueryMetricsResponse + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/MetricBucket' + description: Metrics grouped by event type. + Organization: + title: Organization + type: object + description: Organization details with usage limits and counts. + properties: + organization_id: + $ref: '#/components/schemas/OrganizationId' + inbox_count: + type: integer + description: Current number of inboxes. + domain_count: + type: integer + description: Current number of domains. + inbox_limit: + type: integer + nullable: true + description: Maximum number of inboxes allowed. + domain_limit: + type: integer + nullable: true + description: Maximum number of domains allowed. + billing_id: + type: string + nullable: true + description: Provider-agnostic billing customer ID. + billing_type: + type: string + nullable: true + description: Billing provider type (e.g. "stripe"). + billing_subscription_id: + type: string + nullable: true + description: Active billing subscription ID. + authentication_id: + type: string + nullable: true + description: Provider-agnostic authentication ID. + authentication_type: + type: string + nullable: true + description: Authentication provider type. + updated_at: + type: string + format: date-time + description: Time at which organization was last updated. + created_at: + type: string + format: date-time + description: Time at which organization was created. + required: + - organization_id + - inbox_count + - domain_count + - updated_at + - created_at + ThreadId: + title: ThreadId + type: string + description: ID of thread. + ThreadLabels: + title: ThreadLabels + type: array + items: + type: string + description: Labels of thread. + ThreadTimestamp: + title: ThreadTimestamp + type: string + format: date-time + description: Timestamp of last sent or received message. + ThreadReceivedTimestamp: + title: ThreadReceivedTimestamp + type: string + format: date-time + description: Timestamp of last received message. + ThreadSentTimestamp: + title: ThreadSentTimestamp + type: string + format: date-time + description: Timestamp of last sent message. + ThreadSenders: + title: ThreadSenders + type: array + items: + type: string + description: >- + Senders in thread. In format `username@domain.com` or `Display Name + `. + ThreadRecipients: + title: ThreadRecipients + type: array + items: + type: string + description: >- + Recipients in thread. In format `username@domain.com` or `Display Name + `. + ThreadSubject: + title: ThreadSubject + type: string + description: Subject of thread. + ThreadPreview: + title: ThreadPreview + type: string + description: Text preview of last message in thread. + ThreadAttachments: + title: ThreadAttachments + type: array + items: + $ref: '#/components/schemas/Attachment' + description: Attachments in thread. + ThreadLastMessageId: + title: ThreadLastMessageId + type: string + description: ID of last message in thread. + ThreadMessageCount: + title: ThreadMessageCount + type: integer + description: Number of messages in thread. + ThreadSize: + title: ThreadSize + type: integer + description: Size of thread in bytes. + ThreadUpdatedAt: + title: ThreadUpdatedAt + type: string + format: date-time + description: Time at which thread was last updated. + ThreadCreatedAt: + title: ThreadCreatedAt + type: string + format: date-time + description: Time at which thread was created. + ThreadItem: + title: ThreadItem + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + labels: + $ref: '#/components/schemas/ThreadLabels' + timestamp: + $ref: '#/components/schemas/ThreadTimestamp' + received_timestamp: + $ref: '#/components/schemas/ThreadReceivedTimestamp' + nullable: true + sent_timestamp: + $ref: '#/components/schemas/ThreadSentTimestamp' + nullable: true + senders: + $ref: '#/components/schemas/ThreadSenders' + recipients: + $ref: '#/components/schemas/ThreadRecipients' + subject: + $ref: '#/components/schemas/ThreadSubject' + nullable: true + preview: + $ref: '#/components/schemas/ThreadPreview' + nullable: true + attachments: + $ref: '#/components/schemas/ThreadAttachments' + nullable: true + last_message_id: + $ref: '#/components/schemas/ThreadLastMessageId' + message_count: + $ref: '#/components/schemas/ThreadMessageCount' + size: + $ref: '#/components/schemas/ThreadSize' + updated_at: + $ref: '#/components/schemas/ThreadUpdatedAt' + created_at: + $ref: '#/components/schemas/ThreadCreatedAt' + required: + - inbox_id + - thread_id + - labels + - timestamp + - senders + - recipients + - last_message_id + - message_count + - size + - updated_at + - created_at + Thread: + title: Thread + type: object + properties: + inbox_id: + $ref: '#/components/schemas/inboxesInboxId' + thread_id: + $ref: '#/components/schemas/ThreadId' + labels: + $ref: '#/components/schemas/ThreadLabels' + timestamp: + $ref: '#/components/schemas/ThreadTimestamp' + received_timestamp: + $ref: '#/components/schemas/ThreadReceivedTimestamp' + nullable: true + sent_timestamp: + $ref: '#/components/schemas/ThreadSentTimestamp' + nullable: true + senders: + $ref: '#/components/schemas/ThreadSenders' + recipients: + $ref: '#/components/schemas/ThreadRecipients' + subject: + $ref: '#/components/schemas/ThreadSubject' + nullable: true + preview: + $ref: '#/components/schemas/ThreadPreview' + nullable: true + attachments: + $ref: '#/components/schemas/ThreadAttachments' + nullable: true + last_message_id: + $ref: '#/components/schemas/ThreadLastMessageId' + message_count: + $ref: '#/components/schemas/ThreadMessageCount' + size: + $ref: '#/components/schemas/ThreadSize' + updated_at: + $ref: '#/components/schemas/ThreadUpdatedAt' + created_at: + $ref: '#/components/schemas/ThreadCreatedAt' + messages: + type: array + items: + $ref: '#/components/schemas/Message' + description: Messages in thread. Ordered by `timestamp` ascending. + required: + - inbox_id + - thread_id + - labels + - timestamp + - senders + - recipients + - last_message_id + - message_count + - size + - updated_at + - created_at + - messages + UpdateThreadRequest: + title: UpdateThreadRequest + type: object + properties: + add_labels: + type: array + items: + type: string + nullable: true + description: Labels to add to thread. Cannot be system labels. + remove_labels: + type: array + items: + type: string + nullable: true + description: >- + Labels to remove from thread. Cannot be system labels. Takes + priority over `add_labels` (in the event of duplicate labels passed + in). + UpdateThreadResponse: + title: UpdateThreadResponse + type: object + properties: + thread_id: + $ref: '#/components/schemas/ThreadId' + labels: + $ref: '#/components/schemas/ThreadLabels' + required: + - thread_id + - labels + ListThreadsResponse: + title: ListThreadsResponse + type: object + properties: + count: + $ref: '#/components/schemas/Count' + limit: + $ref: '#/components/schemas/Limit' + nullable: true + next_page_token: + $ref: '#/components/schemas/PageToken' + nullable: true + threads: + type: array + items: + $ref: '#/components/schemas/ThreadItem' + description: Ordered by `timestamp` descending. + required: + - count + - threads + webhooksSvixId: + title: webhooksSvixId + type: string + description: ID of webhook message. + webhooksSvixTimestamp: + title: webhooksSvixTimestamp + type: string + format: date-time + description: Timestamp of webhook message. + webhooksSvixSignature: + title: webhooksSvixSignature + type: string + description: Signature of webhook message. + Subscribe: + title: Subscribe + type: object + properties: + type: + type: string + const: subscribe + event_types: + $ref: '#/components/schemas/EventTypes' + nullable: true + inbox_ids: + $ref: '#/components/schemas/InboxIds' + nullable: true + pod_ids: + $ref: '#/components/schemas/PodIds' + nullable: true + required: + - type + Subscribed: + title: Subscribed + type: object + properties: + type: + type: string + const: subscribed + event_types: + $ref: '#/components/schemas/EventTypes' + nullable: true + inbox_ids: + $ref: '#/components/schemas/InboxIds' + nullable: true + pod_ids: + $ref: '#/components/schemas/PodIds' + nullable: true + required: + - type + Error: + title: Error + type: object + properties: + type: + type: string + const: error + name: + $ref: '#/components/schemas/ErrorName' + message: + $ref: '#/components/schemas/ErrorMessage' + required: + - type + - name + - message + securitySchemes: + BearerAuth: + type: http + scheme: bearer From 1fee6699cd406d0ddeeeb00276662cd4e85d84bb Mon Sep 17 00:00:00 2001 From: jsklan Date: Wed, 27 May 2026 17:59:20 -0400 Subject: [PATCH 8/8] ci: rely on ubuntu-latest for Docker in regenerate-cli (matches fern/anduril convention) fern generate --local runs generators in Docker; GitHub-hosted ubuntu runners provide it pre-installed with a running daemon, so no explicit docker setup or guard step is needed (matches fern's python-generator CI and anduril fern-mock-andurilapis generate-sdks). --- .github/workflows/regenerate-cli.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/regenerate-cli.yml b/.github/workflows/regenerate-cli.yml index 147ab3c5..8d8fd03d 100644 --- a/.github/workflows/regenerate-cli.yml +++ b/.github/workflows/regenerate-cli.yml @@ -13,6 +13,10 @@ on: jobs: regenerate-cli: + # `fern generate --local` runs the generators in Docker. GitHub-hosted + # ubuntu runners ship Docker pre-installed with the daemon running, so no + # explicit Docker setup is needed (same pattern as fern's own + # python-generator CI and anduril/fern-mock-andurilapis generate-sdks). runs-on: ubuntu-latest steps: - name: Checkout