Skip to content

Commit 578c769

Browse files
bajnokkc00kiemon5ter
authored andcommitted
storage: add Redis storage backend
This change adds Redis as an alternative storage backend. Implementation notes: - The database name (which is a number) should be provided in the connection URI. - The keys are prefixed with the 'collection' string, so that it is possible to share the same database with other applications. - All values are stored as JSON documents ({"value": value}) in order to try to keep the original data type, since Redis stores all values as bytes. Note however that it is still possible that the retrieved object differs from the stored one. (Dictionaries with non-string keys are an example.) - All values are stored together with a TTL. The TTL is None by default, which means that the record never expires. The TTL value can be set during initialisation. This commit changes the storage tests to use 'fakeredis' and 'mongomock' instead of trying to launch a real MongoDB server. Expiration tests have been added, too.
1 parent e50b3f9 commit 578c769

File tree

5 files changed

+233
-99
lines changed

5 files changed

+233
-99
lines changed

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
description='OpenID Connect Provider (OP) library in Python.',
1313
install_requires=[
1414
'oic >= 1.2.1',
15-
'pymongo'
15+
'pymongo',
16+
'redis'
1617
]
1718
)

src/pyop/storage.py

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,59 @@
11
# -*- coding: utf-8 -*-
22

3+
from abc import ABC, abstractmethod
34
import copy
5+
import json
46
import pymongo
7+
from redis.client import Redis
58
from time import time
69

710

8-
class MongoWrapper(object):
11+
class StorageBase(ABC):
12+
_ttl = None
13+
14+
@abstractmethod
15+
def __setitem__(self, key, value):
16+
pass
17+
18+
@abstractmethod
19+
def __getitem__(self, key):
20+
pass
21+
22+
@abstractmethod
23+
def __delitem__(self, key):
24+
pass
25+
26+
@abstractmethod
27+
def __contains__(self, key):
28+
pass
29+
30+
@abstractmethod
31+
def items(self):
32+
pass
33+
34+
def pop(self, key, default=None):
35+
try:
36+
data = self[key]
37+
except KeyError:
38+
return default
39+
del self[key]
40+
return data
41+
42+
@classmethod
43+
def from_uri(cls, db_uri, collection, db_name=None, ttl=None):
44+
if db_uri.startswith("mongodb"):
45+
return MongoWrapper(db_uri, db_name, collection, ttl)
46+
if db_uri.startswith("redis") or db_uri.startswith("unix"):
47+
return RedisWrapper(db_uri, collection, ttl)
48+
49+
return ValueError(f"Invalid DB URI: {db_uri}")
50+
51+
@property
52+
def ttl(self):
53+
return self._ttl
54+
55+
56+
class MongoWrapper(StorageBase):
957
def __init__(self, db_uri, db_name, collection):
1058
self._db_uri = db_uri
1159
self._coll_name = collection
@@ -38,13 +86,52 @@ def items(self):
3886
for doc in self._coll.find():
3987
yield (doc['lookup_key'], doc['data'])
4088

41-
def pop(self, key, default=None):
42-
try:
43-
data = self[key]
44-
except KeyError:
45-
return default
46-
del self[key]
47-
return data
89+
90+
class RedisWrapper(StorageBase):
91+
"""
92+
Simple wrapper for a dict-like storage in Redis.
93+
Supports JSON-serializable data types.
94+
"""
95+
96+
def __init__(self, db_uri, collection, ttl=None):
97+
self._db = Redis.from_url(db_uri, decode_responses=True)
98+
self._collection = collection
99+
if ttl is None or (isinstance(ttl, int) and ttl >= 0):
100+
self._ttl = ttl
101+
else:
102+
raise ValueError("TTL must be a non-negative integer or None")
103+
104+
def _make_key(self, key):
105+
if not isinstance(key, str):
106+
raise TypeError(f"Keys must be strings, {type(key).__name__} given")
107+
108+
return ":".join([self._collection, key])
109+
110+
def __setitem__(self, key, value):
111+
# Replacing the value of a key resets the ttl counter
112+
encoded = json.dumps({ "value": value })
113+
self._db.set(self._make_key(key), encoded, ex=self.ttl)
114+
115+
def __getitem__(self, key):
116+
encoded = self._db.get(self._make_key(key))
117+
if encoded is None:
118+
raise KeyError(key)
119+
return json.loads(encoded).get("value")
120+
121+
def __delitem__(self, key):
122+
# Deleting a non-existent key is allowed
123+
self._db.delete(self._make_key(key))
124+
125+
def __contains__(self, key):
126+
return (self._db.get(self._make_key(key)) is not None)
127+
128+
def items(self):
129+
for key in self._db.keys(self._collection + "*"):
130+
visible_key = key[len(self._collection) + 1 :]
131+
try:
132+
yield (visible_key, self[visible_key])
133+
except KeyError:
134+
pass
48135

49136

50137
class MongoDB(object):

tests/pyop/conftest.py

Lines changed: 0 additions & 84 deletions
This file was deleted.

tests/pyop/test_storage.py

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,37 @@
22

33
import pytest
44

5-
from pyop.storage import MongoWrapper
5+
from abc import ABC, abstractmethod
6+
from contextlib import contextmanager
7+
from redis.client import Redis
8+
import datetime
9+
import fakeredis
10+
import mongomock
11+
import pymongo
12+
import time
13+
14+
from pyop.storage import StorageBase
615

716
__author__ = 'lundberg'
817

918

10-
class TestMongoStorage(object):
11-
@pytest.fixture()
12-
def db(self, mongodb_instance):
13-
return MongoWrapper(mongodb_instance.get_uri(), 'pyop', 'test')
19+
uri_list = ["mongodb://localhost:1234/pyop", "redis://localhost/0"]
20+
21+
@pytest.fixture(autouse=True)
22+
def mock_redis(monkeypatch):
23+
def mockreturn(*args, **kwargs):
24+
return fakeredis.FakeStrictRedis(decode_responses=True)
25+
monkeypatch.setattr(Redis, "from_url", mockreturn)
26+
27+
@pytest.fixture(autouse=True)
28+
def mock_mongo():
29+
pymongo.MongoClient = mongomock.MongoClient
30+
31+
32+
class TestStorage(object):
33+
@pytest.fixture(params=uri_list)
34+
def db(self, request):
35+
return StorageBase.from_uri(request.param, db_name="pyop", collection='test')
1436

1537
def test_write(self, db):
1638
db['foo'] = 'bar'
@@ -41,3 +63,108 @@ def test_items(self, db):
4163
for key, item in db.items():
4264
assert key
4365
assert item
66+
67+
@pytest.mark.parametrize(
68+
"args,kwargs",
69+
[
70+
(["redis://localhost"], {"collection": "test"}),
71+
(["redis://localhost", "test"], {}),
72+
(["unix://localhost/0"], {"collection": "test", "ttl": 3}),
73+
(["mongodb://localhost/pyop"], {"collection": "test", "ttl": 3}),
74+
(["mongodb://localhost"], {"db_name": "pyop", "collection": "test"}),
75+
(["mongodb://localhost", "test", "pyop"], {}),
76+
(["mongodb://localhost/pyop", "test"], {}),
77+
(["mongodb://localhost/pyop"], {"db_name": "other", "collection": "test"}),
78+
(["redis://localhost/0"], {"db_name": "pyop", "collection": "test"}),
79+
],
80+
)
81+
def test_from_uri(self, args, kwargs):
82+
store = StorageBase.from_uri(*args, **kwargs)
83+
store["test"] = "value"
84+
assert store["test"] == "value"
85+
86+
@pytest.mark.parametrize(
87+
"error,args,kwargs",
88+
[
89+
(
90+
TypeError,
91+
["redis://localhost", "ouch"],
92+
{"db_name": 3, "collection": "test", "ttl": None},
93+
),
94+
(
95+
TypeError,
96+
["mongodb://localhost", "ouch"],
97+
{"db_name": 3, "collection": "test", "ttl": None},
98+
),
99+
(
100+
TypeError,
101+
["mongodb://localhost", "ouch"],
102+
{"db_name": "pyop", "collection": "test", "ttl": None},
103+
),
104+
(
105+
TypeError,
106+
["mongodb://localhost", "pyop"],
107+
{"collection": "test", "ttl": None},
108+
),
109+
(
110+
TypeError,
111+
["mongodb://localhost"],
112+
{"db_name": "pyop", "collection": "test", "ttl": None, "extra": True},
113+
),
114+
(TypeError, ["redis://localhost/0"], {}),
115+
(TypeError, ["redis://localhost/0"], {"db_name": "pyop"}),
116+
(ValueError, ["mongodb://localhost"], {"collection": "test", "ttl": None}),
117+
],
118+
)
119+
def test_from_uri_invalid_parameters(self, error, args, kwargs):
120+
with pytest.raises(error):
121+
StorageBase.from_uri(*args, **kwargs)
122+
123+
124+
class StorageTTLTest(ABC):
125+
def prepare_db(self, uri, ttl):
126+
self.db = StorageBase.from_uri(
127+
uri,
128+
collection="test",
129+
ttl=ttl,
130+
)
131+
self.db["foo"] = {"bar": "baz"}
132+
133+
@abstractmethod
134+
def set_time(self, offset, monkey):
135+
pass
136+
137+
@contextmanager
138+
def adjust_time(self, offset):
139+
mp = pytest.MonkeyPatch()
140+
try:
141+
yield self.set_time(offset, mp)
142+
finally:
143+
mp.undo()
144+
145+
def execute_ttl_test(self, uri, ttl):
146+
self.prepare_db(uri, ttl)
147+
assert self.db["foo"]
148+
with self.adjust_time(offset=int(ttl / 2)):
149+
assert self.db["foo"]
150+
with self.adjust_time(offset=int(ttl * 2)):
151+
with pytest.raises(KeyError):
152+
self.db["foo"]
153+
154+
@pytest.mark.parametrize("uri", uri_list)
155+
@pytest.mark.parametrize("ttl", ["invalid", -1, 2.3, {}])
156+
def test_invalid_ttl(self, uri, ttl):
157+
with pytest.raises(ValueError):
158+
self.prepare_db(uri, ttl)
159+
160+
161+
class TestRedisTTL(StorageTTLTest):
162+
def set_time(self, offset, monkeypatch):
163+
now = time.time()
164+
def new_time():
165+
return now + offset
166+
167+
monkeypatch.setattr(time, "time", new_time)
168+
169+
def test_ttl(self):
170+
self.execute_ttl_test("redis://localhost/0", 3600)

tests/test_requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
pytest
1+
pytest >= 6.2
2+
pip >= 19.0
23
responses
34
pycryptodomex
5+
fakeredis
6+
mongomock

0 commit comments

Comments
 (0)