From a705cbbc4ac9d824250e2d46c47120829455953e Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Wed, 11 Nov 2026 17:05:53 +0000 Subject: [PATCH 1/4] sans-io parsers --- src/hip/parsers/__init__.py | 2 + src/hip/parsers/sansio.py | 81 +++++++++++++++++++++++++++++++++++++ tests/test_parsers.py | 60 +++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 src/hip/parsers/sansio.py diff --git a/src/hip/parsers/__init__.py b/src/hip/parsers/__init__.py index 9b403da..8be2065 100644 --- a/src/hip/parsers/__init__.py +++ b/src/hip/parsers/__init__.py @@ -1,6 +1,8 @@ from .http import HTTPParser, ProtocolError +from .sansio import BytesParser __all__ = [ "HTTPParser", "ProtocolError", + "BytesParser", ] diff --git a/src/hip/parsers/sansio.py b/src/hip/parsers/sansio.py new file mode 100644 index 0000000..67385a5 --- /dev/null +++ b/src/hip/parsers/sansio.py @@ -0,0 +1,81 @@ +class BytesParser: + """ + A parser following the Sans-IO design pattern. + + Provides parsing operations for "read some data" and + "read some data up until a fixed delimiter". + + Used by the HTTP parsing to handle parsing the different + sections of the request and response. + """ + + def __init__(self): + self._buffer = bytearray() + self._closed = False + self._cursor = 0 + + def read_some(self, max_size: int) -> bytes | None: + """ + Returns up to `max_size` bytes. + Returns b'' to indicate the end of the stream. + Removes the returned data from the buffer. + + Returns `None` to indicate feed required. + """ + if not self._buffer: + if self._closed: + return b'' + return None + + read, unread = self._buffer[:max_size], self._buffer[max_size:] + self._buffer = bytearray(unread) + return bytes(read) + + + def read_until(self, marker: bytes, max_size: int) -> bytes | None: + """ + Returns data up to and excluding the designated `marker`. + Returns at most `max_size` bytes. + Removes both the returned data and the marker from the buffer. + + Returns `None` to indicate feed required. + + Raises a `ParseError` if the marker cannot be found. + """ + if self._cursor >= len(self._buffer): + if self._closed: + raise ... + return None + + start_point = self._cursor + end_point = self._cursor + max_size + len(marker) + idx = self._buffer.find(marker, start_point, end_point) + if idx < 0: + self._cursor += max_size + return None + + read, unread = self._buffer[:idx], self._buffer[idx + len(marker):] + self._buffer = bytearray(unread) + self._cursor = 0 + return bytes(read) + + def feed(self, data: bytes) -> None: + """ + Feed `data` bytes to the parser. + Feed b'' to indicate the end of the stream. + """ + if data: + if self._closed: + raise ValueError('Attempted to feed data to a closed parser.') + self._buffer.extend(data) + else: + self._closed = True + + # def read_is_eof(self) -> bool | None: + # """ + # Returns True to indicate the end of the stream. + # Returns False to indicate the st + # Does not remove any data from the buffer. + + # Returns `None` to indicate feed required. + # """ diff --git a/tests/test_parsers.py b/tests/test_parsers.py index b6a608f..d1fa536 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -874,3 +874,63 @@ def test_request_missing_host_header(): msg = "Request missing 'Host' header" with pytest.raises(hip.parsers.ProtocolError, match=msg): p.recv_headers() + + +# Test Sans-IO parsers... + +def test_bytes_parser(): + p = hip.parsers.BytesParser() + p.feed(b"abc") + p.feed(b"defg") + p.feed(b"") + + assert p.read_some(10) == b"abcdefg" + assert p.read_some(10) == b"" + assert p.read_some(10) == b"" + + +def test_bytes_parser_partial_reads(): + p = hip.parsers.BytesParser() + p.feed(b"abc") + p.feed(b"defg") + p.feed(b"") + + assert p.read_some(1) == b"a" + assert p.read_some(2) == b"bc" + assert p.read_some(3) == b"def" + assert p.read_some(4) == b"g" + assert p.read_some(5) == b"" + assert p.read_some(6) == b"" + + +def test_bytes_parser_need_feeds(): + p = hip.parsers.BytesParser() + + assert p.read_some(1) is None + p.feed(b"abc") + assert p.read_some(1) == b"a" + assert p.read_some(2) == b"bc" + + assert p.read_some(3) is None + p.feed(b"defg") + + assert p.read_some(3) == b"def" + assert p.read_some(4) == b"g" + assert p.read_some(5) is None + p.feed(b"") + + assert p.read_some(5) == b"" + assert p.read_some(6) == b"" + + +def test_bytes_parser_read_until(): + p = hip.parsers.BytesParser() + p.feed(b"GET / HTTP/1.0\r\n") + p.feed(b"Host: example.com\r\n") + p.feed(b"User-Agent: dev/hip\r\n") + p.feed(b"\r\n") + p.feed(b"") + + assert p.read_until(b"\r\n", max_size=5_000) == b"GET / HTTP/1.0" + assert p.read_until(b"\r\n\r\n", max_size=10_000) == b"Host: example.com\r\nUser-Agent: dev/hip" + assert p.read_some(max_size=10_000) == b"" From ad82addfefd4f78f1dd75c93d24e9c98f00c5b24 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Thu, 12 Nov 2026 00:02:01 +0000 Subject: [PATCH 2/4] Linting --- src/hip/parsers/__init__.py | 2 +- src/hip/parsers/sansio.py | 17 ++++++++++------- tests/test_parsers.py | 6 +++++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/hip/parsers/__init__.py b/src/hip/parsers/__init__.py index 8be2065..f279013 100644 --- a/src/hip/parsers/__init__.py +++ b/src/hip/parsers/__init__.py @@ -2,7 +2,7 @@ from .sansio import BytesParser __all__ = [ + "BytesParser", "HTTPParser", "ProtocolError", - "BytesParser", ] diff --git a/src/hip/parsers/sansio.py b/src/hip/parsers/sansio.py index 67385a5..e203799 100644 --- a/src/hip/parsers/sansio.py +++ b/src/hip/parsers/sansio.py @@ -1,6 +1,10 @@ +class ParseError(Exception): + pass + + class BytesParser: """ - A parser following the Sans-IO design pattern. + A parser following the Sans-IO design pattern. Provides parsing operations for "read some data" and "read some data up until a fixed delimiter". @@ -24,14 +28,13 @@ def read_some(self, max_size: int) -> bytes | None: """ if not self._buffer: if self._closed: - return b'' + return b"" return None read, unread = self._buffer[:max_size], self._buffer[max_size:] self._buffer = bytearray(unread) return bytes(read) - def read_until(self, marker: bytes, max_size: int) -> bytes | None: """ Returns data up to and excluding the designated `marker`. @@ -44,7 +47,7 @@ def read_until(self, marker: bytes, max_size: int) -> bytes | None: """ if self._cursor >= len(self._buffer): if self._closed: - raise ... + raise ParseError() return None start_point = self._cursor @@ -54,7 +57,7 @@ def read_until(self, marker: bytes, max_size: int) -> bytes | None: self._cursor += max_size return None - read, unread = self._buffer[:idx], self._buffer[idx + len(marker):] + read, unread = self._buffer[:idx], self._buffer[idx + len(marker) :] self._buffer = bytearray(unread) self._cursor = 0 return bytes(read) @@ -66,7 +69,7 @@ def feed(self, data: bytes) -> None: """ if data: if self._closed: - raise ValueError('Attempted to feed data to a closed parser.') + raise ValueError("Attempted to feed data to a closed parser.") self._buffer.extend(data) else: self._closed = True @@ -76,6 +79,6 @@ def feed(self, data: bytes) -> None: # Returns True to indicate the end of the stream. # Returns False to indicate the st # Does not remove any data from the buffer. - + # Returns `None` to indicate feed required. # """ diff --git a/tests/test_parsers.py b/tests/test_parsers.py index d1fa536..e4390e1 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -878,6 +878,7 @@ def test_request_missing_host_header(): # Test Sans-IO parsers... + def test_bytes_parser(): p = hip.parsers.BytesParser() p.feed(b"abc") @@ -932,5 +933,8 @@ def test_bytes_parser_read_until(): p.feed(b"") assert p.read_until(b"\r\n", max_size=5_000) == b"GET / HTTP/1.0" - assert p.read_until(b"\r\n\r\n", max_size=10_000) == b"Host: example.com\r\nUser-Agent: dev/hip" + assert ( + p.read_until(b"\r\n\r\n", max_size=10_000) + == b"Host: example.com\r\nUser-Agent: dev/hip" + ) assert p.read_some(max_size=10_000) == b"" From 37c8ad29a83abe9a5ff2ec784845aa154d842ccd Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Thu, 12 Nov 2026 00:19:31 +0000 Subject: [PATCH 3/4] Tests, Linting --- src/hip/parsers/__init__.py | 3 ++- src/hip/parsers/sansio.py | 4 ++-- tests/test_parsers.py | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/hip/parsers/__init__.py b/src/hip/parsers/__init__.py index f279013..afb43b8 100644 --- a/src/hip/parsers/__init__.py +++ b/src/hip/parsers/__init__.py @@ -1,8 +1,9 @@ from .http import HTTPParser, ProtocolError -from .sansio import BytesParser +from .sansio import BytesParser, ParseError __all__ = [ "BytesParser", "HTTPParser", + "ParseError", "ProtocolError", ] diff --git a/src/hip/parsers/sansio.py b/src/hip/parsers/sansio.py index e203799..6bb3d68 100644 --- a/src/hip/parsers/sansio.py +++ b/src/hip/parsers/sansio.py @@ -51,10 +51,10 @@ def read_until(self, marker: bytes, max_size: int) -> bytes | None: return None start_point = self._cursor - end_point = self._cursor + max_size + len(marker) + end_point = max_size + len(marker) idx = self._buffer.find(marker, start_point, end_point) if idx < 0: - self._cursor += max_size + self._cursor = max(0, len(self._buffer) - len(marker)) return None read, unread = self._buffer[:idx], self._buffer[idx + len(marker) :] diff --git a/tests/test_parsers.py b/tests/test_parsers.py index e4390e1..a977bce 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -938,3 +938,42 @@ def test_bytes_parser_read_until(): == b"Host: example.com\r\nUser-Agent: dev/hip" ) assert p.read_some(max_size=10_000) == b"" + + +def test_bytes_parser_read_until_needs_feeds(): + p = hip.parsers.BytesParser() + + assert p.read_until(b"\r\n", max_size=5_000) is None + p.feed(b"GET / HTTP/1.0\r\n") + assert p.read_until(b"\r\n", max_size=5_000) == b"GET / HTTP/1.0" + + assert p.read_until(b"\r\n\r\n", max_size=10_000) is None + p.feed(b"Host: example.com\r\n") + assert p.read_until(b"\r\n\r\n", max_size=10_000) is None + p.feed(b"User-Agent: dev/hip\r\n") + assert p.read_until(b"\r\n\r\n", max_size=10_000) is None + p.feed(b"\r\n") + assert ( + p.read_until(b"\r\n\r\n", max_size=10_000) + == b"Host: example.com\r\nUser-Agent: dev/hip" + ) + + assert p.read_some(max_size=10_000) is None + p.feed(b"") + assert p.read_some(max_size=10_000) == b"" + + +def test_bytes_parser_read_until_exception(): + p = hip.parsers.BytesParser() + p.feed(b"") + + with pytest.raises(hip.parsers.ParseError): + assert p.read_until(b"\r\n", max_size=5_000) + + +def test_bytes_parser_feed_after_close(): + p = hip.parsers.BytesParser() + p.feed(b"") + + with pytest.raises(ValueError): + assert p.feed(b"...") From ed89140fdcce330d07f6a6d974ca0e57a4b39849 Mon Sep 17 00:00:00 2001 From: Kim Christie Date: Thu, 12 Nov 2026 17:41:40 +0000 Subject: [PATCH 4/4] First pass at client parser --- src/hip/parsers/__init__.py | 3 +- src/hip/parsers/sansio.py | 235 ++++++++++++++++++++++++++++++++++++ tests/test_parsers.py | 129 +++++++++++++++++--- 3 files changed, 351 insertions(+), 16 deletions(-) diff --git a/src/hip/parsers/__init__.py b/src/hip/parsers/__init__.py index afb43b8..aecac5f 100644 --- a/src/hip/parsers/__init__.py +++ b/src/hip/parsers/__init__.py @@ -1,9 +1,10 @@ from .http import HTTPParser, ProtocolError -from .sansio import BytesParser, ParseError +from .sansio import BytesParser, HTTPClient, ParseError __all__ = [ "BytesParser", "HTTPParser", + "HTTPClient", "ParseError", "ProtocolError", ] diff --git a/src/hip/parsers/sansio.py b/src/hip/parsers/sansio.py index 6bb3d68..037651f 100644 --- a/src/hip/parsers/sansio.py +++ b/src/hip/parsers/sansio.py @@ -82,3 +82,238 @@ def feed(self, data: bytes) -> None: # Returns `None` to indicate feed required. # """ + +# --- + +class ProtocolError(Exception): + pass + + +WAIT = 0 +SEND_START = 1 +SEND_HEADERS = 2 +SEND_BODY = 3 +SEND_COMPLETE = 4 + +MAX_STATUS_LINE_SIZE = 1024 +MAX_METHOD_LINE_SIZE = 16384 +MAX_HEADERS_SIZE = 65536 +MAX_READ = 65536 + + +# def _validate_request_line(data: bytes) -> tuple[bytes, bytes, bytes]: +# try: +# method, url, protocol = data.split(b" ") +# except ValueError: +# raise ProtocolError("Invalid request line") + +# if method.strip() == b"" or len(method) > 20: +# raise ProtocolError("Invalid method") +# if url.strip() == b"" or len(url) > 16384: +# raise ProtocolError("Invalid url") +# if protocol != b"HTTP/1.1" and protocol != b"HTTP/1.0": +# raise ProtocolError("Invalid protocol") + +# return method, url, protocol + + +def _validate_status_line(data: bytes) -> tuple[bytes, int, bytes]: + try: + protocol, status_code, reason = data.split(b" ", 3) + except ValueError: + raise ProtocolError("Invalid status line") + + if protocol != b"HTTP/1.1" and protocol != b"HTTP/1.0": + raise ProtocolError("Invalid protocol") + + if len(status_code) != 3: + raise ProtocolError("Invalid status code") + try: + status = int(status_code) + except ValueError: + raise ProtocolError("Invalid status code") + if not 100 <= status <= 999: + raise ProtocolError("Invalid status code") + + return protocol, status, reason + + +def _validate_headers(data: bytes) -> list[tuple[bytes, bytes]]: + headers = [] + for line in data.split(b"\r\n"): + try: + name, value = line.split(b":", 1) + except ValueError: + raise ProtocolError("Invalid header") + headers.append((name, value.strip())) + return headers + + +# TODO: + +# * Content-Length validation +# * Connection: keep-alive support +# * Transfer-Encoding: chunked support +# * Validate Host header + + + +class HTTPClient: + def __init__(self): + self._parser = BytesParser() + self._client = SEND_START + self._server = WAIT + + def send_request_line(self, method: bytes, target: bytes, protocol: bytes) -> bytes: + if self._client != SEND_START: + raise RuntimeError("Called 'send_method_line' out of sequence.") + + data = b"%b %b %b\r\n" % (method, target, protocol) + + # Request method line too long + # Invalid request method line + # Protocol not supported + + self._client = SEND_HEADERS + self._server = SEND_START + + return data + + def send_headers(self, headers: list[tuple[bytes, bytes]]) -> bytes: + if self._client != SEND_HEADERS: + raise RuntimeError("Called 'send_headers' out of sequence.") + + lines = [b"%b: %b\r\n" % (name, value) for name, value in headers] + lines.append(b"\r\n") + data = b"".join(lines) + + # Headers too long + # Too many headers + # Invalid request header + + self._client = SEND_BODY + + return data + + def send_body(self, body: bytes) -> bytes: + if self._client != SEND_BODY: + raise RuntimeError("Called 'send_body' out of sequence.") + + # Too little body for declared Content-Length + # Invalid framing for Transfer-Encoding + + if body == b'': + self._client = SEND_COMPLETE + + # if self._server == SEND_COMPLETE: + # self._client = SEND_START + # self._server = WAIT + + return body + + def recv_status_line(self) -> tuple[bytes, int, bytes] | None: + if self._server != SEND_START: + raise RuntimeError("Called 'recv_status_line' out of sequence.") + + if (data := self._parser.read_until(b"\r\n", MAX_STATUS_LINE_SIZE)) is None: + return None + + protocol, status, reason = _validate_status_line(data) + + self._server = SEND_HEADERS + + return protocol, status, reason + + def recv_headers(self) -> list[tuple[bytes, bytes]] | None: + if self._server != SEND_HEADERS: + raise RuntimeError("Called 'recv_headers' out of sequence.") + + if (data := self._parser.read_until(b"\r\n\r\n", MAX_HEADERS_SIZE)) is None: + return None + + headers = _validate_headers(data) + + self._server = SEND_BODY + + return headers + + def recv_body(self) -> bytes | None: + if self._server != SEND_BODY: + raise RuntimeError("Called 'recv_body' out of sequence.") + + if (body := self._parser.read_some(max_size=MAX_READ)) is None: + return None + + if body == b'': + self._server = SEND_COMPLETE + # if self._client == SEND_COMPLETE: + # self._client = SEND_START + # self._server = WAIT + + return body + + def feed(self, data: bytes) -> None: + """ + Feed `data` bytes to the parser. + Feeding b'' indicates EOF. + """ + self._parser.feed(data) + + # @property + # def is_keep_alive(self) -> bool: + # ... + + +# class HTTPServerParser: +# def __init__(self): +# self._parser = BytesParser() + +# def recv_method(self) -> tuple[bytes, bytes, bytes] | None: +# if (data := self._parser.read_until("\r\n", _MAX_REQUEST_LINE)) is None: +# return None +# # Request method line too long +# # Invalid request method line +# # Protocol not supported + +# def recv_headers(self) -> list[tuple[bytes, bytes]] | None: +# if (data := self._parser.read_until("\r\n\r\n", _MAX_HEADERS_SIZE)) is None: +# return None +# ... +# # Headers too long +# # Too many headers +# # Invalid request header + +# def recv_body(self) -> bytes | None: +# if (body := self._parser.read_some(max_size=...)) is None: +# return None +# # Too little body for declared Content-Length +# # Invalid framing for Transfer-Encoding + +# def send_response(self, protocol: bytes, status: int, reason: bytes) -> bytes: +# # Protocol not supported +# # Invalid status code +# # Invalid reason +# ... + +# def send_headers(self, headers: list[tuple[bytes, bytes]]) -> bytes: +# # Headers too long +# # Too many headers +# # Missing Host header +# # Conflicting Content-Length / Transfer-Encoding headers +# ... + +# def send_body(self, body: bytes) -> bytes: +# # Too much body for declared Content-Length +# # Too little body for declared Content-Length +# ... + +# def feed(self, data: bytes) -> None: +# """ +# Feed `data` bytes to the parser. +# Feeding b'' indicates EOF. +# """ +# self._parser.feed(data) + +# @property +# def is_keep_alive(self) -> bool: +# ... diff --git a/tests/test_parsers.py b/tests/test_parsers.py index a977bce..11c381d 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -881,10 +881,13 @@ def test_request_missing_host_header(): def test_bytes_parser(): p = hip.parsers.BytesParser() + + # Feed some data, then half-close the buffer. p.feed(b"abc") p.feed(b"defg") p.feed(b"") + # Test a sequence of reads. assert p.read_some(10) == b"abcdefg" assert p.read_some(10) == b"" assert p.read_some(10) == b"" @@ -892,10 +895,13 @@ def test_bytes_parser(): def test_bytes_parser_partial_reads(): p = hip.parsers.BytesParser() + + # Feed some data, then half-close the buffer. p.feed(b"abc") p.feed(b"defg") p.feed(b"") + # Test a sequence of reads. assert p.read_some(1) == b"a" assert p.read_some(2) == b"bc" assert p.read_some(3) == b"def" @@ -904,62 +910,90 @@ def test_bytes_parser_partial_reads(): assert p.read_some(6) == b"" +def test_bytes_parser_feed_after_close(): + p = hip.parsers.BytesParser() + + # Feed some data, then half-close the buffer. + p.feed(b"abc") + p.feed(b"defg") + p.feed(b"") + + # Buffer is half-closed, cannot feed any more data. + with pytest.raises(ValueError): + assert p.feed(b"...") + + def test_bytes_parser_need_feeds(): p = hip.parsers.BytesParser() + # Parser needs some I/O feeding to the buffer assert p.read_some(1) is None p.feed(b"abc") assert p.read_some(1) == b"a" assert p.read_some(2) == b"bc" + # Parser needs some I/O feeding to the buffer assert p.read_some(3) is None p.feed(b"defg") assert p.read_some(3) == b"def" assert p.read_some(4) == b"g" + + # Parser needs some I/O feeding to the buffer assert p.read_some(5) is None p.feed(b"") - assert p.read_some(5) == b"" assert p.read_some(6) == b"" def test_bytes_parser_read_until(): p = hip.parsers.BytesParser() + + # Feed some data, then half-close the buffer. p.feed(b"GET / HTTP/1.0\r\n") p.feed(b"Host: example.com\r\n") - p.feed(b"User-Agent: dev/hip\r\n") + p.feed(b"Connection: close\r\n") p.feed(b"\r\n") p.feed(b"") - assert p.read_until(b"\r\n", max_size=5_000) == b"GET / HTTP/1.0" - assert ( - p.read_until(b"\r\n\r\n", max_size=10_000) - == b"Host: example.com\r\nUser-Agent: dev/hip" - ) - assert p.read_some(max_size=10_000) == b"" + # Parse the different components of the requst + request = p.read_until(b"\r\n", max_size=5_000) + headers = p.read_until(b"\r\n\r\n", max_size=10_000) + content = p.read_some(max_size=10_000) + + assert request == b"GET / HTTP/1.0" + assert headers == b"Host: example.com\r\nConnection: close" + assert content == p.read_some(max_size=10_000) def test_bytes_parser_read_until_needs_feeds(): p = hip.parsers.BytesParser() + # Parser needs some I/O feeding to the buffer assert p.read_until(b"\r\n", max_size=5_000) is None p.feed(b"GET / HTTP/1.0\r\n") + # Request line is complete assert p.read_until(b"\r\n", max_size=5_000) == b"GET / HTTP/1.0" + # Parser needs some I/O feeding to the buffer assert p.read_until(b"\r\n\r\n", max_size=10_000) is None p.feed(b"Host: example.com\r\n") + # Parser needs some I/O feeding to the buffer assert p.read_until(b"\r\n\r\n", max_size=10_000) is None - p.feed(b"User-Agent: dev/hip\r\n") + p.feed(b"Connection: close\r\n") + # Parser needs some I/O feeding to the buffer assert p.read_until(b"\r\n\r\n", max_size=10_000) is None p.feed(b"\r\n") + # Request headers are complete assert ( p.read_until(b"\r\n\r\n", max_size=10_000) - == b"Host: example.com\r\nUser-Agent: dev/hip" + == b"Host: example.com\r\nConnection: close" ) + # Parser needs some I/O feeding to the buffer assert p.read_some(max_size=10_000) is None p.feed(b"") + # Reques is complete assert p.read_some(max_size=10_000) == b"" @@ -971,9 +1005,74 @@ def test_bytes_parser_read_until_exception(): assert p.read_until(b"\r\n", max_size=5_000) -def test_bytes_parser_feed_after_close(): - p = hip.parsers.BytesParser() - p.feed(b"") +# Test HTTP client parser... - with pytest.raises(ValueError): - assert p.feed(b"...") +def test_http_client_parser(): + parser = hip.parsers.HTTPClient() + + # Write the request... + output = b'' + output += parser.send_request_line(b"GET", b"/", b"HTTP/1.1") + output += parser.send_headers([ + (b"Host", b"www.example.com"), + (b"User-Agent", b"hip/dev") + ]) + output += parser.send_body(b'') + + assert output == ( + b"GET / HTTP/1.1\r\n" + b"Host: www.example.com\r\n" + b"User-Agent: hip/dev\r\n" + b"\r\n" + ) + + # Read the response... + input = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/html\r\n" + b"Content-Length: 25\r\n" + b"\r\n" + b"hello, world" + ) + parser.feed(input) + + protocol, status, reason = parser.recv_status_line() + headers = parser.recv_headers() + body = parser.recv_body() + + assert protocol == b'HTTP/1.1' + assert status == 200 + assert reason == b'OK' + assert headers == [ + (b'Content-Type', b'text/html'), + (b'Content-Length', b'25'), + ] + assert body == b'hello, world' + + +def test_http_client_parser_invalid_transitions(): + # Ensure calling parser methods cannot be called in an invalid order. + with pytest.raises(RuntimeError): + parser = hip.parsers.HTTPClient() + parser.send_request_line(b'GET', b'/', b'HTTP/1.1') + parser.send_request_line(b'GET', b'/', b'HTTP/1.1') + + with pytest.raises(RuntimeError): + parser = hip.parsers.HTTPClient() + parser.send_headers([]) + + with pytest.raises(RuntimeError): + parser = hip.parsers.HTTPClient() + parser.send_body(b'') + + with pytest.raises(RuntimeError): + parser = hip.parsers.HTTPClient() + parser.recv_status_line() + + with pytest.raises(RuntimeError): + parser = hip.parsers.HTTPClient() + parser.recv_headers() + + with pytest.raises(RuntimeError): + parser = hip.parsers.HTTPClient() + parser.recv_body()