sql2api 0.1.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.
sql2api/openapi.py ADDED
@@ -0,0 +1,127 @@
1
+ """OpenAPI description of the API, served at /openapi.json and rendered at /docs."""
2
+ from . import config
3
+
4
+ _FORMAT_PARAM = {'name': 'format', 'in': 'query', 'schema': {
5
+ 'type': 'string', 'enum': ['json', 'ndjson', 'csv', 'tsv', 'xml', 'yaml', 'xlsx'], 'default': 'json'}}
6
+ _PAGE_PARAMS = [
7
+ {'name': 'page', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 1}},
8
+ {'name': 'page_size', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 10},
9
+ 'description': 'Upper limit is SQL2API_MAX_PAGE_SIZE (default 1000).'},
10
+ ]
11
+ _ROWS = {'description': 'The requested page. Header X-Has-More says whether another page follows.',
12
+ 'headers': {'X-Page': {'schema': {'type': 'integer'}}, 'X-Page-Size': {'schema': {'type': 'integer'}},
13
+ 'X-Has-More': {'schema': {'type': 'string', 'enum': ['true', 'false']}}},
14
+ 'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'object'}}}}}
15
+ _ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '500')}
16
+
17
+
18
+ def _body(properties, required):
19
+ return {'required': True, 'content': {'application/json': {'schema': {
20
+ 'type': 'object', 'required': required, 'properties': properties}}}}
21
+
22
+
23
+ _EXEC_PROPS = {
24
+ 'connection_name': {'type': 'string'},
25
+ 'format': {'type': 'string'},
26
+ 'placeholders': {'type': 'object', 'description': 'Values for :name (bound) and {name} (text) parameters.'},
27
+ 'params': {'type': 'object', 'description': 'Alias of placeholders.'},
28
+ 'version': {'type': 'integer', 'description': 'Saved version to run (default: latest).'},
29
+ }
30
+
31
+
32
+ def build_spec(version):
33
+ saved = {'filepath': {'type': 'string', 'description': 'Saved query name or path inside saved_sql/.'},
34
+ **_EXEC_PROPS}
35
+ return {
36
+ 'openapi': '3.0.3',
37
+ 'info': {'title': 'SQL2API', 'version': version,
38
+ 'description': 'Run SQL against configured databases and get the results back over HTTP.'},
39
+ 'components': {
40
+ 'securitySchemes': {'ApiKey': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key'}},
41
+ 'responses': {'Error': {'description': 'Error', 'content': {'application/json': {'schema': {
42
+ 'type': 'object', 'properties': {'error': {'type': 'string'}, 'detail': {'type': 'string'}}}}}}},
43
+ },
44
+ 'security': [{}, {'ApiKey': []}],
45
+ 'paths': {
46
+ '/execute_sql': {'post': {
47
+ 'summary': 'Execute SQL', 'tags': ['Query'],
48
+ 'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
49
+ 'requestBody': _body({'sql': {'type': 'string'}, 'connection_name': {'type': 'string'},
50
+ 'params': {'type': 'object',
51
+ 'description': 'Values for :name bound parameters.'}},
52
+ ['sql', 'connection_name']),
53
+ 'responses': {'200': _ROWS, **_ERRORS}}},
54
+ '/q/{name}': {
55
+ 'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}],
56
+ 'get': {'summary': 'Run a saved query; extra query-string arguments become parameters',
57
+ 'tags': ['Saved queries'],
58
+ 'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS,
59
+ {'name': 'connection_name', 'in': 'query', 'schema': {'type': 'string'}},
60
+ {'name': 'version', 'in': 'query', 'schema': {'type': 'integer'}}],
61
+ 'responses': {'200': _ROWS, **_ERRORS}},
62
+ 'post': {'summary': 'Run a saved query with a JSON body of parameters', 'tags': ['Saved queries'],
63
+ 'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
64
+ 'requestBody': _body(_EXEC_PROPS, []),
65
+ 'responses': {'200': _ROWS, **_ERRORS}}},
66
+ '/execute_sql_from_file': {'post': {
67
+ 'summary': 'Run the latest version of a saved query', 'tags': ['Saved queries'],
68
+ 'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
69
+ 'requestBody': _body(saved, ['filepath']), 'responses': {'200': _ROWS, **_ERRORS}}},
70
+ '/execute_sql_with_parameters_from_file': {'post': {
71
+ 'summary': 'Run a saved query with parameter values', 'tags': ['Saved queries'],
72
+ 'parameters': [_FORMAT_PARAM, *_PAGE_PARAMS],
73
+ 'requestBody': _body(saved, ['filepath']), 'responses': {'200': _ROWS, **_ERRORS}}},
74
+ '/save_sql_to_file': {'patch': {
75
+ 'summary': 'Save a query (creates the next version)', 'tags': ['Saved queries'],
76
+ 'requestBody': _body({
77
+ 'filename': {'type': 'string'}, 'sql_query': {'type': 'string'}, 'author': {'type': 'string'},
78
+ 'description': {'type': 'string'}, 'tags': {'type': 'array', 'items': {'type': 'string'}},
79
+ 'query_parameters': {'type': 'object', 'example': {'actor_id': 'int'}},
80
+ 'connection_name': {'type': 'string', 'description': 'Default connection for /q/{name}.'}},
81
+ ['filename', 'sql_query', 'author', 'description']),
82
+ 'responses': {'200': {'description': 'Saved'}, **_ERRORS}}},
83
+ '/list_files': {'get': {
84
+ 'summary': 'List saved queries', 'tags': ['Saved queries'],
85
+ 'parameters': [
86
+ {'name': 'sort_by', 'in': 'query', 'schema': {'type': 'string', 'enum': ['name', 'modified']}},
87
+ {'name': 'sort_order', 'in': 'query', 'schema': {'type': 'string', 'enum': ['asc', 'desc']}}],
88
+ 'responses': {'200': {'description': 'Saved queries with version metadata'}, **_ERRORS}}},
89
+ '/view_file_content': {'get': {
90
+ 'summary': 'Raw content of a saved query file', 'tags': ['Saved queries'],
91
+ 'parameters': [{'name': 'filename', 'in': 'query', 'required': True, 'schema': {'type': 'string'}}],
92
+ 'responses': {'200': {'description': 'File content'}, **_ERRORS}}},
93
+ '/saved_sql/{name}': {'delete': {
94
+ 'summary': 'Delete a saved query, or one version with ?version=', 'tags': ['Saved queries'],
95
+ 'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}},
96
+ {'name': 'version', 'in': 'query', 'schema': {'type': 'integer'}}],
97
+ 'responses': {'200': {'description': 'Deleted'}, **_ERRORS}}},
98
+ '/connections': {
99
+ 'get': {'summary': 'List connections (passwords masked)', 'tags': ['Connections'],
100
+ 'responses': {'200': {'description': 'Connections'}, **_ERRORS}},
101
+ 'patch': {'summary': 'Add or update connections', 'tags': ['Connections'],
102
+ 'requestBody': _body({'connections': {
103
+ 'type': 'object', 'description': 'Map of name to connection details.',
104
+ 'additionalProperties': {'type': 'object', 'properties': {
105
+ 'db': {'type': 'string', 'enum': list(config.SUPPORTED_DB_TYPES)},
106
+ 'host': {'type': 'string'}, 'port': {'type': 'integer'},
107
+ 'user': {'type': 'string'}, 'password': {'type': 'string'},
108
+ 'database': {'type': 'string'}, 'active': {'type': 'boolean'}}}}},
109
+ ['connections']),
110
+ 'responses': {'200': {'description': 'Updated'}, **_ERRORS}}},
111
+ '/connections/{name}': {'delete': {
112
+ 'summary': 'Delete a connection', 'tags': ['Connections'],
113
+ 'parameters': [{'name': 'name', 'in': 'path', 'required': True, 'schema': {'type': 'string'}}],
114
+ 'responses': {'200': {'description': 'Deleted'}, **_ERRORS}}},
115
+ '/health': {'get': {'summary': 'Liveness check', 'tags': ['Service'],
116
+ 'responses': {'200': {'description': 'OK'}}}},
117
+ },
118
+ }
119
+
120
+
121
+ DOCS_HTML = """<!doctype html>
122
+ <html><head><meta charset="utf-8"><title>SQL2API docs</title>
123
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css"></head>
124
+ <body><div id="ui"></div>
125
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
126
+ <script>SwaggerUIBundle({url: 'openapi.json', dom_id: '#ui'});</script></body></html>
127
+ """
sql2api/runners.py ADDED
@@ -0,0 +1,142 @@
1
+ """One runner per database type. Each connects, runs one statement, closes, and returns (columns, rows).
2
+
3
+ Runners fetch ``limit + 1`` rows: the extra row is how the caller learns whether another page exists.
4
+ Database drivers are imported lazily so only the ones you actually use need to be installed.
5
+ """
6
+ import os
7
+ import sqlite3
8
+ from pathlib import Path
9
+
10
+ from . import config
11
+ from .errors import ApiError
12
+ from .sqltools import bind_parameters, is_paginated, paginate
13
+
14
+
15
+ def _connect_args(details, **renames):
16
+ """Pick the standard connection fields out of a stored connection, renaming keys per driver."""
17
+ args = {}
18
+ for key in ('host', 'port', 'user', 'password', 'database'):
19
+ if details.get(key) not in (None, ''):
20
+ args[renames.get(key, key)] = details[key]
21
+ return args
22
+
23
+
24
+ def _prepare(sql, params, style, limit, offset):
25
+ """Return (sql, args, sliced) - the statement to send, its bound arguments and whether the
26
+ database already applied the requested window (LIMIT/OFFSET) for us."""
27
+ window = limit + 1
28
+ if is_paginated(sql):
29
+ sql, sliced = paginate(sql, window, offset), True
30
+ else:
31
+ sliced = False
32
+ sql, args = bind_parameters(sql, params or {}, style)
33
+ return sql, args, sliced
34
+
35
+
36
+ def _fetch_page(cursor, sql, params, style, limit, offset):
37
+ sql, args, sliced = _prepare(sql, params, style, limit, offset)
38
+ if args is None:
39
+ cursor.execute(sql)
40
+ else:
41
+ cursor.execute(sql, args)
42
+ if not cursor.description:
43
+ return [], []
44
+ rows = cursor.fetchall() if sliced else cursor.fetchmany(offset + limit + 1)[offset:]
45
+ return [d[0] for d in cursor.description], rows
46
+
47
+
48
+ def _run_mysql(details, sql, params, limit, offset, read_only):
49
+ import mysql.connector
50
+ conn = mysql.connector.connect(connection_timeout=config.CONNECT_TIMEOUT, **_connect_args(details))
51
+ try:
52
+ cursor = conn.cursor()
53
+ if read_only:
54
+ cursor.execute('SET SESSION TRANSACTION READ ONLY')
55
+ result = _fetch_page(cursor, sql, params, 'format', limit, offset)
56
+ if not read_only:
57
+ conn.commit()
58
+ return result
59
+ finally:
60
+ conn.close()
61
+
62
+
63
+ def _run_postgres(details, sql, params, limit, offset, read_only):
64
+ import psycopg2
65
+ conn = psycopg2.connect(connect_timeout=config.CONNECT_TIMEOUT, **_connect_args(details, database='dbname'))
66
+ try:
67
+ if read_only:
68
+ conn.set_session(readonly=True)
69
+ with conn.cursor() as cursor:
70
+ result = _fetch_page(cursor, sql, params, 'format', limit, offset)
71
+ if not read_only:
72
+ conn.commit()
73
+ return result
74
+ finally:
75
+ conn.close()
76
+
77
+
78
+ def _run_clickhouse(details, sql, params, limit, offset, read_only):
79
+ from clickhouse_driver import Client
80
+ client = Client(connect_timeout=config.CONNECT_TIMEOUT, **_connect_args(details))
81
+ try:
82
+ sql, args, sliced = _prepare(sql, params, 'pyformat', limit, offset)
83
+ result = client.execute(sql, args, with_column_types=True,
84
+ settings={'readonly': 1} if read_only else None)
85
+ # Statements without a result set (DDL, INSERT) may not return a (rows, types) pair.
86
+ rows, column_types = result if isinstance(result, tuple) else ([], [])
87
+ if not sliced:
88
+ rows = rows[offset:offset + limit + 1]
89
+ return [c[0] for c in column_types or []], rows
90
+ finally:
91
+ client.disconnect()
92
+
93
+
94
+ def _run_sqlite(details, sql, params, limit, offset, read_only):
95
+ path = details.get('database')
96
+ if not path:
97
+ raise ApiError('Database file path not provided')
98
+ if not os.path.isabs(path):
99
+ path = os.path.join(config.home(), path)
100
+ if not os.path.isfile(path):
101
+ raise ApiError('SQLite database file not found', 404)
102
+ if read_only:
103
+ conn = sqlite3.connect(f'{Path(path).as_uri()}?mode=ro', uri=True, timeout=config.CONNECT_TIMEOUT)
104
+ else:
105
+ conn = sqlite3.connect(path, timeout=config.CONNECT_TIMEOUT)
106
+ try:
107
+ cursor = conn.cursor()
108
+ result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
109
+ if not read_only:
110
+ conn.commit()
111
+ return result
112
+ finally:
113
+ conn.close()
114
+
115
+
116
+ def _run_h2(details, sql, params, limit, offset, read_only):
117
+ import jaydebeapi
118
+ host = details.get('host') or 'localhost'
119
+ if details.get('port') and ':' not in host:
120
+ host = f"{host}:{details['port']}"
121
+ url = f"jdbc:h2:tcp://{host}/~/{details.get('database')}"
122
+ conn = jaydebeapi.connect('org.h2.Driver', url, [details.get('user'), details.get('password')],
123
+ [config.h2_jar()])
124
+ try:
125
+ # Unlike the other drivers, H2's JDBC setReadOnly() is only a hint and does not block writes,
126
+ # so here the read-only guarantee rests on validate_sql() alone.
127
+ cursor = conn.cursor()
128
+ result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
129
+ if not read_only:
130
+ conn.commit()
131
+ return result
132
+ finally:
133
+ conn.close()
134
+
135
+
136
+ RUNNERS = {
137
+ 'mysql': _run_mysql,
138
+ 'postgres': _run_postgres,
139
+ 'clickhouse': _run_clickhouse,
140
+ 'sqlite': _run_sqlite,
141
+ 'h2': _run_h2,
142
+ }
sql2api/sqltools.py ADDED
@@ -0,0 +1,156 @@
1
+ """Pure SQL text handling: validation, pagination, and parameter substitution/binding."""
2
+ import math
3
+ import re
4
+
5
+ from . import config
6
+ from .errors import ApiError
7
+
8
+ # Quoted strings, quoted identifiers and comments - text inside these is never inspected.
9
+ _LITERAL = r"'(?:[^']|'')*'|\"(?:[^\"]|\"\")*\"|`[^`]*`|--[^\n]*|/\*.*?\*/"
10
+ _LITERALS_RE = re.compile(_LITERAL, re.S)
11
+ # A ":name" marker, skipping literals and Postgres "::" casts.
12
+ _PARAM_RE = re.compile(rf'(?P<skip>{_LITERAL}|::)|:(?P<name>[A-Za-z_]\w*)', re.S)
13
+ _BRACE_RE = re.compile(r'\{(\w+)\}')
14
+ _TRAILING_LIMIT_RE = re.compile(r'\s+LIMIT\s+\d+(?:\s*,\s*\d+|\s+OFFSET\s+\d+)?\s*$', re.I)
15
+ _SAFE_TEXT_RE = re.compile(r'^[\w\s.,:@%+/\-]*$')
16
+
17
+ READ_ONLY_STATEMENTS = ('select', 'with', 'show', 'describe', 'desc', 'explain', 'values', 'table')
18
+ PAGINATED_STATEMENTS = ('select', 'with')
19
+
20
+
21
+ def first_keyword(sql):
22
+ match = re.match(r'[\s(]*([A-Za-z]+)', _LITERALS_RE.sub(' ', sql))
23
+ return match.group(1).lower() if match else ''
24
+
25
+
26
+ def is_paginated(sql):
27
+ return first_keyword(sql) in PAGINATED_STATEMENTS
28
+
29
+
30
+ def validate_sql(sql):
31
+ """Normalise a client-supplied statement and enforce the single-statement/read-only rules."""
32
+ if not isinstance(sql, str) or not sql.strip():
33
+ raise ApiError('SQL query is missing')
34
+ sql = re.sub(r'[\s;]+$', '', sql.strip())
35
+ if ';' in _LITERALS_RE.sub(' ', sql):
36
+ raise ApiError('Only a single SQL statement can be executed at a time')
37
+ if not config.allow_writes():
38
+ if first_keyword(sql) not in READ_ONLY_STATEMENTS or '/*!' in sql:
39
+ raise ApiError('Only read-only statements (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN) are allowed. '
40
+ 'Set SQL2API_ALLOW_WRITES=1 to lift this restriction.', 403)
41
+ return sql
42
+
43
+
44
+ def paginate(sql, limit, offset):
45
+ """Replace any trailing LIMIT/OFFSET on a SELECT with the requested window."""
46
+ sql = _TRAILING_LIMIT_RE.sub('', sql)
47
+ return f'{sql}\nLIMIT {int(limit)} OFFSET {int(offset)}'
48
+
49
+
50
+ # --------------------------------------------------------------------------------------
51
+ # Parameters
52
+ # --------------------------------------------------------------------------------------
53
+
54
+ def _to_bool(text):
55
+ lowered = text.strip().lower()
56
+ if lowered in ('true', '1', 'yes'):
57
+ return True
58
+ if lowered in ('false', '0', 'no'):
59
+ return False
60
+ raise ValueError(text)
61
+
62
+
63
+ _COERCERS = {'int': int, 'integer': int, 'float': float, 'number': float, 'str': str, 'string': str,
64
+ 'bool': _to_bool, 'boolean': _to_bool}
65
+
66
+
67
+ def coerce_params(declared, params):
68
+ """Convert string values (e.g. from a query string) to the types a saved query declares.
69
+
70
+ ``declared`` is the saved version's ``query_parameters``: ``{"actor_id": "int"}`` or
71
+ ``{"actor_id": {"type": "int"}}``. Undeclared parameters are left untouched.
72
+ """
73
+ result = dict(params)
74
+ for name, spec in (declared or {}).items():
75
+ type_name = spec.get('type') if isinstance(spec, dict) else spec
76
+ coerce = _COERCERS.get(str(type_name).lower())
77
+ if coerce and isinstance(result.get(name), str):
78
+ try:
79
+ result[name] = coerce(result[name])
80
+ except ValueError:
81
+ raise ApiError(f"Parameter '{name}' must be of type {type_name}") from None
82
+ return result
83
+
84
+
85
+ def _check_value(name, value):
86
+ if value is None or isinstance(value, (bool, str)):
87
+ return
88
+ if isinstance(value, (int, float)) and math.isfinite(value):
89
+ return
90
+ raise ApiError(f"Parameter '{name}' must be a string, finite number, boolean or null")
91
+
92
+
93
+ def fill_placeholders(sql, values):
94
+ """Substitute ``{name}`` text placeholders (legacy style; prefer bound ``:name`` parameters).
95
+
96
+ Because the value becomes part of the SQL text it is restricted to numbers, booleans and
97
+ plain text so it cannot break out of the query. Only placeholders present in the SQL are
98
+ validated, so the same dict can also carry values for bound parameters.
99
+ """
100
+ for key, value in values.items():
101
+ token = f'{{{key}}}'
102
+ if token not in sql:
103
+ continue
104
+ if isinstance(value, bool):
105
+ text = 'TRUE' if value else 'FALSE'
106
+ elif isinstance(value, (int, float)):
107
+ if not math.isfinite(value):
108
+ raise ApiError(f"Placeholder '{key}' must be a finite number")
109
+ text = str(value)
110
+ elif isinstance(value, str) and _SAFE_TEXT_RE.match(value) and '--' not in value:
111
+ text = value
112
+ else:
113
+ raise ApiError(f"Placeholder '{key}' is substituted into the SQL text, so it must be a number, "
114
+ "boolean or a string containing only letters, digits, whitespace and . , : @ % + / - "
115
+ f"(use a bound :{key} parameter for arbitrary text)")
116
+ sql = sql.replace(token, text)
117
+ missing = sorted(set(_BRACE_RE.findall(sql)))
118
+ if missing:
119
+ raise ApiError(f"No value provided for placeholder(s): {', '.join(missing)}")
120
+ return sql
121
+
122
+
123
+ def named_parameters(sql):
124
+ """Names of the ``:name`` bound parameters used by ``sql``, in order of appearance."""
125
+ return [m.group('name') for m in _PARAM_RE.finditer(sql) if m.group('name')]
126
+
127
+
128
+ _MARKERS = {'qmark': lambda name: '?', 'format': lambda name: '%s', 'pyformat': lambda name: f'%({name})s'}
129
+
130
+
131
+ def bind_parameters(sql, params, style):
132
+ """Rewrite ``:name`` markers into a driver's paramstyle and return ``(sql, args)``.
133
+
134
+ ``style`` is 'qmark' (``?``, positional), 'format' (``%s``, positional) or 'pyformat'
135
+ (``%(name)s``, mapping). Values never become part of the SQL text. Returns ``(sql, None)``
136
+ when the statement has no parameters, so drivers do not apply ``%`` formatting to it.
137
+ """
138
+ matches = [m for m in _PARAM_RE.finditer(sql) if m.group('name')]
139
+ if not matches:
140
+ return sql, None
141
+ names = [m.group('name') for m in matches]
142
+ missing = sorted(set(names) - set(params))
143
+ if missing:
144
+ raise ApiError(f"No value provided for parameter(s): {', '.join(missing)}")
145
+ for name in set(names):
146
+ _check_value(name, params[name])
147
+
148
+ escape = (lambda text: text.replace('%', '%%')) if style in ('format', 'pyformat') else (lambda text: text)
149
+ pieces, position = [], 0
150
+ for match in matches:
151
+ pieces.append(escape(sql[position:match.start()]))
152
+ pieces.append(_MARKERS[style](match.group('name')))
153
+ position = match.end()
154
+ pieces.append(escape(sql[position:]))
155
+ args = {name: params[name] for name in names} if style == 'pyformat' else [params[name] for name in names]
156
+ return ''.join(pieces), args