Skip to content

Commit

Permalink
Merge pull request #1 from secondlife/signal/quirks
Browse files Browse the repository at this point in the history
Add quirks mode for legacy clients
  • Loading branch information
bennettgoble authored Feb 19, 2024
2 parents ef0da55 + 4bdd54c commit 3775ee1
Show file tree
Hide file tree
Showing 3 changed files with 65 additions and 12 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ flowchart TD
Your ASGI application is wrapped around the `LLSDMiddleware`, which performs
content negotiation based on `Content-Type` and `Accept` HTTP headers.

## Quirks mode

Passing `quirks=True` to the middleware enables 🤪 **quirks mode**. The behavior
of this mode matches that of poorly behaved Linden Lab services, where the
server returns LLSD even if the client has not requested it.

[FastAPI]: https://fastapi.tiangolo.com/
[LLSD]: https://wiki.secondlife.com/wiki/LLSD
[MessagePack]: https://msgpack.org/index.html
Expand Down
34 changes: 22 additions & 12 deletions llsd_asgi/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@


class LLSDMiddleware:
def __init__(
self,
app: ASGIApp,
) -> None:
def __init__(self, app: ASGIApp, quirks: bool = False) -> None:
self.app = app
self.quirks = quirks

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http":
responder = _LLSDResponder(self.app)
responder = _LLSDResponder(self.app, self.quirks)
await responder(scope, receive, send)
return
await self.app(scope, receive, send)
Expand All @@ -35,11 +33,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:

class _LLSDResponder:

def __init__(
self,
app: ASGIApp,
) -> None:
def __init__(self, app: ASGIApp, quirks: bool = False) -> None:
self.app = app
self.quirks = quirks
self.should_decode_from_llsd_to_json = False
self.should_encode_from_json_to_llsd = False
self.receive: Receive = unattached_receive
Expand All @@ -61,7 +57,17 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
self.format = _CONTENT_TYPE_TO_FORMAT[self.accept_header]
self.should_encode_from_json_to_llsd = True
except KeyError:
self.should_encode_from_json_to_llsd = False
# Quirks mode matches the behavior of some poorly behaved clients,
# which expect llsd responses even if they don't send an Accept header.
if self.quirks:
if not self.accept_header or (
"*/*" in self.accept_header and "application/json" not in self.accept_header
):
self.format = llsd.format_xml
self.should_encode_from_json_to_llsd = True
self.accept_header = None
else:
self.should_encode_from_json_to_llsd = False

self.receive = receive
self.send = send
Expand Down Expand Up @@ -104,7 +110,7 @@ async def send_with_llsd(self, message: Message) -> None:

if message["type"] == "http.response.start":
headers = Headers(raw=message["headers"])
if headers["content-type"] != "application/json":
if headers.get("content-type") != "application/json":
# Client accepts llsd, but the app did not send JSON data.
# (Note that it may have sent llsd-encoded data.)
self.should_encode_from_json_to_llsd = False
Expand All @@ -126,7 +132,11 @@ async def send_with_llsd(self, message: Message) -> None:
body = self.format(json.loads(body))

headers = MutableHeaders(raw=self.initial_message["headers"])
headers["Content-Type"] = self.accept_header
if self.accept_header:
headers["Content-Type"] = self.accept_header
else:
# quirks mode allows a response without a Content-Type response header
del headers["Content-Type"]
headers["Content-Length"] = str(len(body))
message["body"] = body

Expand Down
37 changes: 37 additions & 0 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,40 @@ async def lifespan_only_app(scope: Scope, receive: Receive, send: Send) -> None:
app = LLSDMiddleware(lifespan_only_app)
scope = {"type": "lifespan"}
await app(scope, mock_receive, mock_send)


@pytest.mark.asyncio
@pytest.mark.parametrize(
"accept",
[(None), ("*/*"), ("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")],
)
async def test_quirks(accept: str) -> None:
app = LLSDMiddleware(JSONResponse({"message": "Hello, world!"}), quirks=True)

async with httpx.AsyncClient(app=app, base_url="http://testserver") as client:
headers = {}
if accept is None:
# Emulate a client that doesn't send an Accept header
del client._headers["accept"]
else:
headers["accept"] = accept
r = await client.get("/")
assert r.status_code == 200
assert "content-type" not in r.headers
assert llsd.parse_xml(r.content) == {"message": "Hello, world!"}


@pytest.mark.asyncio
@pytest.mark.parametrize(
"accept",
[("application/json,*/*")],
)
async def test_quirks_exceptions(accept: str) -> None:
app = LLSDMiddleware(JSONResponse({"message": "Hello, world!"}), quirks=True)

async with httpx.AsyncClient(app=app, base_url="http://testserver") as client:
client.headers["accept"] = accept
r = await client.get("/")
assert r.status_code == 200
assert r.headers["content-type"] == "application/json"
assert r.json() == {"message": "Hello, world!"}

0 comments on commit 3775ee1

Please sign in to comment.