Skip to content

Commit d7417da

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 2806f16 commit d7417da

File tree

9 files changed

+290
-13
lines changed

9 files changed

+290
-13
lines changed

.github/workflows/python-app.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# This workflow will install Python dependencies, run tests and lint with a single version of Python
2+
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
3+
4+
name: Python application
5+
6+
on:
7+
push:
8+
branches: [ main ]
9+
pull_request:
10+
branches: [ main ]
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
build:
17+
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- uses: actions/checkout@v3
22+
- name: Set up Python 3.10
23+
uses: actions/setup-python@v3
24+
with:
25+
python-version: "3.10"
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
pip install flake8 pytest
30+
pip install -r requirements.txt
31+
pip install -e .[sqla]
32+
- name: Lint with flake8
33+
run: |
34+
# stop the build if there are Python syntax errors or undefined names
35+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
36+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
37+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
38+
- name: Test with pytest
39+
run: |
40+
pytest

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,9 @@ dmypy.json
130130

131131
# Project specific
132132
configurable_http_proxy/version.txt
133+
/.idea/configurable-http-proxy.iml
134+
/.idea/inspectionProfiles/profiles_settings.xml
135+
/.idea/misc.xml
136+
/.idea/modules.xml
137+
/.idea/vcs.xml
138+
*sqlite*

.idea/.gitignore

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 26 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,28 @@ 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 chp to be replicated
44+
in a High Availability scenario.
45+
46+
To use a DBMS as the storage backend:
47+
48+
Install DBMS support
49+
50+
$ pip install configurable-http-proxy[sqla]
51+
52+
Set the CHP_DATABASE_URL env var to any db URL supported by SQLAlchemy.
53+
The default is "sqlite://chp.sqlite".
54+
55+
$ export CHP_DATABASE_URL="sqlite:///chp.sqlite"
56+
$ configurable-http-proxy --storage-backend configurable_http_proxy.dbstore.DatabaseStore
57+
58+
Optionally you may set the table name by setting the CHP_DATABASE_TABLE.
59+
The default is 'chp_routes'
60+
61+
$ export CHP_DATABASE_TABLE="chp_routes"
62+
63+

configurable_http_proxy/dbstore.py

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

configurable_http_proxy_test/test_api.py

Lines changed: 26 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 test cases for MemoryStore and DatabaseStore backends
1614
def fetch(self, path, raise_error=True, with_auth=True, **kwargs):
1715
headers = kwargs.pop("headers", {})
1816
if with_auth:
@@ -147,8 +145,28 @@ 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_MemoryStore(APITestsMixin, AsyncHTTPTestCase):
156+
# actual test case
157+
def get_app(self):
158+
self.proxy = PythonProxy({"auth_token": "secret"})
159+
self.proxy.add_route("/", {"target": "http://127.0.0.1:54321"})
160+
return self.proxy.api_app
161+
162+
163+
class TestAPI_DatabaseStore(APITestsMixin, AsyncHTTPTestCase):
164+
def get_app(self):
165+
os.environ["CHP_DATABASE_URL"] = "sqlite:///chp_test.sqlite"
166+
self.proxy = PythonProxy(
167+
{"auth_token": "secret", "storage_backend": "configurable_http_proxy.dbstore.DatabaseStore"}
168+
)
169+
self.proxy._routes.clean()
170+
assert self.proxy._routes.get_all() == {}
171+
self.proxy.add_route("/", {"target": "http://127.0.0.1:54321"})
172+
return self.proxy.api_app

configurable_http_proxy_test/test_proxy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async def get(self, path=None):
3232
}
3333
self.set_status(200)
3434
self.set_header("Content-Type", "application/json")
35-
if self.get_argument("with_set_cookie"):
35+
if self.get_argument("with_set_cookie", None):
3636
# Values that set-cookie can take:
3737
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
3838
values = {

configurable_http_proxy_test/test_store.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
from configurable_http_proxy.store import MemoryStore
1+
import os
22

3+
from configurable_http_proxy.dbstore import DatabaseStore
4+
from configurable_http_proxy.store import MemoryStore
35

4-
class TestMemoryStore:
5-
def setup_method(self, method):
6-
self.subject = MemoryStore()
76

7+
class StoreTestMixin:
8+
# test cases for the storage
9+
# -- this allows to reuse tests for MemoryStore and DatabaseStore
810
def test_get(self):
911
self.subject.add("/myRoute", {"test": "value"})
1012
route = self.subject.get("/myRoute")
@@ -73,3 +75,15 @@ def test_has_route(self):
7375
def test_has_route_path_not_found(self):
7476
route = self.subject.get("/wut")
7577
assert route is None
78+
79+
80+
class TestMemoryStore(StoreTestMixin):
81+
def setup_method(self, method):
82+
self.subject = MemoryStore()
83+
84+
85+
class TestDataBaseStore(StoreTestMixin):
86+
def setup_method(self, method):
87+
os.environ["CHP_DATABASE_URL"] = "sqlite:///chp_test.sqlite"
88+
self.subject = DatabaseStore()
89+
self.subject.clean()

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)