diff --git a/packages/reflex-build-sdk/README.md b/packages/reflex-build-sdk/README.md index a3a9005799d..960aa66eaaf 100644 --- a/packages/reflex-build-sdk/README.md +++ b/packages/reflex-build-sdk/README.md @@ -123,6 +123,20 @@ client.apps.sign_in.invite(app.id, "someone@example.com") `client.apps.sign_in` lets an app's users sign in with their Reflex accounts, through `rxe.AuthPlugin` from `reflex-enterprise`. It sets the app's sign-in settings as secrets, which take effect when the app is next deployed with `rxe.AuthPlugin`, chooses who may sign in, and lists, exports and blocks the app's users. Restricting who may sign in and inviting addresses need the Pro or Enterprise plan. Changing sign-in needs a token with full access; tokens from `reflex login` are refused. +### Third-party connections + +```python +# Inside a deployed app, which is started with its own access token. +with ReflexCloud() as client: + token = client.apps.connections.credential(app_id, "openai").access_token + # ... and for one of the app's users: + token = client.apps.connections.credential( + app_id, "openai", end_user=user_id + ).access_token +``` + +`client.apps.connections` calls third-party services an app is connected to without the app holding their keys: Reflex Cloud stores the credentials and hands out a live one per call, so read one for each call rather than storing it. `connect_link` starts a connection and returns the page to send someone to, `status` and `list` report what is connected, and `disconnect` ends it. A connection belongs either to the app or to one of its users, named with `end_user`. These need the app's own token, so a client built with no arguments inside a deployed app is already the right one. + ## Authentication The client uses the first access token it finds: @@ -189,7 +203,7 @@ with open("key.json") as key_file: ## Errors -Every exception derives from `reflex_build_sdk.ReflexCloudError`. Error responses raise a subclass of `APIStatusError` matching the status code (`AuthenticationError`, `NotFoundError`, ...), carrying `status_code`, the server's `detail`, and the `request_id` to quote to support. Failed requests are retried up to `max_retries` times when repeating them cannot apply them twice: requests that never reached the server or were turned away with 408 or 429, and requests that are harmless to repeat (`GET`, `HEAD`, `OPTIONS` and `PUT` requests, and calls such as `apps.environments.update` that settle on the same result) that timed out, lost their connection, or got a 500, 502, 503 or 504 response. +Every exception derives from `reflex_build_sdk.ReflexCloudError`. Error responses raise a subclass of `APIStatusError` matching the status code (`AuthenticationError`, `NotFoundError`, ...), carrying `status_code`, the server's `detail`, the `code` naming the condition where the API names one (e.g. `"not_connected"`), and the `request_id` to quote to support. Failed requests are retried up to `max_retries` times when repeating them cannot apply them twice: requests that never reached the server or were turned away with 408 or 429, and requests that are harmless to repeat (`GET`, `HEAD`, `OPTIONS` and `PUT` requests, and calls such as `apps.environments.update` that settle on the same result) that timed out, lost their connection, or got a 500, 502, 503 or 504 response. ## Transports diff --git a/packages/reflex-build-sdk/news/7192.feature.md b/packages/reflex-build-sdk/news/7192.feature.md new file mode 100644 index 00000000000..5a24e8b0806 --- /dev/null +++ b/packages/reflex-build-sdk/news/7192.feature.md @@ -0,0 +1 @@ +Call third-party services from an app with `reflex-build-sdk`: `client.apps.connections` reads a live credential for the app or for one of its users, lists what is connected, starts a connection and disconnects it, without the app ever holding the provider's key. `APIStatusError.code` now carries the condition the API names for a refusal, such as `not_connected`. diff --git a/packages/reflex-build-sdk/openapi.json b/packages/reflex-build-sdk/openapi.json index d446fcc39b3..48958646eed 100644 --- a/packages/reflex-build-sdk/openapi.json +++ b/packages/reflex-build-sdk/openapi.json @@ -573,6 +573,70 @@ "title": "Body_create_deployment_v1_deployments_post", "type": "object" }, + "ConnectLinkResponse": { + "properties": { + "expires_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "url": { + "title": "Url", + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "ConnectLinkResponse", + "type": "object" + }, + "ConnectionStatusResponse": { + "properties": { + "broker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Broker" + }, + "connected": { + "title": "Connected", + "type": "boolean" + }, + "connected_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Connected At" + }, + "provider": { + "title": "Provider", + "type": "string" + } + }, + "required": [ + "provider", + "connected" + ], + "title": "ConnectionStatusResponse", + "type": "object" + }, "CreateEnvironmentParams": { "description": "Params for adding an app environment to the pipeline.", "properties": { @@ -774,6 +838,46 @@ "title": "CreateTokenRequest", "type": "object" }, + "CredentialResponse": { + "properties": { + "access_token": { + "title": "Access Token", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At" + }, + "kind": { + "default": "oauth2", + "title": "Kind", + "type": "string" + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Username" + } + }, + "required": [ + "access_token" + ], + "title": "CredentialResponse", + "type": "object" + }, "CustomDomain": { "properties": { "domain": { @@ -1322,6 +1426,7 @@ "type": "object" }, "GenerationResponse": { + "description": "Where a generation got to.\n\n``status`` is ``preparing`` / ``queued`` / ``running`` / ``pushing`` while\nthe run is live, then ``finished`` / ``failed`` / ``stopped``, or an\n``awaiting_*`` -- ``awaiting_question``, ``awaiting_permission``,\n``awaiting_plan``, ``awaiting_integration``, ``awaiting_env_vars`` -- for a\nrun the agent stopped to ask something.\n\nOnly ``awaiting_question`` is resolvable here, by creating another\ngeneration on the same ``thread_id`` with the answer as the prompt. A\nprompt sent at any other ``awaiting_`` returns the same status: the agent\nis instructed that a free-text reply is not a permission grant, and an\nintegration is connected rather than described.\n\nA plain string, not an enum: a worker one release ahead can send an\n``awaiting_`` this client has no name for, and it should read as a parked\nrun rather than fail to parse.", "properties": { "id": { "format": "uuid", @@ -2904,6 +3009,10 @@ ], "title": "Default Kubernetes Namespace" }, + "grant_overridable": { + "title": "Grant Overridable", + "type": "boolean" + }, "id": { "format": "uuid", "title": "Id", @@ -2913,6 +3022,17 @@ "title": "Member Count", "type": "integer" }, + "merged_into_org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Merged Into Org Id" + }, "monthly_allowance": { "title": "Monthly Allowance", "type": "string" @@ -2932,6 +3052,10 @@ "title": "Name", "type": "string" }, + "paid": { + "title": "Paid", + "type": "boolean" + }, "pilot": { "title": "Pilot", "type": "boolean" @@ -2982,6 +3106,8 @@ "creator_username", "admin_emails", "tier", + "paid", + "grant_overridable", "seats", "project_count", "member_count", @@ -3021,6 +3147,11 @@ ], "title": "Monthly Usd Allowance" }, + "notify": { + "default": false, + "title": "Notify", + "type": "boolean" + }, "pilot": { "anyOf": [ { @@ -3488,6 +3619,40 @@ "title": "PromoteEnvironmentParams", "type": "object" }, + "ProviderResponse": { + "properties": { + "broker": { + "title": "Broker", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "logo_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Logo Url" + } + }, + "required": [ + "id", + "display_name", + "broker" + ], + "title": "ProviderResponse", + "type": "object" + }, "QueryResultOrder": { "description": "Enum for query result order.", "enum": [ @@ -4119,6 +4284,24 @@ "title": "SecurityViolation", "type": "object" }, + "SessionBody": { + "description": "Where a browser flow should send the person afterwards, if anywhere.", + "properties": { + "return_to": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Return To" + } + }, + "title": "SessionBody", + "type": "object" + }, "SetProjectNamespaceParams": { "description": "Params for pinning a project to a Kubernetes namespace.\n\nBlank and absent both clear the pin, so a provisioner asserting \"no\nnamespace\" does not have to know which form this API prefers.", "properties": { @@ -8464,9 +8647,10 @@ "summary": "Unblock App Auth User" } }, - "/v1/apps/{app_id}/custom_domain": { + "/v1/apps/{app_id}/connections": { "get": { - "operationId": "get_current_domain_v1_apps__app_id__custom_domain_get", + "description": "The accounts the app itself has connected, one per provider.\n\nApp-owned only: an end user's connection is theirs, made inside the\nrunning app, and the builder is not shown other people's accounts.", + "operationId": "get_app_connections_v1_apps__app_id__connections_get", "parameters": [ { "in": "path", @@ -8499,7 +8683,13 @@ "200": { "content": { "application/json": { - "schema": {} + "schema": { + "items": { + "$ref": "#/components/schemas/ConnectionStatusResponse" + }, + "title": "Response Get App Connections V1 Apps App Id Connections Get", + "type": "array" + } } }, "description": "Successful Response" @@ -8515,10 +8705,13 @@ "description": "Validation Error" } }, - "summary": "Get Current Domain" - }, - "post": { - "operationId": "create_custom_hostname_v1_apps__app_id__custom_domain_post", + "summary": "Get App Connections" + } + }, + "/v1/apps/{app_id}/connections/{provider}": { + "delete": { + "description": "Revoke the app's own connection to *provider*.\n\nThe owner is read ahead of the access check and bound into the revoke's\nread, as the authorize route binds its mint: a move landing between the\ntwo would otherwise have this request revoke the connection the new owner\nrecorded, on the strength of access the move has since revoked.", + "operationId": "disconnect_app_connection_v1_apps__app_id__connections__provider__delete", "parameters": [ { "in": "path", @@ -8530,6 +8723,15 @@ "type": "string" } }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "title": "Provider", + "type": "string" + } + }, { "in": "header", "name": "X-API-Token", @@ -8547,21 +8749,15 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CustomDomain" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "title": "Response Disconnect App Connection V1 Apps App Id Connections Provider Delete", + "type": "object" + } } }, "description": "Successful Response" @@ -8577,12 +8773,13 @@ "description": "Validation Error" } }, - "summary": "Create Custom Hostname" + "summary": "Disconnect App Connection" } }, - "/v1/apps/{app_id}/custom_domain/{domain}": { - "delete": { - "operationId": "delete_custom_hostname_v1_apps__app_id__custom_domain__domain__delete", + "/v1/apps/{app_id}/connections/{provider}/authorize": { + "post": { + "description": "Where the builder goes to connect *provider* on the app's behalf.\n\nGated like a secret write: what comes back is a credential the app's code\nwill read, so connecting one is editing the app's secrets by another door.\nThe owner is read *before* the access check and handed to the mint as the\none the session may be made under: a move landing between the two would\notherwise tag the session with the new owner, on the strength of access\nthe move has since revoked.", + "operationId": "authorize_app_connection_v1_apps__app_id__connections__provider__authorize_post", "parameters": [ { "in": "path", @@ -8596,10 +8793,10 @@ }, { "in": "path", - "name": "domain", + "name": "provider", "required": true, "schema": { - "title": "Domain", + "title": "Provider", "type": "string" } }, @@ -8620,11 +8817,30 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionBody" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, "responses": { "200": { "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/ConnectLinkResponse" + } } }, "description": "Successful Response" @@ -8640,12 +8856,13 @@ "description": "Validation Error" } }, - "summary": "Delete Custom Hostname" + "summary": "Authorize App Connection" } }, - "/v1/apps/{app_id}/database": { - "delete": { - "operationId": "delete_app_database_v1_apps__app_id__database_delete", + "/v1/apps/{app_id}/connections/{provider}/credential": { + "get": { + "description": "A live access token for this identity's connection.\n\nRead under the org the token resolved to, so a move landing inside this\nrequest does not hand the old owner's token the new owner's connection.", + "operationId": "read_connection_credential_v1_apps__app_id__connections__provider__credential_get", "parameters": [ { "in": "path", @@ -8657,6 +8874,31 @@ "type": "string" } }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "title": "Provider", + "type": "string" + } + }, + { + "in": "header", + "name": "X-End-User", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-End-User" + } + }, { "in": "header", "name": "X-API-Token", @@ -8679,9 +8921,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "title": "Response Delete App Database V1 Apps App Id Database Delete", - "type": "object" + "$ref": "#/components/schemas/CredentialResponse" } } }, @@ -8698,10 +8938,13 @@ "description": "Validation Error" } }, - "summary": "Delete App Database" - }, - "get": { - "operationId": "get_app_database_v1_apps__app_id__database_get", + "summary": "Read Connection Credential" + } + }, + "/v1/apps/{app_id}/connections/{provider}/disconnect": { + "post": { + "description": "Revoke one of the app's end users' connection to *provider*.\n\nAn end user's, never the app's own: that one is the builder's to revoke,\nthrough the route gated as a secret write.", + "operationId": "disconnect_connection_v1_apps__app_id__connections__provider__disconnect_post", "parameters": [ { "in": "path", @@ -8713,6 +8956,31 @@ "type": "string" } }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "title": "Provider", + "type": "string" + } + }, + { + "in": "header", + "name": "X-End-User", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-End-User" + } + }, { "in": "header", "name": "X-API-Token", @@ -8736,7 +9004,7 @@ "application/json": { "schema": { "additionalProperties": true, - "title": "Response Get App Database V1 Apps App Id Database Get", + "title": "Response Disconnect Connection V1 Apps App Id Connections Provider Disconnect Post", "type": "object" } } @@ -8754,10 +9022,13 @@ "description": "Validation Error" } }, - "summary": "Get App Database" - }, + "summary": "Disconnect Connection" + } + }, + "/v1/apps/{app_id}/connections/{provider}/session": { "post": { - "operationId": "create_app_database_v1_apps__app_id__database_post", + "description": "Mint the link one of the app's end users authorizes through.\n\nAn end user, never the app itself: the subject is required, and the\nsession is bound to the org the token resolved under, so a move landing\nbetween that resolve and the mint refuses the session rather than tagging\nit with the new owner.", + "operationId": "create_connection_session_v1_apps__app_id__connections__provider__session_post", "parameters": [ { "in": "path", @@ -8770,8 +9041,479 @@ } }, { - "in": "header", - "name": "X-API-Token", + "in": "path", + "name": "provider", + "required": true, + "schema": { + "title": "Provider", + "type": "string" + } + }, + { + "in": "header", + "name": "X-End-User", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-End-User" + } + }, + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionBody" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectLinkResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Connection Session" + } + }, + "/v1/apps/{app_id}/connections/{provider}/status": { + "get": { + "description": "Whether this identity has connected *provider*.\n\nFor a page deciding between a connect button and the feature behind it.", + "operationId": "read_connection_status_v1_apps__app_id__connections__provider__status_get", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "format": "uuid", + "title": "App Id", + "type": "string" + } + }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "title": "Provider", + "type": "string" + } + }, + { + "in": "header", + "name": "X-End-User", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-End-User" + } + }, + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionStatusResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Read Connection Status" + } + }, + "/v1/apps/{app_id}/custom_domain": { + "get": { + "operationId": "get_current_domain_v1_apps__app_id__custom_domain_get", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "format": "uuid", + "title": "App Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Current Domain" + }, + "post": { + "operationId": "create_custom_hostname_v1_apps__app_id__custom_domain_post", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "format": "uuid", + "title": "App Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomDomain" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Custom Hostname" + } + }, + "/v1/apps/{app_id}/custom_domain/{domain}": { + "delete": { + "operationId": "delete_custom_hostname_v1_apps__app_id__custom_domain__domain__delete", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "format": "uuid", + "title": "App Id", + "type": "string" + } + }, + { + "in": "path", + "name": "domain", + "required": true, + "schema": { + "title": "Domain", + "type": "string" + } + }, + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Custom Hostname" + } + }, + "/v1/apps/{app_id}/database": { + "delete": { + "operationId": "delete_app_database_v1_apps__app_id__database_delete", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "format": "uuid", + "title": "App Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Delete App Database V1 Apps App Id Database Delete", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete App Database" + }, + "get": { + "operationId": "get_app_database_v1_apps__app_id__database_get", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "format": "uuid", + "title": "App Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get App Database V1 Apps App Id Database Get", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get App Database" + }, + "post": { + "operationId": "create_app_database_v1_apps__app_id__database_post", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "format": "uuid", + "title": "App Id", + "type": "string" + } + }, + { + "in": "header", + "name": "X-API-Token", "required": false, "schema": { "anyOf": [ @@ -11837,6 +12579,131 @@ "summary": "Get Cli Token" } }, + "/v1/connections/providers": { + "get": { + "description": "Which providers this deployment can connect.", + "operationId": "get_connection_providers_v1_connections_providers_get", + "parameters": [ + { + "in": "header", + "name": "X-API-Token", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Api-Token" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProviderResponse" + }, + "title": "Response Get Connection Providers V1 Connections Providers Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Connection Providers" + } + }, + "/v1/connections/{broker}/callback": { + "get": { + "description": "A completion arriving from the broker named in the path.\n\nHanded over whole -- method, query, headers, raw body -- because only the\nbroker knows how to verify it: Nango signs its webhook over the raw bytes,\nand a hand-written OAuth flow carries its state in the query string.\nAnswered 2xx whatever the body turns out to describe, since a delivery this\ndeployment has no use for is not a failure and a webhook sender would\nredeliver it forever. A browser is redirected where the broker says.\n\nNot gated on the feature flag, unlike every route above: a session minted\nbefore the flag went off completes at the broker regardless, and a\ncompletion refused here is a live connection no row ever names. New\nsessions are what the flag withholds, through ``handles()``; a completion\nis recorded so the teardown can find it.", + "operationId": "complete_connection_v1_connections__broker__callback_get", + "parameters": [ + { + "in": "path", + "name": "broker", + "required": true, + "schema": { + "title": "Broker", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Complete Connection" + }, + "post": { + "description": "A completion arriving from the broker named in the path.\n\nHanded over whole -- method, query, headers, raw body -- because only the\nbroker knows how to verify it: Nango signs its webhook over the raw bytes,\nand a hand-written OAuth flow carries its state in the query string.\nAnswered 2xx whatever the body turns out to describe, since a delivery this\ndeployment has no use for is not a failure and a webhook sender would\nredeliver it forever. A browser is redirected where the broker says.\n\nNot gated on the feature flag, unlike every route above: a session minted\nbefore the flag went off completes at the broker regardless, and a\ncompletion refused here is a live connection no row ever names. New\nsessions are what the flag withholds, through ``handles()``; a completion\nis recorded so the teardown can find it.", + "operationId": "complete_connection_v1_connections__broker__callback_get", + "parameters": [ + { + "in": "path", + "name": "broker", + "required": true, + "schema": { + "title": "Broker", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Complete Connection" + } + }, "/v1/deployments": { "post": { "description": "Accept a built app and hand it to the deployment pipeline.\n\nThe archives are already in the bucket, uploaded through\n``/deployments/reserve``; what arrives here is their id. ``stored_build_id``\nis optional in this signature so that a caller that omits it is refused by\nthe version gate below, which names the upgrade, rather than by FastAPI\nwith a validation report the CLI renders as an internal error.", @@ -11893,7 +12760,7 @@ }, "/v1/deployments/regions": { "get": { - "description": "Endpoint to get the list of regions.", + "description": "The regions a deploy may name -- what `reflex cloud regions` prints.\n\nA retired region is filtered out rather than offered and then refused at\nsubmit time. The rows themselves stay: a machine already in one resolves its\nregion through them.", "operationId": "get_regions_public_v1_deployments_regions_get", "responses": { "200": { diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_async/_client.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_async/_client.py index 105c15f7d1e..a0c8f040e47 100644 --- a/packages/reflex-build-sdk/src/reflex_build_sdk/_async/_client.py +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_async/_client.py @@ -133,6 +133,7 @@ async def _request( form: Mapping[str, str] | None = None, authenticated: bool = True, idempotent: bool | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> T: ... @overload @@ -147,6 +148,7 @@ async def _request( form: Mapping[str, str] | None = None, authenticated: bool = True, idempotent: bool | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> None: ... async def _request( @@ -160,6 +162,7 @@ async def _request( form: Mapping[str, str] | None = None, authenticated: bool = True, idempotent: bool | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> Any: """Send an API request, retrying transient failures that are safe to retry. @@ -176,6 +179,7 @@ async def _request( idempotent: Whether repeating the request is harmless, which decides whether it is retried after it may have reached the server. Defaults to whether the method is idempotent. + extra_headers: Headers to send beside the ones every request carries. Returns: The decoded response body. @@ -192,6 +196,7 @@ async def _request( json=json, form=form, authenticated=authenticated, + extra_headers=extra_headers, ) attempt = 0 while True: diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/apps.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/apps.py index 0afae9a875b..12bf48e897d 100644 --- a/packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/apps.py +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/apps.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, NoReturn +from reflex_build_sdk._async.resources.connections import AsyncConnections from reflex_build_sdk._async.resources.databases import AsyncDatabase from reflex_build_sdk._async.resources.environments import AsyncEnvironments from reflex_build_sdk._async.resources.sign_in import AsyncSignIn @@ -291,6 +292,8 @@ class AsyncApps: database: AsyncDatabase # Sign an app's users in with their Reflex accounts. sign_in: AsyncSignIn + # Call third-party services an app is connected to. + connections: AsyncConnections def __init__(self, client: AsyncReflexCloud) -> None: """Bind the resource to a client. @@ -304,6 +307,7 @@ def __init__(self, client: AsyncReflexCloud) -> None: self.environments = AsyncEnvironments(client) self.database = AsyncDatabase(client) self.sign_in = AsyncSignIn(client) + self.connections = AsyncConnections(client) async def list( self, *, project_id: uuid.UUID | str | None = None diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/connections.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/connections.py new file mode 100644 index 00000000000..484b5b50ee4 --- /dev/null +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/connections.py @@ -0,0 +1,207 @@ +"""The endpoints for an app's connections to third-party services.""" + +from __future__ import annotations + +import builtins +import uuid +from typing import TYPE_CHECKING, Any + +from reflex_build_sdk._base import path_segment +from reflex_build_sdk.types import ( + ConnectionProvider, + ConnectionStatus, + ConnectLink, + Credential, +) + +if TYPE_CHECKING: + from reflex_build_sdk._async._client import AsyncReflexCloud + +# Names whose connection a request acts on: absent, a route acts on the app's own. +_END_USER_HEADER = "X-End-User" + + +def _end_user_headers(end_user: str | None) -> dict[str, str] | None: + """Build the headers naming whose connection to act on. + + Args: + end_user: The user, or None for the app's own connection. + + Returns: + The headers, or None to send none: the header must be absent rather than + empty, since the API reads an absent one as the app's own connection. + """ + return None if end_user is None else {_END_USER_HEADER: end_user} + + +class AsyncConnections: + """Call third-party services an app is connected to, without holding their keys. + + Reflex Cloud keeps the credentials and hands out a live one per call, so an app + stores none. A connection belongs either to the app itself or to one of its + users, named by ``end_user``. + + Every method needs the app's own access token, which a deployed app is started + with, so a client built with no arguments inside a running app is already the + right one. A personal token is refused with ``PermissionDeniedError``. While + connections are switched off for a deployment, every method raises + ``NotFoundError``. + + Refusals name their condition in ``APIStatusError.code``, e.g. + ``"not_connected"``, ``"connection_gone"`` for a connection the provider no + longer honours, or ``"unsupported_credential"``. + """ + + def __init__(self, client: AsyncReflexCloud) -> None: + """Bind the resource to a client. + + Args: + client: The client that sends the requests. + """ + self._client = client + + def _path(self, app_id: uuid.UUID | str, provider: str, suffix: str = "") -> str: + """Build the path of one provider's connection endpoint. + + Args: + app_id: The app. + provider: The provider. + suffix: The rest of the path, e.g. ``"/credential"``. + + Returns: + The path. + """ + return ( + f"apps/{path_segment(app_id)}/connections/{path_segment(provider)}{suffix}" + ) + + async def providers(self) -> builtins.list[ConnectionProvider]: + """List the third-party services apps can connect to. + + Returns: + The providers. + """ + return await self._client._request( + "GET", "connections/providers", builtins.list[ConnectionProvider] + ) + + async def list(self, app_id: uuid.UUID | str) -> builtins.list[ConnectionStatus]: + """List the providers an app itself is connected to. + + Args: + app_id: The app. + + Returns: + One status per provider the app has ever connected. + """ + return await self._client._request( + "GET", + f"apps/{path_segment(app_id)}/connections", + builtins.list[ConnectionStatus], + ) + + async def status( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + ) -> ConnectionStatus: + """Get whether an app, or one of its users, is connected to a provider. + + Args: + app_id: The app. + provider: The provider, from ``providers``. + end_user: The user whose connection to read. Defaults to the app's own. + + Returns: + The status. + """ + return await self._client._request( + "GET", + self._path(app_id, provider, "/status"), + ConnectionStatus, + extra_headers=_end_user_headers(end_user), + ) + + async def credential( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + ) -> Credential: + """Get a live credential to call a provider with. + + Read it for each call rather than storing it: it is refreshed as it is + handed out, so a stored copy outlives what the provider accepts. + + Args: + app_id: The app. + provider: The provider. + end_user: The user whose connection to use. Defaults to the app's own. + + Returns: + The credential. + """ + return await self._client._request( + "GET", + self._path(app_id, provider, "/credential"), + Credential, + extra_headers=_end_user_headers(end_user), + ) + + async def connect_link( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + return_to: str | None = None, + ) -> ConnectLink: + """Start connecting a provider, and get the page to send someone to. + + Args: + app_id: The app. + provider: The provider. + end_user: The user connecting their own account. Defaults to connecting + the app's own. + return_to: Where to send the browser once the provider is connected. + + Returns: + The link, which expires. + """ + body: dict[str, Any] = {"return_to": return_to} + # Two routes, one for the app's own connection and one for a user's. + suffix = "/authorize" if end_user is None else "/session" + return await self._client._request( + "POST", + self._path(app_id, provider, suffix), + ConnectLink, + json=body, + extra_headers=_end_user_headers(end_user), + ) + + async def disconnect( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + ) -> None: + """Forget a connection, and end it at the provider. + + Args: + app_id: The app. + provider: The provider. + end_user: The user whose connection to end. Defaults to the app's own. + """ + if end_user is None: + await self._client._request("DELETE", self._path(app_id, provider), None) + return + await self._client._request( + "POST", + self._path(app_id, provider, "/disconnect"), + None, + extra_headers=_end_user_headers(end_user), + ) diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_base.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_base.py index b9601bdaafe..edc93c2d3b0 100644 --- a/packages/reflex-build-sdk/src/reflex_build_sdk/_base.py +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_base.py @@ -195,6 +195,7 @@ def _build_request( json: Any, authenticated: bool, form: Mapping[str, str] | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> Request: """Build an API request. @@ -207,6 +208,8 @@ def _build_request( json: The JSON body, if any. authenticated: Whether to send the access token. form: A form-encoded body, sent instead of ``json``. + extra_headers: Headers to send beside the ones every request carries, + which win over these. Returns: The request, carrying a fresh ``X-Request-ID``. @@ -215,6 +218,9 @@ def _build_request( MissingTokenError: If the request needs a token and the client has none. """ headers = { + # First, so that the headers every request carries win: a caller cannot + # replace the request id an error is traced by. + **(extra_headers or {}), "Accept": "application/json", "User-Agent": user_agent(), "X-Request-ID": uuid.uuid4().hex, diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_errors.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_errors.py index fbc87f080fc..4db3f06820c 100644 --- a/packages/reflex-build-sdk/src/reflex_build_sdk/_errors.py +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_errors.py @@ -119,12 +119,19 @@ def __init__(self, message: str, *, response: Response) -> None: self.response = response +# The header naming the condition a refusal reports, beside its prose ``detail``. +REFUSAL_CODE_HEADER = "x-reflex-error-code" + + class APIStatusError(APIError): """The API responded with a 4xx or 5xx status code.""" response: Response status_code: int detail: Any + # What the API called the refusal, e.g. ``"not_connected"``, or an empty string + # when it did not name one. More stable than ``detail``, which is prose. + code: str def __init__(self, message: str, *, response: Response, detail: Any) -> None: """Initialize the error. @@ -139,6 +146,7 @@ def __init__(self, message: str, *, response: Response, detail: Any) -> None: self.response = response self.status_code = response.status_code self.detail = detail + self.code = response.headers.get(REFUSAL_CODE_HEADER, "") class BadRequestError(APIStatusError): diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/_client.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/_client.py index 0eaa206c4dc..f940468c57a 100644 --- a/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/_client.py +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/_client.py @@ -134,6 +134,7 @@ def _request( form: Mapping[str, str] | None = None, authenticated: bool = True, idempotent: bool | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> T: ... @overload @@ -148,6 +149,7 @@ def _request( form: Mapping[str, str] | None = None, authenticated: bool = True, idempotent: bool | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> None: ... def _request( @@ -161,6 +163,7 @@ def _request( form: Mapping[str, str] | None = None, authenticated: bool = True, idempotent: bool | None = None, + extra_headers: Mapping[str, str] | None = None, ) -> Any: """Send an API request, retrying transient failures that are safe to retry. @@ -177,6 +180,7 @@ def _request( idempotent: Whether repeating the request is harmless, which decides whether it is retried after it may have reached the server. Defaults to whether the method is idempotent. + extra_headers: Headers to send beside the ones every request carries. Returns: The decoded response body. @@ -193,6 +197,7 @@ def _request( json=json, form=form, authenticated=authenticated, + extra_headers=extra_headers, ) attempt = 0 while True: diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/resources/apps.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/resources/apps.py index bb477c22978..8aeedd1375b 100644 --- a/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/resources/apps.py +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/resources/apps.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal, NoReturn from reflex_build_sdk._base import path_segment +from reflex_build_sdk._sync.resources.connections import Connections from reflex_build_sdk._sync.resources.databases import Database from reflex_build_sdk._sync.resources.environments import Environments from reflex_build_sdk._sync.resources.sign_in import SignIn @@ -292,6 +293,8 @@ class Apps: database: Database # Sign an app's users in with their Reflex accounts. sign_in: SignIn + # Call third-party services an app is connected to. + connections: Connections def __init__(self, client: ReflexCloud) -> None: """Bind the resource to a client. @@ -305,6 +308,7 @@ def __init__(self, client: ReflexCloud) -> None: self.environments = Environments(client) self.database = Database(client) self.sign_in = SignIn(client) + self.connections = Connections(client) def list( self, *, project_id: uuid.UUID | str | None = None diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/resources/connections.py b/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/resources/connections.py new file mode 100644 index 00000000000..420ef445eae --- /dev/null +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/_sync/resources/connections.py @@ -0,0 +1,208 @@ +# Generated from packages/reflex-build-sdk/src/reflex_build_sdk/_async/resources/connections.py by packages/reflex-build-sdk/scripts/unasync.py. Do not edit. +"""The endpoints for an app's connections to third-party services.""" + +from __future__ import annotations + +import builtins +import uuid +from typing import TYPE_CHECKING, Any + +from reflex_build_sdk._base import path_segment +from reflex_build_sdk.types import ( + ConnectionProvider, + ConnectionStatus, + ConnectLink, + Credential, +) + +if TYPE_CHECKING: + from reflex_build_sdk._sync._client import ReflexCloud + +# Names whose connection a request acts on: absent, a route acts on the app's own. +_END_USER_HEADER = "X-End-User" + + +def _end_user_headers(end_user: str | None) -> dict[str, str] | None: + """Build the headers naming whose connection to act on. + + Args: + end_user: The user, or None for the app's own connection. + + Returns: + The headers, or None to send none: the header must be absent rather than + empty, since the API reads an absent one as the app's own connection. + """ + return None if end_user is None else {_END_USER_HEADER: end_user} + + +class Connections: + """Call third-party services an app is connected to, without holding their keys. + + Reflex Cloud keeps the credentials and hands out a live one per call, so an app + stores none. A connection belongs either to the app itself or to one of its + users, named by ``end_user``. + + Every method needs the app's own access token, which a deployed app is started + with, so a client built with no arguments inside a running app is already the + right one. A personal token is refused with ``PermissionDeniedError``. While + connections are switched off for a deployment, every method raises + ``NotFoundError``. + + Refusals name their condition in ``APIStatusError.code``, e.g. + ``"not_connected"``, ``"connection_gone"`` for a connection the provider no + longer honours, or ``"unsupported_credential"``. + """ + + def __init__(self, client: ReflexCloud) -> None: + """Bind the resource to a client. + + Args: + client: The client that sends the requests. + """ + self._client = client + + def _path(self, app_id: uuid.UUID | str, provider: str, suffix: str = "") -> str: + """Build the path of one provider's connection endpoint. + + Args: + app_id: The app. + provider: The provider. + suffix: The rest of the path, e.g. ``"/credential"``. + + Returns: + The path. + """ + return ( + f"apps/{path_segment(app_id)}/connections/{path_segment(provider)}{suffix}" + ) + + def providers(self) -> builtins.list[ConnectionProvider]: + """List the third-party services apps can connect to. + + Returns: + The providers. + """ + return self._client._request( + "GET", "connections/providers", builtins.list[ConnectionProvider] + ) + + def list(self, app_id: uuid.UUID | str) -> builtins.list[ConnectionStatus]: + """List the providers an app itself is connected to. + + Args: + app_id: The app. + + Returns: + One status per provider the app has ever connected. + """ + return self._client._request( + "GET", + f"apps/{path_segment(app_id)}/connections", + builtins.list[ConnectionStatus], + ) + + def status( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + ) -> ConnectionStatus: + """Get whether an app, or one of its users, is connected to a provider. + + Args: + app_id: The app. + provider: The provider, from ``providers``. + end_user: The user whose connection to read. Defaults to the app's own. + + Returns: + The status. + """ + return self._client._request( + "GET", + self._path(app_id, provider, "/status"), + ConnectionStatus, + extra_headers=_end_user_headers(end_user), + ) + + def credential( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + ) -> Credential: + """Get a live credential to call a provider with. + + Read it for each call rather than storing it: it is refreshed as it is + handed out, so a stored copy outlives what the provider accepts. + + Args: + app_id: The app. + provider: The provider. + end_user: The user whose connection to use. Defaults to the app's own. + + Returns: + The credential. + """ + return self._client._request( + "GET", + self._path(app_id, provider, "/credential"), + Credential, + extra_headers=_end_user_headers(end_user), + ) + + def connect_link( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + return_to: str | None = None, + ) -> ConnectLink: + """Start connecting a provider, and get the page to send someone to. + + Args: + app_id: The app. + provider: The provider. + end_user: The user connecting their own account. Defaults to connecting + the app's own. + return_to: Where to send the browser once the provider is connected. + + Returns: + The link, which expires. + """ + body: dict[str, Any] = {"return_to": return_to} + # Two routes, one for the app's own connection and one for a user's. + suffix = "/authorize" if end_user is None else "/session" + return self._client._request( + "POST", + self._path(app_id, provider, suffix), + ConnectLink, + json=body, + extra_headers=_end_user_headers(end_user), + ) + + def disconnect( + self, + app_id: uuid.UUID | str, + provider: str, + *, + end_user: str | None = None, + ) -> None: + """Forget a connection, and end it at the provider. + + Args: + app_id: The app. + provider: The provider. + end_user: The user whose connection to end. Defaults to the app's own. + """ + if end_user is None: + self._client._request("DELETE", self._path(app_id, provider), None) + return + self._client._request( + "POST", + self._path(app_id, provider, "/disconnect"), + None, + extra_headers=_end_user_headers(end_user), + ) diff --git a/packages/reflex-build-sdk/src/reflex_build_sdk/types.py b/packages/reflex-build-sdk/src/reflex_build_sdk/types.py index 8f8b48dad79..22f6d7daf96 100644 --- a/packages/reflex-build-sdk/src/reflex_build_sdk/types.py +++ b/packages/reflex-build-sdk/src/reflex_build_sdk/types.py @@ -959,6 +959,58 @@ class UsageEntry: description: str | None +@dataclass(frozen=True, slots=True, kw_only=True) +class ConnectionProvider: + """A third-party service an app can connect to.""" + + # The id to pass as ``provider``, e.g. ``"openai"``. + id: str + display_name: str + # The service brokering the connection, e.g. ``"nango"``. + broker: str + logo_url: str | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ConnectionStatus: + """Whether an app, or one of its users, is connected to a provider.""" + + provider: str + connected: bool + # The service brokering the connection, while it is connected. + broker: str | None = None + connected_at: datetime.datetime | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ConnectLink: + """Where to send someone to connect a provider.""" + + url: str + # When the link stops working. + expires_at: datetime.datetime | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Credential: + """A third-party credential to call a provider with. + + Read it when the call is made rather than storing it: it is refreshed on the + way out, so a copy kept in the app outlives what the provider accepts. + """ + + # The token, API key or password, depending on ``kind``. Kept out of this + # model's ``repr`` so that printing it, or an exception rendering it, does not + # disclose it. + access_token: str = field(repr=False) + # ``"oauth2"``, ``"api_key"``, ``"basic"``, or another the broker supports. + kind: str = "oauth2" + # When the credential stops working, for the kinds that expire. + expires_at: datetime.datetime | None = None + # The user name that goes with ``access_token`` for ``"basic"``. + username: str | None = None + + @dataclass(frozen=True, slots=True, kw_only=True) class SecurityViolation: """A security or logic issue found by a security review.""" diff --git a/tests/units/reflex_build_sdk/_async/resources/test_connections.py b/tests/units/reflex_build_sdk/_async/resources/test_connections.py new file mode 100644 index 00000000000..dc27bdc95fc --- /dev/null +++ b/tests/units/reflex_build_sdk/_async/resources/test_connections.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import datetime +from collections.abc import AsyncIterator + +import pytest +from reflex_build_sdk import AsyncReflexCloud, ConflictError, InternalServerError +from reflex_build_sdk.types import ( + ConnectionProvider, + ConnectionStatus, + ConnectLink, + Credential, +) + +from tests.units.reflex_build_sdk.conftest import ( + AsyncMockTransport, + MockAPI, + json_body, + reply, +) + +APP_ID = "5f0c5e0e-8f6a-4d57-9a55-3c1c1d7b6a01" +CONNECTIONS_PATH = f"/api/v1/apps/{APP_ID}/connections" +PROVIDER_PATH = f"{CONNECTIONS_PATH}/openai" +END_USER = "auth0|visitor-7" +UTC = datetime.timezone.utc +NOON = datetime.datetime(2026, 9, 17, 12, tzinfo=UTC) + + +@pytest.fixture +async def client(mock_api: MockAPI) -> AsyncIterator[AsyncReflexCloud]: + """A client talking to the mock API. + + Args: + mock_api: The mock API. + + Yields: + The client. + """ + async with AsyncReflexCloud( + token="test-token", transport=AsyncMockTransport(mock_api) + ) as client: + yield client + + +async def test_providers(client: AsyncReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + "/api/v1/connections/providers", + reply( + 200, + json=[ + { + "id": "openai", + "display_name": "OpenAI", + "broker": "nango", + "logo_url": None, + } + ], + ), + ) + assert await client.apps.connections.providers() == [ + ConnectionProvider( + id="openai", display_name="OpenAI", broker="nango", logo_url=None + ) + ] + + +async def test_list(client: AsyncReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + CONNECTIONS_PATH, + reply( + 200, + json=[ + { + "provider": "openai", + "connected": True, + "broker": "nango", + "connected_at": "2026-09-17T12:00:00+00:00", + } + ], + ), + ) + assert await client.apps.connections.list(APP_ID) == [ + ConnectionStatus( + provider="openai", connected=True, broker="nango", connected_at=NOON + ) + ] + + +async def test_status_reads_the_apps_own_connection( + client: AsyncReflexCloud, mock_api: MockAPI +): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/status", + reply(200, json={"provider": "openai", "connected": False}), + ) + assert await client.apps.connections.status(APP_ID, "openai") == ConnectionStatus( + provider="openai", connected=False + ) + # Absent, not empty: the API reads a missing header as the app's own connection. + assert "X-End-User" not in mock_api.requests[0].headers + + +async def test_status_names_a_user(client: AsyncReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/status", + reply(200, json={"provider": "openai", "connected": True}), + ) + await client.apps.connections.status(APP_ID, "openai", end_user=END_USER) + assert mock_api.requests[0].headers["X-End-User"] == END_USER + + +@pytest.mark.parametrize( + ("body", "credential"), + [ + ( + { + "access_token": "sk-live", + "kind": "oauth2", + "expires_at": "2026-09-17T12:00:00+00:00", + "username": None, + }, + Credential(access_token="sk-live", kind="oauth2", expires_at=NOON), + ), + ( + {"access_token": "hunter2", "kind": "basic", "username": "svc"}, + Credential(access_token="hunter2", kind="basic", username="svc"), + ), + # The API defaults the kind for a connection that predates it. + ({"access_token": "sk-live"}, Credential(access_token="sk-live")), + ], +) +async def test_credential( + client: AsyncReflexCloud, mock_api: MockAPI, body: dict, credential: Credential +): + mock_api.add("GET", f"{PROVIDER_PATH}/credential", reply(200, json=body)) + assert await client.apps.connections.credential(APP_ID, "openai") == credential + assert "X-End-User" not in mock_api.requests[0].headers + + +async def test_credential_for_a_user(client: AsyncReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/credential", + reply(200, json={"access_token": "sk-live"}), + ) + await client.apps.connections.credential(APP_ID, "openai", end_user=END_USER) + assert mock_api.requests[0].headers["X-End-User"] == END_USER + + +async def test_credential_keeps_the_token_out_of_its_repr( + client: AsyncReflexCloud, mock_api: MockAPI +): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/credential", + reply(200, json={"access_token": "sk-live"}), + ) + credential = await client.apps.connections.credential(APP_ID, "openai") + assert credential.access_token == "sk-live" + assert "sk-live" not in repr(credential) + + +async def test_credential_refusal_names_its_condition( + client: AsyncReflexCloud, mock_api: MockAPI +): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/credential", + reply( + 409, + json={"detail": "not_connected"}, + headers={"x-reflex-error-code": "not_connected"}, + ), + ) + with pytest.raises(ConflictError) as exc_info: + await client.apps.connections.credential(APP_ID, "openai") + assert exc_info.value.code == "not_connected" + + +async def test_connect_link_for_the_app(client: AsyncReflexCloud, mock_api: MockAPI): + mock_api.add( + "POST", + f"{PROVIDER_PATH}/authorize", + reply( + 200, + json={ + "url": "https://connect.example.com/abc", + "expires_at": "2026-09-17T12:00:00+00:00", + }, + ), + ) + assert await client.apps.connections.connect_link( + APP_ID, "openai", return_to="https://dashboard.reflex.run/done" + ) == ConnectLink(url="https://connect.example.com/abc", expires_at=NOON) + (request,) = mock_api.requests + assert json_body(request) == {"return_to": "https://dashboard.reflex.run/done"} + assert "X-End-User" not in request.headers + + +async def test_connect_link_for_a_user(client: AsyncReflexCloud, mock_api: MockAPI): + # A user connects through a different route than the app itself. + mock_api.add( + "POST", + f"{PROVIDER_PATH}/session", + reply(200, json={"url": "https://connect.example.com/xyz"}), + ) + link = await client.apps.connections.connect_link( + APP_ID, "openai", end_user=END_USER + ) + assert link == ConnectLink(url="https://connect.example.com/xyz") + (request,) = mock_api.requests + assert json_body(request) == {"return_to": None} + assert request.headers["X-End-User"] == END_USER + + +async def test_disconnect_the_app(client: AsyncReflexCloud, mock_api: MockAPI): + mock_api.add("DELETE", PROVIDER_PATH, reply(200, json={"disconnected": True})) + assert await client.apps.connections.disconnect(APP_ID, "openai") is None + assert "X-End-User" not in mock_api.requests[0].headers + + +async def test_disconnect_a_user(client: AsyncReflexCloud, mock_api: MockAPI): + mock_api.add( + "POST", f"{PROVIDER_PATH}/disconnect", reply(200, json={"disconnected": True}) + ) + await client.apps.connections.disconnect(APP_ID, "openai", end_user=END_USER) + assert mock_api.requests[0].headers["X-End-User"] == END_USER + + +async def test_mutations_are_not_retried(client: AsyncReflexCloud, mock_api: MockAPI): + # A retried session mints a second link, so an ambiguous failure is raised. + mock_api.add("POST", f"{PROVIDER_PATH}/session", reply(503)) + with pytest.raises(InternalServerError): + await client.apps.connections.connect_link(APP_ID, "openai", end_user=END_USER) + assert len(mock_api.requests) == 1 diff --git a/tests/units/reflex_build_sdk/_sync/resources/test_connections.py b/tests/units/reflex_build_sdk/_sync/resources/test_connections.py new file mode 100644 index 00000000000..9ccd57cc7f2 --- /dev/null +++ b/tests/units/reflex_build_sdk/_sync/resources/test_connections.py @@ -0,0 +1,233 @@ +# Generated from tests/units/reflex_build_sdk/_async/resources/test_connections.py by packages/reflex-build-sdk/scripts/unasync.py. Do not edit. +from __future__ import annotations + +import datetime +from collections.abc import Iterator + +import pytest +from reflex_build_sdk import ConflictError, InternalServerError, ReflexCloud +from reflex_build_sdk.types import ( + ConnectionProvider, + ConnectionStatus, + ConnectLink, + Credential, +) + +from tests.units.reflex_build_sdk.conftest import ( + MockAPI, + MockTransport, + json_body, + reply, +) + +APP_ID = "5f0c5e0e-8f6a-4d57-9a55-3c1c1d7b6a01" +CONNECTIONS_PATH = f"/api/v1/apps/{APP_ID}/connections" +PROVIDER_PATH = f"{CONNECTIONS_PATH}/openai" +END_USER = "auth0|visitor-7" +UTC = datetime.timezone.utc +NOON = datetime.datetime(2026, 9, 17, 12, tzinfo=UTC) + + +@pytest.fixture +def client(mock_api: MockAPI) -> Iterator[ReflexCloud]: + """A client talking to the mock API. + + Args: + mock_api: The mock API. + + Yields: + The client. + """ + with ReflexCloud(token="test-token", transport=MockTransport(mock_api)) as client: + yield client + + +def test_providers(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + "/api/v1/connections/providers", + reply( + 200, + json=[ + { + "id": "openai", + "display_name": "OpenAI", + "broker": "nango", + "logo_url": None, + } + ], + ), + ) + assert client.apps.connections.providers() == [ + ConnectionProvider( + id="openai", display_name="OpenAI", broker="nango", logo_url=None + ) + ] + + +def test_list(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + CONNECTIONS_PATH, + reply( + 200, + json=[ + { + "provider": "openai", + "connected": True, + "broker": "nango", + "connected_at": "2026-09-17T12:00:00+00:00", + } + ], + ), + ) + assert client.apps.connections.list(APP_ID) == [ + ConnectionStatus( + provider="openai", connected=True, broker="nango", connected_at=NOON + ) + ] + + +def test_status_reads_the_apps_own_connection(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/status", + reply(200, json={"provider": "openai", "connected": False}), + ) + assert client.apps.connections.status(APP_ID, "openai") == ConnectionStatus( + provider="openai", connected=False + ) + # Absent, not empty: the API reads a missing header as the app's own connection. + assert "X-End-User" not in mock_api.requests[0].headers + + +def test_status_names_a_user(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/status", + reply(200, json={"provider": "openai", "connected": True}), + ) + client.apps.connections.status(APP_ID, "openai", end_user=END_USER) + assert mock_api.requests[0].headers["X-End-User"] == END_USER + + +@pytest.mark.parametrize( + ("body", "credential"), + [ + ( + { + "access_token": "sk-live", + "kind": "oauth2", + "expires_at": "2026-09-17T12:00:00+00:00", + "username": None, + }, + Credential(access_token="sk-live", kind="oauth2", expires_at=NOON), + ), + ( + {"access_token": "hunter2", "kind": "basic", "username": "svc"}, + Credential(access_token="hunter2", kind="basic", username="svc"), + ), + # The API defaults the kind for a connection that predates it. + ({"access_token": "sk-live"}, Credential(access_token="sk-live")), + ], +) +def test_credential( + client: ReflexCloud, mock_api: MockAPI, body: dict, credential: Credential +): + mock_api.add("GET", f"{PROVIDER_PATH}/credential", reply(200, json=body)) + assert client.apps.connections.credential(APP_ID, "openai") == credential + assert "X-End-User" not in mock_api.requests[0].headers + + +def test_credential_for_a_user(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/credential", + reply(200, json={"access_token": "sk-live"}), + ) + client.apps.connections.credential(APP_ID, "openai", end_user=END_USER) + assert mock_api.requests[0].headers["X-End-User"] == END_USER + + +def test_credential_keeps_the_token_out_of_its_repr( + client: ReflexCloud, mock_api: MockAPI +): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/credential", + reply(200, json={"access_token": "sk-live"}), + ) + credential = client.apps.connections.credential(APP_ID, "openai") + assert credential.access_token == "sk-live" + assert "sk-live" not in repr(credential) + + +def test_credential_refusal_names_its_condition(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "GET", + f"{PROVIDER_PATH}/credential", + reply( + 409, + json={"detail": "not_connected"}, + headers={"x-reflex-error-code": "not_connected"}, + ), + ) + with pytest.raises(ConflictError) as exc_info: + client.apps.connections.credential(APP_ID, "openai") + assert exc_info.value.code == "not_connected" + + +def test_connect_link_for_the_app(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "POST", + f"{PROVIDER_PATH}/authorize", + reply( + 200, + json={ + "url": "https://connect.example.com/abc", + "expires_at": "2026-09-17T12:00:00+00:00", + }, + ), + ) + assert client.apps.connections.connect_link( + APP_ID, "openai", return_to="https://dashboard.reflex.run/done" + ) == ConnectLink(url="https://connect.example.com/abc", expires_at=NOON) + (request,) = mock_api.requests + assert json_body(request) == {"return_to": "https://dashboard.reflex.run/done"} + assert "X-End-User" not in request.headers + + +def test_connect_link_for_a_user(client: ReflexCloud, mock_api: MockAPI): + # A user connects through a different route than the app itself. + mock_api.add( + "POST", + f"{PROVIDER_PATH}/session", + reply(200, json={"url": "https://connect.example.com/xyz"}), + ) + link = client.apps.connections.connect_link(APP_ID, "openai", end_user=END_USER) + assert link == ConnectLink(url="https://connect.example.com/xyz") + (request,) = mock_api.requests + assert json_body(request) == {"return_to": None} + assert request.headers["X-End-User"] == END_USER + + +def test_disconnect_the_app(client: ReflexCloud, mock_api: MockAPI): + mock_api.add("DELETE", PROVIDER_PATH, reply(200, json={"disconnected": True})) + assert client.apps.connections.disconnect(APP_ID, "openai") is None + assert "X-End-User" not in mock_api.requests[0].headers + + +def test_disconnect_a_user(client: ReflexCloud, mock_api: MockAPI): + mock_api.add( + "POST", f"{PROVIDER_PATH}/disconnect", reply(200, json={"disconnected": True}) + ) + client.apps.connections.disconnect(APP_ID, "openai", end_user=END_USER) + assert mock_api.requests[0].headers["X-End-User"] == END_USER + + +def test_mutations_are_not_retried(client: ReflexCloud, mock_api: MockAPI): + # A retried session mints a second link, so an ambiguous failure is raised. + mock_api.add("POST", f"{PROVIDER_PATH}/session", reply(503)) + with pytest.raises(InternalServerError): + client.apps.connections.connect_link(APP_ID, "openai", end_user=END_USER) + assert len(mock_api.requests) == 1 diff --git a/tests/units/reflex_build_sdk/test_base.py b/tests/units/reflex_build_sdk/test_base.py index 0f8bf2131cc..6a2aa2827eb 100644 --- a/tests/units/reflex_build_sdk/test_base.py +++ b/tests/units/reflex_build_sdk/test_base.py @@ -294,3 +294,17 @@ def test_decode_response_invalid_body(kwargs: dict[str, Any]): decode_response(response, Me) assert exc_info.value.response is response assert "POST https://example.com/api/v1/authenticate/me" in str(exc_info.value) + + +def test_build_request_keeps_its_own_headers(): + request = _client()._build_request( + "GET", + "apps", + params=None, + json=None, + authenticated=False, + extra_headers={"X-End-User": "visitor", "X-Request-ID": "caller-chosen"}, + ) + assert request.headers["X-End-User"] == "visitor" + # The request id an error is traced by is the client's, not the caller's. + assert request.headers["X-Request-ID"] != "caller-chosen" diff --git a/tests/units/reflex_build_sdk/test_errors.py b/tests/units/reflex_build_sdk/test_errors.py index 963b8f95fe3..ca7f1bb19c5 100644 --- a/tests/units/reflex_build_sdk/test_errors.py +++ b/tests/units/reflex_build_sdk/test_errors.py @@ -64,6 +64,21 @@ def test_status_error_type(status_code: int, error_type: type[APIStatusError]): assert error.request_id == "abc" +def test_status_error_code_from_the_header(): + # Deliberately different from the detail, so that reading the body instead of + # the header would fail this. + response = _response( + 409, + json={"detail": "this app is not connected to that provider"}, + headers={"x-reflex-error-code": "not_connected"}, + ) + assert status_error_from_response(response).code == "not_connected" + + +def test_status_error_code_is_empty_when_unnamed(): + assert status_error_from_response(_response(409)).code == "" + + def test_status_error_detail_from_json_object(): response = _response(401, json={"detail": "Token not found or is inactive"}) error = status_error_from_response(response) diff --git a/tests/units/reflex_build_sdk/test_types.py b/tests/units/reflex_build_sdk/test_types.py index f4e24329d31..f768fec7b05 100644 --- a/tests/units/reflex_build_sdk/test_types.py +++ b/tests/units/reflex_build_sdk/test_types.py @@ -54,6 +54,10 @@ types.DeploymentReport: ("DeploymentFailureResponse",), UploadReservation: ("ReserveUploadResponse",), UploadTarget: ("UploadTargetResponse",), + types.ConnectionProvider: ("ProviderResponse",), + types.ConnectionStatus: ("ConnectionStatusResponse",), + types.ConnectLink: ("ConnectLinkResponse",), + types.Credential: ("CredentialResponse",), types.SecurityViolation: ("SecurityViolation",), types.SecurityReviewResult: ("SecurityReviewResult",), types.SecurityReviewJob: ("SecurityReviewJobResult",),