Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adapter refactoring #23

Merged
merged 23 commits into from
Apr 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
Client(...)

.request(method, url, ...)

.get(url, ...)
.options(url, ...)
.head(url, ...)
.post(url, ...)
.put(url, ...)
.patch(url, ...)
.delete(url, ...)

.prepare_request(request)
.send(request, ...)
.close()


Adapter()

.prepare_request(request)
.send(request)
.close()


+ EnvironmentAdapter
+ RedirectAdapter
+ CookieAdapter
+ AuthAdapter
+ ConnectionPool
+ HTTPConnection
+ HTTP11Connection
+ HTTP2Connection



Response(...)
.status_code - int
.reason_phrase - str
.protocol - "HTTP/2" or "HTTP/1.1"
.url - URL
.headers - Headers

.content - bytes
.text - str
.encoding - str
.json() - Any

.read() - bytes
.stream() - bytes iterator
.raw() - bytes iterator
.close() - None

.is_redirect - bool
.request - Request
.cookies - Cookies
.history - List[Response]

.raise_for_status()
.next()


Request(...)
.method
.url
.headers

...


Headers

URL

Origin

Cookies


# Sync

SyncClient
SyncResponse
SyncRequest
SyncAdapter



SSE
HTTP/2 server push support
Concurrency
16 changes: 13 additions & 3 deletions httpcore/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
from .adapters.redirects import RedirectAdapter
from .client import Client
from .config import PoolLimits, SSLConfig, TimeoutConfig
from .connectionpool import ConnectionPool
from .datastructures import URL, Origin, Request, Response
from .dispatch.connection import HTTPConnection
from .dispatch.connection_pool import ConnectionPool
from .dispatch.http2 import HTTP2Connection
from .dispatch.http11 import HTTP11Connection
from .exceptions import (
ConnectTimeout,
PoolTimeout,
ProtocolError,
ReadTimeout,
RedirectBodyUnavailable,
RedirectLoop,
ResponseClosed,
StreamConsumed,
Timeout,
TooManyRedirects,
)
from .http11 import HTTP11Connection
from .interfaces import Adapter
from .models import URL, Headers, Origin, Request, Response
from .status_codes import codes
from .streams import BaseReader, BaseWriter, Protocol, Reader, Writer, connect
from .sync import SyncClient, SyncConnectionPool

__version__ = "0.2.1"
4 changes: 4 additions & 0 deletions httpcore/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""
Adapter classes layer additional behavior over the raw dispatching of the
HTTP request/response.
"""
18 changes: 18 additions & 0 deletions httpcore/adapters/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import typing

from ..interfaces import Adapter
from ..models import Request, Response


class AuthenticationAdapter(Adapter):
def __init__(self, dispatch: Adapter):
self.dispatch = dispatch

def prepare_request(self, request: Request) -> None:
self.dispatch.prepare_request(request)

async def send(self, request: Request, **options: typing.Any) -> Response:
return await self.dispatch.send(request, **options)

async def close(self) -> None:
await self.dispatch.close()
18 changes: 18 additions & 0 deletions httpcore/adapters/cookies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import typing

from ..interfaces import Adapter
from ..models import Request, Response


class CookieAdapter(Adapter):
def __init__(self, dispatch: Adapter):
self.dispatch = dispatch

def prepare_request(self, request: Request) -> None:
self.dispatch.prepare_request(request)

async def send(self, request: Request, **options: typing.Any) -> Response:
return await self.dispatch.send(request, **options)

async def close(self) -> None:
await self.dispatch.close()
27 changes: 27 additions & 0 deletions httpcore/adapters/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import typing

from ..interfaces import Adapter
from ..models import Request, Response


class EnvironmentAdapter(Adapter):
def __init__(self, dispatch: Adapter, trust_env: bool = True):
self.dispatch = dispatch
self.trust_env = trust_env

def prepare_request(self, request: Request) -> None:
self.dispatch.prepare_request(request)

async def send(self, request: Request, **options: typing.Any) -> Response:
if self.trust_env:
self.merge_environment_options(options)
return await self.dispatch.send(request, **options)

async def close(self) -> None:
await self.dispatch.close()

def merge_environment_options(self, options: dict) -> None:
"""
Add environment options.
"""
#  TODO
125 changes: 125 additions & 0 deletions httpcore/adapters/redirects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import functools
import typing
from urllib.parse import urljoin, urlparse

from ..config import DEFAULT_MAX_REDIRECTS
from ..exceptions import RedirectBodyUnavailable, RedirectLoop, TooManyRedirects
from ..interfaces import Adapter
from ..models import URL, Headers, Request, Response
from ..status_codes import codes
from ..utils import requote_uri


class RedirectAdapter(Adapter):
def __init__(self, dispatch: Adapter, max_redirects: int = DEFAULT_MAX_REDIRECTS):
self.dispatch = dispatch
self.max_redirects = max_redirects

def prepare_request(self, request: Request) -> None:
self.dispatch.prepare_request(request)

async def send(self, request: Request, **options: typing.Any) -> Response:
allow_redirects = options.pop("allow_redirects", True)
history = options.pop("history", []) # type: typing.List[Response]
seen_urls = options.pop("seen_urls", set()) # type: typing.Set[URL]
seen_urls.add(request.url)

while True:
response = await self.dispatch.send(request, **options)
response.history = list(history)
if not response.is_redirect:
break
history.append(response)
request = self.build_redirect_request(request, response)
if not allow_redirects:
next_options = dict(options)
next_options["seen_urls"] = seen_urls
next_options["history"] = history
response.next = functools.partial(self.send, request=request, **next_options)
break
if len(history) > self.max_redirects:
raise TooManyRedirects()
if request.url in seen_urls:
raise RedirectLoop()
seen_urls.add(request.url)

return response

async def close(self) -> None:
await self.dispatch.close()

def build_redirect_request(self, request: Request, response: Response) -> Request:
method = self.redirect_method(request, response)
url = self.redirect_url(request, response)
headers = self.redirect_headers(request, url)
body = self.redirect_body(request, method)
return Request(method=method, url=url, headers=headers, body=body)

def redirect_method(self, request: Request, response: Response) -> str:
"""
When being redirected we may want to change the method of the request
based on certain specs or browser behavior.
"""
method = request.method

# https://tools.ietf.org/html/rfc7231#section-6.4.4
if response.status_code == codes.see_other and method != "HEAD":
method = "GET"

# Do what the browsers do, despite standards...
# Turn 302s into GETs.
if response.status_code == codes.found and method != "HEAD":
method = "GET"

# If a POST is responded to with a 301, turn it into a GET.
# This bizarre behaviour is explained in 'requests' issue 1704.
if response.status_code == codes.moved_permanently and method == "POST":
method = "GET"

return method

def redirect_url(self, request: Request, response: Response) -> URL:
"""
Return the URL for the redirect to follow.
"""
location = response.headers["Location"]

# Handle redirection without scheme (see: RFC 1808 Section 4)
if location.startswith("//"):
location = f"{request.url.scheme}:{location}"

# Normalize url case and attach previous fragment if needed (RFC 7231 7.1.2)
parsed = urlparse(location)
if parsed.fragment == "" and request.url.fragment:
parsed = parsed._replace(fragment=request.url.fragment)
url = parsed.geturl()

# Facilitate relative 'location' headers, as allowed by RFC 7231.
# (e.g. '/path/to/resource' instead of 'http://domain.tld/path/to/resource')
# Compliant with RFC3986, we percent encode the url.
if not parsed.netloc:
url = urljoin(str(request.url), requote_uri(url))
else:
url = requote_uri(url)

return URL(url)

def redirect_headers(self, request: Request, url: URL) -> Headers:
"""
Strip Authorization headers when responses are redirected away from
the origin.
"""
headers = Headers(request.headers)
if url.origin != request.url.origin:
del headers["Authorization"]
return headers

def redirect_body(self, request: Request, method: str) -> bytes:
"""
Return the body that should be used for the redirect request.
"""
if method != request.method and method == "GET":
return b""
if request.is_streaming:
raise RedirectBodyUnavailable()
return request.body
Loading