├── tests ├── test_project │ ├── __init__.py │ ├── urls.py │ └── settings.py └── test_django_http_debug.py ├── django_http_debug ├── migrations │ ├── __init__.py │ ├── 0004_debugendpoint_logging_enabled.py │ ├── 0003_requestlog_query_string.py │ ├── 0002_debugendpoint_content_type_debugendpoint_is_base64.py │ └── 0001_initial.py ├── __init__.py ├── middleware.py ├── views.py ├── models.py └── admin.py ├── .gitignore ├── .github └── workflows │ ├── test.yml │ └── publish.yml ├── pyproject.toml ├── README.md └── LICENSE /tests/test_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_http_debug/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_http_debug/__init__.py: -------------------------------------------------------------------------------- 1 | def example_function(): 2 | return 1 + 1 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | venv 6 | .eggs 7 | .pytest_cache 8 | *.egg-info 9 | .DS_Store 10 | dist 11 | build 12 | -------------------------------------------------------------------------------- /tests/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /django_http_debug/middleware.py: -------------------------------------------------------------------------------- 1 | from .views import debug_view 2 | 3 | 4 | class DebugMiddleware: 5 | def __init__(self, get_response): 6 | self.get_response = get_response 7 | 8 | def __call__(self, request): 9 | response = self.get_response(request) 10 | if response.status_code == 404: 11 | path = request.path.lstrip("/") 12 | debug_response = debug_view(request, path) 13 | if debug_response: 14 | return debug_response 15 | return response 16 | -------------------------------------------------------------------------------- /django_http_debug/migrations/0004_debugendpoint_logging_enabled.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-08-07 20:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("django_http_debug", "0003_requestlog_query_string"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="debugendpoint", 14 | name="logging_enabled", 15 | field=models.BooleanField(default=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /django_http_debug/migrations/0003_requestlog_query_string.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-08-07 20:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "django_http_debug", 10 | "0002_debugendpoint_content_type_debugendpoint_is_base64", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="requestlog", 17 | name="query_string", 18 | field=models.CharField(blank=True, max_length=255), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/test_project/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "django-insecure-test-key" 2 | DEBUG = True 3 | ALLOWED_HOSTS = ["*"] 4 | 5 | INSTALLED_APPS = [ 6 | "django.contrib.admin", 7 | "django.contrib.auth", 8 | "django.contrib.contenttypes", 9 | # "django.contrib.sessions", 10 | # "django.contrib.messages", 11 | # "django.contrib.staticfiles", 12 | "django_http_debug", 13 | ] 14 | 15 | MIDDLEWARE = [ 16 | "django_http_debug.middleware.DebugMiddleware", 17 | ] 18 | 19 | ROOT_URLCONF = "tests.test_project.urls" 20 | 21 | DATABASES = { 22 | "default": { 23 | "ENGINE": "django.db.backends.sqlite3", 24 | "NAME": ":memory:", 25 | } 26 | } 27 | 28 | USE_TZ = True 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | cache: pip 21 | cache-dependency-path: pyproject.toml 22 | - name: Install dependencies 23 | run: | 24 | pip install '.[test]' 25 | - name: Run tests 26 | run: | 27 | pytest 28 | 29 | -------------------------------------------------------------------------------- /django_http_debug/migrations/0002_debugendpoint_content_type_debugendpoint_is_base64.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-08-07 19:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("django_http_debug", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="debugendpoint", 14 | name="content_type", 15 | field=models.CharField(default="text/plain; charset=utf-8", max_length=64), 16 | ), 17 | migrations.AddField( 18 | model_name="debugendpoint", 19 | name="is_base64", 20 | field=models.BooleanField(default=False), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-http-debug" 3 | version = "0.2" 4 | description = "Django app for creating database-backed HTTP debug endpoints" 5 | readme = "README.md" 6 | requires-python = ">=3.8" 7 | authors = [{name = "Simon Willison"}] 8 | license = {text = "Apache-2.0"} 9 | classifiers = [ 10 | "License :: OSI Approved :: Apache Software License" 11 | ] 12 | dependencies = [ 13 | "filetype", 14 | "django" 15 | ] 16 | 17 | [build-system] 18 | requires = ["setuptools"] 19 | build-backend = "setuptools.build_meta" 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/simonw/django-http-debug" 23 | Changelog = "https://github.com/simonw/django-http-debug/releases" 24 | Issues = "https://github.com/simonw/django-http-debug/issues" 25 | CI = "https://github.com/simonw/django-http-debug/actions" 26 | 27 | 28 | [project.optional-dependencies] 29 | test = ["pytest", "pytest-django"] 30 | 31 | [tool.pytest.ini_options] 32 | DJANGO_SETTINGS_MODULE = "tests.test_project.settings" 33 | pythonpath = ["."] 34 | -------------------------------------------------------------------------------- /django_http_debug/views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from django.http import HttpResponse 3 | from django.views.decorators.csrf import csrf_exempt 4 | from .models import DebugEndpoint, RequestLog 5 | 6 | 7 | @csrf_exempt 8 | def debug_view(request, path): 9 | try: 10 | endpoint = DebugEndpoint.objects.get(path=path) 11 | except DebugEndpoint.DoesNotExist: 12 | return None # Allow normal 404 handling to continue 13 | 14 | if endpoint.logging_enabled: 15 | log_entry = RequestLog( 16 | endpoint=endpoint, 17 | method=request.method, 18 | query_string=request.META.get("QUERY_STRING", ""), 19 | headers=dict(request.headers), 20 | ) 21 | log_entry.set_body(request.body) 22 | log_entry.save() 23 | 24 | content = endpoint.content 25 | if endpoint.is_base64: 26 | content = base64.b64decode(content) 27 | 28 | response = HttpResponse( 29 | content=content, 30 | status=endpoint.status_code, 31 | content_type=endpoint.content_type, 32 | ) 33 | for key, value in endpoint.headers.items(): 34 | response[key] = value 35 | 36 | return response 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | cache: pip 23 | cache-dependency-path: pyproject.toml 24 | - name: Install dependencies 25 | run: | 26 | pip install '.[test]' 27 | - name: Run tests 28 | run: | 29 | pytest 30 | deploy: 31 | runs-on: ubuntu-latest 32 | needs: [test] 33 | environment: release 34 | permissions: 35 | id-token: write 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Set up Python 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: "3.12" 42 | cache: pip 43 | cache-dependency-path: pyproject.toml 44 | - name: Install dependencies 45 | run: | 46 | pip install setuptools wheel build 47 | - name: Build 48 | run: | 49 | python -m build 50 | - name: Publish 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | 53 | -------------------------------------------------------------------------------- /django_http_debug/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | import base64 3 | 4 | 5 | class DebugEndpoint(models.Model): 6 | path = models.CharField(max_length=255, unique=True) 7 | status_code = models.IntegerField(default=200) 8 | content_type = models.CharField(max_length=64, default="text/plain; charset=utf-8") 9 | headers = models.JSONField(default=dict, blank=True) 10 | content = models.TextField(blank=True) 11 | is_base64 = models.BooleanField(default=False) 12 | logging_enabled = models.BooleanField(default=True) 13 | 14 | def __str__(self): 15 | return self.path 16 | 17 | def get_absolute_url(self): 18 | return f"/{self.path}" 19 | 20 | 21 | class RequestLog(models.Model): 22 | endpoint = models.ForeignKey(DebugEndpoint, on_delete=models.CASCADE) 23 | method = models.CharField(max_length=10) 24 | query_string = models.CharField(max_length=255, blank=True) 25 | headers = models.JSONField() 26 | body = models.TextField(blank=True) 27 | is_base64 = models.BooleanField(default=False) 28 | timestamp = models.DateTimeField(auto_now_add=True) 29 | 30 | def __str__(self): 31 | return f"{self.method} {self.endpoint.path} at {self.timestamp}" 32 | 33 | def set_body(self, body): 34 | try: 35 | # Try to decode as UTF-8 36 | self.body = body.decode("utf-8") 37 | self.is_base64 = False 38 | except UnicodeDecodeError: 39 | # If that fails, store as base64 40 | self.body = base64.b64encode(body).decode("ascii") 41 | self.is_base64 = True 42 | 43 | def get_body(self): 44 | if self.is_base64: 45 | return base64.b64decode(self.body.encode("ascii")) 46 | return self.body 47 | -------------------------------------------------------------------------------- /django_http_debug/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-08-07 18:12 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="DebugEndpoint", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("path", models.CharField(max_length=255, unique=True)), 26 | ("status_code", models.IntegerField(default=200)), 27 | ("headers", models.JSONField(blank=True, default=dict)), 28 | ("content", models.TextField(blank=True)), 29 | ], 30 | ), 31 | migrations.CreateModel( 32 | name="RequestLog", 33 | fields=[ 34 | ( 35 | "id", 36 | models.AutoField( 37 | auto_created=True, 38 | primary_key=True, 39 | serialize=False, 40 | verbose_name="ID", 41 | ), 42 | ), 43 | ("method", models.CharField(max_length=10)), 44 | ("headers", models.JSONField()), 45 | ("body", models.TextField(blank=True)), 46 | ("is_base64", models.BooleanField(default=False)), 47 | ("timestamp", models.DateTimeField(auto_now_add=True)), 48 | ( 49 | "endpoint", 50 | models.ForeignKey( 51 | on_delete=django.db.models.deletion.CASCADE, 52 | to="django_http_debug.debugendpoint", 53 | ), 54 | ), 55 | ], 56 | ), 57 | ] 58 | -------------------------------------------------------------------------------- /tests/test_django_http_debug.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django_http_debug.models import DebugEndpoint, RequestLog 3 | from django.test.client import Client 4 | 5 | 6 | @pytest.mark.django_db 7 | def test_debug_view(): 8 | assert Client().get("/test/endpoint").status_code == 404 9 | DebugEndpoint.objects.create( 10 | path="test/endpoint", 11 | status_code=200, 12 | content="Test content", 13 | content_type="text/plain", 14 | ) 15 | response = Client().get("/test/endpoint") 16 | assert response.status_code == 200 17 | assert response.content == b"Test content" 18 | assert response["Content-Type"] == "text/plain" 19 | 20 | 21 | @pytest.mark.django_db 22 | def test_request_logging(): 23 | endpoint = DebugEndpoint.objects.create( 24 | path="test/log", 25 | status_code=200, 26 | content="Log test", 27 | ) 28 | assert endpoint.requestlog_set.count() == 0 29 | 30 | Client().get("/test/log?param=value") 31 | 32 | log = RequestLog.objects.filter(endpoint=endpoint).first() 33 | assert log is not None 34 | assert log.method == "GET" 35 | assert log.query_string == "param=value" 36 | 37 | 38 | @pytest.mark.django_db 39 | def test_logging_disabled(): 40 | DebugEndpoint.objects.create( 41 | path="test/nolog", 42 | status_code=200, 43 | content="No log test", 44 | logging_enabled=False, 45 | ) 46 | 47 | assert Client().get("/test/nolog").status_code == 200 48 | 49 | assert RequestLog.objects.count() == 0 50 | 51 | 52 | @pytest.mark.django_db 53 | def test_base64_content(): 54 | import base64 55 | 56 | content = base64.b64encode(b"Binary content").decode() 57 | DebugEndpoint.objects.create( 58 | path="test/binary", 59 | status_code=200, 60 | content=content, 61 | is_base64=True, 62 | content_type="application/octet-stream", 63 | ) 64 | 65 | response = Client().get("/test/binary") 66 | assert response.status_code == 200 67 | assert response.content == b"Binary content" 68 | assert response["Content-Type"] == "application/octet-stream" 69 | 70 | 71 | @pytest.mark.django_db 72 | def test_custom_headers(): 73 | DebugEndpoint.objects.create( 74 | path="test/headers", 75 | status_code=200, 76 | content="Custom headers test", 77 | headers={"X-Custom-Header": "Test Value"}, 78 | ) 79 | 80 | response = Client().get("/test/headers") 81 | assert response.status_code == 200 82 | assert response["X-Custom-Header"] == "Test Value" 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-http-debug 2 | 3 | [](https://pypi.org/project/django-http-debug/) 4 | [](https://github.com/simonw/django-http-debug/actions/workflows/test.yml) 5 | [](https://github.com/simonw/django-http-debug/releases) 6 | [](https://github.com/simonw/django-http-debug/blob/main/LICENSE) 7 | 8 | Django app for creating database-backed HTTP debug endpoints 9 | 10 | Background on this project: [django-http-debug, a new Django app mostly written by Claude](https://simonwillison.net/2024/Aug/8/django-http-debug/) 11 | 12 | ## Installation 13 | 14 | Install this library using `pip`: 15 | ```bash 16 | pip install django-http-debug 17 | ``` 18 | ## Configuration 19 | 20 | Once installed in the same environment as your Django application, add the following to `INSTALLED_APPS` in your Django settings: 21 | ```python 22 | INSTALLED_APPS = [ 23 | # ... 24 | 'django_http_debug', 25 | # ... 26 | ] 27 | ``` 28 | And add this to `MIDDLEWARE`: 29 | ```python 30 | MIDDLEWARE = [ 31 | # ... 32 | "django_http_debug.middleware.DebugMiddleware", 33 | # ... 34 | ] 35 | ``` 36 | Then run `./manage.py migrate` to create the necessary database tables. 37 | 38 | ## Usage 39 | 40 | You can configure new endpoints in the Django admin. These will only work if they are for URLs that are not yet being served by the rest of your application. 41 | 42 | Give an endpoint a path (starting without a `/`) such as: 43 | 44 | webhooks/debug/ 45 | 46 | You can optionally configure the returned body or HTTP headers here too. 47 | 48 | If you want to return a binary body - a GIF for example - you can set that endpoint to use Base64 encoding and then paste a base64-encoded string into the body field. 49 | 50 | On macOS you can create base64 strings like this: 51 | ```bash 52 | base64 -i pixel.gif -o - 53 | ``` 54 | Any HTTP requests made to `/webhooks/debug/` will be logged in the database. You can view these requests in the Django admin. 55 | 56 | You can turn off the "Logging enabled" option on an endpoint to stop logging requests to it to the database. 57 | 58 | ## Development 59 | 60 | To contribute to this library, first checkout the code. Then create a new virtual environment: 61 | ```bash 62 | cd django-http-debug 63 | python -m venv venv 64 | source venv/bin/activate 65 | ``` 66 | Now install the dependencies and test dependencies: 67 | ```bash 68 | pip install -e '.[test]' 69 | ``` 70 | To run the tests: 71 | ```bash 72 | pytest 73 | ``` 74 | -------------------------------------------------------------------------------- /django_http_debug/admin.py: -------------------------------------------------------------------------------- 1 | import re 2 | import filetype 3 | from django.contrib import admin 4 | from django.utils.html import format_html 5 | from django.utils.safestring import mark_safe 6 | from .models import DebugEndpoint, RequestLog 7 | 8 | sequence_re = re.compile(r"((?:\\x[0-9a-f]{2})+)") 9 | octet_re = re.compile(r"(\\x[0-9a-f]{2})") 10 | 11 | QUERY_STRING_TRUNCATE = 16 12 | 13 | 14 | @admin.register(DebugEndpoint) 15 | class DebugEndpointAdmin(admin.ModelAdmin): 16 | list_display = ("path", "status_code", "logging_enabled") 17 | list_filter = ("logging_enabled",) 18 | 19 | 20 | @admin.register(RequestLog) 21 | class RequestLogAdmin(admin.ModelAdmin): 22 | list_display = ( 23 | "timestamp", 24 | "endpoint", 25 | "method", 26 | "query_string_truncated", 27 | "body_preview", 28 | "is_base64", 29 | ) 30 | list_filter = ("endpoint", "method", "is_base64") 31 | readonly_fields = ( 32 | "endpoint", 33 | "query_string", 34 | "method", 35 | "headers", 36 | "body", 37 | "body_display", 38 | "is_base64", 39 | "timestamp", 40 | ) 41 | 42 | def has_add_permission(self, request): 43 | return False 44 | 45 | def has_change_permission(self, request, obj=None): 46 | return False 47 | 48 | def query_string_truncated(self, obj): 49 | return obj.query_string[:QUERY_STRING_TRUNCATE] + ( 50 | "…" if len(obj.query_string) > QUERY_STRING_TRUNCATE else "" 51 | ) 52 | 53 | query_string_truncated.short_description = "Query string" 54 | 55 | def body_preview(self, obj): 56 | body = obj.get_body() 57 | if isinstance(body, bytes): 58 | return f"Binary data ({len(body)} bytes)" 59 | return body[:50] + ("..." if len(body) > 50 else "") 60 | 61 | body_preview.short_description = "Body preview" 62 | 63 | def body_display(self, obj): 64 | body = obj.get_body() 65 | if not isinstance(body, bytes): 66 | return format_html("
{}", body)
67 |
68 | # Attempt to guess filetype
69 | suggestion = None
70 | match = filetype.guess(body[:1000])
71 | if match:
72 | suggestion = "{} ({})".format(match.extension, match.mime)
73 |
74 | encoded = repr(body)
75 | # Ditch the b' and trailing '
76 | if encoded.startswith("b'") and encoded.endswith("'"):
77 | encoded = encoded[2:-1]
78 |
79 | # Split it into sequences of octets and characters
80 | chunks = sequence_re.split(encoded)
81 | html = []
82 | if suggestion:
83 | html.append(
84 | 'Suggestion: {}
'.format( 85 | suggestion 86 | ) 87 | ) 88 | for chunk in chunks: 89 | if sequence_re.match(chunk): 90 | octets = octet_re.findall(chunk) 91 | octets = [o[2:] for o in octets] 92 | html.append( 93 | '{}'.format(
94 | " ".join(octets).upper()
95 | )
96 | )
97 | else:
98 | html.append(chunk.replace("\\\\", "\\"))
99 |
100 | return mark_safe(" ".join(html).strip().replace("\\r\\n", "