Skip to content

Commit d9f1469

Browse files
committed
Add SQLAlchemy storage backend
- add sqlastore.DatabaseStore and unittests - update API unittests for use in database tests - update README
1 parent 01fbcb3 commit d9f1469

File tree

7 files changed

+298
-10
lines changed

7 files changed

+298
-10
lines changed

.github/workflows/python-app.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: Install application
2727
run: |
2828
python -m pip install --upgrade pip setuptools wheel
29-
pip install -e .
29+
pip install -e .[sqla]
3030
- name: Lint with flake8 and black
3131
run: |
3232
pip install -r requirements/lint.txt
@@ -35,7 +35,7 @@ jobs:
3535
- name: Test with pytest
3636
run: |
3737
pip install -r requirements/test.txt
38-
pytest
38+
python -m pytest
3939
- name: Build package
4040
run: |
4141
pip install -r requirements/build.txt

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,4 @@ dmypy.json
134134

135135
# Project specific
136136
configurable_http_proxy/version.txt
137+
*sqlite*

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ The following items are supported:
2727
- Customizable storage backends
2828
- PID file writing
2929
- Logging
30+
- Configurable storage backend
31+
32+
The following options are additional (not available in nodejs CHP currently):
33+
- Ready to use DBMS storage backend
3034

3135
The following options are not supported (yet):
3236

@@ -35,3 +39,29 @@ The following options are not supported (yet):
3539
- Change Origin: `--change-origin`
3640
- Rewrites in Location header: `--protocol-rewrite` and `--auto-rewrite`
3741
- Metrics server: `--metrics-port` and `--metrics-ip`
42+
43+
## Database-backed storage backend
44+
45+
Using a SQL DBMS instead of the default in-memory store enables chp to be replicated
46+
in a High Availability scenario.
47+
48+
To use a SQL DBMS as the storage backend:
49+
50+
1. Install DBMS support
51+
52+
```bash
53+
$ pip install configurable-http-proxy[sqla]
54+
```
55+
56+
2. Set the CHP_DATABASE_URL env var to any db URL supported by SQLAlchemy. The default is `sqlite:///chp.sqlite`.
57+
58+
```bash
59+
$ export CHP_DATABASE_URL="sqlite:///chp.sqlite"
60+
$ configurable-http-proxy --storage-backend configurable_http_proxy.dbstore.DatabaseStore
61+
```
62+
63+
3. Optionally you may set the table name by setting the CHP_DATABASE_TABLE. The default is 'chp_routes'
64+
65+
```bash
66+
$ export CHP_DATABASE_TABLE="chp_routes"
67+
```

configurable_http_proxy/dbstore.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
# NOTE: Also support getting these settings from CLI args
41+
db_url = os.environ.get("CHP_DATABASE_URL", self.default_db_url)
42+
db_table = os.environ.get("CHP_DATABASE_TABLE", self.default_db_table)
43+
self.routes: TableTrie = TableTrie(db_url, table=db_table)
44+
log.info(f"Using database {db_url}")
45+
for route, data in self.get_all().items():
46+
log.info(f'Restoring {route} => {data.get("target", "<no target>")}')
47+
48+
def clean(self):
49+
# remove all information stored so far
50+
self.routes.clean()
51+
52+
def get_target(self, path: str):
53+
# return the data for the most specific matching route
54+
return self.routes.get(self.clean_path(path), trie=True)
55+
56+
def get_all(self):
57+
# return all routes as route => data
58+
return self.routes.all()
59+
60+
def add(self, path: str, data):
61+
# add a new route /path, storing data
62+
if self.get(path):
63+
self.update(path, data)
64+
else:
65+
self.routes.add(path, data)
66+
67+
def update(self, path: str, data):
68+
# update an existing route
69+
self.routes.update(self.clean_path(path), data)
70+
71+
def remove(self, path: str):
72+
# remove an existing route
73+
path = self.clean_path(path)
74+
route = self.routes.get(path)
75+
if route:
76+
self.routes.remove(path)
77+
return route
78+
79+
def get(self, path):
80+
# return the data for the exact match
81+
return self.routes.get(self.clean_path(path))
82+
83+
84+
class TableTrie:
85+
"""A URLtrie-like backed by a database
86+
87+
This stores URL-path => data mappings. On retrieving, it will try
88+
to retrieve all subpaths up to the default path.
89+
90+
Usage:
91+
92+
# create mapping
93+
routes = TableTrie('sqlite:///:memory:')
94+
routes.add('/', {'some': 'default'})
95+
routes.add('/foo/bar', {'some': 'value'})
96+
97+
# query a mapping that exists
98+
routes.get('/foo/bar/baz')
99+
=> {
100+
'prefix': '/foo/bar',
101+
'some': 'value'
102+
}
103+
104+
# query a mapping that does not exist
105+
routes.get('/fox/bax')
106+
=> {
107+
'prefix': '/',
108+
'some': 'default'
109+
}
110+
111+
How values are stored:
112+
113+
Routes are stored in the given table (defaults to 'chp_routes').
114+
The table has the following columns:
115+
116+
id: integer (primary key)
117+
key: varchar(128, unique)
118+
data: varchar
119+
120+
The data is the serialized JSON equivalent of the dictionary stored
121+
by TableTrie.add() or .update(). The rationale for storing a serialized
122+
version of the dict instead of using the sqlalchemy JSON support directly
123+
is to improve compatibility across db dialects.
124+
125+
DB backend:
126+
127+
The backend is any database supported by SQLAlchemy. To simplify
128+
implementation this uses the dataset library, which provides a very
129+
straight-forward way of working with tables created from Python dicts.
130+
"""
131+
132+
def __init__(self, url, table=None):
133+
table = table or "chp_routes"
134+
self.db = connect(url)
135+
self.table = self.db[table]
136+
self.table.create_column("path", self.db.types.string(length=128), unique=True)
137+
138+
def get(self, path, trie=False):
139+
# return the data store for path
140+
# -- if trie is False (default), will return data for the exact path
141+
# -- if trie is True, will return the data and the matching prefix
142+
try_routes = self._split_routes(path) if trie else [path]
143+
for path in try_routes:
144+
doc = self.table.find_one(path=path, order_by="id")
145+
if doc:
146+
if not trie:
147+
data = self._from_json(doc["data"])
148+
else:
149+
data = doc
150+
data["data"] = self._from_json(doc["data"])
151+
data["prefix"] = path
152+
break
153+
else:
154+
data = None
155+
return attrdict(data) if data else None
156+
157+
def add(self, path, data):
158+
# add the data for the given exact path
159+
self.table.insert({"path": path, "data": self._to_json(data)})
160+
161+
def update(self, path, data):
162+
# update the data for the given exact path
163+
doc = self.table.find_one(path=path, order_by="id")
164+
doc["data"] = self._from_json(doc["data"])
165+
doc["data"].update(data)
166+
doc["data"] = self._to_json(doc["data"])
167+
self.table.update(doc, "id")
168+
169+
def remove(self, path):
170+
# remove all matching routes for the given path, except default route
171+
for subpath in self._split_routes(path):
172+
if subpath == "/" and path != "/":
173+
continue
174+
self.table.delete(path=subpath)
175+
176+
def all(self):
177+
# return all data for all paths
178+
return {item["path"]: self._from_json(item["data"]) for item in self.table.find(order_by="id")}
179+
180+
def _to_json(self, data):
181+
# simple converter for serializable data
182+
for k, v in dict(data).items():
183+
if isinstance(v, datetime):
184+
data[k] = f"_dt_:{v.isoformat()}"
185+
elif isinstance(v, dict):
186+
data[k] = self._to_json(v)
187+
return json.dumps(data)
188+
189+
def _from_json(self, data):
190+
# simple converter from serialized data
191+
data = json.loads(data) if isinstance(data, (str, bytes)) else data
192+
for k, v in dict(data).items():
193+
if isinstance(v, str) and v.startswith("_dt_:"):
194+
data[k] = datetime.fromisoformat(v.split(":", 1)[-1])
195+
elif isinstance(v, dict):
196+
data[k] = self._from_json(v)
197+
return data
198+
199+
def _split_routes(self, path):
200+
# generator for reverse tree of routes
201+
# e.g. /path/to/document
202+
# => yields /path/to/document, /path/to, /path, /
203+
levels = path.split("/")
204+
for i, e in enumerate(levels):
205+
yield "/".join(levels[: len(levels) - i + 1])
206+
# always yield top level route
207+
yield "/"
208+
209+
def clean(self):
210+
self.table.delete()
211+
212+
213+
class attrdict(dict):
214+
# enable .attribute for dicts
215+
def __init__(self, *args, **kwargs):
216+
super().__init__(*args, **kwargs)
217+
self.__dict__ = self

configurable_http_proxy_test/test_api.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
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
11+
class APITestsMixin:
12+
"""
13+
Test cases for TestAPI
14+
This allows to reuse test cases for MemoryStore and DatabaseStore backends
15+
"""
1516

1617
def fetch(self, path, raise_error=True, with_auth=True, **kwargs):
1718
headers = kwargs.pop("headers", {})
@@ -144,3 +145,22 @@ def test_get_routes_with_inactive_since(self):
144145
resp = self.fetch(f"/api/routes?inactiveSince={hour_from_now.isoformat()}")
145146
reply = json.loads(resp.body)
146147
assert set(reply.keys()) == {"/", "/today", "/yesterday"}
148+
149+
150+
class TestAPIWithMemoryStore(APITestsMixin, AsyncHTTPTestCase):
151+
def get_app(self):
152+
self.proxy = PythonProxy({"auth_token": "secret"})
153+
self.proxy.add_route("/", {"target": "http://127.0.0.1:54321"})
154+
return self.proxy.api_app
155+
156+
157+
class TestAPIWithDatabaseStore(APITestsMixin, AsyncHTTPTestCase):
158+
def get_app(self):
159+
os.environ["CHP_DATABASE_URL"] = "sqlite:///chp_test.sqlite"
160+
self.proxy = PythonProxy(
161+
{"auth_token": "secret", "storage_backend": "configurable_http_proxy.dbstore.DatabaseStore"}
162+
)
163+
self.proxy._routes.clean()
164+
assert self.proxy._routes.get_all() == {}
165+
self.proxy.add_route("/", {"target": "http://127.0.0.1:54321"})
166+
return self.proxy.api_app

configurable_http_proxy_test/test_store.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import os
2+
3+
from configurable_http_proxy.dbstore import DatabaseStore
14
from configurable_http_proxy.store import MemoryStore
25

36

4-
class TestMemoryStore:
5-
def setup_method(self, method):
6-
self.subject = MemoryStore()
7+
class StoreTestsMixin:
8+
"""
9+
Test cases for the storage
10+
This allows to reuse tests for MemoryStore and DatabaseStore
11+
"""
712

813
def test_get(self):
914
self.subject.add("/myRoute", {"test": "value"})
@@ -73,3 +78,15 @@ def test_has_route(self):
7378
def test_has_route_path_not_found(self):
7479
route = self.subject.get("/wut")
7580
assert route is None
81+
82+
83+
class TestMemoryStore(StoreTestsMixin):
84+
def setup_method(self, method):
85+
self.subject = MemoryStore()
86+
87+
88+
class TestDataBaseStore(StoreTestsMixin):
89+
def setup_method(self, method):
90+
os.environ["CHP_DATABASE_URL"] = "sqlite:///chp_test.sqlite"
91+
self.subject = DatabaseStore()
92+
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", "base.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)