diff --git a/.pylintrc b/.pylintrc index f789fc6..afe4b0f 100644 --- a/.pylintrc +++ b/.pylintrc @@ -2,6 +2,9 @@ good-names=id,k,v,cc,to,ip max-line-length=120 +[DESIGN] +max-public-methods=25 + [MESSAGES CONTROL] disable= missing-module-docstring, diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b4946..4adbc2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,17 @@ nylas-python Changelog ====================== Unreleased ---------- +* Aligned Lists create support with the public `POST /v3/lists` schema and added create response/schema coverage +* Added Manage Domains service-account auth support with canonical signed request bodies, bearer-auth suppression, encoded domain path segments, and `dmarc`/`arc` verification types +* Added Workspaces resource (`client.workspaces`) with `list`, `find`, `create`, `update` (PATCH), `destroy`, `auto_group`, `manual_assign`, `default`, `policy_id`, and `rule_ids` +* Corrected RedirectUris `update` to use PATCH instead of PUT; added `deleted_at` to the RedirectUri model and made `platform` optional on create +* Verified and extended Applications: added `update` (PATCH `/v3/applications`) and public response fields (`idp_settings`, hosted-authentication legal URLs, `domain`, `blocked`, timestamps) * Fix draft and other JSON API requests failing with "only JSON and multipart supported" by sending `Content-Type: application/json` instead of `application/json; charset=utf-8` v6.15.0 ---------- * Added Lists support (`Client.lists`, `/v3/lists`): list, create, find, update, and delete lists, plus `list_items`, `add_items`, and `remove_items` for `/v3/lists/{list_id}/items`, with typed request/response models in `nylas.models.lists` -* Added Manage Domains (`Client.domains`, `/v3/admin/domains`): list, create, find, update, delete, `get_info`, and `verify` with models in `nylas.models.domains`; optional `ServiceAccountSigner` (`nylas.handler.service_account`) for service-account headers (`X-Nylas-Kid`, `X-Nylas-Nonce`, `X-Nylas-Timestamp`, `X-Nylas-Signature`) on each `Domains` method; new `cryptography` dependency, RSA signing, and `HttpClient` `serialized_json_body` so signed payloads match the wire body +* Added Manage Domains (`Client.domains`, `/v3/admin/domains`): list, create, find, update, delete, `get_info`, and `verify` with models in `nylas.models.domains`; optional `ServiceAccountSigner` (`nylas.handler.service_account`) for service-account auth headers (`X-Nylas-Kid`, `X-Nylas-Nonce`, `X-Nylas-Timestamp`, `X-Nylas-Signature`) on each `Domains` method; new `cryptography` dependency, RSA signing, bearer-auth suppression for signed domain requests, and `HttpClient` `serialized_json_body` so signed payloads match the wire body * Added Transactional Send: `Client.transactional_send.send()` for `POST /v3/domains/{domain_name}/messages/send`, with `TransactionalSendMessageRequest` and `TransactionalTemplate` models (JSON and multipart send behavior aligned with grant `messages.send`) * Added Policies support (`Client.policies`, `/v3/policies`): list, create, find, update, and delete, with typed request/response models in `nylas.models.policies` * Added Rules support (`Client.rules`): list, create, find, update, and delete for `/v3/rules`, plus `list_evaluations` for `/v3/grants/{grant_id}/rule-evaluations`, with typed request/response models in `nylas.models.rules` @@ -548,4 +553,3 @@ Added tests v0.3.5 ------ Drafts can now be sent without an implicit intermediate save to the mail provider. - diff --git a/nylas/client.py b/nylas/client.py index 66ce84b..d8ac120 100644 --- a/nylas/client.py +++ b/nylas/client.py @@ -20,6 +20,7 @@ from nylas.resources.scheduler import Scheduler from nylas.resources.notetakers import Notetakers from nylas.resources.rules import Rules +from nylas.resources.workspaces import Workspaces class Client: @@ -246,3 +247,13 @@ def notetakers(self) -> Notetakers: The Notetakers API. """ return Notetakers(self.http_client) + + @property + def workspaces(self) -> Workspaces: + """ + Access the Workspaces API. + + Returns: + The Workspaces API. + """ + return Workspaces(self.http_client) diff --git a/nylas/config.py b/nylas/config.py index ee625e0..0d55064 100644 --- a/nylas/config.py +++ b/nylas/config.py @@ -22,12 +22,15 @@ class RequestOverrides(TypedDict): api_uri: The API URI to use for the request. timeout: The timeout to use for the request. headers: Additional headers to include in the request. + skip_auth: Suppress the default bearer Authorization header for endpoints + that use a different authentication mechanism. """ api_key: NotRequired[str] api_uri: NotRequired[str] timeout: NotRequired[int] headers: NotRequired[dict] + skip_auth: NotRequired[bool] DEFAULT_REGION = Region.US diff --git a/nylas/handler/http_client.py b/nylas/handler/http_client.py index 2ded902..44aaf4c 100644 --- a/nylas/handler/http_client.py +++ b/nylas/handler/http_client.py @@ -28,7 +28,9 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: or "connect/revoke" in parsed_url.path ): parsed_error = NylasOAuthErrorResponse.from_dict(response_data) - raise NylasOAuthError(parsed_error, response.status_code, response.headers) + raise NylasOAuthError( + parsed_error, response.status_code, response.headers + ) parsed_error = NylasApiErrorResponse.from_dict(response_data) raise NylasApiError(parsed_error, response.status_code, response.headers) @@ -47,6 +49,7 @@ def _validate_response(response: Response) -> Tuple[Dict, CaseInsensitiveDict]: ) from exc return (response_data, response.headers) + def _build_query_params(base_url: str, query_params: dict = None) -> str: query_param_parts = [] for key, value in query_params.items(): @@ -107,7 +110,9 @@ def _execute( if serialized_json_body is not None and data is None: json_data = serialized_json_body elif request_body is not None and data is None: - json_data = json.dumps(request_body, ensure_ascii=False, allow_nan=True).encode("utf-8") + json_data = json.dumps( + request_body, ensure_ascii=False, allow_nan=True + ).encode("utf-8") try: response = requests.request( request["method"], @@ -128,7 +133,7 @@ def _execute_download_request( query_params=None, stream=False, overrides=None, - ) -> Union[bytes, Response,dict]: + ) -> Union[bytes, Response, dict]: request = self._build_request("GET", path, headers, query_params, overrides) timeout = self.timeout @@ -204,8 +209,9 @@ def _build_headers( headers = { "X-Nylas-API-Wrapper": "python", "User-Agent": user_agent_header, - "Authorization": f"Bearer {api_key}", } + if not (overrides and overrides.get("skip_auth")): + headers["Authorization"] = f"Bearer {api_key}" if data is not None and data.content_type is not None: headers["Content-type"] = data.content_type elif response_body is not None: diff --git a/nylas/models/application_details.py b/nylas/models/application_details.py index c017f9d..a03085c 100644 --- a/nylas/models/application_details.py +++ b/nylas/models/application_details.py @@ -1,15 +1,16 @@ from dataclasses import dataclass, field -from typing import Literal, Optional, List +from typing import Optional, List from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired from nylas.models.redirect_uri import RedirectUri -Region = Literal["us", "eu"] -""" Literal representing the available Nylas API regions. """ +Region = str +""" The Nylas API region (free-form string, e.g. ``us``, ``eu``). """ -Environment = Literal["production", "staging", "development", "sandbox"] -""" Literal representing the different Nylas API environments. """ +Environment = str +""" The Nylas API environment (free-form string, e.g. ``sandbox``). """ @dataclass_json @@ -46,6 +47,8 @@ class HostedAuthentication: subtitle: Subtitle for the hosted authentication page. background_color: Background color of the hosted authentication page. spacing: CSS spacing attribute in px. + terms_of_service_url: URL pointing to the terms of service. + privacy_policy_url: URL pointing to the privacy policy. """ background_image_url: Optional[str] = None @@ -56,6 +59,23 @@ class HostedAuthentication: subtitle: Optional[str] = None background_color: Optional[str] = None spacing: Optional[int] = None + terms_of_service_url: Optional[str] = None + privacy_policy_url: Optional[str] = None + + +@dataclass_json +@dataclass +class IdpSettings: + """ + Class representation of identity provider settings for the application. + + Attributes: + origins: Comma-separated list of allowed origins. + issuers: Comma-separated list of allowed issuers. + """ + + origins: Optional[str] = None + issuers: Optional[str] = None @dataclass_json @@ -70,8 +90,13 @@ class ApplicationDetails: region: Region identifier. environment: Environment identifier. branding: Branding details for the application. + domain: The white-label domain associated with the application, if any. hosted_authentication: Hosted authentication branding details. + idp_settings: Identity provider settings. callback_uris: List of redirect URIs. + created_at: Unix timestamp (seconds) when the application was created. + updated_at: Unix timestamp (seconds) when the application was last updated. + blocked: Whether the application is blocked. """ application_id: str @@ -79,5 +104,118 @@ class ApplicationDetails: region: Region environment: Environment branding: Branding + domain: Optional[str] = None hosted_authentication: Optional[HostedAuthentication] = None + idp_settings: Optional[IdpSettings] = None callback_uris: List[RedirectUri] = field(default_factory=list) + created_at: Optional[int] = None + updated_at: Optional[int] = None + blocked: Optional[bool] = None + + +class WritableBranding(TypedDict): + """ + Class representing branding details for a create/update application call. + + Attributes: + name: Name of the application. + icon_url: URL pointing to the application icon. + website_url: Application/publisher website URL. + description: Description of the application. + """ + + name: NotRequired[str] + icon_url: NotRequired[str] + website_url: NotRequired[str] + description: NotRequired[str] + + +class WritableHostedAuthentication(TypedDict): + """ + Class representing hosted authentication details for a create/update application call. + + Attributes: + background_image_url: URL pointing to the background image. + alignment: Alignment of the background image. + color_primary: Primary color of the hosted authentication page. + color_secondary: Secondary color of the hosted authentication page. + title: Title of the hosted authentication page. + subtitle: Subtitle for the hosted authentication page. + background_color: Background color of the hosted authentication page. + spacing: CSS spacing attribute in px. + terms_of_service_url: URL pointing to the terms of service. + privacy_policy_url: URL pointing to the privacy policy. + """ + + background_image_url: NotRequired[str] + alignment: NotRequired[str] + color_primary: NotRequired[str] + color_secondary: NotRequired[str] + title: NotRequired[str] + subtitle: NotRequired[str] + background_color: NotRequired[str] + spacing: NotRequired[int] + terms_of_service_url: NotRequired[str] + privacy_policy_url: NotRequired[str] + + +class WritableIdpSettings(TypedDict): + """ + Class representing identity provider settings for a create/update application call. + + Attributes: + origins: Comma-separated list of allowed origins. + issuers: Comma-separated list of allowed issuers. + """ + + origins: NotRequired[str] + issuers: NotRequired[str] + + +class WritableAdditionalSettings(TypedDict): + """ + Class representing additional application settings for an update call. + + These settings are write-only: they can be set via the update call but are + stripped from every response and are not bound on the application model. + + Attributes: + login_url: The login URL. + logout_url: The logout URL. + refresh_token_expiration_absolute: Absolute refresh token expiration. + refresh_token_expiration_idle: Idle refresh token expiration. + rotate_refresh_token: Whether to rotate the refresh token. + allow_query_param_in_redirect_uri: Whether query params are allowed in redirect URIs. + """ + + login_url: NotRequired[str] + logout_url: NotRequired[str] + refresh_token_expiration_absolute: NotRequired[int] + refresh_token_expiration_idle: NotRequired[int] + rotate_refresh_token: NotRequired[bool] + allow_query_param_in_redirect_uri: NotRequired[bool] + + +class UpdateApplicationRequest(TypedDict): + """ + Class representing a request to update a Nylas application. + + Note: + ``callback_uris`` / ``redirect_uris`` cannot be set via this request; the + server silently ignores them. Manage callback URIs via the dedicated + redirect-uris endpoints. ``additional_settings`` is write-only and is + stripped from the response. + + Attributes: + branding: Branding details for the application. + hosted_authentication: Hosted authentication branding details. + idp_settings: Identity provider settings. + domain: The white-label domain associated with the application. + additional_settings: Additional (write-only) application settings. + """ + + branding: NotRequired[WritableBranding] + hosted_authentication: NotRequired[WritableHostedAuthentication] + idp_settings: NotRequired[WritableIdpSettings] + domain: NotRequired[str] + additional_settings: NotRequired[WritableAdditionalSettings] diff --git a/nylas/models/domains.py b/nylas/models/domains.py index cca1b7c..b6b068b 100644 --- a/nylas/models/domains.py +++ b/nylas/models/domains.py @@ -2,11 +2,20 @@ from typing import Any, Literal, Optional from dataclasses_json import config, dataclass_json -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from nylas.models.list_query_params import ListQueryParams -DomainVerificationType = Literal["ownership", "dkim", "spf", "feedback", "mx"] +DomainVerificationRequestType = Literal["ownership", "dkim", "spf", "feedback", "mx"] +DomainVerificationType = Literal[ + "ownership", "dkim", "spf", "feedback", "mx", "dmarc", "arc" +] + + +class DomainVerificationOptions(TypedDict, total=False): + """Options for domain verification operations.""" + + key_length: int class ListDomainsQueryParams(ListQueryParams): @@ -37,13 +46,15 @@ class UpdateDomainRequest(TypedDict, total=False): class GetDomainInfoRequest(TypedDict): """Request body for retrieving DNS records for a verification type.""" - type: DomainVerificationType + type: DomainVerificationRequestType + options: NotRequired[DomainVerificationOptions] class VerifyDomainRequest(TypedDict): """Request body for triggering DNS verification.""" - type: DomainVerificationType + type: DomainVerificationRequestType + options: NotRequired[DomainVerificationOptions] @dataclass_json diff --git a/nylas/models/lists.py b/nylas/models/lists.py index c4732e6..cf07557 100644 --- a/nylas/models/lists.py +++ b/nylas/models/lists.py @@ -50,7 +50,7 @@ class NylasList: id: Optional[str] = None name: Optional[str] = None description: Optional[str] = None - type: Optional[str] = None + type: Optional[ListType] = None items_count: Optional[int] = None application_id: Optional[str] = None organization_id: Optional[str] = None diff --git a/nylas/models/redirect_uri.py b/nylas/models/redirect_uri.py index 2189402..367dc21 100644 --- a/nylas/models/redirect_uri.py +++ b/nylas/models/redirect_uri.py @@ -39,12 +39,14 @@ class RedirectUri: url: Redirect URL. platform: Platform identifier. settings: Configuration settings. + deleted_at: Soft-delete timestamp (Unix seconds); omitted when not deleted. """ id: str url: str platform: str settings: Optional[RedirectUriSettings] = None + deleted_at: Optional[int] = None class WritableRedirectUriSettings(TypedDict): @@ -74,12 +76,12 @@ class CreateRedirectUriRequest(TypedDict): Attributes: url: Redirect URL. - platform: Platform identifier. + platform: Platform identifier. Optional; defaults to "web" server-side. settings: Optional settings for the redirect uri. """ url: str - platform: str + platform: NotRequired[str] settings: NotRequired[WritableRedirectUriSettings] diff --git a/nylas/models/workspaces.py b/nylas/models/workspaces.py new file mode 100644 index 0000000..cef345c --- /dev/null +++ b/nylas/models/workspaces.py @@ -0,0 +1,157 @@ +from dataclasses import dataclass +from typing import List, Optional + +from dataclasses_json import dataclass_json +from typing_extensions import TypedDict, NotRequired + + +@dataclass_json +@dataclass +class Workspace: + """ + Class representing a Nylas workspace. + + A workspace groups grants in a Nylas application by email domain. Grants can + be auto-grouped (by matching email domain) or manually assigned/removed. + + Attributes: + workspace_id: Globally unique workspace identifier (UUID). + application_id: The owning Nylas application UUID. + name: Descriptive workspace name. + domain: Top-level email domain. May be an empty string when the workspace was + created with auto_group=false and no domain. + auto_group: When true, new grants whose email domain matches `domain` are + automatically assigned to the workspace. + default: When true, this is the application's default workspace. + created_at: Creation timestamp, represented as a Unix timestamp in seconds. + updated_at: Last-update timestamp, represented as a Unix timestamp in seconds. + policy_id: Inbox policy attached to the workspace (UUID). + rule_ids: Inbox rules attached to the workspace (list of UUIDs). + """ + + workspace_id: str + application_id: str + name: str + domain: str + auto_group: bool + created_at: int + updated_at: int + default: Optional[bool] = None + policy_id: Optional[str] = None + rule_ids: Optional[List[str]] = None + + +@dataclass_json +@dataclass +class WorkspaceAutoGroupResponse: + """ + Class representing the response from starting a workspace auto-group job. + + Attributes: + job_id: The background job ID (UUID). + message: A human-readable message describing the started job. + """ + + job_id: str + message: str + + +@dataclass_json +@dataclass +class WorkspaceManualAssignResponse: + """ + Class representing the response from manually assigning/removing grants. + + Attributes: + application_id: The application owning the workspace (UUID). + workspace_id: The workspace that was updated (UUID). + domain: The workspace domain (empty string if none). + grants_assigned: Grant IDs that were actually assigned. Serializes as `null` + (deserialized as None) when no assigned grant matched. + grants_removed: Grant IDs that were actually removed. Serializes as `null` + (deserialized as None) when no removed grant matched. + """ + + application_id: str + workspace_id: str + domain: str + grants_assigned: Optional[List[str]] = None + grants_removed: Optional[List[str]] = None + + +class CreateWorkspaceRequest(TypedDict): + """ + Class representation of a Nylas create workspace request. + + Attributes: + name: The descriptive workspace name. Required. + domain: The top-level email domain to group grants by. + auto_group: When true, new grants whose email domain matches `domain` are + auto-assigned. Defaults server-side to true when a domain is provided, + false otherwise. + policy_id: Inbox policy to attach to the workspace (UUID). + rule_ids: Inbox rules to attach to the workspace (list of UUIDs). + """ + + name: str + domain: NotRequired[str] + auto_group: NotRequired[bool] + policy_id: NotRequired[str] + rule_ids: NotRequired[List[str]] + + +class UpdateWorkspaceRequest(TypedDict): + """ + Class representation of a Nylas update workspace request. + + At least one field must be present. The workspace's domain is immutable; sending + a changed domain is rejected by the API. + + Attributes: + name: A new non-empty workspace name. + domain: The workspace domain. Validated but immutable; changing it is rejected. + auto_group: Whether to auto-group matching grants. Cannot be set to true on a + workspace with an empty domain. + policy_id: Inbox policy to attach (UUID). Send `None` to clear, omit to preserve. + rule_ids: Inbox rules to attach (list of UUIDs). Send a list (including `[]`) + to overwrite, omit to preserve. + """ + + name: NotRequired[str] + domain: NotRequired[str] + auto_group: NotRequired[bool] + policy_id: NotRequired[Optional[str]] + rule_ids: NotRequired[Optional[List[str]]] + + +class WorkspaceAutoGroupRequest(TypedDict): + """ + Class representation of a Nylas workspace auto-group request. + + All fields are optional. + + Attributes: + after_created_at: Only group grants created at/after this Unix timestamp. + invalid_also: When true, includes invalid grants in the grouping pass. + Defaults to false. + specific_domain: Only group grants whose email domain matches this domain. + """ + + after_created_at: NotRequired[int] + invalid_also: NotRequired[bool] + specific_domain: NotRequired[str] + + +class WorkspaceManualAssignRequest(TypedDict): + """ + Class representation of a Nylas workspace manual-assign request. + + At least one of `assign_grants` or `remove_grants` must contain a grant ID. + + Attributes: + assign_grants: Grant IDs to assign to the workspace. Max 500 entries. + remove_grants: Grant IDs to remove from the workspace. Max 500 entries. + """ + + assign_grants: NotRequired[List[str]] + remove_grants: NotRequired[List[str]] diff --git a/nylas/resources/applications.py b/nylas/resources/applications.py index ed20c5a..7b788f9 100644 --- a/nylas/resources/applications.py +++ b/nylas/resources/applications.py @@ -1,11 +1,14 @@ from nylas.config import RequestOverrides -from nylas.models.application_details import ApplicationDetails +from nylas.handler.api_resources import UpdatablePatchApiResource +from nylas.models.application_details import ( + ApplicationDetails, + UpdateApplicationRequest, +) from nylas.models.response import Response from nylas.resources.redirect_uris import RedirectUris -from nylas.resources.resource import Resource -class Applications(Resource): +class Applications(UpdatablePatchApiResource): """ Nylas Applications API @@ -38,3 +41,31 @@ def info(self, overrides: RequestOverrides = None) -> Response[ApplicationDetail method="GET", path="/v3/applications", overrides=overrides ) return Response.from_dict(json_response, ApplicationDetails, headers) + + def update( + self, + request_body: UpdateApplicationRequest, + overrides: RequestOverrides = None, + ) -> Response[ApplicationDetails]: + """ + Update the application information. + + Note: + ``callback_uris`` / ``redirect_uris`` cannot be updated here; the server + silently ignores them. Use the redirect URIs endpoints instead. + ``additional_settings`` is write-only and is stripped from the response. + + Args: + request_body: The values to update the application with. + overrides: The request overrides to apply to the request. + + Returns: + Response: The updated application information. + """ + + return super().patch( + path="/v3/applications", + request_body=request_body, + response_type=ApplicationDetails, + overrides=overrides, + ) diff --git a/nylas/resources/domains.py b/nylas/resources/domains.py index bde153e..dd0c2ec 100644 --- a/nylas/resources/domains.py +++ b/nylas/resources/domains.py @@ -1,3 +1,4 @@ +import urllib.parse from typing import Optional from nylas.config import RequestOverrides @@ -8,7 +9,7 @@ ListableApiResource, UpdatableApiResource, ) -from nylas.handler.service_account import ServiceAccountSigner +from nylas.handler.service_account import ServiceAccountSigner, canonical_json from nylas.models.domains import ( CreateDomainRequest, Domain, @@ -20,6 +21,13 @@ ) from nylas.models.response import DeleteResponse, ListResponse, Response +_REQUIRED_SERVICE_ACCOUNT_HEADERS = ( + "x-nylas-kid", + "x-nylas-timestamp", + "x-nylas-nonce", + "x-nylas-signature", +) + def _merge_signer_headers( overrides: Optional[RequestOverrides], signer_headers: Optional[dict] @@ -33,6 +41,36 @@ def _merge_signer_headers( return merged +def _service_account_overrides( + overrides: Optional[RequestOverrides], +) -> RequestOverrides: + merged: RequestOverrides = dict(overrides) if overrides else {} + merged["skip_auth"] = True + return merged + + +def _require_service_account_headers(overrides: Optional[RequestOverrides]) -> None: + headers = (overrides or {}).get("headers") or {} + normalized = {key.lower(): value for key, value in headers.items()} + missing = [ + header + for header in _REQUIRED_SERVICE_ACCOUNT_HEADERS + if not str(normalized.get(header, "")).strip() + ] + if missing: + raise ValueError( + "Manage Domains API requests require Nylas Service Account signing headers." + ) + + +def _encode_domain_id(domain_id: str) -> str: + return urllib.parse.quote(domain_id, safe="") + + +def _canonical_body_bytes(request_body: dict) -> bytes: + return canonical_json(dict(request_body)).encode("utf-8") + + class Domains( ListableApiResource, FindableApiResource, @@ -59,6 +97,8 @@ def list( if signer: hdrs, _ = signer.build_headers("GET", path, None) merged = _merge_signer_headers(overrides, hdrs) + merged = _service_account_overrides(merged) + _require_service_account_headers(merged) return super().list( path=path, response_type=Domain, @@ -74,13 +114,15 @@ def create( ) -> Response[Domain]: path = "/v3/admin/domains" merged = overrides - serialized = None + serialized = _canonical_body_bytes(request_body) body_arg = request_body if signer: hdrs, serialized = signer.build_headers("POST", path, dict(request_body)) merged = _merge_signer_headers(overrides, hdrs) - if serialized is not None: - body_arg = None + if serialized is not None: + body_arg = None + merged = _service_account_overrides(merged) + _require_service_account_headers(merged) return super().create( path=path, request_body=body_arg, @@ -95,11 +137,13 @@ def find( signer: Optional[ServiceAccountSigner] = None, overrides: RequestOverrides = None, ) -> Response[Domain]: - path = f"/v3/admin/domains/{domain_id}" + path = f"/v3/admin/domains/{_encode_domain_id(domain_id)}" merged = overrides if signer: hdrs, _ = signer.build_headers("GET", path, None) merged = _merge_signer_headers(overrides, hdrs) + merged = _service_account_overrides(merged) + _require_service_account_headers(merged) return super().find( path=path, response_type=Domain, @@ -113,15 +157,17 @@ def update( signer: Optional[ServiceAccountSigner] = None, overrides: RequestOverrides = None, ) -> Response[Domain]: - path = f"/v3/admin/domains/{domain_id}" + path = f"/v3/admin/domains/{_encode_domain_id(domain_id)}" merged = overrides - serialized = None + serialized = _canonical_body_bytes(request_body) body_arg = request_body if signer: hdrs, serialized = signer.build_headers("PUT", path, dict(request_body)) merged = _merge_signer_headers(overrides, hdrs) - if serialized is not None: - body_arg = None + if serialized is not None: + body_arg = None + merged = _service_account_overrides(merged) + _require_service_account_headers(merged) return super().update( path=path, request_body=body_arg, @@ -136,11 +182,13 @@ def destroy( signer: Optional[ServiceAccountSigner] = None, overrides: RequestOverrides = None, ) -> DeleteResponse: - path = f"/v3/admin/domains/{domain_id}" + path = f"/v3/admin/domains/{_encode_domain_id(domain_id)}" merged = overrides if signer: hdrs, _ = signer.build_headers("DELETE", path, None) merged = _merge_signer_headers(overrides, hdrs) + merged = _service_account_overrides(merged) + _require_service_account_headers(merged) return super().destroy(path=path, overrides=merged) def get_info( @@ -162,13 +210,15 @@ def get_info( Returns: Verification details including required DNS records. """ - path = f"/v3/admin/domains/{domain_id}/info" + path = f"/v3/admin/domains/{_encode_domain_id(domain_id)}/info" body = dict(request_body) merged = overrides - serialized = None + serialized = _canonical_body_bytes(body) if signer: hdrs, serialized = signer.build_headers("POST", path, body) merged = _merge_signer_headers(overrides, hdrs) + merged = _service_account_overrides(merged) + _require_service_account_headers(merged) exec_kwargs = {"overrides": merged} if serialized is not None: exec_kwargs["serialized_json_body"] = serialized @@ -201,13 +251,15 @@ def verify( Returns: Verification attempt details and status. """ - path = f"/v3/admin/domains/{domain_id}/verify" + path = f"/v3/admin/domains/{_encode_domain_id(domain_id)}/verify" body = dict(request_body) merged = overrides - serialized = None + serialized = _canonical_body_bytes(body) if signer: hdrs, serialized = signer.build_headers("POST", path, body) merged = _merge_signer_headers(overrides, hdrs) + merged = _service_account_overrides(merged) + _require_service_account_headers(merged) exec_kwargs = {"overrides": merged} if serialized is not None: exec_kwargs["serialized_json_body"] = serialized diff --git a/nylas/resources/redirect_uris.py b/nylas/resources/redirect_uris.py index b2831af..950f297 100644 --- a/nylas/resources/redirect_uris.py +++ b/nylas/resources/redirect_uris.py @@ -3,7 +3,7 @@ ListableApiResource, FindableApiResource, CreatableApiResource, - UpdatableApiResource, + UpdatablePatchApiResource, DestroyableApiResource, ) from nylas.models.redirect_uri import ( @@ -18,7 +18,7 @@ class RedirectUris( ListableApiResource, FindableApiResource, CreatableApiResource, - UpdatableApiResource, + UpdatablePatchApiResource, DestroyableApiResource, ): """ @@ -103,7 +103,7 @@ def update( The updated Redirect URI. """ - return super().update( + return super().patch( path=f"/v3/applications/redirect-uris/{redirect_uri_id}", request_body=request_body, response_type=RedirectUri, diff --git a/nylas/resources/workspaces.py b/nylas/resources/workspaces.py new file mode 100644 index 0000000..fcf284c --- /dev/null +++ b/nylas/resources/workspaces.py @@ -0,0 +1,181 @@ +from nylas.config import RequestOverrides +from nylas.handler.api_resources import ( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatablePatchApiResource, + DestroyableApiResource, +) +from nylas.models.response import Response, ListResponse, DeleteResponse +from nylas.models.workspaces import ( + Workspace, + WorkspaceAutoGroupResponse, + WorkspaceManualAssignResponse, + CreateWorkspaceRequest, + UpdateWorkspaceRequest, + WorkspaceAutoGroupRequest, + WorkspaceManualAssignRequest, +) + + +class Workspaces( + ListableApiResource, + FindableApiResource, + CreatableApiResource, + UpdatablePatchApiResource, + DestroyableApiResource, +): + """ + Nylas Workspaces API + + The Nylas Workspaces API allows you to group grants in a Nylas application by email + domain. Grants can be auto-grouped by matching email domain or manually assigned and + removed. + """ + + def list(self, overrides: RequestOverrides = None) -> ListResponse[Workspace]: + """ + Return all workspaces for the application. + + Args: + overrides: The request overrides to apply to the request. + + Returns: + The list of workspaces. + """ + return super().list( + path="/v3/workspaces", response_type=Workspace, overrides=overrides + ) + + def find( + self, workspace_id: str, overrides: RequestOverrides = None + ) -> Response[Workspace]: + """ + Return a workspace. + + Args: + workspace_id: The ID of the workspace to retrieve. Accepts a UUID or a domain. + overrides: The request overrides to apply to the request. + + Returns: + The workspace. + """ + return super().find( + path=f"/v3/workspaces/{workspace_id}", + response_type=Workspace, + overrides=overrides, + ) + + def create( + self, request_body: CreateWorkspaceRequest, overrides: RequestOverrides = None + ) -> Response[Workspace]: + """ + Create a workspace. + + Args: + request_body: The values to create the workspace with. + overrides: The request overrides to apply to the request. + + Returns: + The created workspace. + """ + return super().create( + path="/v3/workspaces", + request_body=request_body, + response_type=Workspace, + overrides=overrides, + ) + + def update( + self, + workspace_id: str, + request_body: UpdateWorkspaceRequest, + overrides: RequestOverrides = None, + ) -> Response[Workspace]: + """ + Update a workspace. + + The Workspaces API only supports updating via PATCH; the workspace must be + addressed by its UUID (a domain path value is not accepted on update). + + Args: + workspace_id: The UUID of the workspace to update. + request_body: The values to update the workspace with. + overrides: The request overrides to apply to the request. + + Returns: + The updated workspace. + """ + return super().patch( + path=f"/v3/workspaces/{workspace_id}", + request_body=request_body, + response_type=Workspace, + overrides=overrides, + ) + + def destroy( + self, workspace_id: str, overrides: RequestOverrides = None + ) -> DeleteResponse: + """ + Delete a workspace. + + Args: + workspace_id: The ID of the workspace to delete. Accepts a UUID or a domain. + overrides: The request overrides to apply to the request. + + Returns: + The deletion response (request ID only). + """ + return super().destroy( + path=f"/v3/workspaces/{workspace_id}", overrides=overrides + ) + + def auto_group( + self, + request_body: WorkspaceAutoGroupRequest = None, + overrides: RequestOverrides = None, + ) -> Response[WorkspaceAutoGroupResponse]: + """ + Start a background job that auto-groups grants into workspaces by email domain. + + This endpoint is rate-limited to one call per minute per application. + + Args: + request_body: Optional filters to scope which grants are grouped. + overrides: The request overrides to apply to the request. + + Returns: + The started auto-group job. + """ + res, headers = self._http_client._execute( + method="POST", + path="/v3/workspaces/auto-group", + request_body=request_body, + overrides=overrides, + ) + return Response.from_dict(res, WorkspaceAutoGroupResponse, headers) + + def manual_assign( + self, + workspace_id: str, + request_body: WorkspaceManualAssignRequest, + overrides: RequestOverrides = None, + ) -> Response[WorkspaceManualAssignResponse]: + """ + Manually assign grants to or remove grants from a workspace. + + Args: + workspace_id: The ID of the workspace. Accepts a UUID or a domain. + request_body: The grants to assign and/or remove. + overrides: The request overrides to apply to the request. + + Returns: + The grants that were assigned and removed. + """ + res, headers = self._http_client._execute( + method="POST", + path=f"/v3/workspaces/{workspace_id}/manual-assign", + request_body=request_body, + overrides=overrides, + ) + return Response.from_dict(res, WorkspaceManualAssignResponse, headers) diff --git a/tests/handler/test_http_client.py b/tests/handler/test_http_client.py index a80d3bd..416b357 100644 --- a/tests/handler/test_http_client.py +++ b/tests/handler/test_http_client.py @@ -112,6 +112,14 @@ def test_build_headers_override_api_key(self, http_client, patched_version_and_s "Authorization": "Bearer test-key-override", } + def test_build_headers_skip_auth(self, http_client, patched_version_and_sys): + headers = http_client._build_headers(overrides={"skip_auth": True}) + + assert headers == { + "X-Nylas-API-Wrapper": "python", + "User-Agent": "Nylas Python SDK 2.0.0 - 1.2.3", + } + def test_build_request_default(self, http_client, patched_version_and_sys): request = http_client._build_request( method="GET", @@ -442,7 +450,9 @@ def test_validate_response_auth_error_with_headers(self): _validate_response(response) assert e.value.headers == {"X-Test-Header": "test"} - def test_execute_with_headers(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_headers( + self, http_client, patched_version_and_sys, patched_request + ): mock_response = Mock() mock_response.json.return_value = {"foo": "bar"} mock_response.headers = {"X-Test-Header": "test"} @@ -473,7 +483,9 @@ def test_execute_with_headers(self, http_client, patched_version_and_sys, patche timeout=30, ) - def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_utf8_characters( + self, http_client, patched_version_and_sys, patched_request + ): """Test that UTF-8 characters are preserved in JSON requests (not escaped).""" mock_response = Mock() mock_response.json.return_value = {"success": True} @@ -500,7 +512,7 @@ def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys assert call_kwargs["headers"]["Content-type"] == "application/json" assert "data" in call_kwargs sent_data = call_kwargs["data"] - + # The data should be bytes with actual UTF-8 characters (not escape sequences) assert isinstance(sent_data, bytes) decoded_data = sent_data.decode("utf-8") @@ -510,7 +522,9 @@ def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys # Should NOT contain unicode escape sequences assert "\\u" not in decoded_data - def test_execute_with_none_request_body(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_none_request_body( + self, http_client, patched_version_and_sys, patched_request + ): """Test that None request_body is handled correctly.""" mock_response = Mock() mock_response.json.return_value = {"success": True} @@ -532,7 +546,9 @@ def test_execute_with_none_request_body(self, http_client, patched_version_and_s assert "json" not in call_kwargs assert call_kwargs["data"] is None - def test_execute_with_none_request_body_and_none_data(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_none_request_body_and_none_data( + self, http_client, patched_version_and_sys, patched_request + ): """Test that both None request_body and None data are handled correctly.""" mock_response = Mock() mock_response.json.return_value = {"success": True} @@ -554,7 +570,9 @@ def test_execute_with_none_request_body_and_none_data(self, http_client, patched assert "json" not in call_kwargs assert call_kwargs["data"] is None - def test_execute_with_emoji_and_international_characters(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_emoji_and_international_characters( + self, http_client, patched_version_and_sys, patched_request + ): """Test that emoji and various international characters are preserved.""" mock_response = Mock() mock_response.json.return_value = {"success": True} @@ -580,7 +598,7 @@ def test_execute_with_emoji_and_international_characters(self, http_client, patc assert response_json == {"success": True} call_kwargs = patched_request.call_args[1] sent_data = call_kwargs["data"] - + # All characters should be preserved as UTF-8 encoded bytes assert isinstance(sent_data, bytes) decoded_data = sent_data.decode("utf-8") @@ -591,9 +609,11 @@ def test_execute_with_emoji_and_international_characters(self, http_client, patc assert "Größe" in decoded_data assert "¿Cómo estás?" in decoded_data - def test_execute_with_right_single_quotation_mark(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_right_single_quotation_mark( + self, http_client, patched_version_and_sys, patched_request + ): """Test that right single quotation mark (\\u2019) is handled correctly. - + This character caused UnicodeEncodeError: 'latin-1' codec can't encode character '\\u2019'. """ mock_response = Mock() @@ -618,7 +638,7 @@ def test_execute_with_right_single_quotation_mark(self, http_client, patched_ver assert response_json == {"success": True} call_kwargs = patched_request.call_args[1] sent_data = call_kwargs["data"] - + # The data should be UTF-8 encoded bytes with the \u2019 character preserved assert isinstance(sent_data, bytes) decoded_data = sent_data.decode("utf-8") @@ -626,9 +646,11 @@ def test_execute_with_right_single_quotation_mark(self, http_client, patched_ver assert "It's a test" in decoded_data assert "Here's another" in decoded_data - def test_execute_with_emojis(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_emojis( + self, http_client, patched_version_and_sys, patched_request + ): """Test that emojis are handled correctly in request bodies. - + Emojis are multi-byte UTF-8 characters that could cause encoding issues if not handled properly. """ @@ -654,7 +676,7 @@ def test_execute_with_emojis(self, http_client, patched_version_and_sys, patched assert response_json == {"success": True} call_kwargs = patched_request.call_args[1] sent_data = call_kwargs["data"] - + # All emojis should be preserved in UTF-8 encoded bytes assert isinstance(sent_data, bytes) decoded_data = sent_data.decode("utf-8") @@ -666,9 +688,11 @@ def test_execute_with_emojis(self, http_client, patched_version_and_sys, patched assert "📅" in decoded_data assert "⏰" in decoded_data - def test_execute_with_nan_and_infinity(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_nan_and_infinity( + self, http_client, patched_version_and_sys, patched_request + ): """Test that NaN and Infinity float values are handled correctly. - + The requests library's json= parameter uses allow_nan=False which raises ValueError for NaN/Infinity. Our implementation uses json.dumps with allow_nan=True to maintain backward compatibility. @@ -696,7 +720,7 @@ def test_execute_with_nan_and_infinity(self, http_client, patched_version_and_sy assert response_json == {"success": True} call_kwargs = patched_request.call_args[1] sent_data = call_kwargs["data"] - + # The data should be UTF-8 encoded bytes with NaN/Infinity serialized assert isinstance(sent_data, bytes) decoded_data = sent_data.decode("utf-8") @@ -706,7 +730,9 @@ def test_execute_with_nan_and_infinity(self, http_client, patched_version_and_sy assert "-Infinity" in decoded_data assert "42.5" in decoded_data - def test_execute_with_multipart_data_not_affected(self, http_client, patched_version_and_sys, patched_request): + def test_execute_with_multipart_data_not_affected( + self, http_client, patched_version_and_sys, patched_request + ): """Test that multipart/form-data is not affected by the change.""" mock_response = Mock() mock_response.json.return_value = {"success": True} diff --git a/tests/resources/test_applications.py b/tests/resources/test_applications.py index 9de17fb..7612882 100644 --- a/tests/resources/test_applications.py +++ b/tests/resources/test_applications.py @@ -14,44 +14,47 @@ def test_redirect_uris_property(self, http_client): def test_info(self): mock_http_client = Mock() - mock_http_client._execute.return_value = ({ - "request_id": "req-123", - "data": { - "application_id": "ad410018-d306-43f9-8361-fa5d7b2172e0", - "organization_id": "f5db4482-dbbe-4b32-b347-61c260d803ce", - "region": "us", - "environment": "production", - "branding": { - "name": "My application", - "icon_url": "https://my-app.com/my-icon.png", - "website_url": "https://my-app.com", - "description": "Online banking application.", + mock_http_client._execute.return_value = ( + { + "request_id": "req-123", + "data": { + "application_id": "ad410018-d306-43f9-8361-fa5d7b2172e0", + "organization_id": "f5db4482-dbbe-4b32-b347-61c260d803ce", + "region": "us", + "environment": "production", + "branding": { + "name": "My application", + "icon_url": "https://my-app.com/my-icon.png", + "website_url": "https://my-app.com", + "description": "Online banking application.", + }, + "hosted_authentication": { + "background_image_url": "https://my-app.com/bg.jpg", + "alignment": "left", + "color_primary": "#dc0000", + "color_secondary": "#000056", + "title": "string", + "subtitle": "string", + "background_color": "#003400", + "spacing": 5, + }, + "callback_uris": [ + { + "id": "0556d035-6cb6-4262-a035-6b77e11cf8fc", + "url": "string", + "platform": "web", + "settings": { + "origin": "string", + "bundle_id": "string", + "package_name": "string", + "sha1_certificate_fingerprint": "string", + }, + } + ], }, - "hosted_authentication": { - "background_image_url": "https://my-app.com/bg.jpg", - "alignment": "left", - "color_primary": "#dc0000", - "color_secondary": "#000056", - "title": "string", - "subtitle": "string", - "background_color": "#003400", - "spacing": 5, - }, - "callback_uris": [ - { - "id": "0556d035-6cb6-4262-a035-6b77e11cf8fc", - "url": "string", - "platform": "web", - "settings": { - "origin": "string", - "bundle_id": "string", - "package_name": "string", - "sha1_certificate_fingerprint": "string", - }, - } - ], }, - }, {"X-Test-Header": "test"}) + {"X-Test-Header": "test"}, + ) app = Applications(mock_http_client) res = app.info() @@ -88,3 +91,102 @@ def test_info(self): assert ( res.data.callback_uris[0].settings.sha1_certificate_fingerprint == "string" ) + + def test_info_extended_response_fields(self): + """idp_settings, hosted_authentication URL fields, and optional top-level + fields are public response fields and must deserialize.""" + mock_http_client = Mock() + mock_http_client._execute.return_value = ( + { + "request_id": "req-123", + "data": { + "application_id": "ad410018-d306-43f9-8361-fa5d7b2172e0", + "organization_id": "f5db4482-dbbe-4b32-b347-61c260d803ce", + "unexpected_response_id": "unexpected-value", + "region": "eu", + "environment": "sandbox", + "domain": "auth.example.com", + "branding": {"name": "My application"}, + "hosted_authentication": { + "alignment": "diagonal", + "terms_of_service_url": "https://my-app.com/tos", + "privacy_policy_url": "https://my-app.com/privacy", + }, + "idp_settings": { + "origins": "https://a.com,https://b.com", + "issuers": "issuer-a,issuer-b", + }, + "created_at": 1700000000, + "updated_at": 1700000001, + "blocked": True, + }, + }, + {"X-Test-Header": "test"}, + ) + app = Applications(mock_http_client) + + res = app.info() + + assert type(res.data) == ApplicationDetails + assert not hasattr(res.data, "unexpected_response_id") + assert "unexpected_response_id" not in res.data.to_dict() + # region/environment are free-form strings, not enums (discrepancies #6, #7) + assert res.data.region == "eu" + assert res.data.environment == "sandbox" + assert res.data.domain == "auth.example.com" + # alignment is free-form, not a closed enum (discrepancy #5) + assert res.data.hosted_authentication.alignment == "diagonal" + assert ( + res.data.hosted_authentication.terms_of_service_url + == "https://my-app.com/tos" + ) + assert ( + res.data.hosted_authentication.privacy_policy_url + == "https://my-app.com/privacy" + ) + assert res.data.idp_settings.origins == "https://a.com,https://b.com" + assert res.data.idp_settings.issuers == "issuer-a,issuer-b" + assert res.data.created_at == 1700000000 + assert res.data.updated_at == 1700000001 + assert res.data.blocked is True + + def test_update(self): + """Update is PATCH /v3/applications; additional_settings is write-only + and accepted in the body (spec: PATCH-only, discrepancy #13).""" + mock_http_client = Mock() + mock_http_client._execute.return_value = ( + { + "request_id": "req-456", + "data": { + "application_id": "ad410018-d306-43f9-8361-fa5d7b2172e0", + "organization_id": "f5db4482-dbbe-4b32-b347-61c260d803ce", + "region": "us", + "environment": "production", + "branding": {"name": "Updated application"}, + }, + }, + {"X-Test-Header": "test"}, + ) + app = Applications(mock_http_client) + + request_body = { + "branding": {"name": "Updated application"}, + "hosted_authentication": {"title": "Welcome"}, + "idp_settings": {"origins": "https://a.com"}, + "domain": "auth.example.com", + "additional_settings": {"rotate_refresh_token": True}, + } + + res = app.update(request_body=request_body) + + # Must be PATCH (no PUT) at /v3/applications with the exact body + mock_http_client._execute.assert_called_once_with( + "PATCH", + "/v3/applications", + None, + None, + request_body, + overrides=None, + ) + assert type(res.data) == ApplicationDetails + assert res.data.branding.name == "Updated application" diff --git a/tests/resources/test_domains.py b/tests/resources/test_domains.py index fe9d1f9..72cbaee 100644 --- a/tests/resources/test_domains.py +++ b/tests/resources/test_domains.py @@ -10,6 +10,17 @@ from nylas.resources import domains as domains_module from nylas.resources.domains import Domains +SERVICE_ACCOUNT_HEADERS = { + "X-Nylas-Kid": "service-account-key-id", + "X-Nylas-Timestamp": "1742932766", + "X-Nylas-Nonce": "nonce-1234567890123456", + "X-Nylas-Signature": "signed-request", +} + + +def service_account_overrides(headers=None): + return {"headers": headers or SERVICE_ACCOUNT_HEADERS, "skip_auth": True} + def _test_rsa_pem(): key = rsa.generate_private_key(public_exponent=65537, key_size=2048) @@ -43,7 +54,9 @@ def domain_data(): class TestMergeSignerHeaders: def test_returns_overrides_when_no_signer_headers(self): - assert domains_module._merge_signer_headers({"timeout": 5}, None) == {"timeout": 5} + assert domains_module._merge_signer_headers({"timeout": 5}, None) == { + "timeout": 5 + } assert domains_module._merge_signer_headers(None, {}) is None def test_merges_headers_preserving_existing(self): @@ -57,9 +70,7 @@ def test_merges_headers_preserving_existing(self): assert merged["headers"]["X-Nylas-Signature"] == "sig" def test_creates_overrides_when_none(self): - merged = domains_module._merge_signer_headers( - None, {"X-Nylas-Kid": "kid"} - ) + merged = domains_module._merge_signer_headers(None, {"X-Nylas-Kid": "kid"}) assert merged == {"headers": {"X-Nylas-Kid": "kid"}} @@ -81,20 +92,28 @@ def test_domain_verification_details_from_dict(self): assert d.attempt.verification_type == "dkim" assert d.message == "add TXT" - def test_list_without_signer(self, http_client_list_response): + def test_rejects_api_key_only_requests(self, http_client_list_response): + domains = Domains(http_client_list_response) + + with pytest.raises(ValueError, match="Service Account signing headers"): + domains.list() + + http_client_list_response._execute.assert_not_called() + + def test_list_with_signed_headers(self, http_client_list_response): with patch( "nylas.models.response.ListResponse.from_dict", return_value=ListResponse([], "rid", None, {}), ): domains = Domains(http_client_list_response) - domains.list() + domains.list(overrides={"headers": SERVICE_ACCOUNT_HEADERS}) http_client_list_response._execute.assert_called_once_with( "GET", "/v3/admin/domains", None, None, None, - overrides=None, + overrides=service_account_overrides(), ) def test_list_with_query_and_signer(self, http_client_list_response): @@ -110,9 +129,10 @@ def test_list_with_query_and_signer(self, http_client_list_response): assert args[0] == "GET" assert "/v3/admin/domains" in args[1] ov = kwargs.get("overrides") or {} + assert ov.get("skip_auth") is True assert "X-Nylas-Signature" in (ov.get("headers") or {}) - def test_create_without_signer(self, http_client_response, domain_data): + def test_create_with_signed_headers(self, http_client_response, domain_data): with patch( "nylas.models.response.Response.from_dict", return_value=Response(domain_data, "rid", {}), @@ -120,30 +140,34 @@ def test_create_without_signer(self, http_client_response, domain_data): domains = Domains(http_client_response) domains.create( {"name": "My domain", "domain_address": "mail.example.com"}, + overrides={"headers": SERVICE_ACCOUNT_HEADERS}, ) http_client_response._execute.assert_called_once_with( "POST", "/v3/admin/domains", None, None, - {"name": "My domain", "domain_address": "mail.example.com"}, - overrides=None, + None, + overrides=service_account_overrides(), + serialized_json_body=b'{"domain_address":"mail.example.com","name":"My domain"}', ) - def test_find_without_signer(self, http_client_response, domain_data): + def test_find_with_signed_headers(self, http_client_response, domain_data): with patch( "nylas.models.response.Response.from_dict", return_value=Response(domain_data, "rid", {}), ): domains = Domains(http_client_response) - domains.find("dom_abc") + domains.find( + "mail/example.com", overrides={"headers": SERVICE_ACCOUNT_HEADERS} + ) http_client_response._execute.assert_called_once_with( "GET", - "/v3/admin/domains/dom_abc", + "/v3/admin/domains/mail%2Fexample.com", None, None, None, - overrides=None, + overrides=service_account_overrides(), ) def test_find_with_signer(self, http_client_response, domain_data): @@ -156,22 +180,28 @@ def test_find_with_signer(self, http_client_response, domain_data): domains = Domains(http_client_response) domains.find("dom_abc", signer=signer) ov = http_client_response._execute.call_args.kwargs.get("overrides") or {} + assert ov.get("skip_auth") is True assert "X-Nylas-Signature" in (ov.get("headers") or {}) - def test_update_without_signer(self, http_client_response, domain_data): + def test_update_with_signed_headers(self, http_client_response, domain_data): with patch( "nylas.models.response.Response.from_dict", return_value=Response(domain_data, "rid", {}), ): domains = Domains(http_client_response) - domains.update("dom_123", {"name": "Renamed"}) + domains.update( + "dom_123", + {"name": "Renamed"}, + overrides={"headers": SERVICE_ACCOUNT_HEADERS}, + ) http_client_response._execute.assert_called_once_with( "PUT", "/v3/admin/domains/dom_123", None, None, - {"name": "Renamed"}, - overrides=None, + None, + overrides=service_account_overrides(), + serialized_json_body=b'{"name":"Renamed"}', ) def test_update_with_signer(self, http_client_response, domain_data): @@ -184,10 +214,13 @@ def test_update_with_signer(self, http_client_response, domain_data): domains = Domains(http_client_response) domains.update("dom_123", {"name": "Renamed"}, signer=signer) kwargs = http_client_response._execute.call_args.kwargs + assert kwargs["overrides"]["skip_auth"] is True assert "serialized_json_body" in kwargs assert http_client_response._execute.call_args[0][4] is None - def test_create_with_signer_sends_serialized_body(self, http_client_response, domain_data): + def test_create_with_signer_sends_serialized_body( + self, http_client_response, domain_data + ): pem = _test_rsa_pem() signer = ServiceAccountSigner(pem, "kid-1") with patch( @@ -200,6 +233,7 @@ def test_create_with_signer_sends_serialized_body(self, http_client_response, do signer=signer, ) kwargs = http_client_response._execute.call_args.kwargs + assert kwargs["overrides"]["skip_auth"] is True assert "serialized_json_body" in kwargs assert kwargs["serialized_json_body"].startswith(b"{") pos = http_client_response._execute.call_args[0] @@ -217,9 +251,10 @@ def test_destroy_with_signer(self, http_client_delete_response): domains = Domains(http_client_delete_response) domains.destroy("dom_123", signer=signer) ov = http_client_delete_response._execute.call_args.kwargs["overrides"] + assert ov["skip_auth"] is True assert "X-Nylas-Signature" in ov["headers"] - def test_destroy(self, http_client_delete_response): + def test_destroy_with_signed_headers(self, http_client_delete_response): from nylas.models.response import DeleteResponse http_client_delete_response._execute.return_value = ( @@ -227,7 +262,7 @@ def test_destroy(self, http_client_delete_response): {}, ) domains = Domains(http_client_delete_response) - out = domains.destroy("dom_123") + out = domains.destroy("dom_123", overrides={"headers": SERVICE_ACCOUNT_HEADERS}) assert isinstance(out, DeleteResponse) http_client_delete_response._execute.assert_called_once_with( "DELETE", @@ -235,7 +270,7 @@ def test_destroy(self, http_client_delete_response): None, None, None, - overrides=None, + overrides=service_account_overrides(), ) def test_get_info_with_signer(self, http_client_response): @@ -253,10 +288,11 @@ def test_get_info_with_signer(self, http_client_response): domains = Domains(http_client_response) domains.get_info("dom_123", {"type": "spf"}, signer=signer) kwargs = http_client_response._execute.call_args.kwargs + assert kwargs["overrides"]["skip_auth"] is True assert "serialized_json_body" in kwargs assert http_client_response._execute.call_args[0][4] is None - def test_verify_without_signer(self, http_client_response): + def test_verify_with_signed_headers(self, http_client_response): info = {"domain_id": "dom_123", "attempt": {"type": "mx"}} http_client_response._execute.return_value = ( {"request_id": "rv", "data": info}, @@ -267,14 +303,19 @@ def test_verify_without_signer(self, http_client_response): return_value=Response(info, "rv", {}), ): domains = Domains(http_client_response) - domains.verify("dom_123", {"type": "mx"}) + domains.verify( + "dom_123", + {"type": "mx"}, + overrides={"headers": SERVICE_ACCOUNT_HEADERS}, + ) http_client_response._execute.assert_called_once_with( "POST", "/v3/admin/domains/dom_123/verify", None, None, - {"type": "mx"}, - overrides=None, + None, + overrides=service_account_overrides(), + serialized_json_body=b'{"type":"mx"}', ) def test_verify_with_signer(self, http_client_response): @@ -291,9 +332,11 @@ def test_verify_with_signer(self, http_client_response): ): domains = Domains(http_client_response) domains.verify("dom_123", {"type": "dkim"}, signer=signer) - assert "serialized_json_body" in http_client_response._execute.call_args.kwargs + kwargs = http_client_response._execute.call_args.kwargs + assert kwargs["overrides"]["skip_auth"] is True + assert "serialized_json_body" in kwargs - def test_get_info(self, http_client_response): + def test_get_info_with_signed_headers(self, http_client_response): info = { "domain_id": "dom_123", "attempt": {"type": "ownership", "status": "pending"}, @@ -307,14 +350,48 @@ def test_get_info(self, http_client_response): return_value=Response(info, "r1", {}), ): domains = Domains(http_client_response) - domains.get_info("dom_123", {"type": "ownership"}) + domains.get_info( + "dom_123", + {"type": "ownership"}, + overrides={"headers": SERVICE_ACCOUNT_HEADERS}, + ) http_client_response._execute.assert_called_once_with( "POST", "/v3/admin/domains/dom_123/info", None, None, - {"type": "ownership"}, - overrides=None, + None, + overrides=service_account_overrides(), + serialized_json_body=b'{"type":"ownership"}', + ) + + def test_get_info_accepts_extended_verification_options(self, http_client_response): + info = { + "domain_id": "dom_123", + "attempt": {"type": "dkim", "options": {"key_length": 2048}}, + } + http_client_response._execute.return_value = ( + {"request_id": "r1", "data": info}, + {}, + ) + with patch( + "nylas.models.response.Response.from_dict", + return_value=Response(info, "r1", {}), + ): + domains = Domains(http_client_response) + domains.get_info( + "dom_123", + {"type": "dkim", "options": {"key_length": 2048}}, + overrides={"headers": SERVICE_ACCOUNT_HEADERS}, + ) + http_client_response._execute.assert_called_once_with( + "POST", + "/v3/admin/domains/dom_123/info", + None, + None, + None, + overrides=service_account_overrides(), + serialized_json_body=b'{"options":{"key_length":2048},"type":"dkim"}', ) def test_merge_signer_with_existing_headers(self, http_client_list_response): @@ -329,6 +406,14 @@ def test_merge_signer_with_existing_headers(self, http_client_list_response): signer=signer, overrides={"headers": {"X-Custom": "precedence"}}, ) - headers = http_client_list_response._execute.call_args.kwargs["overrides"]["headers"] + headers = http_client_list_response._execute.call_args.kwargs["overrides"][ + "headers" + ] + assert ( + http_client_list_response._execute.call_args.kwargs["overrides"][ + "skip_auth" + ] + is True + ) assert headers["X-Custom"] == "precedence" assert "X-Nylas-Kid" in headers diff --git a/tests/resources/test_lists.py b/tests/resources/test_lists.py index 168a3d0..6fc3974 100644 --- a/tests/resources/test_lists.py +++ b/tests/resources/test_lists.py @@ -1,10 +1,17 @@ from unittest.mock import patch -from nylas.models.lists import ListItem, NylasList +from nylas.models.lists import CreateListRequest, ListItem, ListType, NylasList +from nylas.models.response import Response from nylas.resources.lists import Lists class TestLists: + def test_create_list_request_schema(self): + assert CreateListRequest.__required_keys__ == frozenset({"name", "type"}) + assert CreateListRequest.__optional_keys__ == frozenset({"description"}) + assert set(CreateListRequest.__annotations__) == {"name", "type", "description"} + assert set(ListType.__args__) == {"domain", "tld", "address"} + def test_list_deserialization(self): list_json = { "id": "list-123", @@ -62,6 +69,31 @@ def test_list_item_deserialization_with_minimal_fields(self): assert item.value is None assert item.created_at is None + def test_create_list_deserialization(self): + response_json = { + "request_id": "abc-123", + "data": { + "id": "list-123", + "name": "Blocked domains", + "description": "Known spam senders", + "type": "domain", + "items_count": 0, + "application_id": "app-123", + "organization_id": "org-123", + "created_at": 1712450952, + "updated_at": 1712450952, + }, + } + + response = Response.from_dict(response_json, NylasList) + + assert response.request_id == "abc-123" + assert response.data.id == "list-123" + assert response.data.name == "Blocked domains" + assert response.data.description == "Known spam senders" + assert response.data.type == "domain" + assert response.data.items_count == 0 + def test_list_lists(self, http_client_list_response): lists = Lists(http_client_list_response) diff --git a/tests/resources/test_redirect_uris.py b/tests/resources/test_redirect_uris.py index cc8c5ff..7cee60d 100644 --- a/tests/resources/test_redirect_uris.py +++ b/tests/resources/test_redirect_uris.py @@ -17,6 +17,7 @@ def test_redirect_uri_deserialization(self): "package_name": "string", "sha1_certificate_fingerprint": "string", }, + "deleted_at": 1620000000, } redirect_uri = RedirectUri.from_dict(redirect_uri_json) @@ -30,6 +31,20 @@ def test_redirect_uri_deserialization(self): assert redirect_uri.settings.team_id == "string" assert redirect_uri.settings.package_name == "string" assert redirect_uri.settings.sha1_certificate_fingerprint == "string" + # deleted_at is an Optional soft-delete timestamp per the applications spec + assert redirect_uri.deleted_at == 1620000000 + + def test_redirect_uri_deserialization_without_deleted_at(self): + # deleted_at is omitted when the URI is not soft-deleted; must default to None + redirect_uri_json = { + "id": "0556d035-6cb6-4262-a035-6b77e11cf8fc", + "url": "http://localhost/abc", + "platform": "web", + } + + redirect_uri = RedirectUri.from_dict(redirect_uri_json) + + assert redirect_uri.deleted_at is None def test_list_redirect_uris(self, http_client_list_response): redirect_uris = RedirectUris(http_client_list_response) @@ -100,8 +115,10 @@ def test_update_redirect_uri(self, http_client_response): request_body=request_body, ) + # Update must use PATCH, not PUT, per the applications spec + # (POST regenerates the id server-side; PATCH preserves the path id). http_client_response._execute.assert_called_once_with( - "PUT", + "PATCH", "/v3/applications/redirect-uris/redirect_uri-123", None, None, diff --git a/tests/resources/test_workspaces.py b/tests/resources/test_workspaces.py new file mode 100644 index 0000000..6345dbd --- /dev/null +++ b/tests/resources/test_workspaces.py @@ -0,0 +1,187 @@ +from nylas.models.workspaces import ( + Workspace, + WorkspaceManualAssignResponse, +) +from nylas.resources.workspaces import Workspaces + + +class TestWorkspaces: + def test_workspace_deserialization(self, http_client): + workspace_json = { + "workspace_id": "ws-123", + "application_id": "app-456", + "name": "Acme Workspace", + "domain": "acme.com", + "auto_group": True, + "default": True, + "policy_id": "policy-789", + "rule_ids": ["rule-1", "rule-2"], + "created_at": 1234567890, + "updated_at": 1234567899, + } + + workspace = Workspace.from_dict(workspace_json) + + assert workspace.workspace_id == "ws-123" + assert workspace.application_id == "app-456" + assert workspace.name == "Acme Workspace" + assert workspace.domain == "acme.com" + assert workspace.auto_group is True + assert workspace.default is True + assert workspace.policy_id == "policy-789" + assert workspace.rule_ids == ["rule-1", "rule-2"] + assert workspace.created_at == 1234567890 + assert workspace.updated_at == 1234567899 + + def test_workspace_deserialization_optional_fields_absent(self, http_client): + # default / policy_id / rule_ids may be absent; they must default + # to None rather than raising. + workspace_json = { + "workspace_id": "ws-123", + "application_id": "app-456", + "name": "Empty Domain Workspace", + "domain": "", + "auto_group": False, + "created_at": 1234567890, + "updated_at": 1234567899, + } + + workspace = Workspace.from_dict(workspace_json) + + assert workspace.domain == "" + assert workspace.auto_group is False + assert workspace.default is None + assert workspace.policy_id is None + assert workspace.rule_ids is None + + def test_manual_assign_response_null_grants(self, http_client): + # grants_assigned / grants_removed serialize as null when no grant matched; + # they must deserialize to None, not raise or coerce to []. + response_json = { + "application_id": "app-456", + "workspace_id": "ws-123", + "domain": "acme.com", + "grants_assigned": None, + "grants_removed": None, + } + + result = WorkspaceManualAssignResponse.from_dict(response_json) + + assert result.application_id == "app-456" + assert result.workspace_id == "ws-123" + assert result.domain == "acme.com" + assert result.grants_assigned is None + assert result.grants_removed is None + + def test_manual_assign_response_populated_grants(self, http_client): + response_json = { + "application_id": "app-456", + "workspace_id": "ws-123", + "domain": "acme.com", + "grants_assigned": ["grant-1"], + "grants_removed": ["grant-2", "grant-3"], + } + + result = WorkspaceManualAssignResponse.from_dict(response_json) + + assert result.grants_assigned == ["grant-1"] + assert result.grants_removed == ["grant-2", "grant-3"] + + def test_list_workspaces(self, http_client_list_response): + workspaces = Workspaces(http_client_list_response) + + workspaces.list() + + http_client_list_response._execute.assert_called_once_with( + "GET", "/v3/workspaces", None, None, None, overrides=None + ) + + def test_find_workspace(self, http_client_response): + workspaces = Workspaces(http_client_response) + + workspaces.find("ws-123") + + http_client_response._execute.assert_called_once_with( + "GET", "/v3/workspaces/ws-123", None, None, None, overrides=None + ) + + def test_create_workspace(self, http_client_response): + workspaces = Workspaces(http_client_response) + request_body = { + "name": "Acme Workspace", + "domain": "acme.com", + "auto_group": True, + "policy_id": "policy-789", + "rule_ids": ["rule-1"], + } + + workspaces.create(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "POST", "/v3/workspaces", None, None, request_body, overrides=None + ) + + def test_update_workspace_uses_patch(self, http_client_response): + # Update must issue PATCH (no PUT route exists) against the workspace UUID. + workspaces = Workspaces(http_client_response) + request_body = {"name": "Renamed Workspace"} + + workspaces.update(workspace_id="ws-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + "PATCH", "/v3/workspaces/ws-123", None, None, request_body, overrides=None + ) + + def test_destroy_workspace(self, http_client_delete_response): + workspaces = Workspaces(http_client_delete_response) + + workspaces.destroy("ws-123") + + http_client_delete_response._execute.assert_called_once_with( + "DELETE", "/v3/workspaces/ws-123", None, None, None, overrides=None + ) + + def test_auto_group(self, http_client_response): + workspaces = Workspaces(http_client_response) + request_body = { + "after_created_at": 1234567890, + "invalid_also": True, + "specific_domain": "acme.com", + } + + workspaces.auto_group(request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/workspaces/auto-group", + request_body=request_body, + overrides=None, + ) + + def test_auto_group_no_body(self, http_client_response): + workspaces = Workspaces(http_client_response) + + workspaces.auto_group() + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/workspaces/auto-group", + request_body=None, + overrides=None, + ) + + def test_manual_assign(self, http_client_response): + workspaces = Workspaces(http_client_response) + request_body = { + "assign_grants": ["grant-1", "grant-2"], + "remove_grants": ["grant-3"], + } + + workspaces.manual_assign(workspace_id="ws-123", request_body=request_body) + + http_client_response._execute.assert_called_once_with( + method="POST", + path="/v3/workspaces/ws-123/manual-assign", + request_body=request_body, + overrides=None, + )