queryapigate 0.5.0__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.

Potentially problematic release.


This version of queryapigate might be problematic. Click here for more details.

queryapigate/params.py ADDED
@@ -0,0 +1,275 @@
1
+ """Parameter definitions of a saved query: checking the definitions, and enforcing them on request values.
2
+
3
+ A saved query declares its parameters in ``query_parameters``. The short form is just a type
4
+ (``{"id": "int"}``); the full form adds rules::
5
+
6
+ {
7
+ "rating": {"type": "str", "enum": ["G", "PG", "R"], "default": "PG", "description": "MPAA rating"},
8
+ "max_length": {"type": "int", "min": 1, "max": 600},
9
+ "title": {"type": "str", "required": false, "min_length": 2, "pattern": "[A-Za-z ]+"}
10
+ }
11
+
12
+ Rules: ``type`` (int, float, str, bool), ``required`` (default: true unless a ``default`` is given), ``default``,
13
+ ``enum``, ``min``/``max`` (numbers), ``min_length``/``max_length``/``pattern`` (text) and ``description``.
14
+ An optional parameter that is neither supplied nor defaulted is bound as NULL.
15
+
16
+ Definitions are validated strictly when a query is saved. At run time they are read leniently, so older saved
17
+ files with unusual definitions keep working.
18
+ """
19
+ import math
20
+ import re
21
+
22
+ from .errors import ApiError
23
+
24
+ _TYPES = {'int': 'integer', 'integer': 'integer', 'float': 'number', 'number': 'number',
25
+ 'str': 'string', 'string': 'string', 'bool': 'boolean', 'boolean': 'boolean'}
26
+ _RULE_KEYS = {'type', 'required', 'default', 'enum', 'min', 'max', 'min_length', 'max_length', 'pattern',
27
+ 'description'}
28
+ _NAME_RE = re.compile(r'^[A-Za-z_]\w*$')
29
+ _MAX_PATTERN_LENGTH = 500
30
+
31
+
32
+ class _Invalid(Exception):
33
+ """A value or a definition that breaks a rule; the message completes the sentence '<name> ...'."""
34
+
35
+
36
+ def _to_bool(text):
37
+ lowered = text.strip().lower()
38
+ if lowered in ('true', '1', 'yes'):
39
+ return True
40
+ if lowered in ('false', '0', 'no'):
41
+ return False
42
+ raise ValueError(text)
43
+
44
+
45
+ def _is_number(value):
46
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
47
+
48
+
49
+ def _plain(spec):
50
+ return {'type': None, 'required': True, 'has_default': False, 'default': None, 'enum': None, 'min': None,
51
+ 'max': None, 'min_length': None, 'max_length': None, 'pattern': None, 'description': None, **spec}
52
+
53
+
54
+ def _coerce(type_name, value):
55
+ """Convert a value (usually a query-string text) to the declared type, or raise _Invalid."""
56
+ if type_name == 'integer':
57
+ if isinstance(value, str):
58
+ try:
59
+ return int(value)
60
+ except ValueError:
61
+ raise _Invalid('must be an integer') from None
62
+ if isinstance(value, int) and not isinstance(value, bool):
63
+ return value
64
+ raise _Invalid('must be an integer')
65
+ if type_name == 'number':
66
+ if isinstance(value, str):
67
+ try:
68
+ value = float(value)
69
+ except ValueError:
70
+ raise _Invalid('must be a number') from None
71
+ if _is_number(value):
72
+ return value
73
+ raise _Invalid('must be a number')
74
+ if type_name == 'boolean':
75
+ if isinstance(value, str):
76
+ try:
77
+ return _to_bool(value)
78
+ except ValueError:
79
+ raise _Invalid('must be true or false') from None
80
+ if isinstance(value, bool):
81
+ return value
82
+ raise _Invalid('must be true or false')
83
+ if type_name == 'string':
84
+ if isinstance(value, str):
85
+ return value
86
+ raise _Invalid('must be text')
87
+ if isinstance(value, (str, bool)) or _is_number(value): # untyped: any scalar
88
+ return value
89
+ raise _Invalid('must be text, a number or true/false')
90
+
91
+
92
+ def _check(spec, value):
93
+ """Return the value converted to the declared type after applying every rule, or raise _Invalid."""
94
+ value = _coerce(spec['type'], value)
95
+ if spec['enum'] is not None and value not in spec['enum']:
96
+ raise _Invalid('must be one of: ' + ', '.join(str(v) for v in spec['enum']))
97
+ if _is_number(value):
98
+ if spec['min'] is not None and value < spec['min']:
99
+ raise _Invalid(f"must be at least {spec['min']:g}")
100
+ if spec['max'] is not None and value > spec['max']:
101
+ raise _Invalid(f"must be at most {spec['max']:g}")
102
+ if isinstance(value, str):
103
+ if spec['min_length'] is not None and len(value) < spec['min_length']:
104
+ raise _Invalid(f"must be at least {spec['min_length']} characters long")
105
+ if spec['max_length'] is not None and len(value) > spec['max_length']:
106
+ raise _Invalid(f"must be at most {spec['max_length']} characters long")
107
+ if spec['pattern'] is not None and not re.fullmatch(spec['pattern'], value):
108
+ raise _Invalid(f"must match the pattern {spec['pattern']}")
109
+ return value
110
+
111
+
112
+ # --------------------------------------------------------------------------------------
113
+ # Definitions
114
+ # --------------------------------------------------------------------------------------
115
+
116
+ def _parse_strict(spec):
117
+ if isinstance(spec, str):
118
+ spec = {'type': spec}
119
+ if not isinstance(spec, dict):
120
+ raise _Invalid("must be a type name such as 'int' or an object of rules")
121
+ unknown = sorted(set(spec) - _RULE_KEYS)
122
+ if unknown:
123
+ raise _Invalid(f"has unknown rule(s): {', '.join(unknown)} (allowed: {', '.join(sorted(_RULE_KEYS))})")
124
+
125
+ type_name = None
126
+ if spec.get('type') is not None:
127
+ type_name = _TYPES.get(str(spec['type']).lower())
128
+ if type_name is None:
129
+ raise _Invalid(f"has unknown type '{spec['type']}' (use int, float, str or bool)")
130
+ parsed = _plain({'type': type_name})
131
+
132
+ if 'required' in spec:
133
+ if not isinstance(spec['required'], bool):
134
+ raise _Invalid("'required' must be true or false")
135
+ parsed['required'] = spec['required']
136
+ elif 'default' in spec:
137
+ parsed['required'] = False
138
+ if 'description' in spec:
139
+ if not isinstance(spec['description'], str):
140
+ raise _Invalid("'description' must be text")
141
+ parsed['description'] = spec['description']
142
+
143
+ numeric = type_name in (None, 'integer', 'number')
144
+ textual = type_name in (None, 'string')
145
+ for key in ('min', 'max'):
146
+ if key in spec:
147
+ if not numeric:
148
+ raise _Invalid(f"'{key}' only applies to numbers (use min_length/max_length for text)")
149
+ if not _is_number(spec[key]):
150
+ raise _Invalid(f"'{key}' must be a number")
151
+ parsed[key] = spec[key]
152
+ if parsed['min'] is not None and parsed['max'] is not None and parsed['min'] > parsed['max']:
153
+ raise _Invalid("'min' must not be greater than 'max'")
154
+ for key in ('min_length', 'max_length'):
155
+ if key in spec:
156
+ if not textual:
157
+ raise _Invalid(f"'{key}' only applies to text")
158
+ if not isinstance(spec[key], int) or isinstance(spec[key], bool) or spec[key] < 0:
159
+ raise _Invalid(f"'{key}' must be a non-negative integer")
160
+ parsed[key] = spec[key]
161
+ if (parsed['min_length'] is not None and parsed['max_length'] is not None
162
+ and parsed['min_length'] > parsed['max_length']):
163
+ raise _Invalid("'min_length' must not be greater than 'max_length'")
164
+ if 'pattern' in spec:
165
+ if not textual:
166
+ raise _Invalid("'pattern' only applies to text")
167
+ if not isinstance(spec['pattern'], str) or len(spec['pattern']) > _MAX_PATTERN_LENGTH:
168
+ raise _Invalid(f"'pattern' must be text of at most {_MAX_PATTERN_LENGTH} characters")
169
+ try:
170
+ re.compile(spec['pattern'])
171
+ except re.error as error:
172
+ raise _Invalid(f"'pattern' is not a valid regular expression ({error})") from None
173
+ parsed['pattern'] = spec['pattern']
174
+
175
+ if 'enum' in spec:
176
+ if not isinstance(spec['enum'], list) or not spec['enum']:
177
+ raise _Invalid("'enum' must be a non-empty list")
178
+ try:
179
+ parsed['enum'] = [_coerce(type_name, v) for v in spec['enum']]
180
+ except _Invalid as error:
181
+ raise _Invalid(f"has an 'enum' value that {error}") from None
182
+ if 'default' in spec:
183
+ if spec.get('required') is True:
184
+ raise _Invalid("cannot be both 'required' and have a 'default'")
185
+ try:
186
+ parsed['default'] = _check(parsed, spec['default'])
187
+ except _Invalid as error:
188
+ raise _Invalid(f"has a 'default' that {error}") from None
189
+ parsed['has_default'] = True
190
+ return parsed
191
+
192
+
193
+ def parse_definitions(query_parameters):
194
+ """Validate the ``query_parameters`` of a query about to be saved; raises ApiError(400) listing every problem."""
195
+ if not isinstance(query_parameters, dict):
196
+ raise ApiError('query_parameters must be a JSON object')
197
+ errors = {}
198
+ for name, spec in query_parameters.items():
199
+ if not _NAME_RE.match(name):
200
+ errors[name] = 'is not a valid parameter name (letters, digits and underscores; not starting with a digit)'
201
+ continue
202
+ try:
203
+ _parse_strict(spec)
204
+ except _Invalid as error:
205
+ errors[name] = str(error)
206
+ if errors:
207
+ raise ApiError('Invalid query_parameters: ' + '; '.join(f'{n} {m}' for n, m in errors.items()), 400,
208
+ errors=errors)
209
+
210
+
211
+ def read_definition(spec):
212
+ """A definition as the normalised rule dict; anything unusable degrades to an untyped, required parameter."""
213
+ try:
214
+ return _parse_strict(spec)
215
+ except _Invalid:
216
+ if isinstance(spec, str):
217
+ return _plain({'type': _TYPES.get(spec.lower())})
218
+ return _plain({})
219
+
220
+
221
+ def read_definitions(query_parameters):
222
+ if not isinstance(query_parameters, dict):
223
+ return {}
224
+ return {name: read_definition(spec) for name, spec in query_parameters.items()}
225
+
226
+
227
+ # --------------------------------------------------------------------------------------
228
+ # Values
229
+ # --------------------------------------------------------------------------------------
230
+
231
+ def resolve(declared, supplied, used=None):
232
+ """Apply a saved query's parameter definitions to the values a request supplied.
233
+
234
+ Returns every value to bind: supplied ones converted and checked, defaults filled in, optional ones without
235
+ a value set to None. Values for undeclared parameters pass through untouched. Declared parameters that the
236
+ SQL does not use (``used`` is the set of names it does use) are not enforced. Raises ApiError(400) listing
237
+ every violation.
238
+ """
239
+ values = dict(supplied)
240
+ errors = {}
241
+ for name, spec in read_definitions(declared).items():
242
+ if used is not None and name not in used:
243
+ continue
244
+ if name not in values:
245
+ if spec['has_default']:
246
+ values[name] = spec['default']
247
+ elif spec['required']:
248
+ errors[name] = 'is required'
249
+ else:
250
+ values[name] = None
251
+ continue
252
+ if values[name] is None:
253
+ if spec['required']:
254
+ errors[name] = 'must not be null'
255
+ continue
256
+ try:
257
+ values[name] = _check(spec, values[name])
258
+ except _Invalid as error:
259
+ errors[name] = str(error)
260
+ if errors:
261
+ raise ApiError('Invalid parameters: ' + '; '.join(f'{n} {m}' for n, m in errors.items()), 400,
262
+ errors=errors)
263
+ return values
264
+
265
+
266
+ def json_schema(spec):
267
+ """The OpenAPI/JSON-schema description of a parameter's type and rules."""
268
+ schema = {'type': spec['type'] or 'string'}
269
+ for rule, key in (('enum', 'enum'), ('min', 'minimum'), ('max', 'maximum'), ('min_length', 'minLength'),
270
+ ('max_length', 'maxLength'), ('pattern', 'pattern')):
271
+ if spec[rule] is not None:
272
+ schema[key] = spec[rule]
273
+ if spec['has_default']:
274
+ schema['default'] = spec['default']
275
+ return schema
queryapigate/pool.py ADDED
@@ -0,0 +1,146 @@
1
+ """A small thread-safe pool of live database connections.
2
+
3
+ Only *idle* connections are pooled: a connection is checked out exclusively by one request, and on the way
4
+ back it is reset (its transaction ended) and kept for reuse - unless the request failed, in which case its
5
+ state is unknown and it is closed instead. The number of concurrent connections is not limited here; the
6
+ pool only bounds how many idle ones are kept around.
7
+ """
8
+ import atexit
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from contextlib import contextmanager
16
+
17
+ from . import config
18
+
19
+ log = logging.getLogger('queryapigate')
20
+
21
+ # A connection that has been idle for less than this is trusted without a round trip to check it.
22
+ VALIDATE_AFTER = 5.0 # seconds
23
+
24
+
25
+ class Session:
26
+ """A connection plus what the driver wants to remember about it (e.g. limits already applied)."""
27
+
28
+ def __init__(self, conn, driver):
29
+ self.conn = conn
30
+ self.driver = driver
31
+ self.state = {}
32
+ self.idle_since = time.monotonic()
33
+
34
+ def close(self):
35
+ try:
36
+ self.driver.close(self)
37
+ except Exception: # a connection that cannot be closed cleanly is gone anyway
38
+ log.debug('Error while closing a connection', exc_info=True)
39
+
40
+
41
+ def fingerprint(details, read_only):
42
+ """Pool key: connections are interchangeable when their settings and read-only mode are identical."""
43
+ blob = json.dumps(details, sort_keys=True, default=str)
44
+ return hashlib.sha256(blob.encode('utf-8')).hexdigest(), bool(read_only)
45
+
46
+
47
+ class ConnectionPool:
48
+ def __init__(self):
49
+ self._idle = {} # key -> deque of Sessions, least recently used on the left
50
+ self._lock = threading.Lock()
51
+
52
+ @contextmanager
53
+ def checkout(self, driver, details, read_only):
54
+ key = fingerprint(details, read_only)
55
+ session = self._acquire(key)
56
+ if session is None:
57
+ session = Session(driver.connect(details, read_only), driver)
58
+ try:
59
+ yield session
60
+ except BaseException:
61
+ session.close() # after an error its state is unknown - never reuse it
62
+ raise
63
+ self._release(key, session)
64
+
65
+ # ---- internals ------------------------------------------------------------------
66
+
67
+ def _acquire(self, key):
68
+ while True:
69
+ with self._lock:
70
+ queue = self._idle.get(key)
71
+ if not queue:
72
+ return None
73
+ session = queue.pop() # most recently used first, so spare connections age out
74
+ if not queue:
75
+ del self._idle[key]
76
+ idle_for = time.monotonic() - session.idle_since
77
+ if idle_for > config.pool_idle_timeout():
78
+ session.close()
79
+ continue
80
+ if idle_for > VALIDATE_AFTER:
81
+ try:
82
+ alive = session.driver.is_alive(session)
83
+ except Exception:
84
+ alive = False
85
+ if not alive:
86
+ log.info('Discarding a dead pooled connection')
87
+ session.close()
88
+ continue
89
+ return session
90
+
91
+ def _release(self, key, session):
92
+ try:
93
+ session.driver.reset(session) # ends the transaction so the next user sees fresh data
94
+ except Exception:
95
+ log.debug('Could not reset a connection; closing it', exc_info=True)
96
+ session.close()
97
+ return
98
+ session.idle_since = time.monotonic()
99
+ surplus = []
100
+ with self._lock:
101
+ queue = self._idle.setdefault(key, deque())
102
+ queue.append(session)
103
+ while len(queue) > config.pool_size():
104
+ surplus.append(queue.popleft())
105
+ surplus.extend(self._expired_locked())
106
+ for old in surplus:
107
+ old.close()
108
+
109
+ def _expired_locked(self):
110
+ """Remove and return idle sessions past the idle timeout, across every key (caller holds the lock)."""
111
+ cutoff = time.monotonic() - config.pool_idle_timeout()
112
+ expired = []
113
+ for key in list(self._idle):
114
+ queue = self._idle[key]
115
+ while queue and queue[0].idle_since < cutoff:
116
+ expired.append(queue.popleft())
117
+ if not queue:
118
+ del self._idle[key]
119
+ return expired
120
+
121
+ # ---- housekeeping ---------------------------------------------------------------
122
+
123
+ def idle_count(self):
124
+ with self._lock:
125
+ return sum(len(q) for q in self._idle.values())
126
+
127
+ def close_all(self):
128
+ with self._lock:
129
+ sessions = [s for queue in self._idle.values() for s in queue]
130
+ self._idle.clear()
131
+ for session in sessions:
132
+ session.close()
133
+
134
+
135
+ _default = ConnectionPool()
136
+ atexit.register(_default.close_all)
137
+
138
+
139
+ def close_pooled_connections():
140
+ """Close every idle connection in the shared pool (also runs at interpreter exit)."""
141
+ _default.close_all()
142
+
143
+
144
+ def get_pool():
145
+ """The shared pool, or None when pooling is disabled (QUERYAPIGATE_POOL_SIZE=0)."""
146
+ return _default if config.pool_size() > 0 else None
@@ -0,0 +1,88 @@
1
+ """A small in-memory token-bucket rate limiter, off unless QUERYAPIGATE_RATE_LIMIT is set (e.g. ``60/minute``).
2
+
3
+ Each client (by IP address) has a bucket holding up to the full quota, refilled continuously, so short bursts are
4
+ allowed but the sustained rate is capped. State lives in this process: with several worker processes each has its
5
+ own buckets, so the effective limit is multiplied by the number of workers.
6
+
7
+ ``RateLimiter`` handles the server-wide, IP-keyed limit above. ``KeyRateLimiters`` (below) is the same
8
+ mechanism applied per API key instead, for a key's own optional ``rate_limit`` grant (see ``apikeys.py``) -
9
+ both are checked, not one instead of the other, so a per-key limit can never be used to escape the
10
+ server-wide IP-based one.
11
+ """
12
+ import math
13
+ import threading
14
+ import time
15
+ from collections import OrderedDict
16
+
17
+
18
+ class RateLimiter:
19
+ MAX_CLIENTS = 100_000 # bounds memory; the least recently seen clients are forgotten first
20
+
21
+ def __init__(self, clock=time.monotonic):
22
+ self._clock = clock
23
+ self._lock = threading.Lock()
24
+ self._buckets = OrderedDict() # client -> [tokens, last_seen]; least recently seen first
25
+ self._spec = None
26
+ self._last_sweep = clock()
27
+
28
+ def hit(self, client, count, period):
29
+ """Record a request. Returns (allowed, remaining, retry_after_seconds)."""
30
+ now = self._clock()
31
+ with self._lock:
32
+ if self._spec != (count, period): # limit changed: start again rather than mix old and new rules
33
+ self._buckets.clear()
34
+ self._spec = (count, period)
35
+ self._sweep(now, period)
36
+ tokens, seen = self._buckets.pop(client, (float(count), now))
37
+ tokens = min(float(count), tokens + (now - seen) * count / period)
38
+ allowed = tokens >= 1
39
+ if allowed:
40
+ tokens -= 1
41
+ self._buckets[client] = (tokens, now) # re-inserted last: most recently seen
42
+ if len(self._buckets) > self.MAX_CLIENTS:
43
+ self._buckets.popitem(last=False)
44
+ # round first: 20.000000000000004 seconds must not become a 21 second wait
45
+ retry_after = 0 if allowed else max(1, math.ceil(round((1 - tokens) * period / count, 6)))
46
+ return allowed, int(tokens), retry_after
47
+
48
+ def _sweep(self, now, period):
49
+ """Forget clients idle for a full period: their bucket has refilled, so they look brand new anyway."""
50
+ if now - self._last_sweep < period:
51
+ return
52
+ self._last_sweep = now
53
+ while self._buckets:
54
+ client, (_, seen) = next(iter(self._buckets.items()))
55
+ if now - seen < period:
56
+ break
57
+ del self._buckets[client]
58
+
59
+ def size(self):
60
+ with self._lock:
61
+ return len(self._buckets)
62
+
63
+
64
+ class KeyRateLimiters:
65
+ """Per-API-key rate limiting needs a genuinely separate RateLimiter per distinct (count, period) spec,
66
+ not just a different `client` identifier fed into one shared instance: a single RateLimiter enforces
67
+ exactly one spec at a time and wipes every client's bucket the moment a *different* spec arrives (see
68
+ its own `hit()` - by design, so an admin changing QUERYAPIGATE_RATE_LIMIT doesn't mix old and new rules for
69
+ the IP-based limiter). Two keys configured with different `rate_limit` values are exactly that "two
70
+ different specs" case, so they need their own RateLimiter instances; two keys that happen to share the
71
+ same configured limit correctly share one instance instead (and so still get independent per-key
72
+ buckets within it, keyed by name) - there is no reason to multiply instances beyond one per distinct
73
+ spec actually in use.
74
+ """
75
+
76
+ def __init__(self, clock=time.monotonic):
77
+ self._clock = clock
78
+ self._lock = threading.Lock()
79
+ self._limiters = {} # (count, period) -> RateLimiter
80
+
81
+ def hit(self, key_name, count, period):
82
+ spec = (count, period)
83
+ with self._lock:
84
+ limiter = self._limiters.get(spec)
85
+ if limiter is None:
86
+ limiter = RateLimiter(self._clock)
87
+ self._limiters[spec] = limiter
88
+ return limiter.hit(key_name, count, period)