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.
queryapigate/cache.py ADDED
@@ -0,0 +1,61 @@
1
+ """Opt-in, in-process TTL response cache for saved queries.
2
+
3
+ Never applies to ad-hoc ``/execute_sql`` (there is no stable name to hang a TTL off), and the caller
4
+ (``run_saved`` in ``app.py``) only ever consults this cache for a saved query whose SQL is read-only -
5
+ serving a cached response in place of a write would silently skip that write. Cache keys already include
6
+ the resolved parameter values, connection, format and page, so entries are shared across API keys that can
7
+ both use the same connection - that reveals nothing a scoped key could not already see by running the
8
+ query itself.
9
+
10
+ Like ``ratelimit.RateLimiter``, one instance lives per Flask app (``app.extensions['queryapigate_cache']``) so
11
+ tests get a clean cache instead of leaking entries between them; a multi-process deployment would need a
12
+ shared backing store instead of this one process's memory.
13
+ """
14
+ import hashlib
15
+ import json
16
+ import threading
17
+ import time
18
+ from collections import OrderedDict
19
+
20
+ MAX_ENTRIES = 10_000 # bounds memory; the least recently used entry is evicted first
21
+
22
+
23
+ class ResponseCache:
24
+ def __init__(self, clock=time.monotonic):
25
+ self._clock = clock
26
+ self._lock = threading.Lock()
27
+ self._entries = OrderedDict() # key -> (body, content_type, headers, etag, expires_at)
28
+
29
+ @staticmethod
30
+ def key(**parts):
31
+ """A stable cache key from whatever makes the response unique (name, version, connection, resolved
32
+ parameter values, format, page, page_size - never raw, unresolved input)."""
33
+ blob = json.dumps(parts, sort_keys=True, default=str)
34
+ return hashlib.sha256(blob.encode('utf-8')).hexdigest()
35
+
36
+ def get(self, key):
37
+ """(body, content_type, headers, etag) for a live entry, or None if missing or expired."""
38
+ with self._lock:
39
+ entry = self._entries.get(key)
40
+ if entry is None:
41
+ return None
42
+ body, content_type, headers, etag, expires_at = entry
43
+ if self._clock() >= expires_at:
44
+ del self._entries[key]
45
+ return None
46
+ self._entries.move_to_end(key) # most recently used
47
+ return body, content_type, headers, etag
48
+
49
+ def set(self, key, body, content_type, headers, ttl):
50
+ """Store a response and return its ETag (a hash of the body, so it changes only when content does)."""
51
+ etag = hashlib.sha256(body).hexdigest()
52
+ with self._lock:
53
+ self._entries[key] = (body, content_type, headers, etag, self._clock() + ttl)
54
+ self._entries.move_to_end(key)
55
+ while len(self._entries) > MAX_ENTRIES:
56
+ self._entries.popitem(last=False)
57
+ return etag
58
+
59
+ def size(self):
60
+ with self._lock:
61
+ return len(self._entries)
queryapigate/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ """Command line entry point: ``queryapigate serve``, ``queryapigate init`` and ``queryapigate export``."""
2
+ import argparse
3
+ import logging
4
+ import os
5
+ import sys
6
+ from datetime import datetime
7
+
8
+ from . import __version__, config, store
9
+ from .app import create_app
10
+
11
+ LOOPBACK_HOSTS = ('127.0.0.1', 'localhost', '::1')
12
+
13
+
14
+ def _serve(args):
15
+ try:
16
+ app = create_app()
17
+ except ValueError as error: # a malformed setting, e.g. QUERYAPIGATE_RATE_LIMIT
18
+ print(f'queryapigate: {error}', file=sys.stderr)
19
+ return 2
20
+ if args.host not in LOOPBACK_HOSTS and not config.api_key():
21
+ logging.getLogger('queryapigate').warning(
22
+ 'Listening on %s without QUERYAPIGATE_API_KEY set: anyone who can reach this port can run SQL '
23
+ 'on your active connections.', args.host)
24
+ logging.getLogger('queryapigate').info('Using %s (connections: %s)', config.home(), config.connections_file().name)
25
+ app.run(host=args.host, port=args.port, debug=args.debug)
26
+ return 0
27
+
28
+
29
+ def _init(args):
30
+ home = config.home()
31
+ home.mkdir(parents=True, exist_ok=True)
32
+ (home / 'saved_sql').mkdir(exist_ok=True)
33
+ target = config.connections_file()
34
+ if target.exists():
35
+ print(f'{target} already exists - left untouched')
36
+ else:
37
+ store.write_json_atomic(str(target), config.EXAMPLE_CONNECTIONS)
38
+ print(f'Created {target}\nEdit it, set "active": true on the connections you want, '
39
+ 'then run: queryapigate serve')
40
+ return 0
41
+
42
+
43
+ def _resolve_export_path(template, name):
44
+ """Fill in a --out template's {date}/{name} placeholders. Deliberately just these two, not a general
45
+ strftime/templating facility - the exact shape #31 was scoped to."""
46
+ try:
47
+ return template.format(date=datetime.now().strftime('%Y-%m-%d'), name=name)
48
+ except (KeyError, IndexError) as error:
49
+ raise ValueError(f'--out has an unknown placeholder ({error}) - only {{date}} and {{name}} are '
50
+ 'supported') from None
51
+
52
+
53
+ def _export(args):
54
+ """``queryapigate export <query> --format csv --out /path/{date}.csv`` - the last small step a
55
+ cron/systemd/Kubernetes CronJob needs to turn the existing ?stream=true export into a scheduled file
56
+ drop, without becoming a scheduler itself (see BACKLOG.md #31 for why that stays out of scope). Runs
57
+ entirely in-process against QUERYAPIGATE_HOME - no server needs to be running, no HTTP round trip, no API
58
+ key: this is a trusted local operator with the same reach the admin key already has."""
59
+ try:
60
+ config.check_settings()
61
+ except ValueError as error:
62
+ print(f'queryapigate export: {error}', file=sys.stderr)
63
+ return 2
64
+
65
+ from .engine import stream_sql
66
+ from .errors import ApiError
67
+ from .formats import STREAM_FORMATTERS, iter_stream_chunks
68
+ from .params import resolve as resolve_params
69
+ from .sqltools import fill_placeholders, placeholder_names
70
+
71
+ tmp_path = None
72
+ try:
73
+ if args.format not in STREAM_FORMATTERS:
74
+ raise ApiError(f"--format must be one of: {', '.join(sorted(STREAM_FORMATTERS))}")
75
+ try:
76
+ raw_params = dict(p.split('=', 1) for p in args.param)
77
+ except ValueError:
78
+ raise ApiError('--param must look like name=value') from None
79
+
80
+ path = store.resolve_saved_file(args.query)
81
+ name = store.query_name(path)
82
+ _, saved = store.select_version(store.load_versions(path), None)
83
+ connection_name = args.connection or saved.get('connection_name')
84
+ if not connection_name:
85
+ raise ApiError('Connection name is missing - pass --connection or set one on the saved query')
86
+ sql = saved.get('sql_query')
87
+ if not isinstance(sql, str):
88
+ raise ApiError('Saved query has no SQL', 500)
89
+ used = set(placeholder_names(sql))
90
+ values = resolve_params(saved.get('query_parameters'), raw_params, used=used)
91
+ sql = fill_placeholders(sql, values)
92
+
93
+ out_path = _resolve_export_path(args.out, name)
94
+ out_dir = os.path.dirname(out_path) or '.'
95
+ os.makedirs(out_dir, exist_ok=True)
96
+ tmp_path = os.path.join(out_dir, f'.{os.path.basename(out_path)}.part')
97
+
98
+ columns, rows = stream_sql(sql, connection_name, values, config.effective_timeout(args.timeout))
99
+ row_count = 0
100
+
101
+ def counted(row_iter):
102
+ nonlocal row_count
103
+ for row in row_iter:
104
+ row_count += 1
105
+ yield row
106
+
107
+ with open(tmp_path, 'w', newline='') as f:
108
+ for chunk in iter_stream_chunks(args.format, columns, counted(rows)):
109
+ f.write(chunk)
110
+ os.replace(tmp_path, out_path)
111
+ except ApiError as error:
112
+ print(f'queryapigate export: {error.message}', file=sys.stderr)
113
+ return 1
114
+ except (OSError, ValueError) as error:
115
+ print(f'queryapigate export: {error}', file=sys.stderr)
116
+ return 1
117
+ finally:
118
+ if tmp_path is not None and os.path.exists(tmp_path):
119
+ os.remove(tmp_path) # only ever left behind by a failed run - a success already renamed it away
120
+
121
+ print(f'Wrote {row_count} row{"" if row_count == 1 else "s"} to {out_path}')
122
+ return 0
123
+
124
+
125
+ def build_parser():
126
+ parser = argparse.ArgumentParser(prog='queryapigate', description='Expose SQL databases as a REST API.')
127
+ parser.add_argument('--version', action='version', version=f'queryapigate {__version__}')
128
+ commands = parser.add_subparsers(dest='command')
129
+
130
+ serve = commands.add_parser('serve', help='start the HTTP server (default)')
131
+ serve.add_argument('--host', default=os.environ.get('QUERYAPIGATE_HOST', '127.0.0.1'))
132
+ serve.add_argument('--port', type=int, default=int(os.environ.get('QUERYAPIGATE_PORT', 5000)))
133
+ serve.add_argument('--debug', action='store_true', default=config.env_flag('QUERYAPIGATE_DEBUG'),
134
+ help='Flask debug mode - never use on a reachable host')
135
+ serve.set_defaults(func=_serve)
136
+
137
+ init = commands.add_parser('init', help='create db_connections.json and saved_sql/ in the home folder')
138
+ init.set_defaults(func=_init)
139
+
140
+ export = commands.add_parser('export', help='run a saved query and write the full result to a file '
141
+ '(for cron/systemd/Kubernetes CronJob, not a scheduler itself)')
142
+ export.add_argument('query', help='saved query name (as used in a GET /q/<name> request)')
143
+ export.add_argument('--format', choices=('csv', 'tsv', 'ndjson'), default='csv')
144
+ export.add_argument('--connection', help='overrides the saved query\'s own default connection')
145
+ export.add_argument('--out', required=True,
146
+ help='output path; {date} (YYYY-MM-DD) and {name} are filled in, e.g. '
147
+ '/exports/{name}_{date}.csv - written to a temp file and renamed into place '
148
+ 'only on success, so a failed run never leaves a partial or missing file there')
149
+ export.add_argument('--param', action='append', default=[], metavar='name=value',
150
+ help='a query parameter, e.g. --param customer_id=42 (repeatable)')
151
+ export.add_argument('--timeout', type=float, help='seconds allowed for the query (default: the server '
152
+ 'setting, QUERYAPIGATE_QUERY_TIMEOUT)')
153
+ export.set_defaults(func=_export)
154
+
155
+ for sub in (serve, init, export):
156
+ sub.add_argument('--home', help='folder holding db_connections.json and saved_sql/ '
157
+ '(default: $QUERYAPIGATE_HOME or the current directory)')
158
+ return parser
159
+
160
+
161
+ def main(argv=None):
162
+ parser = build_parser()
163
+ args = parser.parse_args(argv)
164
+ if args.command is None:
165
+ args = parser.parse_args(['serve', *(argv or [])])
166
+ if getattr(args, 'home', None):
167
+ os.environ['QUERYAPIGATE_HOME'] = args.home
168
+ return args.func(args)
queryapigate/config.py ADDED
@@ -0,0 +1,268 @@
1
+ """Runtime configuration, read from environment variables at call time."""
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+
6
+ SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2', 'jdbc', 'duckdb')
7
+ PASSWORD_MASK = '********'
8
+ CONNECT_TIMEOUT = 10 # seconds
9
+ HISTORY_LIMIT = 50 # executions remembered per saved-query version
10
+ AUDIT_LOG_LIMIT = 500 # administrative-action entries remembered across the whole server
11
+ DEFAULT_QUERY_TIMEOUT = 30.0 # seconds
12
+ DEFAULT_POOL_SIZE = 5 # idle connections kept per distinct connection
13
+ DEFAULT_POOL_IDLE_TIMEOUT = 300.0 # seconds
14
+ DEFAULT_SLOW_QUERY_THRESHOLD = 1.0 # seconds
15
+
16
+ # Ships with the package; override with QUERYAPIGATE_H2_JAR to use a different H2 version.
17
+ BUNDLED_H2_JAR = Path(__file__).parent / 'lib' / 'h2-2.2.224.jar'
18
+
19
+ # Shown by `queryapigate init`. Every entry starts inactive so nothing connects until you opt in.
20
+ EXAMPLE_CONNECTIONS = {
21
+ 'connections': {
22
+ 'example-sqlite': {'db': 'sqlite', 'database': 'example.db', 'active': False},
23
+ 'example-duckdb': {'db': 'duckdb', 'database': 'example.duckdb', 'active': False},
24
+ 'example-postgres': {'db': 'postgres', 'host': 'localhost', 'port': 5432, 'database': 'postgres',
25
+ 'user': 'postgres', 'password': '${POSTGRES_PASSWORD}', 'active': False},
26
+ 'example-mysql': {'db': 'mysql', 'host': 'localhost', 'port': 3306, 'database': 'mydb',
27
+ 'user': 'root', 'password': '${MYSQL_PASSWORD}', 'active': False},
28
+ 'example-clickhouse': {'db': 'clickhouse', 'host': 'localhost', 'port': 9000, 'database': 'default',
29
+ 'user': 'default', 'password': '', 'active': False},
30
+ 'example-h2': {'db': 'h2', 'host': 'localhost', 'database': 'test', 'user': 'SA', 'password': '',
31
+ 'active': False},
32
+ }
33
+ }
34
+
35
+
36
+ def env_flag(name):
37
+ return os.environ.get(name, '').strip().lower() in ('1', 'true', 'yes', 'on')
38
+
39
+
40
+ def home():
41
+ """Folder holding db_connections.json and saved_sql/ (QUERYAPIGATE_HOME, default: current directory)."""
42
+ return Path(os.environ.get('QUERYAPIGATE_HOME') or os.getcwd()).resolve()
43
+
44
+
45
+ def connections_file():
46
+ return home() / 'db_connections.json'
47
+
48
+
49
+ def api_keys_file():
50
+ return home() / 'api_keys.json'
51
+
52
+
53
+ def roles_file():
54
+ return home() / 'roles.json'
55
+
56
+
57
+ def audit_log_file():
58
+ return home() / 'audit_log.json'
59
+
60
+
61
+ def audit_log_limit():
62
+ """Administrative-action entries remembered in audit_log.json (QUERYAPIGATE_AUDIT_LOG_LIMIT), default
63
+ AUDIT_LOG_LIMIT (500). Always a positive count, never "unbounded" like stream_max_rows() can be: unlike
64
+ a streaming export, audit_log.json is read and rewritten in full on every single audit event (see
65
+ store.record_audit()), so letting it grow without bound would turn every administrative action into an
66
+ ever-slower disk read/write. Raise this for longer retention, or set QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE for
67
+ retention this cap can never roll off. Validated at startup (check_settings()), the same "fail loudly on
68
+ a typo" treatment stream_max_rows() gets."""
69
+ raw = os.environ.get('QUERYAPIGATE_AUDIT_LOG_LIMIT', '').strip()
70
+ return int(raw) if raw else AUDIT_LOG_LIMIT
71
+
72
+
73
+ def audit_log_export_file():
74
+ """Optional path (QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE) to also append every audit entry to, one JSON object per
75
+ line, appended only - never capped or rewritten like audit_log.json itself, so retention here doesn't
76
+ depend on audit_log_limit() being sized generously enough. None when unset (today's behaviour,
77
+ unchanged): nothing exported beyond audit_log.json's own rolling window."""
78
+ raw = os.environ.get('QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE', '').strip()
79
+ return Path(raw) if raw else None
80
+
81
+
82
+ def saved_sql_dir():
83
+ return home() / 'saved_sql'
84
+
85
+
86
+ def h2_jar():
87
+ return os.environ.get('QUERYAPIGATE_H2_JAR') or str(BUNDLED_H2_JAR)
88
+
89
+
90
+ def allow_writes():
91
+ return env_flag('QUERYAPIGATE_ALLOW_WRITES')
92
+
93
+
94
+ def api_key():
95
+ return os.environ.get('QUERYAPIGATE_API_KEY') or None
96
+
97
+
98
+ def query_timeout():
99
+ """Server-wide statement time limit in seconds (QUERYAPIGATE_QUERY_TIMEOUT); None when disabled (set to 0)."""
100
+ try:
101
+ value = float(os.environ.get('QUERYAPIGATE_QUERY_TIMEOUT', DEFAULT_QUERY_TIMEOUT))
102
+ except ValueError:
103
+ return DEFAULT_QUERY_TIMEOUT
104
+ if value == 0:
105
+ return None
106
+ return value if value > 0 else DEFAULT_QUERY_TIMEOUT
107
+
108
+
109
+ def effective_timeout(requested=None):
110
+ """The limit to enforce: a request may ask for less time than the server allows, never more."""
111
+ limit = query_timeout()
112
+ if requested is None:
113
+ return limit
114
+ return requested if limit is None else min(requested, limit)
115
+
116
+
117
+ def pool_size():
118
+ """Idle connections kept per distinct connection (QUERYAPIGATE_POOL_SIZE); 0 disables pooling."""
119
+ try:
120
+ return max(0, int(os.environ.get('QUERYAPIGATE_POOL_SIZE', DEFAULT_POOL_SIZE)))
121
+ except ValueError:
122
+ return DEFAULT_POOL_SIZE
123
+
124
+
125
+ def pool_idle_timeout():
126
+ """Seconds an idle pooled connection is kept before it is closed (QUERYAPIGATE_POOL_IDLE_TIMEOUT)."""
127
+ try:
128
+ value = float(os.environ.get('QUERYAPIGATE_POOL_IDLE_TIMEOUT', DEFAULT_POOL_IDLE_TIMEOUT))
129
+ except ValueError:
130
+ return DEFAULT_POOL_IDLE_TIMEOUT
131
+ return value if value > 0 else DEFAULT_POOL_IDLE_TIMEOUT
132
+
133
+
134
+ def cors_origins():
135
+ """Origins allowed to call the API from a browser (QUERYAPIGATE_CORS_ORIGINS): None (off), '*' or a frozenset.
136
+
137
+ Entries are compared case-insensitively and without a trailing slash, e.g. https://app.example.com.
138
+ """
139
+ raw = os.environ.get('QUERYAPIGATE_CORS_ORIGINS', '').strip()
140
+ if not raw:
141
+ return None
142
+ origins = [item.strip().rstrip('/').lower() for item in raw.split(',') if item.strip()]
143
+ if '*' in origins:
144
+ return '*'
145
+ return frozenset(origins) or None
146
+
147
+
148
+ _RATE_PERIODS = {'second': 1, 'minute': 60, 'hour': 3600, 'day': 86400}
149
+ _RATE_RE = re.compile(r'^\s*(\d+)\s*/\s*(second|minute|hour|day)s?\s*$', re.I)
150
+
151
+
152
+ def parse_rate_limit(text, label='QUERYAPIGATE_RATE_LIMIT'):
153
+ """Parse '60/minute' (also second, hour, day) into (requests, seconds); raises ValueError when malformed.
154
+ ``label`` names the setting in the error message - the default fits this function's own env var, but a
155
+ caller validating the same grammar for something else (e.g. apikeys.py's per-key rate_limit) should
156
+ pass its own name so the message doesn't misleadingly point at QUERYAPIGATE_RATE_LIMIT."""
157
+ match = _RATE_RE.match(text or '')
158
+ if not match or int(match.group(1)) < 1:
159
+ raise ValueError(f"{label} must look like '60/minute' (a positive count, then second, minute, "
160
+ f"hour or day), not {text!r}")
161
+ return int(match.group(1)), _RATE_PERIODS[match.group(2).lower()]
162
+
163
+
164
+ _RATE_PERIOD_NAMES = {seconds: name for name, seconds in _RATE_PERIODS.items()}
165
+
166
+
167
+ def format_rate_limit(rate_limit):
168
+ """The inverse of parse_rate_limit(): (60, 60) -> '60/minute'. None in, None out - for rendering an
169
+ already-resolved (requests, seconds) pair (e.g. a Permission's own rate_limit) back into the same
170
+ human-readable form it was originally configured in."""
171
+ if rate_limit is None:
172
+ return None
173
+ count, seconds = rate_limit
174
+ return f'{count}/{_RATE_PERIOD_NAMES.get(seconds, str(seconds) + "s")}'
175
+
176
+
177
+ def rate_limit():
178
+ """(requests, seconds) allowed per client (QUERYAPIGATE_RATE_LIMIT), or None when limiting is off."""
179
+ raw = os.environ.get('QUERYAPIGATE_RATE_LIMIT', '').strip()
180
+ if not raw:
181
+ return None
182
+ try:
183
+ return parse_rate_limit(raw)
184
+ except ValueError:
185
+ return None # create_app() rejects a malformed value at startup; never limit by accident afterwards
186
+
187
+
188
+ def proxy_hops():
189
+ """Reverse proxies in front of the app whose X-Forwarded-* headers can be trusted (QUERYAPIGATE_TRUST_PROXY)."""
190
+ try:
191
+ return max(0, int(os.environ.get('QUERYAPIGATE_TRUST_PROXY', 0)))
192
+ except ValueError:
193
+ return 0
194
+
195
+
196
+ def check_settings():
197
+ """Raise ValueError for a malformed setting, so a typo fails at startup instead of silently switching off a
198
+ protection."""
199
+ # This project was named SQL2API before it was QueryAPIGate, and its settings were SQL2API_*. They are
200
+ # deliberately not read any more - but silently ignoring a leftover SQL2API_API_KEY would start the server
201
+ # with no admin key configured, which means open access. So a leftover old name is a startup error.
202
+ legacy = sorted(name for name in os.environ if name.startswith('SQL2API_'))
203
+ if legacy:
204
+ renamed = ', '.join(f"{name} -> QUERYAPIGATE_{name[len('SQL2API_'):]}" for name in legacy)
205
+ raise ValueError(f'these settings use the old SQL2API_ prefix, which is no longer read: {renamed}. '
206
+ 'Rename each one; ignoring them silently could leave the server without an API key')
207
+ raw = os.environ.get('QUERYAPIGATE_RATE_LIMIT', '').strip()
208
+ if raw:
209
+ parse_rate_limit(raw)
210
+ raw = os.environ.get('QUERYAPIGATE_STREAM_MAX_ROWS', '').strip()
211
+ if raw and (not raw.isdigit() or int(raw) < 1):
212
+ raise ValueError('QUERYAPIGATE_STREAM_MAX_ROWS must be a positive integer')
213
+ raw = os.environ.get('QUERYAPIGATE_AUDIT_LOG_LIMIT', '').strip()
214
+ if raw and (not raw.isdigit() or int(raw) < 1):
215
+ raise ValueError('QUERYAPIGATE_AUDIT_LOG_LIMIT must be a positive integer')
216
+ raw = os.environ.get('QUERYAPIGATE_SECRET_KEY', '').strip()
217
+ if raw:
218
+ try:
219
+ from cryptography.fernet import Fernet
220
+ except ImportError:
221
+ raise ValueError('QUERYAPIGATE_SECRET_KEY is set but the "cryptography" package is not installed - '
222
+ 'run `pip install "queryapigate[encryption]"`') from None
223
+ try:
224
+ Fernet(raw.encode('utf-8'))
225
+ except (ValueError, TypeError):
226
+ raise ValueError('QUERYAPIGATE_SECRET_KEY must be a valid Fernet key - 32 url-safe base64-encoded '
227
+ 'bytes, e.g. from `python -c "from cryptography.fernet import Fernet; '
228
+ 'print(Fernet.generate_key().decode())"`') from None
229
+
230
+
231
+ def secret_key():
232
+ """The server-side key used to encrypt connection passwords at rest (QUERYAPIGATE_SECRET_KEY), or None when
233
+ unset - encryption at rest is opt-in; without it, a connection's password is stored exactly as given,
234
+ today's unchanged behaviour. Validated as a real Fernet key at startup by check_settings(), not here."""
235
+ return os.environ.get('QUERYAPIGATE_SECRET_KEY') or None
236
+
237
+
238
+ def max_page_size():
239
+ try:
240
+ return max(1, int(os.environ.get('QUERYAPIGATE_MAX_PAGE_SIZE', 1000)))
241
+ except ValueError:
242
+ return 1000
243
+
244
+
245
+ def stream_max_rows():
246
+ """Row cap for a streaming (?stream=true) export (QUERYAPIGATE_STREAM_MAX_ROWS), or None when unbounded -
247
+ today's original behaviour, unchanged unless explicitly opted into. Validated at startup
248
+ (check_settings()) rather than silently falling back like max_page_size() does: this is a safety cap an
249
+ admin is deliberately turning on, so a typo should fail loudly, not silently leave it unbounded."""
250
+ raw = os.environ.get('QUERYAPIGATE_STREAM_MAX_ROWS', '').strip()
251
+ return int(raw) if raw else None
252
+
253
+
254
+ def json_logs():
255
+ """Emit structured (one JSON object per line) logs instead of plain text (QUERYAPIGATE_JSON_LOGS)."""
256
+ return env_flag('QUERYAPIGATE_JSON_LOGS')
257
+
258
+
259
+ def slow_query_threshold():
260
+ """Seconds a query may take before it is logged as a warning (QUERYAPIGATE_SLOW_QUERY_THRESHOLD, default 1);
261
+ None when 0 disables it."""
262
+ try:
263
+ value = float(os.environ.get('QUERYAPIGATE_SLOW_QUERY_THRESHOLD', DEFAULT_SLOW_QUERY_THRESHOLD))
264
+ except ValueError:
265
+ return DEFAULT_SLOW_QUERY_THRESHOLD
266
+ if value == 0:
267
+ return None
268
+ return value if value > 0 else DEFAULT_SLOW_QUERY_THRESHOLD
queryapigate/cors.py ADDED
@@ -0,0 +1,51 @@
1
+ """Cross-origin (CORS) support for browser clients, off unless QUERYAPIGATE_CORS_ORIGINS is set.
2
+
3
+ Without it a web page on another origin cannot call the API: browsers refuse to send the JSON requests and to read
4
+ the answers. CORS only tells the *browser* which sites may call; it is not authentication (use QUERYAPIGATE_API_KEY).
5
+ """
6
+ from flask import Response
7
+
8
+ from . import config
9
+
10
+ ALLOWED_METHODS = 'GET, POST, PATCH, DELETE, OPTIONS'
11
+ ALLOWED_HEADERS = 'Content-Type, X-API-Key'
12
+ # Browsers hide response headers a page has not been told about, and pagination depends on these.
13
+ EXPOSED_HEADERS = 'X-Page, X-Page-Size, X-Has-More, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After'
14
+ PREFLIGHT_MAX_AGE = '600'
15
+
16
+
17
+ def allow_origin_value(origin):
18
+ """The Access-Control-Allow-Origin value for a request from ``origin``, or None when it is not allowed."""
19
+ allowed = config.cors_origins()
20
+ if allowed is None or not origin:
21
+ return None
22
+ if allowed == '*':
23
+ return '*'
24
+ return origin if origin.rstrip('/').lower() in allowed else None
25
+
26
+
27
+ def is_preflight(request):
28
+ return (request.method == 'OPTIONS' and 'Access-Control-Request-Method' in request.headers
29
+ and config.cors_origins() is not None)
30
+
31
+
32
+ def preflight_response(origin):
33
+ """Answer a browser's permission check before it sends the real request (no auth: it carries no key)."""
34
+ response = Response(status=204)
35
+ value = allow_origin_value(origin)
36
+ if value:
37
+ response.headers['Access-Control-Allow-Methods'] = ALLOWED_METHODS
38
+ response.headers['Access-Control-Allow-Headers'] = ALLOWED_HEADERS
39
+ response.headers['Access-Control-Max-Age'] = PREFLIGHT_MAX_AGE
40
+ add_headers(response, origin)
41
+ return response
42
+
43
+
44
+ def add_headers(response, origin):
45
+ value = allow_origin_value(origin)
46
+ if value:
47
+ response.headers['Access-Control-Allow-Origin'] = value
48
+ response.headers['Access-Control-Expose-Headers'] = EXPOSED_HEADERS
49
+ if value != '*':
50
+ response.headers.add('Vary', 'Origin') # the answer depends on who asked
51
+ return response