Skip to content

Commit 2c69170

Browse files
committed
add SQLAlchemy storage backend
- add sqastore.DatabaseStore and unittests - update API unittests for use in database tests - black code formatting - update README
1 parent 018dee6 commit 2c69170

File tree

11 files changed

+296
-16
lines changed

11 files changed

+296
-16
lines changed

.github/workflows/python-app.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
python -m pip install --upgrade pip
2929
pip install flake8 pytest
3030
pip install -r requirements.txt
31-
pip install -e .
31+
pip install -e .[sqla]
3232
- name: Lint with flake8
3333
run: |
3434
# stop the build if there are Python syntax errors or undefined names

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,4 @@ configurable_http_proxy/version.txt
135135
/.idea/misc.xml
136136
/.idea/modules.xml
137137
/.idea/vcs.xml
138+
*sqlite*

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ The following items are supported:
2727
- Customizable storage backends
2828
- PID file writing
2929
- Logging
30+
- Configurable storage backend
3031

3132
The following options are not supported (yet):
3233

@@ -35,3 +36,24 @@ The following options are not supported (yet):
3536
- Change Origin: `--change-origin`
3637
- Rewrites in Location header: `--protocol-rewrite` and `--auto-rewrite`
3738
- Metrics server: `--metrics-port` and `--metrics-ip`
39+
40+
41+
## Database-backed storage backend
42+
43+
Using a DBMS instead of the default in-memory store enables configurable-http-proxy
44+
to be replicated in a High Availability scenario.
45+
46+
To use a DBMS as the storage backend:
47+
48+
Set the CHP_DATABASE_URL env var to any db URL supported by SQLAlchemy.
49+
The default is "sqlite://chp.sqlite".
50+
51+
$ export CHP_DATABASE_URL="sqlite:///chp.sqlite"
52+
$ configurable-http-proxy --storage-backend configurable_http_proxy.sqastore.DatabaseStore
53+
54+
Optionally you may set the table name by setting the CHP_DATABASE_TABLE.
55+
The default is 'chp_routes'
56+
57+
$ export CHP_DATABASE_TABLE="chp_routes"
58+
59+

configurable_http_proxy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
log.setLevel(logging.INFO)
77

88
handler = logging.StreamHandler()
9-
handler.setFormatter(logging.Formatter('[%(levelname)-.1s %(asctime)s %(name)s] %(message)s'))
9+
handler.setFormatter(logging.Formatter("[%(levelname)-.1s %(asctime)s %(name)s] %(message)s"))
1010
logging.root.addHandler(handler)

configurable_http_proxy/_version.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
try:
88
from pkg_resources import get_distribution
99

10-
module_name = __name__.split('.', 1)[0]
10+
module_name = __name__.split(".", 1)[0]
1111
version = get_distribution(module_name).version
1212
except Exception: # noqa: S110
1313
pass
@@ -24,6 +24,6 @@
2424
if version is None:
2525
# When version.txt file is available - use that
2626
try:
27-
version = open(os.path.join(os.path.dirname(__file__), 'version.txt')).read()
27+
version = open(os.path.join(os.path.dirname(__file__), "version.txt")).read()
2828
except Exception: # noqa: S110
2929
pass

configurable_http_proxy/handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ def _get_proxy_request(self, url):
275275
fwd_values = {
276276
"for": self.request.remote_ip,
277277
"port": str(port),
278-
"proto": 'https' if encrypted else 'http',
278+
"proto": "https" if encrypted else "http",
279279
}
280280

281281
for key in ["for", "port", "proto"]:

configurable_http_proxy/sqastore.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import logging
2+
import os
3+
from datetime import datetime
4+
5+
from dataset import connect
6+
7+
from configurable_http_proxy.store import BaseStore
8+
9+
log = logging.getLogger(__name__)
10+
11+
12+
class DatabaseStore(BaseStore):
13+
"""A DBMS storage backend for configurable-http-proxy
14+
15+
This enables chp to run multiple times and serve routes from a central
16+
DBMS. It uses SQLAlchemy as the database backend.
17+
18+
Usage:
19+
Set the CHP_DATABASE_URL env var to any db URL supported by SQLAlchemy.
20+
The default is "sqlite://chp.sqlite".
21+
22+
$ export CHP_DATABASE_URL="sqlite:///chp.sqlite"
23+
$ configurable-http-proxy --storage-backend configurable_http_proxy.sqastore.DatabaseStore
24+
25+
Optionally you may set the table name by setting the CHP_DATABASE_TABLE.
26+
The default is 'chp_routes'
27+
28+
$ export CHP_DATABASE_TABLE="chp_routes"
29+
30+
See Also:
31+
* Valid URLs https://docs.sqlalchemy.org/en/14/core/engines.html#database-urls
32+
"""
33+
34+
default_db_url = "sqlite:///chp.sqlite"
35+
default_db_table = "chp_routes"
36+
37+
def __init__(self):
38+
super().__init__()
39+
db_url = os.environ.get("CHP_DATABASE_URL", self.default_db_url)
40+
db_table = os.environ.get("CHP_DATABASE_TABLE", self.default_db_table)
41+
self.routes: TableTrie = TableTrie(db_url, table=db_table)
42+
log.info(f"Using database {db_url}")
43+
for route, data in self.get_all().items():
44+
log.info(f'Restoring {route} => {data.get("target", "<no target>")}')
45+
46+
def clean(self):
47+
self.routes.clean()
48+
49+
def get_target(self, path: str):
50+
return self.routes.get(self.clean_path(path))
51+
52+
def get_all(self):
53+
return self.routes.all()
54+
55+
def add(self, path: str, data):
56+
if self.get(path):
57+
self.update(path, data)
58+
else:
59+
self.routes.add(path, data)
60+
61+
def update(self, path: str, data):
62+
self.routes.update(self.clean_path(path), data)
63+
64+
def remove(self, path: str):
65+
path = self.clean_path(path)
66+
route = self.routes.get(path)
67+
if route:
68+
self.routes.remove(path)
69+
return route
70+
71+
def get(self, path):
72+
path = self.clean_path(path)
73+
return self.get_all().get(path)
74+
75+
76+
class TableTrie:
77+
# A databased URLTrie-alike
78+
def __init__(self, url, table=None):
79+
table = table or "chp_routes"
80+
self.db = connect(url)
81+
self.table = self.db[table]
82+
83+
def get(self, path):
84+
for path in self._split_routes(path):
85+
doc = self.table.find_one(path=path)
86+
if doc:
87+
data = self._from_json(doc)
88+
data["prefix"] = path
89+
break
90+
else:
91+
data = None
92+
return attrdict(data) if data else None
93+
94+
def add(self, path, data):
95+
self.table.insert(self._clean_json({"path": path, "data": data}))
96+
97+
def update(self, path, data):
98+
doc = self.table.find_one(path=path)
99+
doc["data"].update(data)
100+
self.table.update(self._clean_json(doc), "id")
101+
102+
def remove(self, path):
103+
for path in self._split_routes(path):
104+
self.table.delete(path=path)
105+
106+
def all(self):
107+
return self._from_json({item["path"]: item["data"] for item in self.table.find()})
108+
109+
def _clean_json(self, data):
110+
for k, v in dict(data).items():
111+
if isinstance(v, datetime):
112+
data[k] = f"_dt_:{v.isoformat()}"
113+
if isinstance(v, dict):
114+
data[k] = self._clean_json(v)
115+
return data
116+
117+
def _from_json(self, data):
118+
for k, v in dict(data).items():
119+
if isinstance(v, str) and v.startswith("_dt_:"):
120+
data[k] = datetime.fromisoformat(v.split(":", 1)[-1])
121+
if isinstance(v, dict):
122+
data[k] = self._from_json(v)
123+
return data
124+
125+
def _split_routes(self, path):
126+
# generator for reverse tree of routes
127+
# e.g. /path/to/document
128+
# => yields /path/to/document, /path/to, /path, /
129+
levels = path.split("/")
130+
for i, e in enumerate(levels):
131+
yield "/".join(levels[: len(levels) - i + 1])
132+
# always yield top level route
133+
yield "/"
134+
135+
def clean(self):
136+
self.table.delete()
137+
138+
139+
class attrdict(dict):
140+
# enable .attribute for dicts
141+
def __init__(self, *args, **kwargs):
142+
super().__init__(*args, **kwargs)
143+
self.__dict__ = self

configurable_http_proxy_test/test_api.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import datetime
22
import json
3+
import os
34

45
from tornado.testing import AsyncHTTPTestCase
56

67
from configurable_http_proxy.configproxy import PythonProxy
78
from configurable_http_proxy_test.testutil import pytest_regex
89

910

10-
class TestAPI(AsyncHTTPTestCase):
11-
def get_app(self):
12-
self.proxy = PythonProxy({"auth_token": "secret"})
13-
self.proxy.add_route("/", {"target": "http://127.0.0.1:54321"})
14-
return self.proxy.api_app
15-
11+
class APITestsMixin:
12+
# test cases for TestAPI
13+
# -- this allows to reuse these test cases in test_sqastore.TestAPI_SQAStore
1614
def fetch(self, path, raise_error=True, with_auth=True, **kwargs):
1715
headers = kwargs.pop("headers", {})
1816
if with_auth:
@@ -147,8 +145,17 @@ def test_get_routes_with_inactive_since(self):
147145

148146
resp = self.fetch(f"/api/routes?inactiveSince={hour_ago.isoformat()}")
149147
reply = json.loads(resp.body)
150-
assert set(reply.keys()) == {'/yesterday'}
148+
assert set(reply.keys()) == {"/yesterday"}
151149

152150
resp = self.fetch(f"/api/routes?inactiveSince={hour_from_now.isoformat()}")
153151
reply = json.loads(resp.body)
154-
assert set(reply.keys()) == {'/', '/today', '/yesterday'}
152+
assert set(reply.keys()) == {"/", "/today", "/yesterday"}
153+
154+
155+
class TestAPI(APITestsMixin, AsyncHTTPTestCase):
156+
# actual test case
157+
def get_app(self):
158+
os.environ["CHP_DATABASE_URL"] = "sqlite:///chp_test.sqlite"
159+
self.proxy = PythonProxy({"auth_token": "secret"})
160+
self.proxy.add_route("/", {"target": "http://127.0.0.1:54321"})
161+
return self.proxy.api_app

configurable_http_proxy_test/test_proxy.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,13 +473,16 @@ def test_receiving_headers_setcookie(self):
473473
headers = list(resp.headers.get_all())
474474
cookies = {}
475475
for header_name, header in headers:
476-
if header_name.lower() !='set-cookie':
476+
if header_name.lower() != "set-cookie":
477477
continue
478478
key, val = header.split("=", 1)
479479
cookies[key] = val
480480
assert "key" in cookies
481-
assert cookies['key'] == 'val'
481+
assert cookies["key"] == "val"
482482
assert "combined_key" in cookies
483-
assert cookies['combined_key'] == 'val; Secure=; HttpOnly=; SameSite=None; Path=/; Domain=example.com; Max-Age=999999; Expires=Fri, 01 Oct 2020 06:12:16 GMT'
483+
assert (
484+
cookies["combined_key"]
485+
== "val; Secure=; HttpOnly=; SameSite=None; Path=/; Domain=example.com; Max-Age=999999; Expires=Fri, 01 Oct 2020 06:12:16 GMT"
486+
)
484487
for prefix in ["Secure", "HttpOnly", "SameSite", "Path", "Domain", "Max-Age", "Expires"]:
485488
assert prefix + "_key" in cookies
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import os
2+
3+
from tornado.testing import AsyncHTTPTestCase
4+
5+
from configurable_http_proxy.configproxy import PythonProxy
6+
from configurable_http_proxy.sqastore import DatabaseStore
7+
from configurable_http_proxy_test.test_api import APITestsMixin
8+
9+
10+
class TestDataBaseStore:
11+
def setup_method(self, method):
12+
os.environ["CHP_DATABASE_URL"] = "sqlite:///chp_test.sqlite"
13+
self.subject = DatabaseStore()
14+
self.subject.clean()
15+
16+
def test_get(self):
17+
self.subject.add("/myRoute", {"test": "value"})
18+
route = self.subject.get("/myRoute")
19+
assert route == {"test": "value"}
20+
21+
def test_get_with_invalid_path(self):
22+
route = self.subject.get("/wut")
23+
assert route is None
24+
25+
def test_get_target(self):
26+
self.subject.add("/myRoute", {"target": "http://localhost:8213"})
27+
self.subject.add("/myRoute/specific", {"target": "http://localhost:8214"})
28+
target = self.subject.get_target("/myRoute")
29+
assert target.prefix == "/myRoute"
30+
assert target.data["target"] == "http://localhost:8213"
31+
target = self.subject.get_target("/myRoute/some/path")
32+
assert target.prefix == "/myRoute"
33+
assert target.data["target"] == "http://localhost:8213"
34+
target = self.subject.get_target("/myRoute/specific/path")
35+
assert target.prefix == "/myRoute/specific"
36+
assert target.data["target"] == "http://localhost:8214"
37+
38+
def test_get_all(self):
39+
self.subject.add("/myRoute", {"test": "value1"})
40+
self.subject.add("/myOtherRoute", {"test": "value2"})
41+
42+
routes = self.subject.get_all()
43+
assert len(routes) == 2
44+
assert routes["/myRoute"] == {"test": "value1"}
45+
assert routes["/myOtherRoute"] == {"test": "value2"}
46+
47+
def test_get_all_with_no_routes(self):
48+
routes = self.subject.get_all()
49+
assert routes == {}
50+
51+
def test_add(self):
52+
self.subject.add("/myRoute", {"test": "value"})
53+
54+
route = self.subject.get("/myRoute")
55+
assert route == {"test": "value"}
56+
57+
def test_add_overwrite(self):
58+
self.subject.add("/myRoute", {"test": "value"})
59+
self.subject.add("/myRoute", {"test": "updatedValue"})
60+
61+
route = self.subject.get("/myRoute")
62+
assert route == {"test": "updatedValue"}
63+
64+
def test_update(self):
65+
self.subject.add("/myRoute", {"version": 1, "test": "value"})
66+
self.subject.update("/myRoute", {"version": 2})
67+
68+
route = self.subject.get("/myRoute")
69+
assert route["version"] == 2
70+
assert route["test"] == "value"
71+
72+
def test_remove(self):
73+
self.subject.add("/myRoute", {"test": "value"})
74+
self.subject.remove("/myRoute")
75+
76+
route = self.subject.get("/myRoute")
77+
assert route is None
78+
79+
def test_remove_with_invalid_route(self):
80+
# No error should occur
81+
self.subject.remove("/myRoute/foo/bar")
82+
83+
def test_has_route(self):
84+
self.subject.add("/myRoute", {"test": "value"})
85+
route = self.subject.get("/myRoute")
86+
assert route == {"test": "value"}
87+
88+
def test_has_route_path_not_found(self):
89+
route = self.subject.get("/wut")
90+
assert route is None
91+
92+
93+
class TestAPI_SQAStore(APITestsMixin, AsyncHTTPTestCase):
94+
def get_app(self):
95+
self.proxy = PythonProxy(
96+
{"auth_token": "secret", "storage_backend": "configurable_http_proxy.sqastore.DatabaseStore"}
97+
)
98+
self.proxy._routes.clean()
99+
assert self.proxy._routes.get_all() == {}
100+
self.proxy.add_route("/", {"target": "http://127.0.0.1:54321"})
101+
return self.proxy.api_app

setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
},
2020
setup_requires=["setuptools_scm"],
2121
install_requires=open(os.path.join(BASE_PATH, "requirements.txt")).readlines(),
22+
extras_require={
23+
"sqla": ["dataset"],
24+
},
2225
python_requires=">=3.6",
2326
include_package_data=True,
2427
zip_safe=False,

0 commit comments

Comments
 (0)