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}, )