|
|
- import asyncio
- import collections
- import datetime
- import io
- import re
- import socket
- import string
- import tempfile
- import types
- import warnings
- from email.utils import parsedate
- from http.cookies import SimpleCookie
- from types import MappingProxyType
- from typing import Any, Dict, Mapping, Optional, Tuple, cast # noqa
- from urllib.parse import parse_qsl
-
- import attr
- from multidict import CIMultiDict, CIMultiDictProxy, MultiDict, MultiDictProxy
- from yarl import URL
-
- from . import hdrs
- from .helpers import DEBUG, ChainMapProxy, HeadersMixin, reify, sentinel
- from .multipart import MultipartReader
- from .streams import EmptyStreamReader, StreamReader
- from .typedefs import (DEFAULT_JSON_DECODER, JSONDecoder, LooseHeaders,
- RawHeaders, StrOrURL)
- from .web_exceptions import HTTPRequestEntityTooLarge
- from .web_urldispatcher import UrlMappingMatchInfo
-
-
- __all__ = ('BaseRequest', 'FileField', 'Request')
-
-
- @attr.s(frozen=True, slots=True)
- class FileField:
- name = attr.ib(type=str)
- filename = attr.ib(type=str)
- file = attr.ib(type=io.BufferedReader)
- content_type = attr.ib(type=str)
- headers = attr.ib(type=CIMultiDictProxy)
-
-
- _TCHAR = string.digits + string.ascii_letters + r"!#$%&'*+.^_`|~-"
- # '-' at the end to prevent interpretation as range in a char class
-
- _TOKEN = r'[{tchar}]+'.format(tchar=_TCHAR)
-
- _QDTEXT = r'[{}]'.format(
- r''.join(chr(c) for c in (0x09, 0x20, 0x21) + tuple(range(0x23, 0x7F))))
- # qdtext includes 0x5C to escape 0x5D ('\]')
- # qdtext excludes obs-text (because obsoleted, and encoding not specified)
-
- _QUOTED_PAIR = r'\\[\t !-~]'
-
- _QUOTED_STRING = r'"(?:{quoted_pair}|{qdtext})*"'.format(
- qdtext=_QDTEXT, quoted_pair=_QUOTED_PAIR)
-
- _FORWARDED_PAIR = (
- r'({token})=({token}|{quoted_string})(:\d{{1,4}})?'.format(
- token=_TOKEN,
- quoted_string=_QUOTED_STRING))
-
- _QUOTED_PAIR_REPLACE_RE = re.compile(r'\\([\t !-~])')
- # same pattern as _QUOTED_PAIR but contains a capture group
-
- _FORWARDED_PAIR_RE = re.compile(_FORWARDED_PAIR)
-
- ############################################################
- # HTTP Request
- ############################################################
-
-
- class BaseRequest(collections.MutableMapping, HeadersMixin):
-
- POST_METHODS = {hdrs.METH_PATCH, hdrs.METH_POST, hdrs.METH_PUT,
- hdrs.METH_TRACE, hdrs.METH_DELETE}
-
- ATTRS = HeadersMixin.ATTRS | frozenset([
- '_message', '_protocol', '_payload_writer', '_payload', '_headers',
- '_method', '_version', '_rel_url', '_post', '_read_bytes',
- '_state', '_cache', '_task', '_client_max_size', '_loop'])
-
- def __init__(self, message, payload, protocol, payload_writer, task,
- loop,
- *, client_max_size=1024**2,
- state=None,
- scheme=None, host=None, remote=None):
- if state is None:
- state = {}
- self._message = message
- self._protocol = protocol
- self._payload_writer = payload_writer
-
- self._payload = payload
- self._headers = message.headers
- self._method = message.method
- self._version = message.version
- self._rel_url = message.url
- self._post = None
- self._read_bytes = None
-
- self._state = state
- self._cache = {}
- self._task = task
- self._client_max_size = client_max_size
- self._loop = loop
-
- if scheme is not None:
- self._cache['scheme'] = scheme
- if host is not None:
- self._cache['host'] = host
- if remote is not None:
- self._cache['remote'] = remote
-
- def clone(self, *, method: str=sentinel, rel_url: StrOrURL=sentinel,
- headers: LooseHeaders=sentinel, scheme: str=sentinel,
- host: str=sentinel,
- remote: str=sentinel) -> 'BaseRequest':
- """Clone itself with replacement some attributes.
-
- Creates and returns a new instance of Request object. If no parameters
- are given, an exact copy is returned. If a parameter is not passed, it
- will reuse the one from the current request object.
-
- """
-
- if self._read_bytes:
- raise RuntimeError("Cannot clone request "
- "after reading it's content")
-
- dct = {} # type: Dict[str, Any]
- if method is not sentinel:
- dct['method'] = method
- if rel_url is not sentinel:
- new_url = URL(rel_url)
- dct['url'] = new_url
- dct['path'] = str(new_url)
- if headers is not sentinel:
- # a copy semantic
- dct['headers'] = CIMultiDictProxy(CIMultiDict(headers))
- dct['raw_headers'] = tuple((k.encode('utf-8'), v.encode('utf-8'))
- for k, v in headers.items())
-
- message = self._message._replace(**dct)
-
- kwargs = {}
- if scheme is not sentinel:
- kwargs['scheme'] = scheme
- if host is not sentinel:
- kwargs['host'] = host
- if remote is not sentinel:
- kwargs['remote'] = remote
-
- return self.__class__(
- message,
- self._payload,
- self._protocol,
- self._payload_writer,
- self._task,
- self._loop,
- client_max_size=self._client_max_size,
- state=self._state.copy(),
- **kwargs)
-
- @property
- def task(self):
- return self._task
-
- @property
- def protocol(self):
- return self._protocol
-
- @property
- def transport(self):
- if self._protocol is None:
- return None
- return self._protocol.transport
-
- @property
- def writer(self):
- return self._payload_writer
-
- @reify
- def message(self):
- warnings.warn("Request.message is deprecated",
- DeprecationWarning,
- stacklevel=3)
- return self._message
-
- @reify
- def rel_url(self) -> URL:
- return self._rel_url
-
- @reify
- def loop(self) -> asyncio.AbstractEventLoop:
- return self._loop
-
- # MutableMapping API
-
- def __getitem__(self, key):
- return self._state[key]
-
- def __setitem__(self, key, value):
- self._state[key] = value
-
- def __delitem__(self, key):
- del self._state[key]
-
- def __len__(self):
- return len(self._state)
-
- def __iter__(self):
- return iter(self._state)
-
- ########
-
- @reify
- def secure(self) -> bool:
- """A bool indicating if the request is handled with SSL."""
- return self.scheme == 'https'
-
- @reify
- def forwarded(self):
- """A tuple containing all parsed Forwarded header(s).
-
- Makes an effort to parse Forwarded headers as specified by RFC 7239:
-
- - It adds one (immutable) dictionary per Forwarded 'field-value', ie
- per proxy. The element corresponds to the data in the Forwarded
- field-value added by the first proxy encountered by the client. Each
- subsequent item corresponds to those added by later proxies.
- - It checks that every value has valid syntax in general as specified
- in section 4: either a 'token' or a 'quoted-string'.
- - It un-escapes found escape sequences.
- - It does NOT validate 'by' and 'for' contents as specified in section
- 6.
- - It does NOT validate 'host' contents (Host ABNF).
- - It does NOT validate 'proto' contents for valid URI scheme names.
-
- Returns a tuple containing one or more immutable dicts
- """
- elems = []
- for field_value in self._message.headers.getall(hdrs.FORWARDED, ()):
- length = len(field_value)
- pos = 0
- need_separator = False
- elem = {}
- elems.append(types.MappingProxyType(elem))
- while 0 <= pos < length:
- match = _FORWARDED_PAIR_RE.match(field_value, pos)
- if match is not None: # got a valid forwarded-pair
- if need_separator:
- # bad syntax here, skip to next comma
- pos = field_value.find(',', pos)
- else:
- name, value, port = match.groups()
- if value[0] == '"':
- # quoted string: remove quotes and unescape
- value = _QUOTED_PAIR_REPLACE_RE.sub(r'\1',
- value[1:-1])
- if port:
- value += port
- elem[name.lower()] = value
- pos += len(match.group(0))
- need_separator = True
- elif field_value[pos] == ',': # next forwarded-element
- need_separator = False
- elem = {}
- elems.append(types.MappingProxyType(elem))
- pos += 1
- elif field_value[pos] == ';': # next forwarded-pair
- need_separator = False
- pos += 1
- elif field_value[pos] in ' \t':
- # Allow whitespace even between forwarded-pairs, though
- # RFC 7239 doesn't. This simplifies code and is in line
- # with Postel's law.
- pos += 1
- else:
- # bad syntax here, skip to next comma
- pos = field_value.find(',', pos)
- return tuple(elems)
-
- @reify
- def scheme(self) -> str:
- """A string representing the scheme of the request.
-
- Hostname is resolved in this order:
-
- - overridden value by .clone(scheme=new_scheme) call.
- - type of connection to peer: HTTPS if socket is SSL, HTTP otherwise.
-
- 'http' or 'https'.
- """
- if self.transport.get_extra_info('sslcontext'):
- return 'https'
- else:
- return 'http'
-
- @reify
- def method(self) -> str:
- """Read only property for getting HTTP method.
-
- The value is upper-cased str like 'GET', 'POST', 'PUT' etc.
- """
- return self._method
-
- @reify
- def version(self) -> Tuple[int, int]:
- """Read only property for getting HTTP version of request.
-
- Returns aiohttp.protocol.HttpVersion instance.
- """
- return self._version
-
- @reify
- def host(self) -> str:
- """Hostname of the request.
-
- Hostname is resolved in this order:
-
- - overridden value by .clone(host=new_host) call.
- - HOST HTTP header
- - socket.getfqdn() value
- """
- host = self._message.headers.get(hdrs.HOST)
- if host is not None:
- return host
- else:
- return socket.getfqdn()
-
- @reify
- def remote(self) -> Optional[str]:
- """Remote IP of client initiated HTTP request.
-
- The IP is resolved in this order:
-
- - overridden value by .clone(remote=new_remote) call.
- - peername of opened socket
- """
- if self.transport is None:
- return None
- peername = self.transport.get_extra_info('peername')
- if isinstance(peername, (list, tuple)):
- return peername[0]
- else:
- return peername
-
- @reify
- def url(self) -> URL:
- url = URL.build(scheme=self.scheme, host=self.host)
- return url.join(self._rel_url)
-
- @reify
- def path(self) -> str:
- """The URL including *PATH INFO* without the host or scheme.
-
- E.g., ``/app/blog``
- """
- return self._rel_url.path
-
- @reify
- def path_qs(self) -> str:
- """The URL including PATH_INFO and the query string.
-
- E.g, /app/blog?id=10
- """
- return str(self._rel_url)
-
- @reify
- def raw_path(self) -> str:
- """ The URL including raw *PATH INFO* without the host or scheme.
- Warning, the path is unquoted and may contains non valid URL characters
-
- E.g., ``/my%2Fpath%7Cwith%21some%25strange%24characters``
- """
- return self._message.path
-
- @reify
- def query(self) -> MultiDict:
- """A multidict with all the variables in the query string."""
- return self._rel_url.query
-
- @reify
- def query_string(self) -> str:
- """The query string in the URL.
-
- E.g., id=10
- """
- return self._rel_url.query_string
-
- @reify
- def headers(self) -> CIMultiDictProxy:
- """A case-insensitive multidict proxy with all headers."""
- return self._headers
-
- @reify
- def raw_headers(self) -> RawHeaders:
- """A sequence of pairs for all headers."""
- return self._message.raw_headers
-
- @staticmethod
- def _http_date(_date_str) -> Optional[datetime.datetime]:
- """Process a date string, return a datetime object
- """
- if _date_str is not None:
- timetuple = parsedate(_date_str)
- if timetuple is not None:
- return datetime.datetime(*timetuple[:6],
- tzinfo=datetime.timezone.utc)
- return None
-
- @reify
- def if_modified_since(self) -> Optional[datetime.datetime]:
- """The value of If-Modified-Since HTTP header, or None.
-
- This header is represented as a `datetime` object.
- """
- return self._http_date(self.headers.get(hdrs.IF_MODIFIED_SINCE))
-
- @reify
- def if_unmodified_since(self) -> Optional[datetime.datetime]:
- """The value of If-Unmodified-Since HTTP header, or None.
-
- This header is represented as a `datetime` object.
- """
- return self._http_date(self.headers.get(hdrs.IF_UNMODIFIED_SINCE))
-
- @reify
- def if_range(self) -> Optional[datetime.datetime]:
- """The value of If-Range HTTP header, or None.
-
- This header is represented as a `datetime` object.
- """
- return self._http_date(self.headers.get(hdrs.IF_RANGE))
-
- @reify
- def keep_alive(self) -> bool:
- """Is keepalive enabled by client?"""
- return not self._message.should_close
-
- @reify
- def cookies(self) -> Mapping[str, str]:
- """Return request cookies.
-
- A read-only dictionary-like object.
- """
- raw = self.headers.get(hdrs.COOKIE, '')
- parsed = SimpleCookie(raw) # type: ignore
- return MappingProxyType(
- {key: val.value for key, val in parsed.items()})
-
- @reify
- def http_range(self):
- """The content of Range HTTP header.
-
- Return a slice instance.
-
- """
- rng = self._headers.get(hdrs.RANGE)
- start, end = None, None
- if rng is not None:
- try:
- pattern = r'^bytes=(\d*)-(\d*)$'
- start, end = re.findall(pattern, rng)[0]
- except IndexError: # pattern was not found in header
- raise ValueError("range not in acceptable format")
-
- end = int(end) if end else None
- start = int(start) if start else None
-
- if start is None and end is not None:
- # end with no start is to return tail of content
- start = -end
- end = None
-
- if start is not None and end is not None:
- # end is inclusive in range header, exclusive for slice
- end += 1
-
- if start >= end:
- raise ValueError('start cannot be after end')
-
- if start is end is None: # No valid range supplied
- raise ValueError('No start or end of range specified')
-
- return slice(start, end, 1)
-
- @reify
- def content(self) -> StreamReader:
- """Return raw payload stream."""
- return self._payload
-
- @property
- def has_body(self) -> bool:
- """Return True if request's HTTP BODY can be read, False otherwise."""
- warnings.warn(
- "Deprecated, use .can_read_body #2005",
- DeprecationWarning, stacklevel=2)
- return not self._payload.at_eof()
-
- @property
- def can_read_body(self) -> bool:
- """Return True if request's HTTP BODY can be read, False otherwise."""
- return not self._payload.at_eof()
-
- @reify
- def body_exists(self) -> bool:
- """Return True if request has HTTP BODY, False otherwise."""
- return type(self._payload) is not EmptyStreamReader
-
- async def release(self) -> None:
- """Release request.
-
- Eat unread part of HTTP BODY if present.
- """
- while not self._payload.at_eof():
- await self._payload.readany()
-
- async def read(self) -> bytes:
- """Read request body if present.
-
- Returns bytes object with full request content.
- """
- if self._read_bytes is None:
- body = bytearray()
- while True:
- chunk = await self._payload.readany()
- body.extend(chunk)
- if self._client_max_size:
- body_size = len(body)
- if body_size >= self._client_max_size:
- raise HTTPRequestEntityTooLarge(
- max_size=self._client_max_size,
- actual_size=body_size
- )
- if not chunk:
- break
- self._read_bytes = bytes(body)
- return self._read_bytes
-
- async def text(self) -> str:
- """Return BODY as text using encoding from .charset."""
- bytes_body = await self.read()
- encoding = self.charset or 'utf-8'
- return bytes_body.decode(encoding)
-
- async def json(self, *, loads: JSONDecoder=DEFAULT_JSON_DECODER) -> Any:
- """Return BODY as JSON."""
- body = await self.text()
- return loads(body)
-
- async def multipart(self) -> MultipartReader:
- """Return async iterator to process BODY as multipart."""
- return MultipartReader(self._headers, self._payload)
-
- async def post(self) -> MultiDictProxy:
- """Return POST parameters."""
- if self._post is not None:
- return self._post
- if self._method not in self.POST_METHODS:
- self._post = MultiDictProxy(MultiDict())
- return self._post
-
- content_type = self.content_type
- if (content_type not in ('',
- 'application/x-www-form-urlencoded',
- 'multipart/form-data')):
- self._post = MultiDictProxy(MultiDict())
- return self._post
-
- out = MultiDict() # type: MultiDict
-
- if content_type == 'multipart/form-data':
- multipart = await self.multipart()
- max_size = self._client_max_size
-
- field = await multipart.next()
- while field is not None:
- size = 0
- content_type = field.headers.get(hdrs.CONTENT_TYPE)
-
- if field.filename:
- # store file in temp file
- tmp = tempfile.TemporaryFile()
- chunk = await field.read_chunk(size=2**16)
- while chunk:
- chunk = field.decode(chunk)
- tmp.write(chunk)
- size += len(chunk)
- if 0 < max_size < size:
- raise HTTPRequestEntityTooLarge(
- max_size=max_size,
- actual_size=size
- )
- chunk = await field.read_chunk(size=2**16)
- tmp.seek(0)
-
- ff = FileField(field.name, field.filename,
- cast(io.BufferedReader, tmp),
- content_type, field.headers)
- out.add(field.name, ff)
- else:
- value = await field.read(decode=True)
- if content_type is None or \
- content_type.startswith('text/'):
- charset = field.get_charset(default='utf-8')
- value = value.decode(charset)
- out.add(field.name, value)
- size += len(value)
- if 0 < max_size < size:
- raise HTTPRequestEntityTooLarge(
- max_size=max_size,
- actual_size=size
- )
-
- field = await multipart.next()
- else:
- data = await self.read()
- if data:
- charset = self.charset or 'utf-8'
- out.extend(
- parse_qsl(
- data.rstrip().decode(charset),
- keep_blank_values=True,
- encoding=charset))
-
- self._post = MultiDictProxy(out)
- return self._post
-
- def __repr__(self):
- ascii_encodable_path = self.path.encode('ascii', 'backslashreplace') \
- .decode('ascii')
- return "<{} {} {} >".format(self.__class__.__name__,
- self._method, ascii_encodable_path)
-
- def __eq__(self, other):
- return id(self) == id(other)
-
- @asyncio.coroutine
- def _prepare_hook(self, response):
- return
- yield # pragma: no cover
-
-
- class Request(BaseRequest):
-
- ATTRS = BaseRequest.ATTRS | frozenset(['_match_info'])
-
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs)
-
- # matchdict, route_name, handler
- # or information about traversal lookup
-
- # initialized after route resolving
- self._match_info = None # type: Optional[UrlMappingMatchInfo]
-
- if DEBUG:
- def __setattr__(self, name, val):
- if name not in self.ATTRS:
- warnings.warn("Setting custom {}.{} attribute "
- "is discouraged".format(self.__class__.__name__,
- name),
- DeprecationWarning,
- stacklevel=2)
- super().__setattr__(name, val)
-
- def clone(self, *, method: str=sentinel, rel_url:
- StrOrURL=sentinel, headers: LooseHeaders=sentinel,
- scheme: str=sentinel, host: str=sentinel, remote:
- str=sentinel) -> 'Request':
- ret = super().clone(method=method,
- rel_url=rel_url,
- headers=headers,
- scheme=scheme,
- host=host,
- remote=remote)
- new_ret = cast(Request, ret)
- new_ret._match_info = self._match_info
- return new_ret
-
- @reify
- def match_info(self) -> Optional[UrlMappingMatchInfo]:
- """Result of route resolving."""
- return self._match_info
-
- @property
- def app(self):
- """Application instance."""
- match_info = self._match_info
- if match_info is None:
- return None
- return match_info.current_app
-
- @property
- def config_dict(self) -> ChainMapProxy:
- match_info = self._match_info
- if match_info is None:
- return ChainMapProxy([])
- lst = match_info.apps
- app = self.app
- idx = lst.index(app)
- sublist = list(reversed(lst[:idx + 1]))
- return ChainMapProxy(sublist)
-
- async def _prepare_hook(self, response):
- match_info = self._match_info
- if match_info is None:
- return
- for app in match_info._apps:
- await app.on_response_prepare.send(self, response)
|