From 2f034793c4578794c6c6aedd2a474e8130e52391 Mon Sep 17 00:00:00 2001 From: magi Date: Sun, 6 Sep 2026 20:59:04 +0530 Subject: [PATCH] fix(openapi): exclude WebSocket routes from /openapi.json Found while adding a websocket feature to a real app on velocix. The docs handler's router._registered walk (added in #10) didn't distinguish WEBSOCKET from HTTP methods, so it did setattr(path_item, "websocket", op) -- but PathItem has no such field, so that silently created a dangling attribute to_dict() never serializes. The path stayed in the schema with an empty, spec-invalid {} Path Item Object instead of being left out. OpenAPI 3.x has no operation concept for WebSocket at all, so the correct fix is exclusion, not trying to represent it. Added a regression test. 253/253 tests, mypy clean, ruff clean. --- tests/test_openapi_websocket_exclusion.py | 38 +++++++++++++++++++++++ velocix/core/app.py | 6 ++++ 2 files changed, 44 insertions(+) create mode 100644 tests/test_openapi_websocket_exclusion.py diff --git a/tests/test_openapi_websocket_exclusion.py b/tests/test_openapi_websocket_exclusion.py new file mode 100644 index 0000000..08d2d1a --- /dev/null +++ b/tests/test_openapi_websocket_exclusion.py @@ -0,0 +1,38 @@ +"""Regression test: WebSocket routes used to show up in /openapi.json as an +empty, spec-invalid Path Item Object ({}) -- PathItem has no "websocket" +field, so setattr(path_item, "websocket", op) silently created a dangling +attribute to_dict() never serialized, and the path stayed in the schema +with no operations at all. +""" + +import asyncio + +from velocix import TestClient, Velocix +from velocix.websocket.connection import WebSocket + + +def _run(coro): + return asyncio.run(coro) + + +def test_websocket_route_excluded_from_openapi_schema(): + app = Velocix() + + @app.get("/posts") + async def list_posts(): + return {"posts": []} + + @app.websocket("/ws/posts/{post_id}") + async def watch_post(websocket: WebSocket): + await websocket.accept() + await websocket.close() + + async def scenario(): + async with TestClient(app) as client: + schema = (await client.get("/openapi.json")).json() + paths = schema.get("paths", {}) + assert "/posts" in paths + assert "get" in paths["/posts"] + assert "/ws/posts/{post_id}" not in paths + + _run(scenario()) diff --git a/velocix/core/app.py b/velocix/core/app.py index e26baae..0c597c4 100644 --- a/velocix/core/app.py +++ b/velocix/core/app.py @@ -937,6 +937,12 @@ async def openapi_handler(request: Request) -> Response: # missing from the docs entirely or, once hit, showed up # under the literal ID that happened to hit them first. for method, path, handler, _name in self.router._registered: + if method == "WEBSOCKET": + # OpenAPI 3.x has no operation concept for + # WebSocket routes; PathItem has no matching + # field, so this always produced an empty (and + # spec-invalid) {} entry. + continue if getattr(handler, "__route_include_in_schema__", True) is False: continue if path in (self.openapi_url, self.docs_url, self.redoc_url):