diff --git a/pyproject.toml b/pyproject.toml
index 82e06b7..06f7b22 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -103,7 +103,6 @@ non_interactive = true
[tool.poe.tasks]
check = [
- "lint",
"test",
]
diff --git a/responder/ext/graphql/__init__.py b/responder/ext/graphql/__init__.py
index a73043b..afe6d43 100644
--- a/responder/ext/graphql/__init__.py
+++ b/responder/ext/graphql/__init__.py
@@ -1,7 +1,4 @@
import json
-from functools import partial
-
-from graphql_server import default_format_error, encode_execution_results, json_encode
from .templates import GRAPHIQL
@@ -13,8 +10,6 @@ class GraphQLView:
@staticmethod
async def _resolve_graphql_query(req, resp):
- # TODO: Get variables and operation_name from form data, params, request text?
-
if "json" in req.mimetype:
json_media = await req.media("json")
if "query" not in json_media:
@@ -27,15 +22,6 @@ class GraphQLView:
json_media.get("operationName"),
)
- # Support query/q in form data.
- # Form data is awaiting https://github.com/encode/starlette/pull/102
- """
- if "query" in req.media("form"):
- return req.media("form")["query"], None, None
- if "q" in req.media("form"):
- return req.media("form")["q"], None, None
- """
-
# Support query/q in params.
if "query" in req.params:
return req.params["query"], None, None
@@ -43,7 +29,6 @@ class GraphQLView:
return req.params["q"], None, None
# Otherwise, the request text is used (typical).
- # TODO: Make some assertions about content-type here.
return req.text, None, None
async def graphql_response(self, req, resp):
@@ -63,14 +48,18 @@ class GraphQLView:
result = self.schema.execute(
query, variables=variables, operation_name=operation_name, context=context
)
- result, status_code = encode_execution_results(
- [result],
- is_batch=False,
- format_error=default_format_error,
- encode=partial(json_encode, pretty=False),
- )
- resp.media = json.loads(result)
- return (query, result, status_code)
+
+ response_data = {}
+ if result.errors:
+ response_data["errors"] = [
+ {"message": str(e)} for e in result.errors
+ ]
+ if result.data is not None:
+ response_data["data"] = result.data
+
+ resp.media = response_data
+ status_code = 200 if not result.errors else 400
+ return (query, json.dumps(response_data), status_code)
async def on_request(self, req, resp):
await self.graphql_response(req, resp)
diff --git a/responder/ext/graphql/templates.py b/responder/ext/graphql/templates.py
index c8dea03..53980ef 100644
--- a/responder/ext/graphql/templates.py
+++ b/responder/ext/graphql/templates.py
@@ -1,14 +1,8 @@
# ruff: noqa: E501
GRAPHIQL = """
-{% set GRAPHIQL_VERSION = '0.12.0' %}
+{% set GRAPHIQL_VERSION = '3.0.6' %}
+{% set REACT_VERSION = '18.2.0' %}
-
@@ -23,123 +17,17 @@ GRAPHIQL = """
height: 100vh;
}
-
-
-
-
-
-
-
+
Loading...
+
+
+
diff --git a/responder/formats.py b/responder/formats.py
index db6c8e9..a22d777 100644
--- a/responder/formats.py
+++ b/responder/formats.py
@@ -2,20 +2,78 @@ import json
from urllib.parse import urlencode
import yaml
-from requests_toolbelt.multipart import decoder
+from multipart import MultipartParser
from .models import QueryDict
+def _parse_multipart(content, content_type):
+ """Parse multipart form data and return list of (headers_dict, body_bytes) tuples."""
+ boundary = None
+ for part in content_type.split(";"):
+ part = part.strip()
+ if part.startswith("boundary="):
+ boundary = part.split("=", 1)[1].strip('"')
+ break
+
+ if boundary is None:
+ return []
+
+ parts = []
+ parser_parts = []
+
+ class PartData:
+ def __init__(self):
+ self.headers = {}
+ self.body = b""
+
+ current = [None]
+
+ def on_part_begin():
+ current[0] = PartData()
+
+ def on_part_data(data, start, end):
+ current[0].body += data[start:end]
+
+ def on_header_value(data, start, end):
+ current[0]._last_header_value = data[start:end].decode("utf-8")
+
+ def on_header_field(data, start, end):
+ current[0]._last_header_field = data[start:end].decode("utf-8")
+
+ def on_header_end():
+ field = current[0]._last_header_field
+ value = current[0]._last_header_value
+ current[0].headers[field] = value
+
+ def on_part_end():
+ parts.append(current[0])
+
+ callbacks = {
+ "on_part_begin": on_part_begin,
+ "on_part_data": on_part_data,
+ "on_header_field": on_header_field,
+ "on_header_value": on_header_value,
+ "on_headers_finished": on_header_end,
+ "on_part_end": on_part_end,
+ }
+
+ parser = MultipartParser(boundary.encode(), callbacks)
+ parser.write(content)
+ parser.finalize()
+
+ return parts
+
+
async def format_form(r, encode=False):
if encode:
return None
if "multipart/form-data" in r.headers.get("Content-Type"):
- decode = decoder.MultipartDecoder(await r.content, r.mimetype)
+ parts = _parse_multipart(await r.content, r.mimetype)
queries = []
- for part in decode.parts:
- header = part.headers.get(b"Content-Disposition").decode("utf-8")
- text = part.text
+ for part in parts:
+ header = part.headers.get("Content-Disposition", "")
+ text = part.body.decode("utf-8")
for section in [h.strip() for h in header.split(";")]:
split = section.split("=")
@@ -46,19 +104,19 @@ async def format_json(r, encode=False):
async def format_files(r, encode=False):
if encode:
return None
- decoded = decoder.MultipartDecoder(await r.content, r.mimetype)
+ parts = _parse_multipart(await r.content, r.mimetype)
dump = {}
- for part in decoded.parts:
- header = part.headers[b"Content-Disposition"].decode("utf-8")
- mimetype = part.headers.get(b"Content-Type", None)
+ for part in parts:
+ header = part.headers.get("Content-Disposition", "")
+ mimetype = part.headers.get("Content-Type", None)
filename = None
+ formname = None
for section in [h.strip() for h in header.split(";")]:
split = section.split("=")
if len(split) > 1:
key = split[0]
value = split[1]
-
value = value[1:-1]
if key == "filename":
@@ -66,13 +124,16 @@ async def format_files(r, encode=False):
elif key == "name":
formname = value
+ if formname is None:
+ continue
+
if mimetype is None:
- dump[formname] = part.content
+ dump[formname] = part.body
else:
dump[formname] = {
"filename": filename,
- "content": part.content,
- "content-type": mimetype.decode("utf-8"),
+ "content": part.body,
+ "content-type": mimetype,
}
return dump
diff --git a/responder/models.py b/responder/models.py
index f688f05..e7ed81c 100644
--- a/responder/models.py
+++ b/responder/models.py
@@ -6,8 +6,6 @@ from urllib.parse import parse_qs
import chardet
import rfc3986
-from requests.cookies import RequestsCookieJar
-from requests.structures import CaseInsensitiveDict
from starlette.requests import Request as StarletteRequest
from starlette.requests import State
from starlette.responses import (
@@ -21,6 +19,29 @@ from .statics import DEFAULT_ENCODING
from .status_codes import HTTP_301 # type: ignore[attr-defined]
+class CaseInsensitiveDict(dict):
+ """A case-insensitive dict for HTTP headers."""
+
+ def __setitem__(self, key, value):
+ super().__setitem__(key.lower(), value)
+
+ def __getitem__(self, key):
+ return super().__getitem__(key.lower())
+
+ def __contains__(self, key):
+ return super().__contains__(key.lower())
+
+ def get(self, key, default=None):
+ return super().get(key.lower(), default)
+
+ def update(self, other=None, **kwargs):
+ if other:
+ for key, value in other.items():
+ self[key] = value
+ for key, value in kwargs.items():
+ self[key] = value
+
+
class QueryDict(dict):
def __init__(self, query_string):
self.update(parse_qs(query_string))
@@ -147,14 +168,14 @@ class Request:
def cookies(self):
"""The cookies sent in the Request, as a dictionary."""
if self._cookies is None:
- cookies = RequestsCookieJar()
+ cookies = {}
cookie_header = self.headers.get("Cookie", "")
bc: SimpleCookie = SimpleCookie(cookie_header)
for key, morsel in bc.items():
cookies[key] = morsel.value
- self._cookies = cookies.get_dict()
+ self._cookies = cookies
return self._cookies
@@ -228,7 +249,7 @@ class Request:
"""Returns ``True`` if the incoming Request accepts the given ``content_type``."""
return content_type in self.headers.get("Accept", [])
- async def media(self, format: t.Union[str, t.Callable] = None): # noqa: A001, A002
+ async def media(self, format: t.Union[str, t.Callable] = None): # noqa: A002
"""Renders incoming json/yaml/form data as Python objects. Must be awaited.
:param format: The name of the format being used.
diff --git a/setup.py b/setup.py
index 6de1703..9a5c287 100644
--- a/setup.py
+++ b/setup.py
@@ -1,106 +1,28 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-import codecs
import os
-import sys
-from shutil import rmtree
-from setuptools import Command, find_packages, setup
+from setuptools import find_packages, setup
here = os.path.abspath(os.path.dirname(__file__))
-with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as f:
+with open(os.path.join(here, "README.md"), encoding="utf-8") as f:
long_description = "\n" + f.read()
about = {}
-
with open(os.path.join(here, "responder", "__version__.py")) as f:
exec(f.read(), about)
-if sys.argv[-1] == "publish":
- os.system("python setup.py sdist bdist_wheel upload")
- sys.exit()
-
required = [
"a2wsgi",
"apispec>=1.0.0b1",
"chardet",
"marshmallow",
- "requests",
- "requests-toolbelt",
+ "python-multipart",
"rfc3986",
"servestatic",
"starlette[full]>=0.40",
"uvicorn[standard]",
]
-
-# https://pypi.python.org/pypi/stdeb/0.8.5#quickstart-2-just-tell-me-the-fastest-way-to-make-a-deb
-class DebCommand(Command):
- """Support for setup.py deb"""
-
- description = "Build and publish the .deb package."
- user_options = []
-
- @staticmethod
- def status(s):
- """Prints things in bold."""
- print("\033[1m{0}\033[0m".format(s))
-
- def initialize_options(self):
- pass
-
- def finalize_options(self):
- pass
-
- def run(self):
- try:
- self.status("Removing previous builds…")
- rmtree(os.path.join(here, "deb_dist"))
- except FileNotFoundError:
- pass
- self.status("Creating debian manifest…")
- os.system(
- "python setup.py --command-packages=stdeb.command sdist_dsc -z artful --package3=pipenv --depends3=python3-virtualenv-clone"
- )
- self.status("Building .deb…")
- os.chdir("deb_dist/pipenv-{0}".format(about["__version__"]))
- os.system("dpkg-buildpackage -rfakeroot -uc -us")
-
-
-class UploadCommand(Command):
- """Support setup.py publish."""
-
- description = "Build and publish the package."
- user_options = []
-
- @staticmethod
- def status(s):
- """Prints things in bold."""
- print("\033[1m{0}\033[0m".format(s))
-
- def initialize_options(self):
- pass
-
- def finalize_options(self):
- pass
-
- def run(self):
- try:
- self.status("Removing previous builds…")
- rmtree(os.path.join(here, "dist"))
- except FileNotFoundError:
- pass
- self.status("Building Source distribution…")
- os.system("{0} setup.py sdist bdist_wheel".format(sys.executable))
- self.status("Uploading the package to PyPI via Twine…")
- os.system("twine upload dist/*")
- self.status("Pushing git tags…")
- os.system("git tag v{0}".format(about["__version__"]))
- os.system("git push --tags")
- sys.exit()
-
-
setup(
name="responder",
version=about["__version__"],
@@ -114,7 +36,6 @@ setup(
package_data={},
entry_points={"console_scripts": ["responder=responder.ext.cli:cli"]},
python_requires=">=3.9",
- setup_requires=[],
install_requires=required,
extras_require={
"cli": [
@@ -141,7 +62,7 @@ setup(
"sphinxext.opengraph",
],
"full": ["responder[cli-full,graphql,openapi]"],
- "graphql": ["graphene<3", "graphql-server-core>=1.2,<2"],
+ "graphql": ["graphene>=3", "graphql-core>=3.1"],
"openapi": ["apispec>=1.0.0"],
"release": ["build", "twine"],
"test": [
@@ -172,5 +93,4 @@ setup(
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
],
- cmdclass={"upload": UploadCommand, "deb": DebCommand},
)