Skip to content

Commit 2f0fd9b

Browse files
add system check for sync/async webhook views (#33)
* add deployment check for sync/async webhook views * change to non-deploy check * remove missing function * add back stash * update readme * update changelog * reword * eh, add system back
1 parent 41a0f68 commit 2f0fd9b

File tree

8 files changed

+182
-0
lines changed

8 files changed

+182
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
2121
### Added
2222

2323
- 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+
- Added system check to prevent mixing async and sync webhook views in the same project (`django_github_app.E001`).
2425

2526
### Changed
2627

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,19 @@ issues = await repo.aget_issues(params={"state": "open"})
438438
- `owner`: Repository owner from full name
439439
- `repo`: Repository name from full name
440440
441+
### System Checks
442+
443+
The library includes Django system checks to validate your webhook configuration:
444+
445+
#### `django_github_app.E001`
446+
447+
Error raised when both `AsyncWebhookView` and `SyncWebhookView` are detected in your URL configuration. You must use either async or sync webhooks consistently throughout your project, not both.
448+
449+
To fix this error, ensure all your webhook views are of the same type:
450+
451+
- Use `AsyncWebhookView` for all webhook endpoints in ASGI projects
452+
- Use `SyncWebhookView` for all webhook endpoints in WSGI projects
453+
441454
## Configuration
442455
443456
Configuration of django-github-app is done through a `GITHUB_APP` dictionary in your Django project's `DJANGO_SETTINGS_MODULE`.

src/django_github_app/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ class GitHubAppConfig(AppConfig):
1212

1313
@override
1414
def ready(self):
15+
from . import checks # noqa: F401
1516
from .events import ahandlers # noqa: F401

src/django_github_app/checks.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from django.core.checks import Error
4+
from django.core.checks import Tags
5+
from django.core.checks import register
6+
7+
from django_github_app.views import AsyncWebhookView
8+
from django_github_app.views import get_webhook_views
9+
10+
11+
@register(Tags.urls)
12+
def check_webhook_views(app_configs, **kwargs):
13+
errors = []
14+
views = get_webhook_views()
15+
16+
if views:
17+
view_types = {
18+
"async" if issubclass(v, AsyncWebhookView) else "sync" for v in views
19+
}
20+
if len(view_types) > 1:
21+
errors.append(
22+
Error(
23+
"Multiple webhook view types detected.",
24+
hint="Use either AsyncWebhookView or SyncWebhookView, not both.",
25+
obj="django_github_app.views",
26+
id="django_github_app.E001",
27+
)
28+
)
29+
30+
return errors

src/django_github_app/views.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.core.exceptions import BadRequest
1313
from django.http import HttpRequest
1414
from django.http import JsonResponse
15+
from django.urls import get_resolver
1516
from django.utils.decorators import method_decorator
1617
from django.views.decorators.csrf import csrf_exempt
1718
from django.views.generic import View
@@ -106,3 +107,18 @@ def post(self, request: HttpRequest) -> JsonResponse: # pragma: no cover
106107
self.router.dispatch(event, gh)
107108

108109
return self.get_response(event_log)
110+
111+
112+
def get_webhook_views():
113+
resolver = get_resolver()
114+
found_views = []
115+
116+
for pattern in resolver.url_patterns:
117+
if hasattr(pattern, "callback"):
118+
callback = pattern.callback
119+
view_class = getattr(callback, "view_class", None)
120+
if view_class:
121+
if issubclass(view_class, (AsyncWebhookView, SyncWebhookView)):
122+
found_views.append(view_class)
123+
124+
return found_views

tests/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from asgiref.sync import sync_to_async
1010
from django.conf import settings
1111
from django.test import override_settings
12+
from django.urls import clear_url_caches
13+
from django.urls import path
1214
from model_bakery import baker
1315

1416
from django_github_app.conf import GITHUB_APP_SETTINGS_NAME
@@ -71,6 +73,28 @@ def _override_app_settings(**kwargs):
7173
return _override_app_settings
7274

7375

76+
@pytest.fixture
77+
def urlpatterns():
78+
@contextlib.contextmanager
79+
def _urlpatterns(views):
80+
urlpatterns = [path(f"{i}/", view.as_view()) for i, view in enumerate(views)]
81+
82+
clear_url_caches()
83+
84+
with override_settings(
85+
ROOT_URLCONF=type(
86+
"urls",
87+
(),
88+
{"urlpatterns": urlpatterns},
89+
),
90+
):
91+
yield
92+
93+
clear_url_caches()
94+
95+
return _urlpatterns
96+
97+
7498
@pytest.fixture(scope="session", autouse=True)
7599
def register_modeladmins(test_admin_site):
76100
from django_github_app.admin import EventLogModelAdmin

tests/test_checks.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from __future__ import annotations
2+
3+
from django.core.checks import Error
4+
from django.views.generic import View
5+
6+
from django_github_app.checks import check_webhook_views
7+
from django_github_app.views import AsyncWebhookView
8+
from django_github_app.views import SyncWebhookView
9+
10+
11+
class TestCheckWebhookViews:
12+
def test_async(self, urlpatterns):
13+
with urlpatterns([AsyncWebhookView]):
14+
errors = check_webhook_views(None)
15+
16+
assert not errors
17+
18+
def test_sync(self, urlpatterns):
19+
with urlpatterns([SyncWebhookView]):
20+
errors = check_webhook_views(None)
21+
22+
assert not errors
23+
24+
def test_async_multiple(self, urlpatterns):
25+
with urlpatterns([AsyncWebhookView, AsyncWebhookView]):
26+
errors = check_webhook_views(None)
27+
28+
assert not errors
29+
30+
def test_sync_multiple(self, urlpatterns):
31+
with urlpatterns([SyncWebhookView, SyncWebhookView]):
32+
errors = check_webhook_views(None)
33+
34+
assert not errors
35+
36+
def test_mixed_error(self, urlpatterns):
37+
with urlpatterns([AsyncWebhookView, SyncWebhookView]):
38+
errors = check_webhook_views(None)
39+
40+
assert len(errors) == 1
41+
42+
error = errors[0]
43+
44+
assert isinstance(error, Error)
45+
assert error.id == "django_github_app.E001"
46+
assert "Multiple webhook view types detected" in error.msg
47+
assert "Use either AsyncWebhookView or SyncWebhookView" in error.hint
48+
49+
def test_normal_view(self, urlpatterns):
50+
with urlpatterns([View]):
51+
errors = check_webhook_views(None)
52+
53+
assert not errors
54+
55+
def test_none(self, urlpatterns):
56+
with urlpatterns([]):
57+
errors = check_webhook_views(None)
58+
59+
assert not errors

tests/test_views.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.core.exceptions import BadRequest
1111
from django.http import JsonResponse
1212
from django.utils import timezone
13+
from django.views.generic import View
1314
from gidgethub import sansio
1415
from gidgethub.abc import GitHubAPI
1516
from model_bakery import baker
@@ -21,6 +22,7 @@
2122
from django_github_app.views import AsyncWebhookView
2223
from django_github_app.views import BaseWebhookView
2324
from django_github_app.views import SyncWebhookView
25+
from django_github_app.views import get_webhook_views
2426

2527
pytestmark = pytest.mark.django_db
2628

@@ -303,3 +305,39 @@ def test_router_dispatch_unhandled_event(
303305
response = view.post(request)
304306

305307
assert response.status_code == HTTPStatus.OK
308+
309+
310+
class TestProjectWebhookViews:
311+
def test_get_async(self, urlpatterns):
312+
with urlpatterns([AsyncWebhookView]):
313+
views = get_webhook_views()
314+
315+
assert len(views) == 1
316+
assert views[0] == AsyncWebhookView
317+
318+
def test_get_sync(self, urlpatterns):
319+
with urlpatterns([SyncWebhookView]):
320+
views = get_webhook_views()
321+
322+
assert len(views) == 1
323+
assert views[0] == SyncWebhookView
324+
325+
def test_get_both(self, urlpatterns):
326+
with urlpatterns([AsyncWebhookView, SyncWebhookView]):
327+
views = get_webhook_views()
328+
329+
assert len(views) == 2
330+
assert AsyncWebhookView in views
331+
assert SyncWebhookView in views
332+
333+
def test_get_normal_view(self, urlpatterns):
334+
with urlpatterns([View]):
335+
views = get_webhook_views()
336+
337+
assert len(views) == 0
338+
339+
def test_get_none(self, urlpatterns):
340+
with urlpatterns([]):
341+
views = get_webhook_views()
342+
343+
assert len(views) == 0

0 commit comments

Comments
 (0)