Repository: brettcannon/gidgethub (python)

Issue:
Add `extra_headers` option to HTTP methods in GitHubAPI

**Problem:** Currently, there is no straightforward way to pass custom headers when making requests with the `GitHubAPI` class. This limits the ability to use certain API features or endpoints that require specific headers to be set (such as [these](https://github.com/PyGithub/PyGithub/blob/300c5015d41236eb410b9cbe895258294e3500af/github/Consts.py#L58-L133)).

**Proposed solution:** I propose adding an `extra_headers` parameter to the methods in the `GitHubAPI` class. This optional parameter would accept a dictionary of custom headers that can be passed when making requests. This would allow users to easily set custom headers for their requests as needed.

For example, to fetch stargazers with their corresponding dates:

```python
async def get_stargazers_with_dates(full_repo_name):
    headers = {"Accept": "application/vnd.github.v3.star+json"}
    async with httpx.AsyncClient() as client:
        gh = gidgethub.httpx.GitHubAPI(client, "basnijholt", oauth_token=token)
        repo_owner, repo_name = full_repo_name.split("/")
        stats_contributors = await gh.getitem(
            f"/repos/{repo_owner}/{repo_name}/stargazers", extra_headers=headers
        )
        return stats_contributors

await get_stargazers_with_dates("basnijholt/adaptive-lighting")
```

**Implementation:** I have opened a PR (#192) with the proposed changes.

Pull request:
Add extra_headers option to get methods
This allows to do for example:

```python
async def get_stargazers_with_dates(full_repo_name):
    headers = {"Accept": "application/vnd.github.v3.star+json"}
    async with httpx.AsyncClient() as client:
        gh = gidgethub.httpx.GitHubAPI(client, "basnijholt", oauth_token=token)
        repo_owner, repo_name = full_repo_name.split("/")
        stats_contributors = await gh.getitem(
            f"/repos/{repo_owner}/{repo_name}/stargazers", extra_headers=headers
        )
        return stats_contributors

await get_stargazers_with_dates("basnijholt/adaptive-lighting")
```

Some more extra headers folks might want to use:

https://github.com/PyGithub/PyGithub/blob/300c5015d41236eb410b9cbe895258294e3500af/github/Consts.py#L58-L133

Closes #193 

Patch:
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 8519db0..969ca97 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -9,8 +9,11 @@ version: 2
 sphinx:
    configuration: docs/conf.py
 
-# Optionally set the version of Python and requirements required to build your docs
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.11"
+
 python:
-   version: 3.7
    install:
-   - requirements: docs_requirements.txt
\ No newline at end of file
+      - requirements: docs_requirements.txt
diff --git a/docs/abc.rst b/docs/abc.rst
index 6d771c3..4c0f79a 100644
--- a/docs/abc.rst
+++ b/docs/abc.rst
@@ -48,7 +48,9 @@ experimental APIs without issue.
     methods that send data to GitHub, there is a *data* argument which
     accepts an object which can be serialized to JSON (because
     ``None`` is a legitimate JSON value, ``""`` is used to represent
-    no data).
+    no data). The *extra_headers* argument optionally is ``dict[str, str]``,
+    and allows passing extra headers to the request specifying extra
+    options that the GitHub API allows.
 
     The returned value for GitHub requests is the decoded body of the
     response according to :func:`gidgethub.sansio.decipher_response`.
@@ -119,7 +121,7 @@ experimental APIs without issue.
             Renamed from ``_sleep()``.
 
 
-    .. py:method:: getitem(url, url_vars={}, *, accept=sansio.accept_format(), jwt=None, oauth_token=None)
+    .. py:method:: getitem(url, url_vars={}, *, accept=sansio.accept_format(), jwt=None, oauth_token=None, extra_headers=None)
         :async:
 
         Get a single item from GitHub.
@@ -162,7 +164,7 @@ experimental APIs without issue.
             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")
+    .. py:method:: getiter(url, url_vars={}, *, accept=sansio.accept_format(), jwt=None, oauth_token=None, iterable_key="items", extra_headers=None)
         :async:
 
         Get all items from a GitHub API endpoint.
@@ -203,7 +205,7 @@ experimental APIs without issue.
             :meth:`getitem`.
 
 
-    .. py:method:: post(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None, content_type="application/json")
+    .. py:method:: post(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None, content_type="application/json", extra_headers=None)
         :async:
 
         Send a ``POST`` request to GitHub.
@@ -237,7 +239,7 @@ experimental APIs without issue.
             Added *jwt* and *oauth_token*.
 
 
-    .. py:method:: patch(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None)
+    .. py:method:: patch(url, url_vars={}, *, data, accept=sansio.accept_format(), jwt=None, oauth_token=None, extra_headers=None)
         :async:
 
         Send a ``PATCH`` request to GitHub.
@@ -257,7 +259,7 @@ experimental APIs without issue.
             Added *jwt* and *oauth_token*.
 
 
-    .. py:method:: put(url, url_vars={}, *, data=b"", accept=sansio.accept_format(), jwt=None, oauth_token=None)
+    .. py:method:: put(url, url_vars={}, *, data=b"", accept=sansio.accept_format(), jwt=None, oauth_token=None, extra_headers=None)
         :async:
 
         Send a ``PUT`` request to GitHub.
@@ -281,7 +283,7 @@ experimental APIs without issue.
             Added *jwt* and *oauth_token*.
 
 
-    .. py:method:: delete(url, url_vars={}, *, data=b"", accept=sansio.accept_format(), jwt=None, oauth_token=None)
+    .. py:method:: delete(url, url_vars={}, *, data=b"", accept=sansio.accept_format(), jwt=None, oauth_token=None, extra_headers=None)
         :async:
 
         Send a ``DELETE`` request to GitHub.
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 47564c1..e6100f3 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,12 @@ Changelog
 5.3.0
 -----
 
+- Add extra_headers option to HTTP methods in GitHubAPI
+  (`Issue #193 <https://github.com/brettcannon/gidgethub/pull/193>_`)
+
+- Add support passing ``extra_headers`` when making requests
+  (`PR #192 <https://github.com/brettcannon/gidgethub/pull/192>_`)
+
 - Add a getstatus() method for APIs that do not return content.
   (`PR #194 <https://github.com/brettcannon/gidgethub/pull/194>_`)
 
diff --git a/gidgethub/abc.py b/gidgethub/abc.py
index b80314d..e15d9f6 100644
--- a/gidgethub/abc.py
+++ b/gidgethub/abc.py
@@ -66,6 +66,7 @@ class GitHubAPI(abc.ABC):
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
         content_type: str = JSON_CONTENT_TYPE,
+        extra_headers: Optional[Dict[str, str]] = None,
     ) -> Tuple[bytes, Opt[str], int]:
         """Construct and make an HTTP request."""
         if oauth_token is not None and jwt is not None:
@@ -84,6 +85,8 @@ class GitHubAPI(abc.ABC):
             request_headers = sansio.create_headers(
                 self.requester, accept=accept, oauth_token=self.oauth_token
             )
+        if extra_headers is not None:
+            request_headers.update(extra_headers)
         cached = cacheable = False
         # Can't use None as a "no body" sentinel as it's a legitimate JSON type.
         if data == b"":
@@ -131,11 +134,19 @@ class GitHubAPI(abc.ABC):
         accept: str = sansio.accept_format(),
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
+        extra_headers: Optional[Dict[str, str]] = None,
     ) -> Any:
         """Send a GET request for a single item to the specified endpoint."""
 
         data, _, _ = await self._make_request(
-            "GET", url, url_vars, b"", accept, jwt=jwt, oauth_token=oauth_token
+            "GET",
+            url,
+            url_vars,
+            b"",
+            accept,
+            jwt=jwt,
+            oauth_token=oauth_token,
+            extra_headers=extra_headers,
         )
         return data
 
@@ -167,11 +178,19 @@ class GitHubAPI(abc.ABC):
         accept: str = sansio.accept_format(),
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
+        extra_headers: Optional[Dict[str, str]] = None,
         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(
-            "GET", url, url_vars, b"", accept, jwt=jwt, oauth_token=oauth_token
+            "GET",
+            url,
+            url_vars,
+            b"",
+            accept,
+            jwt=jwt,
+            oauth_token=oauth_token,
+            extra_headers=extra_headers,
         )
 
         if isinstance(data, dict) and iterable_key in data:
@@ -187,6 +206,7 @@ class GitHubAPI(abc.ABC):
                 jwt=jwt,
                 oauth_token=oauth_token,
                 iterable_key=iterable_key,
+                extra_headers=extra_headers,
             ):
                 yield item  # pragma: nocover
 
@@ -199,6 +219,7 @@ class GitHubAPI(abc.ABC):
         accept: str = sansio.accept_format(),
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
+        extra_headers: Optional[Dict[str, str]] = None,
         content_type: str = JSON_CONTENT_TYPE,
     ) -> Any:
         data, _, _ = await self._make_request(
@@ -210,6 +231,7 @@ class GitHubAPI(abc.ABC):
             jwt=jwt,
             oauth_token=oauth_token,
             content_type=content_type,
+            extra_headers=extra_headers,
         )
         return data
 
@@ -222,9 +244,17 @@ class GitHubAPI(abc.ABC):
         accept: str = sansio.accept_format(),
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
+        extra_headers: Optional[Dict[str, str]] = None,
     ) -> Any:
         data, _, _ = await self._make_request(
-            "PATCH", url, url_vars, data, accept, jwt=jwt, oauth_token=oauth_token
+            "PATCH",
+            url,
+            url_vars,
+            data,
+            accept,
+            jwt=jwt,
+            oauth_token=oauth_token,
+            extra_headers=extra_headers,
         )
         return data
 
@@ -237,9 +267,17 @@ class GitHubAPI(abc.ABC):
         accept: str = sansio.accept_format(),
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
+        extra_headers: Optional[Dict[str, str]] = None,
     ) -> Any:
         data, _, _ = await self._make_request(
-            "PUT", url, url_vars, data, accept, jwt=jwt, oauth_token=oauth_token
+            "PUT",
+            url,
+            url_vars,
+            data,
+            accept,
+            jwt=jwt,
+            oauth_token=oauth_token,
+            extra_headers=extra_headers,
         )
         return data
 
@@ -252,9 +290,17 @@ class GitHubAPI(abc.ABC):
         accept: str = sansio.accept_format(),
         jwt: Opt[str] = None,
         oauth_token: Opt[str] = None,
+        extra_headers: Optional[Dict[str, str]] = None,
     ) -> None:
         await self._make_request(
-            "DELETE", url, url_vars, data, accept, jwt=jwt, oauth_token=oauth_token
+            "DELETE",
+            url,
+            url_vars,
+            data,
+            accept,
+            jwt=jwt,
+            oauth_token=oauth_token,
+            extra_headers=extra_headers,
         )
 
     async def graphql(


Test patch:
diff --git a/tests/test_abc.py b/tests/test_abc.py
index 51f42ed..c3b7b64 100644
--- a/tests/test_abc.py
+++ b/tests/test_abc.py
@@ -300,6 +300,31 @@ class TestGitHubAPIGetiter:
         assert data[2] == 1
         assert data[3] == 2
 
+    @pytest.mark.asyncio
+    async def test_getiter_with_extra_headers(self):
+        """Test that getiter() sends extra headers correctly."""
+        original_data = [1, 2]
+        next_url = "https://api.github.com/fake{/extra}?page=2"
+        headers = MockGitHubAPI.DEFAULT_HEADERS.copy()
+        headers["content-type"] = "application/json; charset=UTF-8"
+        headers["link"] = f'<{next_url}>; rel="next"'
+        gh = MockGitHubAPI(
+            headers=headers, body=json.dumps(original_data).encode("utf8")
+        )
+        extra_headers = {"X-Custom-Header": "custom_value"}
+        data = []
+        async for item in gh.getiter(
+            "/fake", {"extra": "stuff"}, extra_headers=extra_headers
+        ):
+            data.append(item)
+        assert gh.method == "GET"
+        assert gh.headers["X-Custom-Header"] == "custom_value"
+        assert len(data) == 4
+        assert data[0] == 1
+        assert data[1] == 2
+        assert data[2] == 1
+        assert data[3] == 2
+
     @pytest.mark.asyncio
     async def test_with_passed_jwt(self):
         original_data = [1, 2]
@@ -401,6 +426,20 @@ class TestGitHubAPIGetiter:
         assert data[2] == 1
         assert data[3] == 2
 
+    @pytest.mark.asyncio
+    async def test_extra_headers(self):
+        """Test that extra headers are passed correctly."""
+        accept = sansio.accept_format()
+        extra_headers = {"X-Custom-Header": "custom_value"}
+        gh = MockGitHubAPI()
+        await gh._make_request(
+            "GET", "/rate_limit", {}, "", accept, extra_headers=extra_headers
+        )
+        assert gh.headers["user-agent"] == "test_abc"
+        assert gh.headers["accept"] == accept
+        assert "X-Custom-Header" in gh.headers
+        assert gh.headers["X-Custom-Header"] == "custom_value"
+
 
 class TestGitHubAPIPost:
     @pytest.mark.asyncio