Skip to content

Commit

Permalink
Allow passing content-type header to GitHubAPI.POST (#131)
Browse files Browse the repository at this point in the history
* Allow passing content-type header to GitHubAPI.POST

When the content-type is passed, the raw body is used in the request.
The default content-type is json, and the body would be parsed to JSON format.

Updated documentation and changelog.

Closes #115

Co-authored-by: Brett Cannon <brett@python.org>
  • Loading branch information
Mariatta and brettcannon committed Aug 23, 2020
1 parent 6cb4078 commit 27c104d
Show file tree
Hide file tree
Showing 5 changed files with 65 additions and 16 deletions.
15 changes: 13 additions & 2 deletions docs/abc.rst
Expand Up @@ -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.

Expand All @@ -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 <https://developer.github.com/v3/apps/#create-a-github-app-from-a-manifest>`_. For this situation, you can pass ``data=b""``.
the endpoint to `create an installation access token <https://developer.github.com/v3/apps/#create-a-github-app-from-a-manifest>`_.
For this situation, you can pass ``data=b""``.


.. versionchanged:: 4.12
Added *content_type*.


.. versionchanged:: 3.0

Expand Down
10 changes: 10 additions & 0 deletions 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 <https://github.com/brettcannon/gidgethub/issues/115>`_).


4.1.1
-----

Expand Down
2 changes: 1 addition & 1 deletion 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
Expand Down
30 changes: 23 additions & 7 deletions gidgethub/abc.py
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)),
}
)
Expand Down
24 changes: 18 additions & 6 deletions tests/test_abc.py
Expand Up @@ -22,14 +22,16 @@

from .samples import GraphQL as graphql_samples

from gidgethub.abc import JSON_UTF_8_CHARSET


class MockGitHubAPI(gh_abc.GitHubAPI):

DEFAULT_HEADERS = {
"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__(
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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")

0 comments on commit 27c104d

Please sign in to comment.