Skip to content

Commit 8774f3c

Browse files
authored
Add WebOb integration (#129)
* Add WebOb integration * Include WebOb in integration tests
1 parent 292643d commit 8774f3c

File tree

7 files changed

+276
-1
lines changed

7 files changed

+276
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ for building GraphQL servers or integrations into existing web frameworks using
1919
| FastAPI | [fastapi](https://github.com/graphql-python/graphql-server/blob/master/docs/fastapi.md) |
2020
| Flask | [flask](https://github.com/graphql-python/graphql-server/blob/master/docs/flask.md) |
2121
| Litestar | [litestar](https://github.com/graphql-python/graphql-server/blob/master/docs/litestar.md) |
22+
| WebOb | [webob](https://github.com/graphql-python/graphql-server/blob/master/docs/webob.md) |
2223
| Quart | [quart](https://github.com/graphql-python/graphql-server/blob/master/docs/quart.md) |
2324
| Sanic | [sanic](https://github.com/graphql-python/graphql-server/blob/master/docs/sanic.md) |
2425

noxfile.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"django",
3939
"fastapi",
4040
"flask",
41+
"webob",
4142
"quart",
4243
"sanic",
4344
"litestar",
@@ -119,6 +120,7 @@ def tests_starlette(session: Session, gql_core: str) -> None:
119120
"channels",
120121
"fastapi",
121122
"flask",
123+
"webob",
122124
"quart",
123125
"sanic",
124126
"litestar",

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "A library for creating GraphQL APIs"
55
authors = [{ name = "Syrus Akbary", email = "[email protected]" }]
66
license = { text = "MIT" }
77
readme = "README.md"
8-
keywords = ["graphql", "api", "rest", "starlette", "async", "fastapi", "django", "flask", "litestar", "sanic", "channels", "aiohttp", "chalice", "pyright", "mypy", "codeflash"]
8+
keywords = ["graphql", "api", "rest", "starlette", "async", "fastapi", "django", "flask", "litestar", "sanic", "channels", "aiohttp", "chalice", "webob", "pyright", "mypy", "codeflash"]
99
classifiers = [
1010
"Development Status :: 5 - Production/Stable",
1111
"Intended Audience :: Developers",
@@ -47,6 +47,7 @@ fastapi = ["fastapi>=0.65.2", "python-multipart>=0.0.7"]
4747
chalice = ["chalice~=1.22"]
4848
litestar = ["litestar>=2; python_version~='3.10'"]
4949
pyinstrument = ["pyinstrument>=4.0.0"]
50+
webob = ["WebOb>=1.8"]
5051

5152
[tool.pytest.ini_options]
5253
# addopts = "--emoji"
@@ -64,6 +65,7 @@ markers = [
6465
"flaky",
6566
"flask",
6667
"litestar",
68+
"webob",
6769
"pydantic",
6870
"quart",
6971
"relay",

src/graphql_server/webob/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .views import GraphQLView
2+
3+
__all__ = ["GraphQLView"]

src/graphql_server/webob/views.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from __future__ import annotations
2+
3+
import warnings
4+
from typing import TYPE_CHECKING, Any, Mapping, Optional, Union, cast
5+
from typing_extensions import TypeGuard
6+
7+
from webob import Request, Response
8+
9+
from graphql_server.http import GraphQLRequestData
10+
from graphql_server.http.exceptions import HTTPException
11+
from graphql_server.http.sync_base_view import SyncBaseHTTPView, SyncHTTPRequestAdapter
12+
from graphql_server.http.typevars import Context, RootValue
13+
from graphql_server.http.types import HTTPMethod, QueryParams
14+
15+
if TYPE_CHECKING:
16+
from graphql.type import GraphQLSchema
17+
from graphql_server.http import GraphQLHTTPResponse
18+
from graphql_server.http.ides import GraphQL_IDE
19+
20+
21+
class WebobHTTPRequestAdapter(SyncHTTPRequestAdapter):
22+
def __init__(self, request: Request) -> None:
23+
self.request = request
24+
25+
@property
26+
def query_params(self) -> QueryParams:
27+
return dict(self.request.GET.items())
28+
29+
@property
30+
def body(self) -> Union[str, bytes]:
31+
return self.request.body
32+
33+
@property
34+
def method(self) -> HTTPMethod:
35+
return cast("HTTPMethod", self.request.method.upper())
36+
37+
@property
38+
def headers(self) -> Mapping[str, str]:
39+
return self.request.headers
40+
41+
@property
42+
def post_data(self) -> Mapping[str, Union[str, bytes]]:
43+
return self.request.POST
44+
45+
@property
46+
def files(self) -> Mapping[str, Any]:
47+
return {
48+
name: value.file
49+
for name, value in self.request.POST.items()
50+
if hasattr(value, "file")
51+
}
52+
53+
@property
54+
def content_type(self) -> Optional[str]:
55+
return self.request.content_type
56+
57+
58+
class GraphQLView(
59+
SyncBaseHTTPView[Request, Response, Response, Context, RootValue],
60+
):
61+
allow_queries_via_get: bool = True
62+
request_adapter_class = WebobHTTPRequestAdapter
63+
64+
def __init__(
65+
self,
66+
schema: GraphQLSchema,
67+
graphiql: Optional[bool] = None,
68+
graphql_ide: Optional[GraphQL_IDE] = "graphiql",
69+
allow_queries_via_get: bool = True,
70+
multipart_uploads_enabled: bool = False,
71+
) -> None:
72+
self.schema = schema
73+
self.allow_queries_via_get = allow_queries_via_get
74+
self.multipart_uploads_enabled = multipart_uploads_enabled
75+
76+
if graphiql is not None:
77+
warnings.warn(
78+
"The `graphiql` argument is deprecated in favor of `graphql_ide`",
79+
DeprecationWarning,
80+
stacklevel=2,
81+
)
82+
self.graphql_ide = "graphiql" if graphiql else None
83+
else:
84+
self.graphql_ide = graphql_ide
85+
86+
def get_root_value(self, request: Request) -> Optional[RootValue]:
87+
return None
88+
89+
def get_context(self, request: Request, response: Response) -> Context:
90+
return {"request": request, "response": response} # type: ignore
91+
92+
def get_sub_response(self, request: Request) -> Response:
93+
return Response(status=200, content_type="application/json")
94+
95+
def create_response(
96+
self,
97+
response_data: GraphQLHTTPResponse,
98+
sub_response: Response,
99+
is_strict: bool,
100+
) -> Response:
101+
sub_response.text = self.encode_json(response_data)
102+
sub_response.content_type = (
103+
"application/graphql-response+json" if is_strict else "application/json"
104+
)
105+
return sub_response
106+
107+
def render_graphql_ide(
108+
self, request: Request, request_data: GraphQLRequestData
109+
) -> Response:
110+
return Response(
111+
text=request_data.to_template_string(self.graphql_ide_html),
112+
content_type="text/html",
113+
status=200,
114+
)
115+
116+
def dispatch_request(self, request: Request) -> Response:
117+
try:
118+
return self.run(request=request)
119+
except HTTPException as e:
120+
return Response(text=e.reason, status=e.status_code)
121+
122+
123+
__all__ = ["GraphQLView"]

src/tests/http/clients/webob.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import contextvars
5+
import functools
6+
import json
7+
import urllib.parse
8+
from io import BytesIO
9+
from typing import Any, Optional, Union
10+
from typing_extensions import Literal
11+
12+
from graphql import ExecutionResult
13+
from webob import Request, Response
14+
15+
from graphql_server.http import GraphQLHTTPResponse
16+
from graphql_server.http.ides import GraphQL_IDE
17+
from graphql_server.webob import GraphQLView as BaseGraphQLView
18+
from tests.http.context import get_context
19+
from tests.views.schema import Query, schema
20+
21+
from .base import JSON, HttpClient, Response as ClientResponse, ResultOverrideFunction
22+
23+
24+
class GraphQLView(BaseGraphQLView[dict[str, object], object]):
25+
result_override: ResultOverrideFunction = None
26+
27+
def get_root_value(self, request: Request) -> Query:
28+
super().get_root_value(request) # for coverage
29+
return Query()
30+
31+
def get_context(self, request: Request, response: Response) -> dict[str, object]:
32+
context = super().get_context(request, response)
33+
return get_context(context)
34+
35+
def process_result(
36+
self, request: Request, result: ExecutionResult, strict: bool = False
37+
) -> GraphQLHTTPResponse:
38+
if self.result_override:
39+
return self.result_override(result)
40+
return super().process_result(request, result, strict)
41+
42+
43+
class WebobHttpClient(HttpClient):
44+
def __init__(
45+
self,
46+
graphiql: Optional[bool] = None,
47+
graphql_ide: Optional[GraphQL_IDE] = "graphiql",
48+
allow_queries_via_get: bool = True,
49+
result_override: ResultOverrideFunction = None,
50+
multipart_uploads_enabled: bool = False,
51+
) -> None:
52+
self.view = GraphQLView(
53+
schema=schema,
54+
graphiql=graphiql,
55+
graphql_ide=graphql_ide,
56+
allow_queries_via_get=allow_queries_via_get,
57+
multipart_uploads_enabled=multipart_uploads_enabled,
58+
)
59+
self.view.result_override = result_override
60+
61+
async def _graphql_request(
62+
self,
63+
method: Literal["get", "post"],
64+
query: Optional[str] = None,
65+
operation_name: Optional[str] = None,
66+
variables: Optional[dict[str, object]] = None,
67+
files: Optional[dict[str, BytesIO]] = None,
68+
headers: Optional[dict[str, str]] = None,
69+
extensions: Optional[dict[str, Any]] = None,
70+
**kwargs: Any,
71+
) -> ClientResponse:
72+
body = self._build_body(
73+
query=query,
74+
operation_name=operation_name,
75+
variables=variables,
76+
files=files,
77+
method=method,
78+
extensions=extensions,
79+
)
80+
81+
data: Union[dict[str, object], str, None] = None
82+
83+
url = "/graphql"
84+
85+
if body and files:
86+
body.update({name: (file, name) for name, file in files.items()})
87+
88+
if method == "get":
89+
body_encoded = urllib.parse.urlencode(body or {})
90+
url = f"{url}?{body_encoded}"
91+
else:
92+
if body:
93+
data = body if files else json.dumps(body)
94+
kwargs["body"] = data
95+
96+
headers = self._get_headers(method=method, headers=headers, files=files)
97+
98+
return await self.request(url, method, headers=headers, **kwargs)
99+
100+
def _do_request(
101+
self,
102+
url: str,
103+
method: Literal["get", "post", "patch", "put", "delete"],
104+
headers: Optional[dict[str, str]] = None,
105+
**kwargs: Any,
106+
) -> ClientResponse:
107+
body = kwargs.get("body", None)
108+
req = Request.blank(
109+
url, method=method.upper(), headers=headers or {}, body=body
110+
)
111+
resp = self.view.dispatch_request(req)
112+
return ClientResponse(
113+
status_code=resp.status_code, data=resp.body, headers=resp.headers
114+
)
115+
116+
async def request(
117+
self,
118+
url: str,
119+
method: Literal["head", "get", "post", "patch", "put", "delete"],
120+
headers: Optional[dict[str, str]] = None,
121+
**kwargs: Any,
122+
) -> ClientResponse:
123+
loop = asyncio.get_running_loop()
124+
ctx = contextvars.copy_context()
125+
func_call = functools.partial(
126+
ctx.run, self._do_request, url=url, method=method, headers=headers, **kwargs
127+
)
128+
return await loop.run_in_executor(None, func_call) # type: ignore
129+
130+
async def get(
131+
self, url: str, headers: Optional[dict[str, str]] = None
132+
) -> ClientResponse:
133+
return await self.request(url, "get", headers=headers)
134+
135+
async def post(
136+
self,
137+
url: str,
138+
data: Optional[bytes] = None,
139+
json: Optional[JSON] = None,
140+
headers: Optional[dict[str, str]] = None,
141+
) -> ClientResponse:
142+
body = json if json is not None else data
143+
return await self.request(url, "post", headers=headers, body=body)

src/tests/http/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def _get_http_client_classes() -> Generator[Any, None, None]:
1818
("DjangoHttpClient", "django", [pytest.mark.django]),
1919
("FastAPIHttpClient", "fastapi", [pytest.mark.fastapi]),
2020
("FlaskHttpClient", "flask", [pytest.mark.flask]),
21+
("WebobHttpClient", "webob", [pytest.mark.webob]),
2122
("QuartHttpClient", "quart", [pytest.mark.quart]),
2223
("SanicHttpClient", "sanic", [pytest.mark.sanic]),
2324
("LitestarHttpClient", "litestar", [pytest.mark.litestar]),

0 commit comments

Comments
 (0)