Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/django-cf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Use Cloudflare D1, a serverless SQL database, as your Django application's datab
from app.wsgi import application

class Default(DjangoCF, WorkerEntrypoint):
async def get_app(self):
def get_app(self):
return application
```

Expand Down
78 changes: 6 additions & 72 deletions packages/django-cf/django_cf/__init__.py
Original file line number Diff line number Diff line change
@@ -1,86 +1,20 @@
import os
from io import BytesIO

from workers import wsgi

async def handle_wsgi(request, app):
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "false")
from js import URL, Object, Response, console

url = URL.new(request.url)
assert url.protocol[-1] == ":"
scheme = url.protocol[:-1]
path = url.pathname
assert "?".startswith(url.search[0:1])
query_string = url.search[1:]
method = str(request.method).upper()

host = url.host.split(":")[0]

wsgi_request = {
"REQUEST_METHOD": method,
"PATH_INFO": path,
"QUERY_STRING": query_string,
"SERVER_NAME": host,
"SERVER_PORT": url.port,
"SERVER_PROTOCOL": "HTTP/1.1",
"wsgi.input": BytesIO(b""),
"wsgi.errors": console.error,
"wsgi.version": (1, 0),
"wsgi.multithread": False,
"wsgi.multiprocess": False,
"wsgi.run_once": True,
"wsgi.url_scheme": scheme,
}

if request.headers.get("content-type"):
wsgi_request["CONTENT_TYPE"] = request.headers.get("content-type")

if request.headers.get("content-length"):
wsgi_request["CONTENT_LENGTH"] = request.headers.get("content-length")

for header in request.headers.items():
wsgi_request[f"HTTP_{header[0].upper().replace('-', '_')}"] = header[1]

if method in ["POST", "PUT", "PATCH"]:
body = (await request._js_request.arrayBuffer()).to_bytes()
wsgi_request["wsgi.input"] = BytesIO(body)

def start_response(status_str, response_headers):
nonlocal status, headers
status = status_str
headers = response_headers

try:
resp = app(wsgi_request, start_response)
except Exception as exc:
# library should always print or console log the exception, because a production django should not show end users errors
print("Caught exception while loading application:", exc.__str__())
print(exc)

raise exc

status = resp.status_code
headers = resp.headers

final_response = Response.new(
resp.content.decode("utf-8"),
headers=Object.fromEntries(headers.items()),
status=status,
)

for v in resp.cookies.values():
value = str(v)
final_response.headers.set("Set-Cookie", value.replace("Set-Cookie: ", "", 1))
async def handle_wsgi(request, app, env=None):
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "false")

return final_response
return await wsgi.fetch(app, request, env)


class DjangoCF:
def get_app(self):
raise NotImplementedError("Please implement get_app in your django_cf worker")

async def fetch(self, request):
return await handle_wsgi(request, self.get_app())
return await handle_wsgi(request, self.get_app(), self.env)


class DjangoCFDurableObject:
Expand All @@ -96,4 +30,4 @@ def __init__(self, ctx, env):
set_storage(self.ctx.storage.sql)

def fetch(self, request):
return handle_wsgi(request, self.get_app())
return handle_wsgi(request, self.get_app(), self.env)
1 change: 1 addition & 0 deletions packages/django-cf/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ authors = [
]
dependencies = [
'sqlparse',
'workers-runtime-sdk>=1.6.0',
]
description = "django-cf is a package that integrates Django with Cloudflare products"
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion packages/django-cf/templates/d1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ template-root/
from django_cf import DjangoCF

class Default(DjangoCF, WorkerEntrypoint):
async def get_app(self):
def get_app(self):
from app.wsgi import application
return application
```
Expand Down
13 changes: 2 additions & 11 deletions packages/django-cf/tests/e2e/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
They require running Cloudflare Workers via wrangler.
"""

import pytest
import requests


Expand Down Expand Up @@ -161,16 +160,8 @@ def test_directory_operations_workflow(self, r2_web_server):
assert "file1.txt" in result["files"]
assert "file2.txt" in result["files"]

@pytest.mark.xfail(
reason="Framework limitation: django_cf/__init__.py:58 calls resp.content.decode('utf-8') "
"which fails for binary content. See handle_wsgi function."
)
def test_binary_file_workflow(self, r2_web_server):
"""Test binary file upload and download.

NOTE: This test documents a known framework limitation where binary
responses fail because handle_wsgi() tries to decode content as UTF-8.
"""
"""Test binary file upload and download."""
base_url = r2_web_server.base_url
test_path = "binary_test/image.bin"

Expand All @@ -184,7 +175,7 @@ def test_binary_file_workflow(self, r2_web_server):
response = requests.post(upload_url, files=files, data=data, timeout=10)
assert response.status_code == 200

# Download and verify - this fails due to UTF-8 decode in handle_wsgi
# Download and verify
download_url = f"{base_url}/__r2_download__/"
response = requests.get(download_url, params={"path": test_path}, timeout=10)
assert response.status_code == 200
Expand Down
66 changes: 61 additions & 5 deletions packages/django-cf/tests/in_worker/test_in_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ def compat_config(request: pytest.FixtureRequest) -> CompatConfig:
register_in_worker_suites(globals(), IN_WORKER_SRC_DIR)


def test_wsgi_header_transformation(in_worker_server: str) -> None:
def test_django_wsgi_header_transformation(in_worker_server: str) -> None:
response = requests.get(
f"{in_worker_server}/wsgi/headers",
f"{in_worker_server}/django/headers/",
headers={
"cf-access-jwt-assertion": "jwt-token",
"x-custom-header": "custom-value",
Expand All @@ -50,13 +50,69 @@ def test_wsgi_header_transformation(in_worker_server: str) -> None:
assert payload["content_type"] == "text/plain"


def test_wsgi_reads_request_body(in_worker_server: str) -> None:
def test_django_wsgi_reads_post_request_body(in_worker_server: str) -> None:
response = requests.post(
f"{in_worker_server}/wsgi/body",
f"{in_worker_server}/django/body/",
headers={"content-type": "text/plain"},
data=b"request-body",
timeout=10,
)

assert response.status_code == 200
assert response.text == "request-body"
assert response.content == b"request-body"


def test_django_wsgi_preserves_binary_response(in_worker_server: str) -> None:
response = requests.get(f"{in_worker_server}/django/binary/", timeout=10)

assert response.status_code == 200
assert response.headers["content-type"] == "application/octet-stream"
assert response.content == bytes(range(256))


def test_django_wsgi_streams_response(in_worker_server: str) -> None:
response = requests.get(
f"{in_worker_server}/django/stream/", stream=True, timeout=10
)

assert response.status_code == 200
assert "content-length" not in response.headers
assert response.content == b"".join(bytes([value]) * 1024 for value in range(5))


def test_django_wsgi_preserves_multiple_cookies(in_worker_server: str) -> None:
response = requests.get(f"{in_worker_server}/django/cookies/", timeout=10)

assert response.status_code == 200
assert len(response.raw.headers.getlist("set-cookie")) == 2
assert response.cookies["first"] == "1"
assert response.cookies["second"] == "2"


def test_django_wsgi_builds_request_metadata(in_worker_server: str) -> None:
response = requests.get(
f"{in_worker_server}/django/meta/%E6%9D%B1%E4%BA%AC/",
params=[("value", "first"), ("value", "second")],
timeout=10,
)

assert response.status_code == 200
assert response.json() == {
"path": "/django/meta/東京/",
"segment": "東京",
"values": ["first", "second"],
"has_env": True,
"has_bucket": True,
}


def test_django_wsgi_reads_delete_request_body(in_worker_server: str) -> None:
response = requests.delete(
f"{in_worker_server}/django/body/",
data=b"\x00\xffrequest-body",
timeout=10,
)

assert response.status_code == 200
assert response.headers["x-request-method"] == "DELETE"
assert response.content == b"\x00\xffrequest-body"
91 changes: 71 additions & 20 deletions packages/django-cf/tests/in_worker/worker/src/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import contextlib
import functools
import importlib.util
import io
import os
Expand All @@ -11,7 +12,8 @@
import django
import django.conf
import pytest
from django.http import HttpResponse, JsonResponse
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
from django.urls import path
from pyodide.webloop import WebLoop
from worker_durable_object import TestDurableObject # noqa: F401
from workers import Response, WorkerEntrypoint
Expand Down Expand Up @@ -69,25 +71,73 @@ async def _noop(*args):

django.setup()

from django_cf import handle_wsgi # noqa: E402
from django_cf import DjangoCF # noqa: E402

urlpatterns = []

@functools.cache
def _django_wsgi_app():
from django.core.wsgi import get_wsgi_application

def _wsgi_header_echo_app(environ, start_response):
payload = {
"cf_access": environ.get("HTTP_CF_ACCESS_JWT_ASSERTION"),
"custom": environ.get("HTTP_X_CUSTOM_HEADER"),
"content_type": environ.get("CONTENT_TYPE"),
"content_length": environ.get("CONTENT_LENGTH"),
}
return JsonResponse(payload)
return get_wsgi_application()


def _wsgi_body_echo_app(environ, start_response):
length = int(environ.get("CONTENT_LENGTH") or 0)
body = environ["wsgi.input"].read(length)
return HttpResponse(body, content_type="application/octet-stream")
def _django_binary_view(request):
return HttpResponse(bytes(range(256)), content_type="application/octet-stream")


def _django_streaming_view(request):
def chunks():
for value in range(5):
yield bytes([value]) * 1024

return StreamingHttpResponse(chunks(), content_type="application/octet-stream")


def _django_cookies_view(request):
response = HttpResponse(b"cookies", content_type="text/plain")
response.set_cookie("first", "1")
response.set_cookie("second", "2")
return response


def _django_meta_view(request, segment):
env = request.META.get("workers.env")
return JsonResponse(
{
"path": request.path_info,
"segment": segment,
"values": request.GET.getlist("value"),
"has_env": env is not None,
"has_bucket": hasattr(env, "BUCKET"),
}
)


def _django_body_view(request):
response = HttpResponse(request.body, content_type="application/octet-stream")
response["X-Request-Method"] = request.method
return response


def _django_headers_view(request):
return JsonResponse(
{
"cf_access": request.META.get("HTTP_CF_ACCESS_JWT_ASSERTION"),
"custom": request.META.get("HTTP_X_CUSTOM_HEADER"),
"content_type": request.META.get("CONTENT_TYPE"),
"content_length": request.META.get("CONTENT_LENGTH"),
}
)


urlpatterns = [
path("django/binary/", _django_binary_view),
path("django/stream/", _django_streaming_view),
path("django/cookies/", _django_cookies_view),
path("django/meta/<str:segment>/", _django_meta_view),
path("django/body/", _django_body_view),
path("django/headers/", _django_headers_view),
]


class ResultCollector:
Expand Down Expand Up @@ -154,7 +204,10 @@ def env(self):
return self._env


class Default(WorkerEntrypoint):
class Default(DjangoCF, WorkerEntrypoint):
def get_app(self):
return _django_wsgi_app()

async def fetch(self, request):
path = urlparse(request.url).path

Expand All @@ -163,10 +216,8 @@ async def fetch(self, request):
return self._run_suite(suite_name)
if path == "/health":
return Response.json({"ok": True})
if path == "/wsgi/headers":
return await handle_wsgi(request, _wsgi_header_echo_app)
if path == "/wsgi/body":
return await handle_wsgi(request, _wsgi_body_echo_app)
if path.startswith("/django/"):
return await super().fetch(request)
return Response.json({"error": "not found"}, status=404)

def _run_suite(self, suite_name):
Expand Down
Loading
Loading