Skip to content

Commit 329855b

Browse files
joamagclaude
andcommitted
feat: add address property to StreamProtocol and proxy integration tests
The proxy's _apply_via accesses parser_prx.owner.address which fails on HTTPProtocol (new architecture) since it lacked the address attribute. Added a delegation property following the existing StreamProtocol pattern. Also adds end-to-end integration tests that start a real reverse proxy server and exercise the full request/response data flow, and a minimal reverse proxy example. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 33d7dd9 commit 329855b

File tree

4 files changed

+212
-15
lines changed

4 files changed

+212
-15
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
*
12+
* `address` property on `StreamProtocol` delegating to underlying `Connection` for backward compatibility with proxy code that accesses `protocol.address`
13+
* End-to-end integration tests for `ReverseProxyServer` exercising the full proxy data flow through a real server with httpbin backend
14+
* Reverse proxy example (`examples/proxy/proxy_reverse.py`) showing minimal setup for forwarding requests to a backend
1315

1416
### Changed
1517

examples/proxy/proxy_reverse.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/python
2+
# -*- coding: utf-8 -*-
3+
4+
# Hive Netius System
5+
# Copyright (c) 2008-2024 Hive Solutions Lda.
6+
#
7+
# This file is part of Hive Netius System.
8+
#
9+
# Hive Netius System is free software: you can redistribute it and/or modify
10+
# it under the terms of the Apache License as published by the Apache
11+
# Foundation, either version 2.0 of the License, or (at your option) any
12+
# later version.
13+
#
14+
# Hive Netius System is distributed in the hope that it will be useful,
15+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
# Apache License for more details.
18+
#
19+
# You should have received a copy of the Apache License along with
20+
# Hive Netius System. If not, see <http://www.apache.org/licenses/>.
21+
22+
__author__ = "João Magalhães <joamag@hive.pt>"
23+
""" The author(s) of the module """
24+
25+
__copyright__ = "Copyright (c) 2008-2024 Hive Solutions Lda."
26+
""" The copyright for the module """
27+
28+
__license__ = "Apache License, Version 2.0"
29+
""" The license for the module """
30+
31+
# Simple reverse proxy that forwards all incoming HTTP requests
32+
# to httpbin.org and relays the responses back to the client,
33+
# run it from the repository root with:
34+
#
35+
# PYTHONPATH=src python examples/proxy/proxy_reverse.py
36+
#
37+
# then try:
38+
#
39+
# curl -H "Host: httpbin.org" http://localhost:8080/get
40+
# curl -H "Host: httpbin.org" http://localhost:8080/headers
41+
# curl -H "Host: httpbin.org" http://localhost:8080/ip
42+
43+
import logging
44+
45+
import netius.extra
46+
47+
server = netius.extra.ReverseProxyServer(
48+
hosts={"default": "http://httpbin.org"},
49+
level=logging.INFO,
50+
)
51+
server.serve(host="127.0.0.1", port=8080, env=True)

src/netius/base/protocol.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,20 @@ def socket(self):
418418
return None
419419
return connection.socket
420420

421+
@property
422+
def address(self):
423+
connection = self.connection
424+
if connection:
425+
return connection.address
426+
return getattr(self, "_address", None)
427+
428+
@address.setter
429+
def address(self, value):
430+
self._address = value
431+
connection = self.connection
432+
if connection:
433+
connection.address = value
434+
421435
@property
422436
def renable(self):
423437
connection = self.connection

src/netius/test/extra/proxy_r.py

Lines changed: 144 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,15 @@
2929
""" The license for the module """
3030

3131
import re
32+
import json
33+
import time
34+
import socket
3235
import unittest
36+
import threading
3337
import collections
3438

39+
import http.client
40+
3541
import netius
3642
import netius.extra
3743
import netius.clients
@@ -849,20 +855,6 @@ def _make_response_parser(self, backend, code="200", status="OK"):
849855
return parser
850856

851857
def test_close_no_loop_destroys_before_event(self):
852-
"""
853-
When a `Protocol` has no event loop (`_loop` is None),
854-
`close_c()` calls `delay(self.finish)` which invokes
855-
`finish()` immediately (synchronously). `finish()` calls
856-
`destroy()` -> `unbind_all()` which removes all event
857-
handlers. Then when `close()` reaches `trigger("close")`
858-
the handler list is already empty and the relay never
859-
fires, so `_on_prx_close` is never called and the
860-
`conn_map` entry is never cleaned up.
861-
862-
This test reproduces the scenario using a real
863-
`StreamProtocol` whose `_loop` is None.
864-
"""
865-
866858
if mock == None:
867859
self.skipTest("Skipping test: mock unavailable")
868860

@@ -905,3 +897,141 @@ def test_close_no_loop_destroys_before_event(self):
905897
# the entry remains (this is the bug)
906898
self.assertNotIn(backend, self.server.conn_map)
907899
self.assertEqual(self.server.busy_conn, 0)
900+
901+
902+
class ReverseProxyIntegrationTest(unittest.TestCase):
903+
"""
904+
End-to-end integration tests for the reverse proxy.
905+
906+
Starts a real ReverseProxyServer in a background thread
907+
and makes HTTP requests through it to an httpbin
908+
backend. Verifies the complete data flow including
909+
routing, header forwarding (Via, X-Forwarded-*),
910+
response relay, body integrity, and error handling
911+
for unmatched hosts.
912+
913+
These tests exercise the protocol-level address
914+
attribute and other backward-compatibility properties
915+
that are only reachable through actual network I/O.
916+
917+
Requires network; skipped when NO_NETWORK is set.
918+
"""
919+
920+
@classmethod
921+
def setUpClass(cls):
922+
if netius.conf("NO_NETWORK", False, cast=bool):
923+
return
924+
925+
cls.httpbin = netius.conf("HTTPBIN", "httpbin.org")
926+
927+
# create a reverse proxy that forwards all requests to httpbin
928+
cls.server = netius.extra.ReverseProxyServer(
929+
hosts={"default": "http://%s" % cls.httpbin},
930+
env=False,
931+
resolve=False,
932+
)
933+
cls.server.x_forwarded_proto = None
934+
cls.server.x_forwarded_port = None
935+
936+
# call serve() with start=False to bind the socket and set up
937+
# the poll without entering the event loop yet, using port 0
938+
# lets the OS pick a free port which is retrieved afterwards
939+
cls.server.serve(host="127.0.0.1", port=0, start=False)
940+
cls.proxy_port = cls.server.port
941+
942+
# start the proxy server event loop in a background thread
943+
cls.server_thread = threading.Thread(target=cls.server.start, daemon=True)
944+
cls.server_thread.start()
945+
946+
# wait for the server to be ready (accepting connections)
947+
for _i in range(50):
948+
time.sleep(0.1)
949+
try:
950+
probe = socket.create_connection(
951+
("127.0.0.1", cls.proxy_port), timeout=1
952+
)
953+
probe.close()
954+
break
955+
except (ConnectionRefusedError, OSError):
956+
continue
957+
958+
@classmethod
959+
def tearDownClass(cls):
960+
if not hasattr(cls, "server"):
961+
return
962+
cls.server.stop()
963+
cls.server_thread.join(timeout=5)
964+
965+
def setUp(self):
966+
if netius.conf("NO_NETWORK", False, cast=bool):
967+
self.skipTest("Network access is disabled")
968+
969+
def _request(self, path, headers=None):
970+
conn = http.client.HTTPConnection("127.0.0.1", self.proxy_port, timeout=30)
971+
try:
972+
_headers = {"Host": self.httpbin}
973+
if headers:
974+
_headers.update(headers)
975+
conn.request("GET", path, headers=_headers)
976+
response = conn.getresponse()
977+
body = response.read()
978+
response_headers = dict(response.getheaders())
979+
return response.status, response_headers, body
980+
finally:
981+
conn.close()
982+
983+
def test_simple_get(self):
984+
code, headers, body = self._request("/get")
985+
self.assertEqual(code, 200)
986+
self.assertGreater(len(body), 0)
987+
988+
def test_response_body_integrity(self):
989+
code, headers, body = self._request("/get")
990+
self.assertEqual(code, 200)
991+
data = json.loads(body.decode("utf-8"))
992+
self.assertIn("headers", data)
993+
self.assertIn("url", data)
994+
995+
def test_via_header(self):
996+
code, headers, body = self._request("/get")
997+
self.assertEqual(code, 200)
998+
via = headers.get("Via", None)
999+
self.assertIsNotNone(via, "Proxy should add a Via header to the response")
1000+
1001+
def test_x_forwarded_headers_sent(self):
1002+
code, headers, body = self._request("/get")
1003+
self.assertEqual(code, 200)
1004+
data = json.loads(body.decode("utf-8"))
1005+
request_headers = data.get("headers", {})
1006+
1007+
# httpbin echoes the request headers back to us,
1008+
# build a case-insensitive lookup since different httpbin
1009+
# implementations normalize casing differently
1010+
headers_lower = {k.lower(): v for k, v in request_headers.items()}
1011+
1012+
# verify the proxy injected the x-forwarded-* headers,
1013+
# note that some httpbin variants or upstream proxies may
1014+
# strip certain headers, so we check for the ones that are
1015+
# reliably passed through
1016+
self.assertIn("x-forwarded-host", headers_lower)
1017+
self.assertIn("x-client-ip", headers_lower)
1018+
1019+
def test_404_no_match(self):
1020+
connection = http.client.HTTPConnection(
1021+
"127.0.0.1", self.proxy_port, timeout=30
1022+
)
1023+
try:
1024+
connection.request(
1025+
"GET", "/get", headers={"Host": "unknown.host.example.com"}
1026+
)
1027+
response = connection.getresponse()
1028+
response.read()
1029+
self.assertEqual(response.status, 404)
1030+
finally:
1031+
connection.close()
1032+
1033+
def test_multiple_requests(self):
1034+
for _i in range(3):
1035+
code, headers, body = self._request("/get")
1036+
self.assertEqual(code, 200)
1037+
self.assertGreater(len(body), 0)

0 commit comments

Comments
 (0)