kinto 23.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kinto/__init__.py +92 -0
- kinto/__main__.py +249 -0
- kinto/authorization.py +134 -0
- kinto/config/__init__.py +94 -0
- kinto/config/kinto.tpl +270 -0
- kinto/contribute.json +27 -0
- kinto/core/__init__.py +246 -0
- kinto/core/authentication.py +48 -0
- kinto/core/authorization.py +311 -0
- kinto/core/cache/__init__.py +131 -0
- kinto/core/cache/memcached.py +112 -0
- kinto/core/cache/memory.py +104 -0
- kinto/core/cache/postgresql/__init__.py +178 -0
- kinto/core/cache/postgresql/schema.sql +23 -0
- kinto/core/cache/testing.py +208 -0
- kinto/core/cornice/__init__.py +93 -0
- kinto/core/cornice/cors.py +144 -0
- kinto/core/cornice/errors.py +40 -0
- kinto/core/cornice/pyramidhook.py +373 -0
- kinto/core/cornice/renderer.py +89 -0
- kinto/core/cornice/resource.py +205 -0
- kinto/core/cornice/service.py +641 -0
- kinto/core/cornice/util.py +138 -0
- kinto/core/cornice/validators/__init__.py +94 -0
- kinto/core/cornice/validators/_colander.py +142 -0
- kinto/core/cornice/validators/_marshmallow.py +182 -0
- kinto/core/cornice_swagger/__init__.py +92 -0
- kinto/core/cornice_swagger/converters/__init__.py +21 -0
- kinto/core/cornice_swagger/converters/exceptions.py +6 -0
- kinto/core/cornice_swagger/converters/parameters.py +90 -0
- kinto/core/cornice_swagger/converters/schema.py +249 -0
- kinto/core/cornice_swagger/swagger.py +725 -0
- kinto/core/cornice_swagger/templates/index.html +73 -0
- kinto/core/cornice_swagger/templates/index_script_template.html +21 -0
- kinto/core/cornice_swagger/util.py +42 -0
- kinto/core/cornice_swagger/views.py +78 -0
- kinto/core/decorators.py +74 -0
- kinto/core/errors.py +216 -0
- kinto/core/events.py +301 -0
- kinto/core/initialization.py +738 -0
- kinto/core/listeners/__init__.py +9 -0
- kinto/core/metrics.py +94 -0
- kinto/core/openapi.py +115 -0
- kinto/core/permission/__init__.py +202 -0
- kinto/core/permission/memory.py +167 -0
- kinto/core/permission/postgresql/__init__.py +489 -0
- kinto/core/permission/postgresql/migrations/migration_001_002.sql +18 -0
- kinto/core/permission/postgresql/schema.sql +41 -0
- kinto/core/permission/testing.py +487 -0
- kinto/core/resource/__init__.py +1311 -0
- kinto/core/resource/model.py +412 -0
- kinto/core/resource/schema.py +502 -0
- kinto/core/resource/viewset.py +230 -0
- kinto/core/schema.py +119 -0
- kinto/core/scripts.py +50 -0
- kinto/core/statsd.py +1 -0
- kinto/core/storage/__init__.py +436 -0
- kinto/core/storage/exceptions.py +53 -0
- kinto/core/storage/generators.py +58 -0
- kinto/core/storage/memory.py +651 -0
- kinto/core/storage/postgresql/__init__.py +1131 -0
- kinto/core/storage/postgresql/client.py +120 -0
- kinto/core/storage/postgresql/migrations/migration_001_002.sql +10 -0
- kinto/core/storage/postgresql/migrations/migration_002_003.sql +33 -0
- kinto/core/storage/postgresql/migrations/migration_003_004.sql +18 -0
- kinto/core/storage/postgresql/migrations/migration_004_005.sql +20 -0
- kinto/core/storage/postgresql/migrations/migration_005_006.sql +11 -0
- kinto/core/storage/postgresql/migrations/migration_006_007.sql +74 -0
- kinto/core/storage/postgresql/migrations/migration_007_008.sql +66 -0
- kinto/core/storage/postgresql/migrations/migration_008_009.sql +41 -0
- kinto/core/storage/postgresql/migrations/migration_009_010.sql +98 -0
- kinto/core/storage/postgresql/migrations/migration_010_011.sql +14 -0
- kinto/core/storage/postgresql/migrations/migration_011_012.sql +9 -0
- kinto/core/storage/postgresql/migrations/migration_012_013.sql +71 -0
- kinto/core/storage/postgresql/migrations/migration_013_014.sql +14 -0
- kinto/core/storage/postgresql/migrations/migration_014_015.sql +95 -0
- kinto/core/storage/postgresql/migrations/migration_015_016.sql +4 -0
- kinto/core/storage/postgresql/migrations/migration_016_017.sql +81 -0
- kinto/core/storage/postgresql/migrations/migration_017_018.sql +25 -0
- kinto/core/storage/postgresql/migrations/migration_018_019.sql +8 -0
- kinto/core/storage/postgresql/migrations/migration_019_020.sql +7 -0
- kinto/core/storage/postgresql/migrations/migration_020_021.sql +68 -0
- kinto/core/storage/postgresql/migrations/migration_021_022.sql +62 -0
- kinto/core/storage/postgresql/migrations/migration_022_023.sql +5 -0
- kinto/core/storage/postgresql/migrations/migration_023_024.sql +6 -0
- kinto/core/storage/postgresql/migrations/migration_024_025.sql +6 -0
- kinto/core/storage/postgresql/migrator.py +98 -0
- kinto/core/storage/postgresql/pool.py +55 -0
- kinto/core/storage/postgresql/schema.sql +143 -0
- kinto/core/storage/testing.py +1857 -0
- kinto/core/storage/utils.py +37 -0
- kinto/core/testing.py +182 -0
- kinto/core/utils.py +553 -0
- kinto/core/views/__init__.py +0 -0
- kinto/core/views/batch.py +163 -0
- kinto/core/views/errors.py +145 -0
- kinto/core/views/heartbeat.py +106 -0
- kinto/core/views/hello.py +69 -0
- kinto/core/views/openapi.py +35 -0
- kinto/core/views/version.py +50 -0
- kinto/events.py +3 -0
- kinto/plugins/__init__.py +0 -0
- kinto/plugins/accounts/__init__.py +94 -0
- kinto/plugins/accounts/authentication.py +63 -0
- kinto/plugins/accounts/scripts.py +61 -0
- kinto/plugins/accounts/utils.py +13 -0
- kinto/plugins/accounts/views.py +136 -0
- kinto/plugins/admin/README.md +3 -0
- kinto/plugins/admin/VERSION +1 -0
- kinto/plugins/admin/__init__.py +40 -0
- kinto/plugins/admin/build/VERSION +1 -0
- kinto/plugins/admin/build/assets/index-CYFwtKtL.css +6 -0
- kinto/plugins/admin/build/assets/index-DJ0m93zA.js +149 -0
- kinto/plugins/admin/build/assets/logo-VBRiKSPX.png +0 -0
- kinto/plugins/admin/build/index.html +18 -0
- kinto/plugins/admin/public/help.html +25 -0
- kinto/plugins/admin/views.py +42 -0
- kinto/plugins/default_bucket/__init__.py +191 -0
- kinto/plugins/flush.py +28 -0
- kinto/plugins/history/__init__.py +65 -0
- kinto/plugins/history/listener.py +181 -0
- kinto/plugins/history/views.py +66 -0
- kinto/plugins/openid/__init__.py +131 -0
- kinto/plugins/openid/utils.py +14 -0
- kinto/plugins/openid/views.py +193 -0
- kinto/plugins/prometheus.py +300 -0
- kinto/plugins/statsd.py +85 -0
- kinto/schema_validation.py +135 -0
- kinto/views/__init__.py +34 -0
- kinto/views/admin.py +195 -0
- kinto/views/buckets.py +45 -0
- kinto/views/collections.py +58 -0
- kinto/views/contribute.py +39 -0
- kinto/views/groups.py +90 -0
- kinto/views/permissions.py +235 -0
- kinto/views/records.py +133 -0
- kinto-23.2.1.dist-info/METADATA +232 -0
- kinto-23.2.1.dist-info/RECORD +142 -0
- kinto-23.2.1.dist-info/WHEEL +5 -0
- kinto-23.2.1.dist-info/entry_points.txt +5 -0
- kinto-23.2.1.dist-info/licenses/LICENSE +13 -0
- kinto-23.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from kinto.core.cache import CacheBase
|
|
5
|
+
from kinto.core.storage.postgresql.client import create_from_config
|
|
6
|
+
from kinto.core.utils import json
|
|
7
|
+
from kinto.core.utils import sqlalchemy as sa
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Cache(CacheBase):
|
|
14
|
+
"""Cache backend using PostgreSQL.
|
|
15
|
+
|
|
16
|
+
Enable in configuration::
|
|
17
|
+
|
|
18
|
+
kinto.cache_backend = kinto.core.cache.postgresql
|
|
19
|
+
|
|
20
|
+
Database location URI can be customized::
|
|
21
|
+
|
|
22
|
+
kinto.cache_url = postgresql://user:pass@db.server.lan:5432/dbname
|
|
23
|
+
|
|
24
|
+
Alternatively, username and password could also rely on system user ident
|
|
25
|
+
or even specified in :file:`~/.pgpass` (*see PostgreSQL documentation*).
|
|
26
|
+
|
|
27
|
+
.. note::
|
|
28
|
+
|
|
29
|
+
Some tables and indices are created when ``kinto migrate`` is run.
|
|
30
|
+
This requires some privileges on the database, or some error will
|
|
31
|
+
be raised.
|
|
32
|
+
|
|
33
|
+
**Alternatively**, the schema can be initialized outside the
|
|
34
|
+
python application, using the SQL file located in
|
|
35
|
+
:file:`kinto/core/cache/postgresql/schema.sql`. This allows to
|
|
36
|
+
distinguish schema manipulation privileges from schema usage.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
A connection pool is enabled by default::
|
|
40
|
+
|
|
41
|
+
kinto.cache_pool_size = 10
|
|
42
|
+
kinto.cache_maxoverflow = 10
|
|
43
|
+
kinto.cache_max_backlog = -1
|
|
44
|
+
kinto.cache_pool_recycle = -1
|
|
45
|
+
kinto.cache_pool_timeout = 30
|
|
46
|
+
kinto.cache_poolclass =
|
|
47
|
+
kinto.core.storage.postgresql.pool.QueuePoolWithMaxBacklog
|
|
48
|
+
|
|
49
|
+
The ``max_backlog`` limits the number of threads that can be in the queue
|
|
50
|
+
waiting for a connection. Once this limit has been reached, any further
|
|
51
|
+
attempts to acquire a connection will be rejected immediately, instead of
|
|
52
|
+
locking up all threads by keeping them waiting in the queue.
|
|
53
|
+
|
|
54
|
+
See `dedicated section in SQLAlchemy documentation
|
|
55
|
+
<http://docs.sqlalchemy.org/en/rel_1_0/core/engines.html>`_
|
|
56
|
+
for default values and behaviour.
|
|
57
|
+
|
|
58
|
+
.. note::
|
|
59
|
+
|
|
60
|
+
Using a `dedicated connection pool <http://pgpool.net>`_ is still
|
|
61
|
+
recommended to allow load balancing, replication or limit the number
|
|
62
|
+
of connections used in a multi-process deployment.
|
|
63
|
+
|
|
64
|
+
:noindex:
|
|
65
|
+
""" # NOQA
|
|
66
|
+
|
|
67
|
+
def __init__(self, client, *args, **kwargs):
|
|
68
|
+
super().__init__(*args, **kwargs)
|
|
69
|
+
self.client = client
|
|
70
|
+
|
|
71
|
+
def initialize_schema(self, dry_run=False):
|
|
72
|
+
# Check if cache table exists.
|
|
73
|
+
query = """
|
|
74
|
+
SELECT 1
|
|
75
|
+
FROM information_schema.tables
|
|
76
|
+
WHERE table_name = 'cache';
|
|
77
|
+
"""
|
|
78
|
+
with self.client.connect(readonly=True) as conn:
|
|
79
|
+
result = conn.execute(sa.text(query))
|
|
80
|
+
if result.rowcount > 0:
|
|
81
|
+
logger.info("PostgreSQL cache schema is up-to-date.")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Create schema
|
|
85
|
+
here = os.path.dirname(__file__)
|
|
86
|
+
sql_file = os.path.join(here, "schema.sql")
|
|
87
|
+
|
|
88
|
+
if dry_run:
|
|
89
|
+
logger.info(f"Create cache schema from '{sql_file}'")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Since called outside request, force commit.
|
|
93
|
+
with open(sql_file) as f:
|
|
94
|
+
schema = f.read()
|
|
95
|
+
with self.client.connect(force_commit=True) as conn:
|
|
96
|
+
conn.execute(sa.text(schema))
|
|
97
|
+
logger.info("Created PostgreSQL cache tables")
|
|
98
|
+
|
|
99
|
+
def flush(self):
|
|
100
|
+
query = """
|
|
101
|
+
DELETE FROM cache;
|
|
102
|
+
"""
|
|
103
|
+
# Since called outside request (e.g. tests), force commit.
|
|
104
|
+
with self.client.connect(force_commit=True) as conn:
|
|
105
|
+
conn.execute(sa.text(query))
|
|
106
|
+
logger.debug("Flushed PostgreSQL cache tables")
|
|
107
|
+
|
|
108
|
+
def ttl(self, key):
|
|
109
|
+
query = """
|
|
110
|
+
SELECT EXTRACT(SECOND FROM (ttl - now())) AS ttl
|
|
111
|
+
FROM cache
|
|
112
|
+
WHERE key = :key
|
|
113
|
+
AND ttl IS NOT NULL;
|
|
114
|
+
"""
|
|
115
|
+
with self.client.connect(readonly=True) as conn:
|
|
116
|
+
result = conn.execute(sa.text(query), dict(key=self.prefix + key))
|
|
117
|
+
if result.rowcount > 0:
|
|
118
|
+
return result.fetchone().ttl
|
|
119
|
+
return -1
|
|
120
|
+
|
|
121
|
+
def expire(self, key, ttl):
|
|
122
|
+
query = """
|
|
123
|
+
UPDATE cache SET ttl = sec2ttl(:ttl) WHERE key = :key;
|
|
124
|
+
"""
|
|
125
|
+
with self.client.connect() as conn:
|
|
126
|
+
conn.execute(sa.text(query), dict(ttl=ttl, key=self.prefix + key))
|
|
127
|
+
|
|
128
|
+
def set(self, key, value, ttl):
|
|
129
|
+
if isinstance(value, bytes):
|
|
130
|
+
raise TypeError("a string-like object is required, not 'bytes'")
|
|
131
|
+
|
|
132
|
+
query = """
|
|
133
|
+
INSERT INTO cache (key, value, ttl)
|
|
134
|
+
VALUES (:key, :value, sec2ttl(:ttl))
|
|
135
|
+
ON CONFLICT (key) DO UPDATE
|
|
136
|
+
SET value = :value,
|
|
137
|
+
ttl = sec2ttl(:ttl);
|
|
138
|
+
"""
|
|
139
|
+
value = json.dumps(value)
|
|
140
|
+
with self.client.connect() as conn:
|
|
141
|
+
conn.execute(sa.text(query), dict(key=self.prefix + key, value=value, ttl=ttl))
|
|
142
|
+
|
|
143
|
+
def get(self, key):
|
|
144
|
+
purge = """
|
|
145
|
+
DELETE FROM cache c
|
|
146
|
+
USING (
|
|
147
|
+
SELECT key
|
|
148
|
+
FROM cache
|
|
149
|
+
WHERE ttl IS NOT NULL AND now() > ttl
|
|
150
|
+
ORDER BY key ASC
|
|
151
|
+
FOR UPDATE
|
|
152
|
+
) del
|
|
153
|
+
WHERE del.key = c.key;"""
|
|
154
|
+
query = "SELECT value FROM cache WHERE key = :key AND now() < ttl;"
|
|
155
|
+
with self.client.connect() as conn:
|
|
156
|
+
conn.execute(sa.text(purge))
|
|
157
|
+
result = conn.execute(sa.text(query), dict(key=self.prefix + key))
|
|
158
|
+
if result.rowcount > 0:
|
|
159
|
+
self.metrics_backend.count_hit()
|
|
160
|
+
value = result.fetchone().value
|
|
161
|
+
return json.loads(value)
|
|
162
|
+
self.metrics_backend.count_miss()
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def delete(self, key):
|
|
166
|
+
query = "DELETE FROM cache WHERE key = :key RETURNING value;"
|
|
167
|
+
with self.client.connect() as conn:
|
|
168
|
+
result = conn.execute(sa.text(query), dict(key=self.prefix + key))
|
|
169
|
+
if result.rowcount > 0:
|
|
170
|
+
value = result.fetchone().value
|
|
171
|
+
return json.loads(value)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_from_config(config):
|
|
176
|
+
settings = config.get_settings()
|
|
177
|
+
client = create_from_config(config, prefix="cache_", with_transaction=False)
|
|
178
|
+
return Cache(client=client, cache_prefix=settings["cache_prefix"])
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
--
|
|
2
|
+
-- Automated script, we do not need NOTICE and WARNING
|
|
3
|
+
--
|
|
4
|
+
SET client_min_messages TO ERROR;
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
7
|
+
key VARCHAR(256) PRIMARY KEY,
|
|
8
|
+
value TEXT NOT NULL,
|
|
9
|
+
ttl TIMESTAMP DEFAULT NULL
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_cache_ttl ON cache(ttl);
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CREATE OR REPLACE FUNCTION sec2ttl(seconds FLOAT)
|
|
16
|
+
RETURNS TIMESTAMP AS $$
|
|
17
|
+
BEGIN
|
|
18
|
+
IF seconds IS NULL THEN
|
|
19
|
+
RETURN NULL;
|
|
20
|
+
END IF;
|
|
21
|
+
RETURN now() + (seconds || ' SECOND')::INTERVAL;
|
|
22
|
+
END;
|
|
23
|
+
$$ LANGUAGE plpgsql;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from unittest import mock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from pyramid import testing
|
|
6
|
+
|
|
7
|
+
from kinto.core.cache import heartbeat
|
|
8
|
+
from kinto.core.storage import exceptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CacheTest:
|
|
12
|
+
backend = None
|
|
13
|
+
settings = {}
|
|
14
|
+
|
|
15
|
+
def setUp(self):
|
|
16
|
+
super().setUp()
|
|
17
|
+
self.cache = self.backend.load_from_config(self._get_config())
|
|
18
|
+
self.cache.initialize_schema()
|
|
19
|
+
self.request = None
|
|
20
|
+
self.client_error_patcher = None
|
|
21
|
+
|
|
22
|
+
def _get_config(self, settings=None):
|
|
23
|
+
"""Mock Pyramid config object."""
|
|
24
|
+
if settings is None:
|
|
25
|
+
settings = self.settings
|
|
26
|
+
config = testing.setUp()
|
|
27
|
+
config.add_settings(settings)
|
|
28
|
+
return config
|
|
29
|
+
|
|
30
|
+
def tearDown(self):
|
|
31
|
+
mock.patch.stopall()
|
|
32
|
+
super().tearDown()
|
|
33
|
+
self.cache.flush()
|
|
34
|
+
|
|
35
|
+
def get_backend_prefix(self, prefix):
|
|
36
|
+
settings_prefix = {**self.settings}
|
|
37
|
+
settings_prefix["cache_prefix"] = prefix
|
|
38
|
+
config_prefix = self._get_config(settings=settings_prefix)
|
|
39
|
+
|
|
40
|
+
# initiating cache backend with prefix:
|
|
41
|
+
backend_prefix = self.backend.load_from_config(config_prefix)
|
|
42
|
+
|
|
43
|
+
return backend_prefix
|
|
44
|
+
|
|
45
|
+
def test_backend_error_is_raised_anywhere(self):
|
|
46
|
+
self.client_error_patcher.start()
|
|
47
|
+
calls = [
|
|
48
|
+
(self.cache.flush,),
|
|
49
|
+
(self.cache.ttl, ""),
|
|
50
|
+
(self.cache.expire, "", 0),
|
|
51
|
+
(self.cache.get, ""),
|
|
52
|
+
(self.cache.set, "", "", 42),
|
|
53
|
+
(self.cache.delete, ""),
|
|
54
|
+
]
|
|
55
|
+
for call in calls:
|
|
56
|
+
self.assertRaises(exceptions.BackendError, *call)
|
|
57
|
+
|
|
58
|
+
def test_initialize_schema_is_idempotent(self):
|
|
59
|
+
self.cache.initialize_schema()
|
|
60
|
+
self.cache.initialize_schema() # not raising.
|
|
61
|
+
|
|
62
|
+
def test_ping_returns_false_if_unavailable(self):
|
|
63
|
+
self.client_error_patcher.start()
|
|
64
|
+
ping = heartbeat(self.cache)
|
|
65
|
+
self.assertFalse(ping(self.request))
|
|
66
|
+
with mock.patch("kinto.core.cache.random.SystemRandom.random", return_value=0.6):
|
|
67
|
+
self.assertFalse(ping(self.request))
|
|
68
|
+
with mock.patch("kinto.core.cache.random.SystemRandom.random", return_value=0.4):
|
|
69
|
+
self.assertFalse(ping(self.request))
|
|
70
|
+
|
|
71
|
+
def test_ping_returns_true_if_available(self):
|
|
72
|
+
ping = heartbeat(self.cache)
|
|
73
|
+
with mock.patch("kinto.core.cache.random.random", return_value=0.6):
|
|
74
|
+
self.assertTrue(ping(self.request))
|
|
75
|
+
with mock.patch("kinto.core.cache.random.random", return_value=0.4):
|
|
76
|
+
self.assertTrue(ping(self.request))
|
|
77
|
+
|
|
78
|
+
def test_ping_logs_error_if_unavailable(self):
|
|
79
|
+
self.client_error_patcher.start()
|
|
80
|
+
ping = heartbeat(self.cache)
|
|
81
|
+
|
|
82
|
+
with mock.patch("kinto.core.cache.logger.exception") as exc_handler:
|
|
83
|
+
self.assertFalse(ping(self.request))
|
|
84
|
+
|
|
85
|
+
self.assertTrue(exc_handler.called)
|
|
86
|
+
|
|
87
|
+
def test_set_adds_the_object(self):
|
|
88
|
+
stored = "toto"
|
|
89
|
+
self.cache.set("foobar", stored, 42)
|
|
90
|
+
retrieved = self.cache.get("foobar")
|
|
91
|
+
self.assertEqual(retrieved, stored)
|
|
92
|
+
|
|
93
|
+
def test_values_remains_python_dict(self):
|
|
94
|
+
def setget(k, v):
|
|
95
|
+
self.cache.set(k, v, 42)
|
|
96
|
+
return (self.cache.get(k), v)
|
|
97
|
+
|
|
98
|
+
self.assertEqual(*setget("foobar", 3))
|
|
99
|
+
self.assertEqual(*setget("foobar", ["a"]))
|
|
100
|
+
self.assertEqual(*setget("foobar", {"b": [1, 2]}))
|
|
101
|
+
self.assertEqual(*setget("foobar", 3.14))
|
|
102
|
+
|
|
103
|
+
def test_bytes_cannot_be_stored_in_the_cache(self):
|
|
104
|
+
with pytest.raises(TypeError):
|
|
105
|
+
self.cache.set("test", b"foo", 42)
|
|
106
|
+
|
|
107
|
+
def test_delete_removes_the_object(self):
|
|
108
|
+
self.cache.set("foobar", "toto", 42)
|
|
109
|
+
returned = self.cache.delete("foobar")
|
|
110
|
+
self.assertEqual(returned, "toto")
|
|
111
|
+
missing = self.cache.get("foobar")
|
|
112
|
+
self.assertIsNone(missing)
|
|
113
|
+
|
|
114
|
+
def test_delete_does_not_fail_if_object_is_unknown(self):
|
|
115
|
+
returned = self.cache.delete("foobar")
|
|
116
|
+
self.assertIsNone(returned)
|
|
117
|
+
|
|
118
|
+
def test_expire_expires_the_value(self):
|
|
119
|
+
self.cache.set("foobar", "toto", 42)
|
|
120
|
+
self.cache.expire("foobar", 0.01)
|
|
121
|
+
time.sleep(0.02)
|
|
122
|
+
retrieved = self.cache.get("foobar")
|
|
123
|
+
self.assertIsNone(retrieved)
|
|
124
|
+
|
|
125
|
+
def test_set_with_ttl_expires_the_value(self):
|
|
126
|
+
self.cache.set("foobar", "toto", 0.01)
|
|
127
|
+
time.sleep(0.02)
|
|
128
|
+
retrieved = self.cache.get("foobar")
|
|
129
|
+
self.assertIsNone(retrieved)
|
|
130
|
+
|
|
131
|
+
def test_ttl_return_the_time_to_live(self):
|
|
132
|
+
self.cache.set("foobar", "toto", 42)
|
|
133
|
+
self.cache.expire("foobar", 10)
|
|
134
|
+
ttl = self.cache.ttl("foobar")
|
|
135
|
+
self.assertGreater(ttl, 0)
|
|
136
|
+
self.assertLessEqual(ttl, 10)
|
|
137
|
+
|
|
138
|
+
def test_ttl_return_none_if_unknown(self):
|
|
139
|
+
ttl = self.cache.ttl("unknown")
|
|
140
|
+
self.assertTrue(ttl < 0)
|
|
141
|
+
|
|
142
|
+
def test_cache_prefix_is_set(self):
|
|
143
|
+
backend_prefix = self.get_backend_prefix(prefix="prefix_")
|
|
144
|
+
|
|
145
|
+
# Set the value
|
|
146
|
+
backend_prefix.set("key", "foo", 42)
|
|
147
|
+
|
|
148
|
+
# Validate that it was set with the prefix.
|
|
149
|
+
obtained = self.cache.get("prefix_key")
|
|
150
|
+
self.assertEqual(obtained, "foo")
|
|
151
|
+
|
|
152
|
+
def test_cache_when_prefix_is_not_set(self):
|
|
153
|
+
backend_prefix = self.get_backend_prefix(prefix="")
|
|
154
|
+
|
|
155
|
+
# Set a value
|
|
156
|
+
backend_prefix.set("key", "foo", 42)
|
|
157
|
+
|
|
158
|
+
# Validate that it was set with no prefix
|
|
159
|
+
obtained = self.cache.get("key")
|
|
160
|
+
self.assertEqual(obtained, "foo")
|
|
161
|
+
|
|
162
|
+
def test_prefix_value_use_to_get_data(self):
|
|
163
|
+
backend_prefix = self.get_backend_prefix(prefix="prefix_")
|
|
164
|
+
|
|
165
|
+
# Set the value with the prefix
|
|
166
|
+
self.cache.set("prefix_key", "foo", 42)
|
|
167
|
+
|
|
168
|
+
# Validate that the prefix was added
|
|
169
|
+
obtained = backend_prefix.get("key")
|
|
170
|
+
self.assertEqual(obtained, "foo")
|
|
171
|
+
|
|
172
|
+
def test_prefix_value_use_to_delete_data(self):
|
|
173
|
+
backend_prefix = self.get_backend_prefix(prefix="prefix_")
|
|
174
|
+
# Set the value
|
|
175
|
+
self.cache.set("prefix_key", "foo", 42)
|
|
176
|
+
|
|
177
|
+
# Delete the value
|
|
178
|
+
backend_prefix.delete("key")
|
|
179
|
+
|
|
180
|
+
# Validate that the value was deleted
|
|
181
|
+
obtained = self.cache.get("prefix_key")
|
|
182
|
+
self.assertEqual(obtained, None)
|
|
183
|
+
|
|
184
|
+
def test_prefix_value_used_with_ttl(self):
|
|
185
|
+
backend_prefix = self.get_backend_prefix(prefix="prefix_")
|
|
186
|
+
|
|
187
|
+
self.cache.set("prefix_key", "foo", 10)
|
|
188
|
+
|
|
189
|
+
# Validate that the ttl add the prefix to the key.
|
|
190
|
+
obtained = backend_prefix.ttl("key")
|
|
191
|
+
self.assertLessEqual(obtained, 10)
|
|
192
|
+
self.assertGreater(obtained, 9)
|
|
193
|
+
|
|
194
|
+
def test_prefix_value_used_with_expire(self):
|
|
195
|
+
backend_prefix = self.get_backend_prefix(prefix="prefix_")
|
|
196
|
+
|
|
197
|
+
self.cache.set("prefix_foobar", "toto", 10)
|
|
198
|
+
|
|
199
|
+
# expiring the ttl of key
|
|
200
|
+
backend_prefix.expire("foobar", 0)
|
|
201
|
+
|
|
202
|
+
# Make sure the TTL was set accordingly.
|
|
203
|
+
ttl = self.cache.ttl("prefix_foobar")
|
|
204
|
+
self.assertLessEqual(ttl, 0)
|
|
205
|
+
|
|
206
|
+
# The object should have expired
|
|
207
|
+
retrieved = self.cache.get("prefix_foobar")
|
|
208
|
+
self.assertIsNone(retrieved)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
3
|
+
# You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
4
|
+
import logging
|
|
5
|
+
from functools import partial
|
|
6
|
+
|
|
7
|
+
from pyramid.events import NewRequest
|
|
8
|
+
from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound
|
|
9
|
+
from pyramid.security import NO_PERMISSION_REQUIRED
|
|
10
|
+
from pyramid.settings import asbool, aslist
|
|
11
|
+
|
|
12
|
+
from kinto.core.cornice.errors import Errors # NOQA
|
|
13
|
+
from kinto.core.cornice.pyramidhook import (
|
|
14
|
+
handle_exceptions,
|
|
15
|
+
register_resource_views,
|
|
16
|
+
register_service_views,
|
|
17
|
+
wrap_request,
|
|
18
|
+
)
|
|
19
|
+
from kinto.core.cornice.renderer import CorniceRenderer
|
|
20
|
+
from kinto.core.cornice.service import Service # NOQA
|
|
21
|
+
from kinto.core.cornice.util import ContentTypePredicate, current_service
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("cornice")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_localizer_for_languages(event, available_languages, default_locale_name):
|
|
28
|
+
"""
|
|
29
|
+
Sets the current locale based on the incoming Accept-Language header, if
|
|
30
|
+
present, and sets a localizer attribute on the request object based on
|
|
31
|
+
the current locale.
|
|
32
|
+
|
|
33
|
+
To be used as an event handler, this function needs to be partially applied
|
|
34
|
+
with the available_languages and default_locale_name arguments. The
|
|
35
|
+
resulting function will be an event handler which takes an event object as
|
|
36
|
+
its only argument.
|
|
37
|
+
"""
|
|
38
|
+
request = event.request
|
|
39
|
+
if request.accept_language:
|
|
40
|
+
accepted = request.accept_language.lookup(available_languages, default=default_locale_name)
|
|
41
|
+
request._LOCALE_ = accepted
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def setup_localization(config):
|
|
45
|
+
"""
|
|
46
|
+
Setup localization based on the available_languages and
|
|
47
|
+
pyramid.default_locale_name settings.
|
|
48
|
+
|
|
49
|
+
These settings are named after suggestions from the "Internationalization
|
|
50
|
+
and Localization" section of the Pyramid documentation.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
config.add_translation_dirs("colander:locale/")
|
|
54
|
+
settings = config.get_settings()
|
|
55
|
+
available_languages = aslist(settings["available_languages"])
|
|
56
|
+
default_locale_name = settings.get("pyramid.default_locale_name", "en")
|
|
57
|
+
set_localizer = partial(
|
|
58
|
+
set_localizer_for_languages,
|
|
59
|
+
available_languages=available_languages,
|
|
60
|
+
default_locale_name=default_locale_name,
|
|
61
|
+
)
|
|
62
|
+
config.add_subscriber(set_localizer, NewRequest)
|
|
63
|
+
except ImportError: # pragma: no cover
|
|
64
|
+
# add_translation_dirs raises an ImportError if colander is not
|
|
65
|
+
# installed
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def includeme(config):
|
|
70
|
+
"""Include the Cornice definitions"""
|
|
71
|
+
# attributes required to maintain services
|
|
72
|
+
config.registry.cornice_services = {}
|
|
73
|
+
|
|
74
|
+
settings = config.get_settings()
|
|
75
|
+
|
|
76
|
+
# localization request subscriber must be set before first call
|
|
77
|
+
# for request.localizer (in wrap_request)
|
|
78
|
+
if settings.get("available_languages"):
|
|
79
|
+
setup_localization(config)
|
|
80
|
+
|
|
81
|
+
config.add_directive("add_cornice_service", register_service_views)
|
|
82
|
+
config.add_directive("add_cornice_resource", register_resource_views)
|
|
83
|
+
config.add_subscriber(wrap_request, NewRequest)
|
|
84
|
+
config.add_renderer("cornicejson", CorniceRenderer())
|
|
85
|
+
config.add_view_predicate("content_type", ContentTypePredicate)
|
|
86
|
+
config.add_request_method(current_service, reify=True)
|
|
87
|
+
|
|
88
|
+
if asbool(settings.get("handle_exceptions", True)):
|
|
89
|
+
config.add_view(handle_exceptions, context=Exception, permission=NO_PERMISSION_REQUIRED)
|
|
90
|
+
config.add_view(handle_exceptions, context=HTTPNotFound, permission=NO_PERMISSION_REQUIRED)
|
|
91
|
+
config.add_view(
|
|
92
|
+
handle_exceptions, context=HTTPForbidden, permission=NO_PERMISSION_REQUIRED
|
|
93
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
3
|
+
# You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
4
|
+
import fnmatch
|
|
5
|
+
import functools
|
|
6
|
+
|
|
7
|
+
from pyramid.settings import asbool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CORS_PARAMETERS = (
|
|
11
|
+
"cors_headers",
|
|
12
|
+
"cors_enabled",
|
|
13
|
+
"cors_origins",
|
|
14
|
+
"cors_credentials",
|
|
15
|
+
"cors_max_age",
|
|
16
|
+
"cors_expose_all_headers",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_cors_preflight_view(service):
|
|
21
|
+
"""Return a view for the OPTION method.
|
|
22
|
+
|
|
23
|
+
Checks that the User-Agent is authorized to do a request to the server, and
|
|
24
|
+
to this particular service, and add the various checks that are specified
|
|
25
|
+
in http://www.w3.org/TR/cors/#resource-processing-model.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def _preflight_view(request):
|
|
29
|
+
response = request.response
|
|
30
|
+
origin = request.headers.get("Origin")
|
|
31
|
+
supported_headers = service.cors_supported_headers_for()
|
|
32
|
+
|
|
33
|
+
if not origin:
|
|
34
|
+
request.errors.add("header", "Origin", "this header is mandatory")
|
|
35
|
+
|
|
36
|
+
requested_method = request.headers.get("Access-Control-Request-Method")
|
|
37
|
+
if not requested_method:
|
|
38
|
+
request.errors.add(
|
|
39
|
+
"header", "Access-Control-Request-Method", "this header is mandatory"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if not (requested_method and origin):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
requested_headers = request.headers.get("Access-Control-Request-Headers", ())
|
|
46
|
+
|
|
47
|
+
if requested_headers:
|
|
48
|
+
requested_headers = map(str.strip, requested_headers.split(","))
|
|
49
|
+
|
|
50
|
+
if requested_method not in service.cors_supported_methods:
|
|
51
|
+
request.errors.add("header", "Access-Control-Request-Method", "Method not allowed")
|
|
52
|
+
|
|
53
|
+
if not service.cors_expose_all_headers:
|
|
54
|
+
for h in requested_headers:
|
|
55
|
+
if h.lower() not in [s.lower() for s in supported_headers]:
|
|
56
|
+
request.errors.add(
|
|
57
|
+
"header", "Access-Control-Request-Headers", 'Header "%s" not allowed' % h
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
supported_headers = set(supported_headers) | set(requested_headers)
|
|
61
|
+
|
|
62
|
+
response.headers["Access-Control-Allow-Headers"] = ",".join(supported_headers)
|
|
63
|
+
|
|
64
|
+
response.headers["Access-Control-Allow-Methods"] = ",".join(service.cors_supported_methods)
|
|
65
|
+
|
|
66
|
+
max_age = service.cors_max_age_for(requested_method)
|
|
67
|
+
if max_age is not None:
|
|
68
|
+
response.headers["Access-Control-Max-Age"] = str(max_age)
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
return _preflight_view
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_method(request):
|
|
76
|
+
"""Return what's supposed to be the method for CORS operations.
|
|
77
|
+
(e.g if the verb is options, look at the A-C-Request-Method header,
|
|
78
|
+
otherwise return the HTTP verb).
|
|
79
|
+
"""
|
|
80
|
+
if request.method == "OPTIONS":
|
|
81
|
+
method = request.headers.get("Access-Control-Request-Method", request.method)
|
|
82
|
+
else:
|
|
83
|
+
method = request.method
|
|
84
|
+
return method
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ensure_origin(service, request, response=None, **kwargs):
|
|
88
|
+
"""Ensure that the origin header is set and allowed."""
|
|
89
|
+
response = response or request.response
|
|
90
|
+
|
|
91
|
+
# Don't check this twice.
|
|
92
|
+
if not request.info.get("cors_checked", False):
|
|
93
|
+
method = _get_method(request)
|
|
94
|
+
|
|
95
|
+
origin = request.headers.get("Origin")
|
|
96
|
+
|
|
97
|
+
if not origin:
|
|
98
|
+
always_cors = asbool(request.registry.settings.get("cornice.always_cors"))
|
|
99
|
+
# With this setting, if the service origins has "*", then
|
|
100
|
+
# always return CORS headers.
|
|
101
|
+
origins = getattr(service, "cors_origins", [])
|
|
102
|
+
if always_cors and "*" in origins:
|
|
103
|
+
origin = "*"
|
|
104
|
+
|
|
105
|
+
if origin:
|
|
106
|
+
if not any([fnmatch.fnmatchcase(origin, o) for o in service.cors_origins_for(method)]):
|
|
107
|
+
request.errors.add("header", "Origin", "%s not allowed" % origin)
|
|
108
|
+
elif service.cors_support_credentials_for(method):
|
|
109
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
110
|
+
else:
|
|
111
|
+
if any([o == "*" for o in service.cors_origins_for(method)]):
|
|
112
|
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
113
|
+
else:
|
|
114
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
115
|
+
request.info["cors_checked"] = True
|
|
116
|
+
return response
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_cors_validator(service):
|
|
120
|
+
return functools.partial(ensure_origin, service)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def apply_cors_post_request(service, request, response):
|
|
124
|
+
"""Handles CORS-related post-request things.
|
|
125
|
+
|
|
126
|
+
Add some response headers, such as the Expose-Headers and the
|
|
127
|
+
Allow-Credentials ones.
|
|
128
|
+
"""
|
|
129
|
+
response = ensure_origin(service, request, response)
|
|
130
|
+
method = _get_method(request)
|
|
131
|
+
|
|
132
|
+
if (
|
|
133
|
+
service.cors_support_credentials_for(method)
|
|
134
|
+
and "Access-Control-Allow-Credentials" not in response.headers
|
|
135
|
+
):
|
|
136
|
+
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
137
|
+
|
|
138
|
+
if request.method != "OPTIONS":
|
|
139
|
+
# Which headers are exposed?
|
|
140
|
+
supported_headers = service.cors_supported_headers_for(request.method)
|
|
141
|
+
if supported_headers:
|
|
142
|
+
response.headers["Access-Control-Expose-Headers"] = ", ".join(supported_headers)
|
|
143
|
+
|
|
144
|
+
return response
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
3
|
+
# You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from pyramid.i18n import TranslationString
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Errors(list):
|
|
10
|
+
"""Holds Request errors"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, status=400, localizer=None):
|
|
13
|
+
self.status = status
|
|
14
|
+
self.localizer = localizer
|
|
15
|
+
super(Errors, self).__init__()
|
|
16
|
+
|
|
17
|
+
def add(self, location, name=None, description=None, **kw):
|
|
18
|
+
"""Registers a new error."""
|
|
19
|
+
allowed = ("body", "querystring", "url", "header", "path", "cookies", "method")
|
|
20
|
+
if location != "" and location not in allowed:
|
|
21
|
+
raise ValueError("%r not in %s" % (location, allowed))
|
|
22
|
+
|
|
23
|
+
if isinstance(description, TranslationString) and self.localizer:
|
|
24
|
+
description = self.localizer.translate(description)
|
|
25
|
+
|
|
26
|
+
self.append(dict(location=location, name=name, description=description, **kw))
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_json(cls, string):
|
|
30
|
+
"""Transforms a json string into an `Errors` instance"""
|
|
31
|
+
obj = json.loads(string.decode())
|
|
32
|
+
return Errors.from_list(obj.get("errors", []))
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_list(cls, obj):
|
|
36
|
+
"""Transforms a python list into an `Errors` instance"""
|
|
37
|
+
errors = Errors()
|
|
38
|
+
for error in obj:
|
|
39
|
+
errors.add(**error)
|
|
40
|
+
return errors
|