diff --git a/docs/abc.rst b/docs/abc.rst index bb7619f..779e859 100644 --- a/docs/abc.rst +++ b/docs/abc.rst @@ -171,7 +171,7 @@ experimental APIs without issue. :meth:`getitem`. - .. coroutine:: post(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None) + .. coroutine:: post(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None, content_type="application/json") Send a ``POST`` request to GitHub. @@ -185,8 +185,19 @@ experimental APIs without issue. raised if both are passed. If neither was passed, it defaults to the value of the *oauth_token* attribute. + *content_type* is the value of the desired request header's content type. + If supplied, the data will be passed as the body in its raw format. + If not supplied, it will assume the default "application/json" content type, + and the data will be parsed as JSON. + A few GitHub POST endpoints do not take any *data* argument, for example - the endpoint to `create an installation access token `_. For this situation, you can pass ``data=b""``. + the endpoint to `create an installation access token `_. + For this situation, you can pass ``data=b""``. + + + .. versionchanged:: 4.12 + Added *content_type*. + .. versionchanged:: 3.0 diff --git a/docs/changelog.rst b/docs/changelog.rst index 38ab3b1..1074979 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,16 @@ Changelog ========= +4.2.0 +----- + +- :meth:`gidgethub.abc.GitHubAPI.post` now accepts ``content_type`` parameter. + If supplied, the ``content_type`` value will be used in the request headers, + and the raw form of the data will be passed to the request. If not supplied, + by default the data will be parsed as JSON, and the "application/json" content + type will be used. (`Issue #115 `_). + + 4.1.1 ----- diff --git a/gidgethub/__init__.py b/gidgethub/__init__.py index d8cd029..f556b99 100644 --- a/gidgethub/__init__.py +++ b/gidgethub/__init__.py @@ -1,5 +1,5 @@ """An async GitHub API library""" -__version__ = "4.1.1" +__version__ = "4.2.0" import http from typing import Any, Optional diff --git a/gidgethub/abc.py b/gidgethub/abc.py index 2ab1650..3923704 100644 --- a/gidgethub/abc.py +++ b/gidgethub/abc.py @@ -19,7 +19,9 @@ # Value represents etag, last-modified, data, and next page. CACHE_TYPE = MutableMapping[str, Tuple[Opt[str], Opt[str], Any, Opt[str]]] -_json_content_type = "application/json; charset=utf-8" +JSON_CONTENT_TYPE = "application/json" +UTF_8_CHARSET = "utf-8" +JSON_UTF_8_CHARSET = f"{JSON_CONTENT_TYPE}; charset={UTF_8_CHARSET}" class GitHubAPI(abc.ABC): @@ -59,6 +61,7 @@ async def _make_request( accept: str, jwt: Opt[str] = None, oauth_token: Opt[str] = None, + content_type: str = JSON_CONTENT_TYPE, ) -> Tuple[bytes, Opt[str]]: """Construct and make an HTTP request.""" if oauth_token is not None and jwt is not None: @@ -95,9 +98,14 @@ async def _make_request( if last_modified is not None: request_headers["if-modified-since"] = last_modified else: - charset = "utf-8" - body = json.dumps(data).encode(charset) - request_headers["content-type"] = f"application/json; charset={charset}" + if content_type != JSON_CONTENT_TYPE: + # We don't know how to handle other content types, so just pass things along. + request_headers["content-type"] = content_type + body = data + else: + # Since JSON is so common, add some niceties. + body = json.dumps(data).encode(UTF_8_CHARSET) + request_headers["content-type"] = JSON_UTF_8_CHARSET request_headers["content-length"] = str(len(body)) if self.rate_limit is not None: self.rate_limit.remaining -= 1 @@ -162,9 +170,17 @@ async def post( accept: str = sansio.accept_format(), jwt: Opt[str] = None, oauth_token: Opt[str] = None, + content_type: str = JSON_CONTENT_TYPE, ) -> Any: data, _ = await self._make_request( - "POST", url, url_vars, data, accept, jwt=jwt, oauth_token=oauth_token + "POST", + url, + url_vars, + data, + accept, + jwt=jwt, + oauth_token=oauth_token, + content_type=content_type, ) return data @@ -229,11 +245,11 @@ async def graphql( payload["variables"] = variables request_data = json.dumps(payload).encode("utf-8") request_headers = sansio.create_headers( - self.requester, accept=_json_content_type, oauth_token=self.oauth_token + self.requester, accept=JSON_UTF_8_CHARSET, oauth_token=self.oauth_token ) request_headers.update( { - "content-type": _json_content_type, + "content-type": JSON_UTF_8_CHARSET, "content-length": str(len(request_data)), } ) diff --git a/tests/test_abc.py b/tests/test_abc.py index 21a597c..bd4a0ba 100644 --- a/tests/test_abc.py +++ b/tests/test_abc.py @@ -22,6 +22,8 @@ from .samples import GraphQL as graphql_samples +from gidgethub.abc import JSON_UTF_8_CHARSET + class MockGitHubAPI(gh_abc.GitHubAPI): @@ -29,7 +31,7 @@ class MockGitHubAPI(gh_abc.GitHubAPI): "x-ratelimit-limit": "2", "x-ratelimit-remaining": "1", "x-ratelimit-reset": "0", - "content-type": "application/json; charset=utf-8", + "content-type": JSON_UTF_8_CHARSET, } def __init__( @@ -343,42 +345,41 @@ async def test_post(self): send_json = json.dumps(send).encode("utf-8") receive = {"hello": "world"} headers = MockGitHubAPI.DEFAULT_HEADERS.copy() - headers["content-type"] = "application/json; charset=utf-8" gh = MockGitHubAPI(headers=headers, body=json.dumps(receive).encode("utf-8")) await gh.post("/fake", data=send) assert gh.method == "POST" assert gh.headers["content-type"] == "application/json; charset=utf-8" assert gh.body == send_json assert gh.headers["content-length"] == str(len(send_json)) + assert gh.headers["content-type"] == JSON_UTF_8_CHARSET @pytest.mark.asyncio async def test_with_passed_jwt(self): send = [1, 2, 3] receive = {"hello": "world"} headers = MockGitHubAPI.DEFAULT_HEADERS.copy() - headers["content-type"] = "application/json; charset=utf-8" gh = MockGitHubAPI(headers=headers, body=json.dumps(receive).encode("utf-8")) await gh.post("/fake", data=send, jwt="json web token") assert gh.method == "POST" assert gh.headers["authorization"] == "bearer json web token" + assert gh.headers["content-type"] == JSON_UTF_8_CHARSET @pytest.mark.asyncio async def test_with_passed_oauth_token(self): send = [1, 2, 3] receive = {"hello": "world"} headers = MockGitHubAPI.DEFAULT_HEADERS.copy() - headers["content-type"] = "application/json; charset=utf-8" gh = MockGitHubAPI(headers=headers, body=json.dumps(receive).encode("utf-8")) await gh.post("/fake", data=send, oauth_token="my oauth token") assert gh.method == "POST" assert gh.headers["authorization"] == "token my oauth token" + assert gh.headers["content-type"] == JSON_UTF_8_CHARSET @pytest.mark.asyncio async def test_cannot_pass_both_oauth_and_jwt(self): send = [1, 2, 3] receive = {"hello": "world"} headers = MockGitHubAPI.DEFAULT_HEADERS.copy() - headers["content-type"] = "application/json; charset=utf-8" gh = MockGitHubAPI(headers=headers, body=json.dumps(receive).encode("utf-8")) with pytest.raises(ValueError) as exc_info: await gh.post( @@ -387,6 +388,17 @@ async def test_cannot_pass_both_oauth_and_jwt(self): assert str(exc_info.value) == "Cannot pass both oauth_token and jwt." + @pytest.mark.asyncio + async def test_with_passed_content_type(self): + """Assert that the body is not parsed to JSON and the content-type header is set.""" + gh = MockGitHubAPI() + data = "blabla" + await gh.post("/fake", data=data, content_type="application/zip") + assert gh.method == "POST" + assert gh.headers["content-type"] == "application/zip" + assert gh.body == data + assert gh.headers["content-length"] == str(len(data)) + class TestGitHubAPIPatch: @pytest.mark.asyncio @@ -842,6 +854,6 @@ async def test_no_response_content_type_gh121(self): @pytest.mark.asyncio async def test_no_response_data(self): # An empty response should raise an exception. - gh = MockGitHubAPI(200, body=b"", oauth_token="oauth-token",) + gh = MockGitHubAPI(200, body=b"", oauth_token="oauth-token") with pytest.raises(GraphQLException): await gh.graphql("does not matter")