From 41d3ff65fe6ee476fc95e75d37509a436f8df2ad Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Sat, 15 Mar 2025 16:29:22 -0600 Subject: [PATCH 01/16] Fixing linter issue with end of file newline --- notion_client/api_endpoints.py | 26 +++++++++++++++++++ notion_client/client.py | 2 ++ tests/cassettes/test_introspect_token.yaml | 29 ++++++++++++++++++++++ tests/cassettes/test_revoke_token.yaml | 28 +++++++++++++++++++++ tests/test_endpoints.py | 12 +++++++++ 5 files changed, 97 insertions(+) create mode 100644 tests/cassettes/test_introspect_token.yaml create mode 100644 tests/cassettes/test_revoke_token.yaml diff --git a/notion_client/api_endpoints.py b/notion_client/api_endpoints.py index e4dc034..65c1270 100644 --- a/notion_client/api_endpoints.py +++ b/notion_client/api_endpoints.py @@ -325,3 +325,29 @@ def list(self, **kwargs: Any) -> SyncAsync[Any]: query=pick(kwargs, "block_id", "start_cursor", "page_size"), auth=kwargs.get("auth"), ) + + +class OAuthEndpoint(Endpoint): + def introspect(self, **kwargs: Any) -> SyncAsync[Any]: + """Introspect the provided token. + + *[🔗 Endpoint documentation](https://developers.notion.com/reference/introspect-token)* + """ + return self.parent.request( + path="oauth/introspect", + method="POST", + body=pick(kwargs, "token"), + auth=kwargs.get("auth"), + ) + + def revoke(self, **kwargs: Any) -> SyncAsync[Any]: + """Revoke the provided token. + + *[🔗 Endpoint documentation](https://developers.notion.com/reference/revoke-token)* + """ + return self.parent.request( + path="oauth/revoke", + method="POST", + body=pick(kwargs, "token"), + auth=kwargs.get("auth"), + ) diff --git a/notion_client/client.py b/notion_client/client.py index e5288b8..d32b349 100644 --- a/notion_client/client.py +++ b/notion_client/client.py @@ -16,6 +16,7 @@ PagesEndpoint, SearchEndpoint, UsersEndpoint, + OAuthEndpoint, ) from notion_client.errors import ( APIResponseError, @@ -77,6 +78,7 @@ def __init__( self.pages = PagesEndpoint(self) self.search = SearchEndpoint(self) self.comments = CommentsEndpoint(self) + self.oauth = OAuthEndpoint(self) @property def client(self) -> Union[httpx.Client, httpx.AsyncClient]: diff --git a/tests/cassettes/test_introspect_token.yaml b/tests/cassettes/test_introspect_token.yaml new file mode 100644 index 0000000..c5b3eb8 --- /dev/null +++ b/tests/cassettes/test_introspect_token.yaml @@ -0,0 +1,29 @@ +interactions: +- request: + body: '{"token": "ntn_..."}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - Basic '"$BASE64_ENCODED_ID_AND_SECRET"' + connection: + - keep-alive + content-length: + - '63' + content-type: + - application/json + host: + - api.notion.com + notion-version: + - '2022-06-28' + method: POST + uri: https://api.notion.com/v1/oauth/introspect + response: + content: '{"active":true,"scope":"read_content insert_content update_content read_user_with_email + read_user_without_email","iat":1742002165921,"request_id":"14314d51-f34c-47a0-886b-4d792d9dad0c"}' + headers: {} + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/cassettes/test_revoke_token.yaml b/tests/cassettes/test_revoke_token.yaml new file mode 100644 index 0000000..5e6bfd9 --- /dev/null +++ b/tests/cassettes/test_revoke_token.yaml @@ -0,0 +1,28 @@ +interactions: +- request: + body: '{"token": "ntn..."}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - Basic '"$BASE64_ENCODED_ID_AND_SECRET"' + connection: + - keep-alive + content-length: + - '63' + content-type: + - application/json + host: + - api.notion.com + notion-version: + - '2022-06-28' + method: POST + uri: https://api.notion.com/v1/oauth/revoke + response: + content: '{"request_id":"ba61313e-c752-49bc-858d-0d961eda422e"}' + headers: {} + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 7f11a13..6556d94 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -199,3 +199,15 @@ def test_pages_delete(client, page_id): assert response client.pages.update(page_id=page_id, archived=False) + + +@pytest.mark.vcr() +def test_revoke_token(client, token): + response = client.oauth.revoke(token=token) + assert response + + +@pytest.mark.vcr() +def test_introspect_token(client, token): + response = client.oauth.introspect(token=token) + assert response From cd096c6613a76ef6dba662659eec3079632a2d37 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Sun, 16 Mar 2025 12:14:36 -0600 Subject: [PATCH 02/16] fixing documentation errors --- notion_client/api_endpoints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/notion_client/api_endpoints.py b/notion_client/api_endpoints.py index 65c1270..56aa2b6 100644 --- a/notion_client/api_endpoints.py +++ b/notion_client/api_endpoints.py @@ -329,10 +329,10 @@ def list(self, **kwargs: Any) -> SyncAsync[Any]: class OAuthEndpoint(Endpoint): def introspect(self, **kwargs: Any) -> SyncAsync[Any]: - """Introspect the provided token. + """Get a token's active status, scope, and issued time. *[🔗 Endpoint documentation](https://developers.notion.com/reference/introspect-token)* - """ + """ # noqa: E501 return self.parent.request( path="oauth/introspect", method="POST", @@ -341,10 +341,10 @@ def introspect(self, **kwargs: Any) -> SyncAsync[Any]: ) def revoke(self, **kwargs: Any) -> SyncAsync[Any]: - """Revoke the provided token. + """Revoke an access token. *[🔗 Endpoint documentation](https://developers.notion.com/reference/revoke-token)* - """ + """ # noqa: E501 return self.parent.request( path="oauth/revoke", method="POST", From 4294ba73c0e60e099dbe2e88b5f15c463e17d24f Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Mon, 17 Mar 2025 18:36:30 -0600 Subject: [PATCH 03/16] Adding functionality for generating a token, and authentication for public integrations. Docs updates to come --- notion_client/api_endpoints.py | 12 +++ notion_client/client.py | 34 ++++++-- .../cassettes/test_client_request_oauth.yaml | 77 +++++++++++++++++++ tests/cassettes/test_introspect_token.yaml | 4 +- tests/cassettes/test_revoke_token.yaml | 6 +- tests/cassettes/test_token.yaml | 28 +++++++ tests/conftest.py | 57 +++++++++++++- tests/test_client.py | 21 +++++ tests/test_endpoints.py | 19 ++++- 9 files changed, 242 insertions(+), 16 deletions(-) create mode 100644 tests/cassettes/test_client_request_oauth.yaml create mode 100644 tests/cassettes/test_token.yaml diff --git a/notion_client/api_endpoints.py b/notion_client/api_endpoints.py index 56aa2b6..58aeb63 100644 --- a/notion_client/api_endpoints.py +++ b/notion_client/api_endpoints.py @@ -328,6 +328,18 @@ def list(self, **kwargs: Any) -> SyncAsync[Any]: class OAuthEndpoint(Endpoint): + def token(self, **kwargs: Any) -> SyncAsync[Any]: + """Creates an access token that a third-party service can use to authenticate with Notion. + + *[🔗 Endpoint documentation](https://developers.notion.com/reference/create-a-token)* + """ # noqa: E501 + return self.parent.request( + path="oauth/token", + method="POST", + body=pick(kwargs, "grant_type", "code", "redirect_uri"), + auth=kwargs.get("auth"), + ) + def introspect(self, **kwargs: Any) -> SyncAsync[Any]: """Get a token's active status, scope, and issued time. diff --git a/notion_client/client.py b/notion_client/client.py index d32b349..ddda559 100644 --- a/notion_client/client.py +++ b/notion_client/client.py @@ -9,6 +9,8 @@ import httpx from httpx import Request, Response +import base64 + from notion_client.api_endpoints import ( BlocksEndpoint, CommentsEndpoint, @@ -23,6 +25,7 @@ HTTPResponseError, RequestTimeoutError, is_api_error_code, + APIErrorCode, ) from notion_client.logging import make_console_logger from notion_client.typing import SyncAsync @@ -33,7 +36,7 @@ class ClientOptions: """Options to configure the client. Attributes: - auth: Bearer token for authentication. If left undefined, the `auth` parameter + auth: Bearer token for authentication, or Base 64 encoded client ID and secret. If left undefined, the `auth` parameter should be set on each request. timeout_ms: Number of milliseconds to wait before emitting a `RequestTimeoutError`. @@ -45,7 +48,7 @@ class ClientOptions: notion_version: Notion version to use. """ - auth: Optional[str] = None + auth: Optional[Union[str, tuple[str, str]]] = None timeout_ms: int = 60_000 base_url: str = "https://api.notion.com" log_level: int = logging.WARNING @@ -95,7 +98,15 @@ def client(self, client: Union[httpx.Client, httpx.AsyncClient]) -> None: } ) if self.options.auth: - client.headers["Authorization"] = f"Bearer {self.options.auth}" + if isinstance(self.options.auth, tuple): + client_id = self.options.auth[0] + client_secret = self.options.auth[1] + auth_header = base64.b64encode( + f"{client_id}:{client_secret}".encode() + ).decode("utf-8") + client.headers["Authorization"] = f'Basic "{auth_header}"' + else: + client.headers["Authorization"] = f"Bearer {self.options.auth}" self._clients.append(client) def _build_request( @@ -104,11 +115,19 @@ def _build_request( path: str, query: Optional[Dict[Any, Any]] = None, body: Optional[Dict[Any, Any]] = None, - auth: Optional[str] = None, + auth: Optional[Union[str, tuple[str, str]]] = None, ) -> Request: headers = httpx.Headers() if auth: - headers["Authorization"] = f"Bearer {auth}" + if isinstance(auth, tuple): + client_id = auth[0] + client_secret = auth[1] + auth_header = base64.b64encode( + f"{client_id}:{client_secret}".encode() + ).decode("utf-8") + headers["Authorization"] = f'Basic "{auth_header}"' + else: + headers["Authorization"] = f"Bearer {auth}" self.logger.info(f"{method} {self.client.base_url}{path}") self.logger.debug(f"=> {query} -- {body}") return self.client.build_request( @@ -122,6 +141,11 @@ def _parse_response(self, response: Response) -> Any: try: body = error.response.json() code = body.get("code") + # Any oauth errors throw this exact error syntax, so handle them as so + if "code" not in body and body.get("error") == "invalid_client": + raise APIResponseError( + response, body["error"], APIErrorCode("unauthorized") + ) except json.JSONDecodeError: code = None if code and is_api_error_code(code): diff --git a/tests/cassettes/test_client_request_oauth.yaml b/tests/cassettes/test_client_request_oauth.yaml new file mode 100644 index 0000000..942861f --- /dev/null +++ b/tests/cassettes/test_client_request_oauth.yaml @@ -0,0 +1,77 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - ntn_... OR base64_encoded(client_id:client_secret) + connection: + - keep-alive + content-length: + - '0' + host: + - api.notion.com + notion-version: + - '2022-06-28' + method: POST + uri: https://api.notion.com/v1/oauth/introspect + response: + content: '{"error":"invalid_client","request_id":"055b49b7-5a59-4a50-a7aa-f7190a726275"}' + headers: {} + http_version: HTTP/1.1 + status_code: 401 +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - ntn_... OR base64_encoded(client_id:client_secret) + connection: + - keep-alive + content-length: + - '0' + host: + - api.notion.com + notion-version: + - '2022-06-28' + method: POST + uri: https://api.notion.com/v1/oauth/introspect + response: + content: '{"error":"invalid_client","request_id":"4da9d985-7a35-4b31-94a1-4539f9beb711"}' + headers: {} + http_version: HTTP/1.1 + status_code: 401 +- request: + body: '{"token": "ntn_..."}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - ntn_... OR base64_encoded(client_id:client_secret) + connection: + - keep-alive + content-length: + - '63' + content-type: + - application/json + host: + - api.notion.com + notion-version: + - '2022-06-28' + method: POST + uri: https://api.notion.com/v1/oauth/introspect + response: + content: '{"active":true,"scope":"read_content insert_content update_content read_user_with_email + read_user_without_email","iat":1742248683519,"request_id":"b5a7fea8-1b92-44ad-ad99-243df7723d75"}' + headers: {} + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/cassettes/test_introspect_token.yaml b/tests/cassettes/test_introspect_token.yaml index c5b3eb8..f38eb80 100644 --- a/tests/cassettes/test_introspect_token.yaml +++ b/tests/cassettes/test_introspect_token.yaml @@ -7,7 +7,7 @@ interactions: accept-encoding: - gzip, deflate authorization: - - Basic '"$BASE64_ENCODED_ID_AND_SECRET"' + - ntn_... OR base64_encoded(client_id:client_secret) connection: - keep-alive content-length: @@ -22,7 +22,7 @@ interactions: uri: https://api.notion.com/v1/oauth/introspect response: content: '{"active":true,"scope":"read_content insert_content update_content read_user_with_email - read_user_without_email","iat":1742002165921,"request_id":"14314d51-f34c-47a0-886b-4d792d9dad0c"}' + read_user_without_email","iat":1742248683519,"request_id":"ddd8cc28-162c-46cb-9268-fb890d4bb044"}' headers: {} http_version: HTTP/1.1 status_code: 200 diff --git a/tests/cassettes/test_revoke_token.yaml b/tests/cassettes/test_revoke_token.yaml index 5e6bfd9..dac3155 100644 --- a/tests/cassettes/test_revoke_token.yaml +++ b/tests/cassettes/test_revoke_token.yaml @@ -1,13 +1,13 @@ interactions: - request: - body: '{"token": "ntn..."}' + body: '{"token": "ntn_..."}' headers: accept: - '*/*' accept-encoding: - gzip, deflate authorization: - - Basic '"$BASE64_ENCODED_ID_AND_SECRET"' + - ntn_... OR base64_encoded(client_id:client_secret) connection: - keep-alive content-length: @@ -21,7 +21,7 @@ interactions: method: POST uri: https://api.notion.com/v1/oauth/revoke response: - content: '{"request_id":"ba61313e-c752-49bc-858d-0d961eda422e"}' + content: '{"request_id":"a1bffba5-3255-4cc7-9495-5676c9464105"}' headers: {} http_version: HTTP/1.1 status_code: 200 diff --git a/tests/cassettes/test_token.yaml b/tests/cassettes/test_token.yaml new file mode 100644 index 0000000..3c7b689 --- /dev/null +++ b/tests/cassettes/test_token.yaml @@ -0,0 +1,28 @@ +interactions: +- request: + body: '{"grant_type": "authorization_code", "code": "...", "redirect_uri": "http://..."}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - ntn_... OR base64_encoded(client_id:client_secret) + connection: + - keep-alive + content-length: + - '134' + content-type: + - application/json + host: + - api.notion.com + notion-version: + - '2022-06-28' + method: POST + uri: https://api.notion.com/v1/oauth/token + response: + content: '{"access_token":"...","token_type":"...","bot_id":"...","workspace_name":"...","workspace_icon":"...","workspace_id":"...","owner":"...","duplicated_template_id":"...","request_id":"..."}' + headers: {} + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/tests/conftest.py b/tests/conftest.py index a709047..36a48c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import re from datetime import datetime from typing import Optional +import json import pytest @@ -14,13 +15,45 @@ def remove_headers(response: dict): response["headers"] = {} return response + def scrub_requests(request: dict): + if request.body: + try: + body_str = request.body.decode("utf-8") + body_json = json.loads(body_str) + if "token" in body_json: + body_json["token"] = "ntn_..." + if "code" in body_json: + body_json["code"] = "..." + if "redirect_uri" in body_json: + body_json["redirect_uri"] = "http://..." + request.body = json.dumps(body_json).encode("utf-8") + + except (json.JSONDecodeError, AttributeError): + pass + return request + + def scrub_response(response: dict): + if "content" in response: + try: + content_json = json.loads(response["content"]) + if "access_token" in content_json: + response["content"] = json.dumps( + {key: "..." for key in content_json}, separators=(",", ":") + ) + + except json.JSONDecodeError: + pass + + return response + return { "filter_headers": [ - ("authorization", "ntn_..."), + ("authorization", "ntn_... OR base64_encoded(client_id:client_secret)"), ("user-agent", None), ("cookie", None), ], - "before_record_response": remove_headers, + "before_record_request": scrub_requests, + "before_record_response": (remove_headers, scrub_response), "match_on": ["method", "remove_page_id_for_matches"], } @@ -40,6 +73,26 @@ def token() -> str: return os.environ.get("NOTION_TOKEN") +@pytest.fixture(scope="session") +def code() -> str: + return os.environ.get("NOTION_CODE") + + +@pytest.fixture(scope="session") +def redirect_uri() -> str: + return os.environ.get("NOTION_REDIRECT_URI") + + +@pytest.fixture(scope="session") +def client_id() -> str: + return os.environ.get("NOTION_CLIENT_ID") + + +@pytest.fixture(scope="session") +def client_secret() -> str: + return os.environ.get("NOTION_CLIENT_SECRET") + + @pytest.fixture(scope="module", autouse=True) def parent_page_id(vcr) -> str: """this is the ID of the Notion page where the tests will be executed diff --git a/tests/test_client.py b/tests/test_client.py index 043b324..4ca9cbf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -53,3 +53,24 @@ async def test_async_client_request_auth(token): assert response["results"] await async_client.aclose() + + +@pytest.mark.vcr() +def test_client_request_oauth(token, client_id, client_secret): + client = Client(auth=("Invalid", "Invalid")) + + with pytest.raises(APIResponseError): + client.request("/oauth/introspect", "POST") + + with pytest.raises(APIResponseError): + client.request("/oauth/introspect", "POST", auth="STRING_INVALID") + + response = client.request( + "/oauth/introspect", + "POST", + auth=(client_id, client_secret), + body={"token": token}, + ) + assert response + + client.close() diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 6556d94..4b50bd5 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -202,12 +202,23 @@ def test_pages_delete(client, page_id): @pytest.mark.vcr() -def test_revoke_token(client, token): - response = client.oauth.revoke(token=token) +def test_token(client, redirect_uri, code, client_id, client_secret): + response = client.oauth.token( + redirect_uri=redirect_uri, + code=code, + grant_type="authorization_code", + auth=(client_id, client_secret), + ) + assert response + + +@pytest.mark.vcr() +def test_introspect_token(client, token, client_id, client_secret): + response = client.oauth.introspect(token=token, auth=(client_id, client_secret)) assert response @pytest.mark.vcr() -def test_introspect_token(client, token): - response = client.oauth.introspect(token=token) +def test_revoke_token(client, token, client_id, client_secret): + response = client.oauth.revoke(token=token, auth=(client_id, client_secret)) assert response From 07804a9ca4302fc8769bdfe3ce7162d7626097ed Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 18 Mar 2025 11:33:47 -0600 Subject: [PATCH 04/16] fixing the auth header scrubber to show correct authorization prefix for private and public integrations. Updated cassettes will come when all proposed changes are complete --- tests/conftest.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 36a48c2..24e912e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,9 +46,16 @@ def scrub_response(response: dict): return response + def scrub_auth_header(key: str, value: str, request: Optional[object] | None): + if key == "authorization": + if value.startswith("Bearer "): + return "ntn_..." + elif value.startswith("Basic "): + return "Basic Base64Encoded($client_id:$client_secret)" + return { "filter_headers": [ - ("authorization", "ntn_... OR base64_encoded(client_id:client_secret)"), + ("authorization", scrub_auth_header), ("user-agent", None), ("cookie", None), ], From 9226de897b44f4ef8bda760e9e3e289100c0e224 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 18 Mar 2025 11:56:39 -0600 Subject: [PATCH 05/16] replacing tuple for public integration authentication with typed dict --- notion_client/client.py | 20 ++++++-------------- tests/test_client.py | 4 ++-- tests/test_endpoints.py | 10 +++++++--- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/notion_client/client.py b/notion_client/client.py index ddda559..9804845 100644 --- a/notion_client/client.py +++ b/notion_client/client.py @@ -48,7 +48,7 @@ class ClientOptions: notion_version: Notion version to use. """ - auth: Optional[Union[str, tuple[str, str]]] = None + auth: Optional[str] = None timeout_ms: int = 60_000 base_url: str = "https://api.notion.com" log_level: int = logging.WARNING @@ -98,15 +98,7 @@ def client(self, client: Union[httpx.Client, httpx.AsyncClient]) -> None: } ) if self.options.auth: - if isinstance(self.options.auth, tuple): - client_id = self.options.auth[0] - client_secret = self.options.auth[1] - auth_header = base64.b64encode( - f"{client_id}:{client_secret}".encode() - ).decode("utf-8") - client.headers["Authorization"] = f'Basic "{auth_header}"' - else: - client.headers["Authorization"] = f"Bearer {self.options.auth}" + client.headers["Authorization"] = f"Bearer {self.options.auth}" self._clients.append(client) def _build_request( @@ -115,13 +107,13 @@ def _build_request( path: str, query: Optional[Dict[Any, Any]] = None, body: Optional[Dict[Any, Any]] = None, - auth: Optional[Union[str, tuple[str, str]]] = None, + auth: Optional[Union[str, Dict[str, str]]] = None, ) -> Request: headers = httpx.Headers() if auth: - if isinstance(auth, tuple): - client_id = auth[0] - client_secret = auth[1] + if isinstance(auth, Dict): + client_id = auth["client_id"] + client_secret = auth["client_secret"] auth_header = base64.b64encode( f"{client_id}:{client_secret}".encode() ).decode("utf-8") diff --git a/tests/test_client.py b/tests/test_client.py index 4ca9cbf..5fcd593 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -57,7 +57,7 @@ async def test_async_client_request_auth(token): @pytest.mark.vcr() def test_client_request_oauth(token, client_id, client_secret): - client = Client(auth=("Invalid", "Invalid")) + client = Client() with pytest.raises(APIResponseError): client.request("/oauth/introspect", "POST") @@ -68,7 +68,7 @@ def test_client_request_oauth(token, client_id, client_secret): response = client.request( "/oauth/introspect", "POST", - auth=(client_id, client_secret), + auth={"client_id": client_id, "client_secret": client_secret}, body={"token": token}, ) assert response diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 4b50bd5..8d75b60 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -207,18 +207,22 @@ def test_token(client, redirect_uri, code, client_id, client_secret): redirect_uri=redirect_uri, code=code, grant_type="authorization_code", - auth=(client_id, client_secret), + auth={"client_id": client_id, "client_secret": client_secret}, ) assert response @pytest.mark.vcr() def test_introspect_token(client, token, client_id, client_secret): - response = client.oauth.introspect(token=token, auth=(client_id, client_secret)) + response = client.oauth.introspect( + token=token, auth={"client_id": client_id, "client_secret": client_secret} + ) assert response @pytest.mark.vcr() def test_revoke_token(client, token, client_id, client_secret): - response = client.oauth.revoke(token=token, auth=(client_id, client_secret)) + response = client.oauth.revoke( + token=token, auth={"client_id": client_id, "client_secret": client_secret} + ) assert response From 6cf214a67bf9b10ccb6722ff72a7a92706405286 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 18 Mar 2025 15:50:40 -0600 Subject: [PATCH 06/16] fixing type of request in srub_auth_header --- tests/conftest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 24e912e..58e1e0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import json import pytest +from vcr.request import Request from notion_client import AsyncClient, Client @@ -46,7 +47,9 @@ def scrub_response(response: dict): return response - def scrub_auth_header(key: str, value: str, request: Optional[object] | None): + # The VCR config requires the passing of the request parameter, despite the face that it is not used + # (https://vcrpy.readthedocs.io/en/latest/advanced.html#advanced-use-of-filter-headers-filter-query-parameters-and-filter-post-data-parameters) + def scrub_auth_header(key: str, value: str, request: Optional[Request]): if key == "authorization": if value.startswith("Bearer "): return "ntn_..." From a61b8e186b295eb41c1c67c783719bed177a5c12 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 18 Mar 2025 16:03:22 -0600 Subject: [PATCH 07/16] fixing oauth header to be a TypedDict --- notion_client/client.py | 7 ++++--- notion_client/typing.py | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/notion_client/client.py b/notion_client/client.py index 9804845..a4d1c36 100644 --- a/notion_client/client.py +++ b/notion_client/client.py @@ -28,7 +28,7 @@ APIErrorCode, ) from notion_client.logging import make_console_logger -from notion_client.typing import SyncAsync +from notion_client.typing import SyncAsync, OAuthHeader @dataclass @@ -36,7 +36,7 @@ class ClientOptions: """Options to configure the client. Attributes: - auth: Bearer token for authentication, or Base 64 encoded client ID and secret. If left undefined, the `auth` parameter + auth: Bearer token for authentication. If left undefined, the `auth` parameter should be set on each request. timeout_ms: Number of milliseconds to wait before emitting a `RequestTimeoutError`. @@ -107,10 +107,11 @@ def _build_request( path: str, query: Optional[Dict[Any, Any]] = None, body: Optional[Dict[Any, Any]] = None, - auth: Optional[Union[str, Dict[str, str]]] = None, + auth: Optional[Union[str, OAuthHeader]] = None, ) -> Request: headers = httpx.Headers() if auth: + # At runtime the TypedDict is the same type as a regular Dict if isinstance(auth, Dict): client_id = auth["client_id"] client_secret = auth["client_secret"] diff --git a/notion_client/typing.py b/notion_client/typing.py index 97ebc80..eecf847 100644 --- a/notion_client/typing.py +++ b/notion_client/typing.py @@ -1,5 +1,10 @@ """Custom type definitions for notion-sdk-py.""" -from typing import Awaitable, TypeVar, Union +from typing import Awaitable, TypeVar, Union, TypedDict T = TypeVar("T") SyncAsync = Union[T, Awaitable[T]] + + +class OAuthHeader(TypedDict): + client_id: str + client_secret: str From 07b588a8ce8baf39d120786fe3d296561810844a Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 18 Mar 2025 22:14:34 -0600 Subject: [PATCH 08/16] Removes support for Python 3.7 Not sure if this will remove this version support in the CI. I have a feeling it won't but should fix the tox tests locally --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 248e075..2f510e9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37,py38,py39,py310,py311,py312,py313 +envlist = py38,py39,py310,py311,py312,py313 [testenv] deps = -r requirements/tests.txt From 1903a451567a76ab2aab6864f9865fd47fc853ff Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Wed, 19 Mar 2025 09:36:34 -0600 Subject: [PATCH 09/16] Further removes support for Python 3.7 Removes Python 3.7 from documentation and Github Actions --- .github/workflows/test.yml | 2 +- README.md | 2 +- setup.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4db3344..f5ac488 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: diff --git a/README.md b/README.md index 3130a6d..1959b0e 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ at the end of the session. This package supports the following minimum versions: -* Python >= 3.7 +* Python >= 3.8 * httpx >= 0.23.0 Earlier versions may still work, but we encourage people building new applications diff --git a/setup.py b/setup.py index 85cfadc..0ed608d 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,6 @@ def get_description(): "httpx >= 0.23.0", ], classifiers=[ - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From 53fd5e1662d37ee0694d5bd8a4670a0306057108 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Wed, 19 Mar 2025 13:48:03 -0600 Subject: [PATCH 10/16] Fixes the accidental silencing of JSON Decoding errors in test configuration --- tests/conftest.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 58e1e0f..ffbf5d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,9 +28,10 @@ def scrub_requests(request: dict): if "redirect_uri" in body_json: body_json["redirect_uri"] = "http://..." request.body = json.dumps(body_json).encode("utf-8") - - except (json.JSONDecodeError, AttributeError): - pass + except json.JSONDecodeError as e: + raise json.JSONDecodeError( + f"Failed to decode request body: {request.body} \n Error occurred at {e.pos} with message: {e.msg}" + ) return request def scrub_response(response: dict): @@ -41,10 +42,10 @@ def scrub_response(response: dict): response["content"] = json.dumps( {key: "..." for key in content_json}, separators=(",", ":") ) - - except json.JSONDecodeError: - pass - + except json.JSONDecodeError as e: + raise json.JSONDecodeError( + f"Failed to decode response body: {response["content"]} \n Error occurred at {e.pos} with message: {e.msg}" + ) return response # The VCR config requires the passing of the request parameter, despite the face that it is not used From 88762b152dc1172d1476e2625b47f30141cd787b Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Wed, 19 Mar 2025 14:44:37 -0600 Subject: [PATCH 11/16] Fixes JSON decoding on non-json response, and recreates cassettes with better authorization information --- tests/cassettes/test_client_request_oauth.yaml | 12 +++++------- tests/cassettes/test_introspect_token.yaml | 4 ++-- tests/cassettes/test_revoke_token.yaml | 4 ++-- tests/cassettes/test_token.yaml | 2 +- tests/conftest.py | 17 +++++++++++++---- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/cassettes/test_client_request_oauth.yaml b/tests/cassettes/test_client_request_oauth.yaml index 942861f..8a76634 100644 --- a/tests/cassettes/test_client_request_oauth.yaml +++ b/tests/cassettes/test_client_request_oauth.yaml @@ -6,8 +6,6 @@ interactions: - '*/*' accept-encoding: - gzip, deflate - authorization: - - ntn_... OR base64_encoded(client_id:client_secret) connection: - keep-alive content-length: @@ -19,7 +17,7 @@ interactions: method: POST uri: https://api.notion.com/v1/oauth/introspect response: - content: '{"error":"invalid_client","request_id":"055b49b7-5a59-4a50-a7aa-f7190a726275"}' + content: '{"error":"invalid_client","request_id":"141bd2e5-09c1-4697-b80b-5fd2fd4dc45b"}' headers: {} http_version: HTTP/1.1 status_code: 401 @@ -31,7 +29,7 @@ interactions: accept-encoding: - gzip, deflate authorization: - - ntn_... OR base64_encoded(client_id:client_secret) + - ntn_... connection: - keep-alive content-length: @@ -43,7 +41,7 @@ interactions: method: POST uri: https://api.notion.com/v1/oauth/introspect response: - content: '{"error":"invalid_client","request_id":"4da9d985-7a35-4b31-94a1-4539f9beb711"}' + content: '{"error":"invalid_client","request_id":"f678e9ff-7a4e-4ba7-a74e-d9edb7b81047"}' headers: {} http_version: HTTP/1.1 status_code: 401 @@ -55,7 +53,7 @@ interactions: accept-encoding: - gzip, deflate authorization: - - ntn_... OR base64_encoded(client_id:client_secret) + - Basic "Base64Encoded($client_id:$client_secret)" connection: - keep-alive content-length: @@ -70,7 +68,7 @@ interactions: uri: https://api.notion.com/v1/oauth/introspect response: content: '{"active":true,"scope":"read_content insert_content update_content read_user_with_email - read_user_without_email","iat":1742248683519,"request_id":"b5a7fea8-1b92-44ad-ad99-243df7723d75"}' + read_user_without_email","iat":1742416470043,"request_id":"498e8bad-8927-4dd9-9b82-bf7e051f86d4"}' headers: {} http_version: HTTP/1.1 status_code: 200 diff --git a/tests/cassettes/test_introspect_token.yaml b/tests/cassettes/test_introspect_token.yaml index f38eb80..7ccbee4 100644 --- a/tests/cassettes/test_introspect_token.yaml +++ b/tests/cassettes/test_introspect_token.yaml @@ -7,7 +7,7 @@ interactions: accept-encoding: - gzip, deflate authorization: - - ntn_... OR base64_encoded(client_id:client_secret) + - Basic "Base64Encoded($client_id:$client_secret)" connection: - keep-alive content-length: @@ -22,7 +22,7 @@ interactions: uri: https://api.notion.com/v1/oauth/introspect response: content: '{"active":true,"scope":"read_content insert_content update_content read_user_with_email - read_user_without_email","iat":1742248683519,"request_id":"ddd8cc28-162c-46cb-9268-fb890d4bb044"}' + read_user_without_email","iat":1742416470043,"request_id":"57f9e373-a28b-4b8f-b2dc-c0d687f6f743"}' headers: {} http_version: HTTP/1.1 status_code: 200 diff --git a/tests/cassettes/test_revoke_token.yaml b/tests/cassettes/test_revoke_token.yaml index dac3155..700ec2a 100644 --- a/tests/cassettes/test_revoke_token.yaml +++ b/tests/cassettes/test_revoke_token.yaml @@ -7,7 +7,7 @@ interactions: accept-encoding: - gzip, deflate authorization: - - ntn_... OR base64_encoded(client_id:client_secret) + - Basic "Base64Encoded($client_id:$client_secret)" connection: - keep-alive content-length: @@ -21,7 +21,7 @@ interactions: method: POST uri: https://api.notion.com/v1/oauth/revoke response: - content: '{"request_id":"a1bffba5-3255-4cc7-9495-5676c9464105"}' + content: '{"request_id":"a6dcf82b-8a97-48af-b851-8b9b3559ed69"}' headers: {} http_version: HTTP/1.1 status_code: 200 diff --git a/tests/cassettes/test_token.yaml b/tests/cassettes/test_token.yaml index 3c7b689..fd1b51e 100644 --- a/tests/cassettes/test_token.yaml +++ b/tests/cassettes/test_token.yaml @@ -7,7 +7,7 @@ interactions: accept-encoding: - gzip, deflate authorization: - - ntn_... OR base64_encoded(client_id:client_secret) + - Basic "Base64Encoded($client_id:$client_secret)" connection: - keep-alive content-length: diff --git a/tests/conftest.py b/tests/conftest.py index ffbf5d8..62d7713 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,21 +30,30 @@ def scrub_requests(request: dict): request.body = json.dumps(body_json).encode("utf-8") except json.JSONDecodeError as e: raise json.JSONDecodeError( - f"Failed to decode request body: {request.body} \n Error occurred at {e.pos} with message: {e.msg}" + f"Failed to decode request body: {request.body} \n Error occurred at {e.pos} with message: {e.msg}", + request.body, + e.pos, ) return request def scrub_response(response: dict): if "content" in response: + content = response["content"] + # Like the case tests/cassettes/test_api_async_request_bad_request_error.yaml, where the response is just a string, not JSON + # We don't want to raise an error here because the response is not JSON and that is ok + if "{" not in content: + return response try: - content_json = json.loads(response["content"]) + content_json = json.loads(content) if "access_token" in content_json: response["content"] = json.dumps( {key: "..." for key in content_json}, separators=(",", ":") ) except json.JSONDecodeError as e: raise json.JSONDecodeError( - f"Failed to decode response body: {response["content"]} \n Error occurred at {e.pos} with message: {e.msg}" + f"Failed to decode response body: {response["content"]} \n Error occurred at {e.pos} with message: {e.msg}", + response["content"], + e.pos, ) return response @@ -55,7 +64,7 @@ def scrub_auth_header(key: str, value: str, request: Optional[Request]): if value.startswith("Bearer "): return "ntn_..." elif value.startswith("Basic "): - return "Basic Base64Encoded($client_id:$client_secret)" + return 'Basic "Base64Encoded($client_id:$client_secret)"' return { "filter_headers": [ From d7c985f6cf59571b9fcce253a47bb04d2e8b030c Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Wed, 19 Mar 2025 14:49:18 -0600 Subject: [PATCH 12/16] Fixes f-string error oops, ugh --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 62d7713..9a93a65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,7 +51,7 @@ def scrub_response(response: dict): ) except json.JSONDecodeError as e: raise json.JSONDecodeError( - f"Failed to decode response body: {response["content"]} \n Error occurred at {e.pos} with message: {e.msg}", + f"Failed to decode response body: {response['content']} \n Error occurred at {e.pos} with message: {e.msg}", response["content"], e.pos, ) From 186996073b303d8dbf075ebe224aa4fa7df28657 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 25 Mar 2025 09:01:38 -0600 Subject: [PATCH 13/16] Makes the auth variable names replicate JS SDK better --- notion_client/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/notion_client/client.py b/notion_client/client.py index a4d1c36..73964dc 100644 --- a/notion_client/client.py +++ b/notion_client/client.py @@ -115,10 +115,11 @@ def _build_request( if isinstance(auth, Dict): client_id = auth["client_id"] client_secret = auth["client_secret"] - auth_header = base64.b64encode( - f"{client_id}:{client_secret}".encode() + unencoded_credential = f"{client_id}:{client_secret}" + encoded_credential = base64.b64encode( + unencoded_credential.encode() ).decode("utf-8") - headers["Authorization"] = f'Basic "{auth_header}"' + headers["Authorization"] = f'Basic "{encoded_credential}"' else: headers["Authorization"] = f"Bearer {auth}" self.logger.info(f"{method} {self.client.base_url}{path}") From a9bf00e2df00c232b8678f6b4fc870833074b335 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 25 Mar 2025 19:12:19 -0600 Subject: [PATCH 14/16] Removes try-expect blocks and just lets the json library raise its own errors, as intended --- tests/conftest.py | 40 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9a93a65..2ca137e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,22 +18,15 @@ def remove_headers(response: dict): def scrub_requests(request: dict): if request.body: - try: - body_str = request.body.decode("utf-8") - body_json = json.loads(body_str) - if "token" in body_json: - body_json["token"] = "ntn_..." - if "code" in body_json: - body_json["code"] = "..." - if "redirect_uri" in body_json: - body_json["redirect_uri"] = "http://..." - request.body = json.dumps(body_json).encode("utf-8") - except json.JSONDecodeError as e: - raise json.JSONDecodeError( - f"Failed to decode request body: {request.body} \n Error occurred at {e.pos} with message: {e.msg}", - request.body, - e.pos, - ) + body_str = request.body.decode("utf-8") + body_json = json.loads(body_str) + if "token" in body_json: + body_json["token"] = "ntn_..." + if "code" in body_json: + body_json["code"] = "..." + if "redirect_uri" in body_json: + body_json["redirect_uri"] = "http://..." + request.body = json.dumps(body_json).encode("utf-8") return request def scrub_response(response: dict): @@ -43,17 +36,10 @@ def scrub_response(response: dict): # We don't want to raise an error here because the response is not JSON and that is ok if "{" not in content: return response - try: - content_json = json.loads(content) - if "access_token" in content_json: - response["content"] = json.dumps( - {key: "..." for key in content_json}, separators=(",", ":") - ) - except json.JSONDecodeError as e: - raise json.JSONDecodeError( - f"Failed to decode response body: {response['content']} \n Error occurred at {e.pos} with message: {e.msg}", - response["content"], - e.pos, + content_json = json.loads(content) + if "access_token" in content_json: + response["content"] = json.dumps( + {key: "..." for key in content_json}, separators=(",", ":") ) return response From e42384398180838e4051971f2518df295f71fa00 Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 25 Mar 2025 19:19:28 -0600 Subject: [PATCH 15/16] Fixes incorrect type hinting of scrub_requests function --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2ca137e..43a091e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ def remove_headers(response: dict): response["headers"] = {} return response - def scrub_requests(request: dict): + def scrub_requests(request: Request): if request.body: body_str = request.body.decode("utf-8") body_json = json.loads(body_str) From 0fff5e0cf4bc11959eeaf383536c4b9f12710c7d Mon Sep 17 00:00:00 2001 From: Vincent Bowen Date: Tue, 25 Mar 2025 19:37:45 -0600 Subject: [PATCH 16/16] Makes unhandles oauth errors match that of JS SDK --- notion_client/client.py | 6 ------ tests/test_client.py | 7 ++++--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/notion_client/client.py b/notion_client/client.py index 73964dc..c4dba77 100644 --- a/notion_client/client.py +++ b/notion_client/client.py @@ -25,7 +25,6 @@ HTTPResponseError, RequestTimeoutError, is_api_error_code, - APIErrorCode, ) from notion_client.logging import make_console_logger from notion_client.typing import SyncAsync, OAuthHeader @@ -135,11 +134,6 @@ def _parse_response(self, response: Response) -> Any: try: body = error.response.json() code = body.get("code") - # Any oauth errors throw this exact error syntax, so handle them as so - if "code" not in body and body.get("error") == "invalid_client": - raise APIResponseError( - response, body["error"], APIErrorCode("unauthorized") - ) except json.JSONDecodeError: code = None if code and is_api_error_code(code): diff --git a/tests/test_client.py b/tests/test_client.py index 5fcd593..6e34605 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ import pytest -from notion_client import APIResponseError, AsyncClient, Client +from notion_client import AsyncClient, Client, APIResponseError +from notion_client.errors import HTTPResponseError def test_client_init(client): @@ -59,10 +60,10 @@ async def test_async_client_request_auth(token): def test_client_request_oauth(token, client_id, client_secret): client = Client() - with pytest.raises(APIResponseError): + with pytest.raises(HTTPResponseError): client.request("/oauth/introspect", "POST") - with pytest.raises(APIResponseError): + with pytest.raises(HTTPResponseError): client.request("/oauth/introspect", "POST", auth="STRING_INVALID") response = client.request(