Repository: brettcannon/gidgethub (python)

Issue:
Recover status code after request
Hi!

I am using ``gidgethub`` to create a bot within a GitHub App. I am struggling when checking whether a user is member of a certain organization, see [GitHub documentation](https://docs.github.com/en/rest/orgs/members#check-organization-membership-for-a-user) for this call. 

From what I understood, ``gh.getitem`` only returns the body of the request. However, the call mentioned above only returns a status code as a response (either 204, 302 or 404). Thus, I am not sure how to recover this status code after using ``await gh.getitem(url, oauth_token=oauth_token)``.

Please, let me know if I am misunderstanding something. Thanks!

Pull request description:
Add a getstatus() method to gidget.abc
This now supports API methods whose responses are expressed in a status
code, such as `/orgs/{org}/members/{username}` to determine if a group
contains a specific user.

-------

## Alternatives

Per the discussion on #188 this could have been achieved through refactoring `_make_request()` to split out request construction. Looking at the intermingling of the caching logic, the rate limit counter, and the header assembly, though, it looked like any method that made use of the full set of request construction would look remarkably similar to the full `_make_request()` call. As such, I augmented the return value to include the status code instead.

Closes #188

Patch:
diff --git a/docs/abc.rst b/docs/abc.rst
index 43d853f..6d771c3 100644
--- a/docs/abc.rst
+++ b/docs/abc.rst
@@ -142,6 +142,25 @@ experimental APIs without issue.
             For ``GET`` calls that can return multiple values and
             potentially require pagination, see ``getiter()``.
 
+    .. py:method:: getstatus(url, url_vars={}, *, accept=sansio.accept_format(), jwt=None, oauth_token=None)
+        :async:
+
+        Get a single item's *HTTP status* from GitHub.
+
+        *jwt* is the value of the JSON web token, for authenticating as a GitHub
+        App.
+
+        *oauth_token* is the value of the oauth token, for making an authenticated
+        API call.
+
+        Only one of *oauth_token* or *jwt* may be passed. A ``ValueError`` is
+        raised if both are passed. If neither was passed, it defaults to the
+        value of the *oauth_token* attribute.
+
+        .. note::
+            This method discards any returned content, and is only for use
+            on API endpoints like /orgs/{org}/members/{username} where the
+            HTTP response code is the relevant answer.
 
     .. py:method:: getiter(url, url_vars={}, *, accept=sansio.accept_format(), jwt=None, oauth_token=None, iterable_key="items")
         :async:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 991da9a..47564c1 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,6 +1,12 @@
 Changelog
 =========
 
+5.3.0
+-----
+
+- Add a getstatus() method for APIs that do not return content.
+  (`PR #194 <https://github.com/brettcannon/gidgethub/pull/194>_`)
+
 5.2.1
 -----
 
diff --git a/gidgethub/__init__.py b/gidgethub/__init__.py
index d6a9fb6..42be3b9 100644
--- a/gidgethub/__init__.py
+++ b/gidgethub/__init__.py
@@ -1,5 +1,5 @@
 """An async GitHub API library"""
-__version__ = "5.2.1"
+__version__ = "5.3.0"
 
 import http
 from typing import Any, Optional
diff --git a/gidgethub/abc.py b/gidgethub/abc.py
index f57b1a3..b80314d 100644
--- a/gidgethub/abc.py
+++ b/gidgethub/abc.py
@@ -12,6 +12,7 @@ from . import (
     GitHubBroken,
     GraphQLAuthorizationFailure,
     GraphQLException,
+    HTTPException,
     QueryError,
     GraphQLResponseTypeError,
 )
@@ -65,7 +66,7 @@ class GitHubAPI(abc.ABC):
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
         content_type: str = JSON_CONTENT_TYPE,
-    ) -> Tuple[bytes, Opt[str]]:
+    ) -> Tuple[bytes, Opt[str], int]:
         """Construct and make an HTTP request."""
         if oauth_token is not None and jwt is not None:
             raise ValueError("Cannot pass both oauth_token and jwt.")
@@ -120,7 +121,7 @@ class GitHubAPI(abc.ABC):
                 etag = response[1].get("etag")
                 last_modified = response[1].get("last-modified")
                 self._cache[filled_url] = etag, last_modified, data, more
-        return data, more
+        return data, more, response[0]
 
     async def getitem(
         self,
@@ -133,11 +134,31 @@ class GitHubAPI(abc.ABC):
     ) -> Any:
         """Send a GET request for a single item to the specified endpoint."""
 
-        data, _ = await self._make_request(
+        data, _, _ = await self._make_request(
             "GET", url, url_vars, b"", accept, jwt=jwt, oauth_token=oauth_token
         )
         return data
 
+    async def getstatus(
+        self,
+        url: str,
+        url_vars: Optional[variable.VariableValueDict] = {},
+        *,
+        accept: str = sansio.accept_format(),
+        jwt: Opt[str] = None,
+        oauth_token: Opt[str] = None,
+    ) -> int:
+        """Send a GET request for a single item to the specifie endpoint and return its status code."""
+
+        try:
+            _, _, status_code = await self._make_request(
+                "GET", url, url_vars, b"", accept, jwt=jwt, oauth_token=oauth_token
+            )
+        except HTTPException as e:
+            status_code = e.status_code
+
+        return status_code
+
     async def getiter(
         self,
         url: str,
@@ -149,7 +170,7 @@ class GitHubAPI(abc.ABC):
         iterable_key: Opt[str] = ITERABLE_KEY,
     ) -> AsyncGenerator[Any, None]:
         """Return an async iterable for all the items at a specified endpoint."""
-        data, more = await self._make_request(
+        data, more, _ = await self._make_request(
             "GET", url, url_vars, b"", accept, jwt=jwt, oauth_token=oauth_token
         )
 
@@ -180,7 +201,7 @@ class GitHubAPI(abc.ABC):
         oauth_token: Opt[str] = None,
         content_type: str = JSON_CONTENT_TYPE,
     ) -> Any:
-        data, _ = await self._make_request(
+        data, _, _ = await self._make_request(
             "POST",
             url,
             url_vars,
@@ -202,7 +223,7 @@ class GitHubAPI(abc.ABC):
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
     ) -> Any:
-        data, _ = await self._make_request(
+        data, _, _ = await self._make_request(
             "PATCH", url, url_vars, data, accept, jwt=jwt, oauth_token=oauth_token
         )
         return data
@@ -217,7 +238,7 @@ class GitHubAPI(abc.ABC):
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
     ) -> Any:
-        data, _ = await self._make_request(
+        data, _, _ = await self._make_request(
             "PUT", url, url_vars, data, accept, jwt=jwt, oauth_token=oauth_token
         )
         return data


Test patch:
diff --git a/tests/test_abc.py b/tests/test_abc.py
index 3763317..51f42ed 100644
--- a/tests/test_abc.py
+++ b/tests/test_abc.py
@@ -164,7 +164,7 @@ class TestGeneralGitHubAPI:
         gh = MockGitHubAPI(
             headers=headers, body=json.dumps(original_data).encode("utf8")
         )
-        data, _ = await gh._make_request(
+        data, _, _ = await gh._make_request(
             "GET", "/rate_limit", {}, "", sansio.accept_format()
         )
         assert data == original_data
@@ -175,9 +175,22 @@ class TestGeneralGitHubAPI:
         headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
         headers["link"] = "<https://api.github.com/fake?page=2>; " 'rel="next"'
         gh = MockGitHubAPI(headers=headers)
-        _, more = await gh._make_request("GET", "/fake", {}, "", sansio.accept_format())
+        _, more, _ = await gh._make_request(
+            "GET", "/fake", {}, "", sansio.accept_format()
+        )
         assert more == "https://api.github.com/fake?page=2"
 
+    @pytest.mark.asyncio
+    async def test_status_code(self):
+        """The status code is returned appropriately."""
+        headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+        headers["link"] = "<https://api.github.com/fake?page=2>; " 'rel="next"'
+        gh = MockGitHubAPI(headers=headers)
+        _, _, status_code = await gh._make_request(
+            "GET", "/fake", {}, "", sansio.accept_format()
+        )
+        assert status_code == 200
+
 
 class TestGitHubAPIGetitem:
     @pytest.mark.asyncio
@@ -232,6 +245,38 @@ class TestGitHubAPIGetitem:
         assert str(exc_info.value) == "Cannot pass both oauth_token and jwt."
 
 
+class TestGitHubAPIGetStatus:
+    @pytest.mark.asyncio
+    async def test_getstatus(self):
+        original_status = 204
+        headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+        headers["content-type"] = "application/json; charset=UTF-8"
+        gh = MockGitHubAPI(headers=headers, status_code=original_status)
+        data = await gh.getstatus("/fake")
+        assert gh.method == "GET"
+        assert data == original_status
+
+    @pytest.mark.asyncio
+    async def test_getstatus_4xx(self):
+        original_status = 404
+        headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+        headers["content-type"] = "application/json; charset=UTF-8"
+        gh = MockGitHubAPI(headers=headers, status_code=original_status)
+        data = await gh.getstatus("/fake")
+        assert gh.method == "GET"
+        assert data == original_status
+
+    @pytest.mark.asyncio
+    async def test_getstatus_5xx(self):
+        original_status = 504
+        headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+        headers["content-type"] = "application/json; charset=UTF-8"
+        gh = MockGitHubAPI(headers=headers, status_code=original_status)
+        data = await gh.getstatus("/fake")
+        assert gh.method == "GET"
+        assert data == original_status
+
+
 class TestGitHubAPIGetiter:
     @pytest.mark.asyncio
     async def test_getiter(self):