Skip to content

Commit a7d7b68

Browse files
implement SyncWebhookView and refactor GitHubRouter for sync support (#31)
* implement `SyncWebhookView` and refactor `GitHubRouter` for sync support * add missing space back * remove spaces
1 parent 80b621b commit a7d7b68

File tree

5 files changed

+197
-53
lines changed

5 files changed

+197
-53
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Added
22+
23+
- Added `SyncWebhookView`, a synchronous counterpart to `AsyncWebhookView` for Django applications running under WSGI. Works with `SyncGitHubAPI` and synchronous event handlers to provide a fully synchronous workflow for processing GitHub webhooks.
24+
2125
### Changed
2226

2327
- `AsyncGitHubAPI` and `SyncGitHubAPI` clients can now take an instance of `Installation` using the `installation` kwarg, in addition to the previous behavior of providing the `installation_id`. One or the other must be used for authenticated requests, not both.

README.md

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ A Django toolkit providing the batteries needed to build GitHub Apps - from webh
1111

1212
Built on [gidgethub](https://github.com/gidgethub/gidgethub) and [httpx](https://github.com/encode/httpx), django-github-app handles the boilerplate of GitHub App development. Features include webhook event routing and storage, API client with automatic authentication, and models for managing GitHub App installations, repositories, and webhook event history.
1313

14-
The library primarily uses async features (following gidgethub), with sync support in active development to better integrate with the majority of Django projects.
14+
Fully supports both sync (WSGI) and async (ASGI) Django applications.
1515

1616
## Requirements
1717

@@ -51,17 +51,29 @@ The library primarily uses async features (following gidgethub), with sync suppo
5151

5252
4. Add django-github-app's webhook view to your Django project's urls.
5353

54+
For Django projects running on ASGI, use `django_github_app.views.AsyncWebhookView`:
55+
5456
```python
5557
from django.urls import path
56-
58+
5759
from django_github_app.views import AsyncWebhookView
58-
60+
5961
urlpatterns = [
6062
path("gh/", AsyncWebhookView.as_view()),
6163
]
6264
```
6365
64-
For the moment, django-github-app only provides an async webhook view. While sync support is being actively developed, the webhook view remains async-only.
66+
For traditional Django projects running on WSGI, use `django_github_app.views.SyncWebhookView`:
67+
68+
```python
69+
from django.urls import path
70+
71+
from django_github_app.views import SyncWebhookView
72+
73+
urlpatterns = [
74+
path("gh/", SyncWebhookView.as_view()),
75+
]
76+
```
6577
6678
5. Setup your GitHub App, either by registering a new one or importing an existing one, and configure django-github-app using your GitHub App's information.
6779
@@ -169,6 +181,8 @@ django-github-app provides a router-based system for handling GitHub webhook eve
169181
170182
To start handling GitHub webhooks, create your event handlers in a new file (e.g., `events.py`) within your Django app.
171183
184+
For ASGI projects using `django_github_app.views.AsyncWebhookView`:
185+
172186
```python
173187
# your_app/events.py
174188
from django_github_app.routing import GitHubRouter
@@ -204,15 +218,52 @@ async def welcome_new_issue(event, gh, *args, **kwargs):
204218
})
205219
```
206220
207-
In this example, we automatically label issues based on their title and post a welcome comment on newly opened issues. The router ensures each webhook is directed to the appropriate handler based on the event type and action.
221+
For WSGI projects using `django_github_app.views.SyncWebhookView`:
208222
209-
> [!NOTE]
210-
> Handlers must be async functions as django-github-app uses gidgethub for webhook event routing which only supports async operations. Sync support is planned to better integrate with Django projects that don't use async.
223+
```python
224+
# your_app/events.py
225+
from django_github_app.routing import GitHubRouter
226+
227+
gh = GitHubRouter()
228+
229+
# Handle any issue event
230+
@gh.event("issues")
231+
def handle_issue(event, gh, *args, **kwargs):
232+
issue = event.data["issue"]
233+
labels = []
234+
235+
# Add labels based on issue title
236+
title = issue["title"].lower()
237+
if "bug" in title:
238+
labels.append("bug")
239+
if "feature" in title:
240+
labels.append("enhancement")
241+
242+
if labels:
243+
gh.post(
244+
issue["labels_url"],
245+
data=labels
246+
)
247+
248+
# Handle specific issue actions
249+
@gh.event("issues", action="opened")
250+
def welcome_new_issue(event, gh, *args, **kwargs):
251+
"""Post a comment when a new issue is opened"""
252+
url = event.data["issue"]["comments_url"]
253+
gh.post(url, data={
254+
"body": "Thanks for opening an issue! We'll take a look soon."
255+
})
256+
```
257+
258+
> [!IMPORTANT]
259+
> Choose either async or sync handlers based on your webhook view - async handlers for `AsyncWebhookView`, sync handlers for `SyncWebhookView`. Mixing async and sync handlers is not supported.
260+
261+
In these examples, we automatically label issues based on their title and post a welcome comment on newly opened issues. The router ensures each webhook is directed to the appropriate handler based on the event type and action.
211262
212263
Each handler receives two arguments:
213264
214265
- `event`: A `gidgethub.sansio.Event` containing the webhook payload
215-
- `gh`: A GitHub API client for making API calls
266+
- `gh`: A GitHub API client for making API calls (`AsyncGitHubAPI` for async handlers, `SyncGitHubAPI` for sync handlers)
216267
217268
To activate your webhook handlers, import them in your app's `AppConfig.ready()` method, similar to how Django signals are registered.
218269

src/django_github_app/routing.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,19 @@
44
from typing import Any
55

66
from django.utils.functional import classproperty
7+
from gidgethub import sansio
78
from gidgethub.routing import AsyncCallback
89
from gidgethub.routing import Router as GidgetHubRouter
910

11+
from ._typing import override
1012

11-
class GitHubRouter:
13+
14+
class GitHubRouter(GidgetHubRouter):
1215
_routers: list[GidgetHubRouter] = []
1316

14-
def __init__(self) -> None:
15-
self.router = GidgetHubRouter()
16-
GitHubRouter._routers.append(self.router)
17+
def __init__(self, *args) -> None:
18+
super().__init__(*args)
19+
GitHubRouter._routers.append(self)
1720

1821
@classproperty
1922
def routers(cls):
@@ -23,7 +26,18 @@ def event(
2326
self, event_type: str, **kwargs: Any
2427
) -> Callable[[AsyncCallback], AsyncCallback]:
2528
def decorator(func: AsyncCallback) -> AsyncCallback:
26-
self.router.add(func, event_type, **kwargs)
29+
self.add(func, event_type, **kwargs)
2730
return func
2831

2932
return decorator
33+
34+
async def adispatch(self, event: sansio.Event, *args: Any, **kwargs: Any) -> None:
35+
found_callbacks = self.fetch(event)
36+
for callback in found_callbacks:
37+
await callback(event, *args, **kwargs)
38+
39+
@override
40+
def dispatch(self, event: sansio.Event, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
41+
found_callbacks = self.fetch(event)
42+
for callback in found_callbacks:
43+
callback(event, *args, **kwargs)

src/django_github_app/views.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from django.utils.decorators import method_decorator
1616
from django.views.decorators.csrf import csrf_exempt
1717
from django.views.generic import View
18-
from gidgethub.routing import Router as GidgetHubRouter
1918
from gidgethub.sansio import Event
2019

2120
from ._typing import override
@@ -59,8 +58,8 @@ def get_response(self, event_log: EventLog) -> JsonResponse:
5958
)
6059

6160
@property
62-
def router(self) -> GidgetHubRouter:
63-
return GidgetHubRouter(*GitHubRouter.routers)
61+
def router(self) -> GitHubRouter:
62+
return GitHubRouter(*GitHubRouter.routers)
6463

6564
@abstractmethod
6665
def post(
@@ -84,7 +83,7 @@ async def post(self, request: HttpRequest) -> JsonResponse:
8483

8584
async with self.get_github_api(installation) as gh:
8685
await gh.sleep(1)
87-
await self.router.dispatch(event, gh)
86+
await self.router.adispatch(event, gh)
8887

8988
return self.get_response(event_log)
9089

@@ -93,12 +92,6 @@ async def post(self, request: HttpRequest) -> JsonResponse:
9392
class SyncWebhookView(BaseWebhookView[SyncGitHubAPI]):
9493
github_api_class = SyncGitHubAPI
9594

96-
def __init__(self, **kwargs: Any) -> None:
97-
super().__init__(**kwargs)
98-
raise NotImplementedError(
99-
"SyncWebhookView is planned for a future release. For now, please use AsyncWebhookView with async/await."
100-
)
101-
10295
def post(self, request: HttpRequest) -> JsonResponse: # pragma: no cover
10396
event = self.get_event(request)
10497

@@ -110,6 +103,6 @@ def post(self, request: HttpRequest) -> JsonResponse: # pragma: no cover
110103

111104
with self.get_github_api(installation) as gh:
112105
time.sleep(1)
113-
self.router.dispatch(event, gh) # type: ignore
106+
self.router.dispatch(event, gh)
114107

115108
return self.get_response(event_log)

tests/test_views.py

Lines changed: 111 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,43 @@ def _make_request(
7171

7272
@pytest.fixture
7373
def test_router():
74-
router = GitHubRouter()
75-
yield router
76-
GitHubRouter._routers.remove(router.router)
74+
GitHubRouter._routers = []
75+
yield GitHubRouter()
76+
GitHubRouter._routers = []
77+
78+
79+
@pytest.fixture
80+
def aregister_webhook_event(test_router):
81+
def _make_handler(event_type, should_fail=False):
82+
data = {}
83+
84+
@test_router.event(event_type)
85+
async def handle_event(event, gh):
86+
if should_fail:
87+
pytest.fail("Should not be called")
88+
data["event"] = event
89+
data["gh"] = gh
90+
91+
return data
92+
93+
return _make_handler
94+
95+
96+
@pytest.fixture
97+
def register_webhook_event(test_router):
98+
def _make_handler(event_type, should_fail=False):
99+
data = {}
100+
101+
@test_router.event(event_type)
102+
def handle_event(event, gh):
103+
if should_fail:
104+
pytest.fail("Should not be called")
105+
data["event"] = event
106+
data["gh"] = gh
107+
108+
return data
109+
110+
return _make_handler
77111

78112

79113
class WebhookView(BaseWebhookView[GitHubAPI]):
@@ -176,14 +210,8 @@ async def test_missing_event(self, webhook_request):
176210
with pytest.raises(BadRequest):
177211
await view.post(request)
178212

179-
async def test_router_dispatch(self, test_router, webhook_request):
180-
called_with = {}
181-
182-
@test_router.event("push")
183-
async def handle_push(event, gh):
184-
called_with["event"] = event
185-
called_with["gh"] = gh
186-
213+
async def test_router_dispatch(self, aregister_webhook_event, webhook_request):
214+
webhook_data = aregister_webhook_event("push")
187215
request = webhook_request(
188216
event_type="push",
189217
body={"action": "created", "repository": {"full_name": "test/repo"}},
@@ -193,15 +221,14 @@ async def handle_push(event, gh):
193221
response = await view.post(request)
194222

195223
assert response.status_code == HTTPStatus.OK
196-
assert called_with["event"].event == "push"
197-
assert called_with["event"].data["repository"]["full_name"] == "test/repo"
198-
assert isinstance(called_with["gh"], AsyncGitHubAPI)
199-
200-
async def test_router_dispatch_unhandled_event(self, test_router, webhook_request):
201-
@test_router.event("push")
202-
async def handle_push(event, gh):
203-
pytest.fail("Should not be called")
224+
assert webhook_data["event"].event == "push"
225+
assert webhook_data["event"].data["repository"]["full_name"] == "test/repo"
226+
assert isinstance(webhook_data["gh"], AsyncGitHubAPI)
204227

228+
async def test_router_dispatch_unhandled_event(
229+
self, aregister_webhook_event, webhook_request
230+
):
231+
aregister_webhook_event("push", should_fail=True)
205232
request = webhook_request(event_type="issues", body={"action": "opened"})
206233
view = AsyncWebhookView()
207234

@@ -211,13 +238,68 @@ async def handle_push(event, gh):
211238

212239

213240
class TestSyncWebhookView:
214-
def test_not_implemented_error(self):
215-
with pytest.raises(NotImplementedError):
216-
SyncWebhookView()
217-
218-
def test_post(self, webhook_request): ...
219-
def test_csrf_exempt(self, webhook_request): ...
220-
def test_event_log_created(self, webhook_request): ...
221-
def test_event_log_cleanup(self, webhook_request): ...
222-
def test_router_dispatch(self, test_router, webhook_request): ...
223-
def test_router_dispatch_unhandled_event(self, test_router, webhook_request): ...
241+
def test_post(self, webhook_request):
242+
request = webhook_request()
243+
view = SyncWebhookView()
244+
245+
response = view.post(request)
246+
247+
assert isinstance(response, JsonResponse)
248+
assert response.status_code == HTTPStatus.OK
249+
250+
def test_csrf_exempt(self, webhook_request):
251+
request = webhook_request()
252+
view = SyncWebhookView()
253+
254+
response = view.post(request)
255+
256+
assert response.status_code != HTTPStatus.FORBIDDEN
257+
258+
def test_event_log_created(self, webhook_request):
259+
request = webhook_request()
260+
view = SyncWebhookView()
261+
262+
response = view.post(request)
263+
264+
event_id = json.loads(response.content)["event_id"]
265+
assert EventLog.objects.filter(id=event_id).count() == 1
266+
267+
def test_event_log_cleanup(self, webhook_request):
268+
request = webhook_request()
269+
view = SyncWebhookView()
270+
271+
event = baker.make(
272+
"django_github_app.EventLog",
273+
received_at=timezone.now() - datetime.timedelta(days=8),
274+
)
275+
assert EventLog.objects.filter(id=event.id).count() == 1
276+
277+
view.post(request)
278+
279+
assert EventLog.objects.filter(id=event.id).count() == 0
280+
281+
def test_router_dispatch(self, register_webhook_event, webhook_request):
282+
webhook_data = register_webhook_event("push")
283+
request = webhook_request(
284+
event_type="push",
285+
body={"action": "created", "repository": {"full_name": "test/repo"}},
286+
)
287+
view = SyncWebhookView()
288+
289+
response = view.post(request)
290+
291+
assert response.status_code == HTTPStatus.OK
292+
assert webhook_data["event"].event == "push"
293+
assert webhook_data["event"].data["repository"]["full_name"] == "test/repo"
294+
assert isinstance(webhook_data["gh"], SyncGitHubAPI)
295+
296+
def test_router_dispatch_unhandled_event(
297+
self, register_webhook_event, webhook_request
298+
):
299+
register_webhook_event("push", should_fail=True)
300+
request = webhook_request(event_type="issues", body={"action": "opened"})
301+
view = SyncWebhookView()
302+
303+
response = view.post(request)
304+
305+
assert response.status_code == HTTPStatus.OK

0 commit comments

Comments
 (0)