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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """SQL2API - expose SQL databases as a REST API."""
2
+ __version__ = '0.1.0'
3
+
4
+ from .app import create_app # noqa: E402 (app imports __version__)
5
+
6
+ __all__ = ['create_app', '__version__']
sql2api/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
sql2api/app.py ADDED
@@ -0,0 +1,296 @@
1
+ """The Flask application: HTTP routes on top of the store, engine and formatters."""
2
+ import hmac
3
+ import logging
4
+
5
+ from flask import Blueprint, Flask, Response, jsonify, redirect, request, url_for
6
+ from flask.json.provider import DefaultJSONProvider
7
+ from werkzeug.exceptions import HTTPException
8
+
9
+ from . import config, engine, openapi, sqltools, store
10
+ from .errors import ApiError
11
+ from .formats import FORMATTERS, json_default
12
+
13
+ log = logging.getLogger('sql2api')
14
+ bp = Blueprint('api', __name__)
15
+
16
+ # Query-string arguments that control a request rather than supplying query parameters.
17
+ RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version'}
18
+ PUBLIC_ENDPOINTS = {'api.index', 'api.favicon', 'api.health', 'api.docs', 'api.openapi_spec'}
19
+
20
+
21
+ class JSONProvider(DefaultJSONProvider):
22
+ default = staticmethod(json_default)
23
+ sort_keys = False # keep result columns in the order the query returned them
24
+
25
+
26
+ def create_app():
27
+ from . import __version__
28
+ app = Flask(__name__)
29
+ app.json = JSONProvider(app)
30
+ app.config['SQL2API_VERSION'] = __version__
31
+
32
+ @app.errorhandler(ApiError)
33
+ def handle_api_error(error):
34
+ return jsonify({'error': error.message, **error.extra}), error.status
35
+
36
+ @app.errorhandler(HTTPException)
37
+ def handle_http_error(error):
38
+ return jsonify({'error': error.description}), error.code
39
+
40
+ @app.errorhandler(Exception)
41
+ def handle_unexpected_error(error):
42
+ log.exception('Unhandled error')
43
+ return jsonify({'error': 'An error occurred'}), 500
44
+
45
+ @app.before_request
46
+ def require_api_key():
47
+ expected = config.api_key()
48
+ if expected and request.endpoint not in PUBLIC_ENDPOINTS:
49
+ # compare_digest rejects non-ASCII str, so compare bytes
50
+ supplied = request.headers.get('X-API-Key', '').encode('utf-8', 'replace')
51
+ if not hmac.compare_digest(supplied, expected.encode('utf-8')):
52
+ return jsonify({'error': 'Unauthorized'}), 401
53
+
54
+ app.register_blueprint(bp)
55
+ return app
56
+
57
+
58
+ # --------------------------------------------------------------------------------------
59
+ # Request helpers
60
+ # --------------------------------------------------------------------------------------
61
+
62
+ def get_json_body(required=True):
63
+ data = request.get_json(silent=True)
64
+ if data is None and not required:
65
+ return {}
66
+ if not isinstance(data, dict):
67
+ raise ApiError('Request body must be a JSON object')
68
+ return data
69
+
70
+
71
+ def get_object(value, label):
72
+ if value is None:
73
+ return {}
74
+ if not isinstance(value, dict):
75
+ raise ApiError(f'{label} must be a JSON object')
76
+ return value
77
+
78
+
79
+ def get_int(value, label):
80
+ if value is None or value == '':
81
+ return None
82
+ try:
83
+ return int(value)
84
+ except (TypeError, ValueError):
85
+ raise ApiError(f'{label} must be an integer') from None
86
+
87
+
88
+ def get_pagination():
89
+ """Return (limit, offset, page) from the ?page and ?page_size query parameters."""
90
+ page = get_int(request.args.get('page'), 'page')
91
+ page_size = get_int(request.args.get('page_size'), 'page_size')
92
+ page = 1 if page is None else page
93
+ page_size = 10 if page_size is None else page_size
94
+ if page < 1 or page_size < 1:
95
+ raise ApiError('page and page_size must be positive')
96
+ if page_size > config.max_page_size():
97
+ raise ApiError(f'page_size must not exceed {config.max_page_size()}')
98
+ return page_size, (page - 1) * page_size, page
99
+
100
+
101
+ def get_output_format(body=None):
102
+ output_format = str(request.args.get('format') or (body or {}).get('format') or 'json').lower()
103
+ if output_format not in FORMATTERS:
104
+ raise ApiError(f"Unsupported format '{output_format}'. Supported formats: {', '.join(FORMATTERS)}")
105
+ return output_format
106
+
107
+
108
+ def render(result, output_format, page, page_size):
109
+ response = jsonify({'message': 'No results returned'}) if not result else FORMATTERS[output_format](result)
110
+ response.headers['X-Page'] = str(page)
111
+ response.headers['X-Page-Size'] = str(page_size)
112
+ response.headers['X-Has-More'] = 'true' if result.has_more else 'false'
113
+ return response
114
+
115
+
116
+ # --------------------------------------------------------------------------------------
117
+ # Query execution
118
+ # --------------------------------------------------------------------------------------
119
+
120
+ @bp.route('/execute_sql', methods=['POST'])
121
+ def execute_sql_endpoint():
122
+ data = get_json_body()
123
+ if not data.get('sql'):
124
+ raise ApiError('SQL query is missing')
125
+ if not data.get('connection_name'):
126
+ raise ApiError('Connection name is missing')
127
+ params = get_object(data.get('params'), 'params')
128
+ output_format = get_output_format(data)
129
+ limit, offset, page = get_pagination()
130
+ result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params)
131
+ return render(result, output_format, page, limit)
132
+
133
+
134
+ def run_saved(ref, body, url_params):
135
+ """Execute a saved query (latest version unless one is requested) and record the run."""
136
+ path = store.resolve_saved_file(ref)
137
+ number, saved = store.select_version(store.load_versions(path),
138
+ get_int(request.args.get('version') or body.get('version'), 'version'))
139
+ connection_name = request.args.get('connection_name') or body.get('connection_name') \
140
+ or saved.get('connection_name')
141
+ if not connection_name:
142
+ raise ApiError('Connection name is missing')
143
+ if not isinstance(saved.get('sql_query'), str):
144
+ raise ApiError('Saved query has no SQL', 500)
145
+
146
+ raw = {**url_params, **get_object(body.get('params'), 'params'),
147
+ **get_object(body.get('placeholders'), 'placeholders')}
148
+ params = sqltools.coerce_params(saved.get('query_parameters'), raw)
149
+ sql = sqltools.fill_placeholders(saved['sql_query'], params)
150
+ output_format = get_output_format(body)
151
+ limit, offset, page = get_pagination()
152
+
153
+ entry = {'executed_at': store.now(), 'connection_name': connection_name}
154
+ try:
155
+ result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params)
156
+ except ApiError as error:
157
+ store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
158
+ raise
159
+ store.record_execution(path, number, {**entry, 'status': 'success', 'rows': len(result.rows),
160
+ 'duration_ms': elapsed_ms})
161
+ return render(result, output_format, page, limit)
162
+
163
+
164
+ @bp.route('/execute_sql_from_file', methods=['POST'])
165
+ @bp.route('/execute_sql_with_parameters_from_file', methods=['POST'])
166
+ def execute_sql_from_file():
167
+ body = get_json_body()
168
+ return run_saved(body.get('filepath'), body, {})
169
+
170
+
171
+ @bp.route('/q/<name>', methods=['GET', 'POST'])
172
+ def run_named_query(name):
173
+ body = get_json_body(required=False) if request.method == 'POST' else {}
174
+ url_params = {k: v for k, v in request.args.items() if k not in RESERVED_ARGS}
175
+ return run_saved(name, body, url_params)
176
+
177
+
178
+ # --------------------------------------------------------------------------------------
179
+ # Saved queries
180
+ # --------------------------------------------------------------------------------------
181
+
182
+ @bp.route('/view_file_content', methods=['GET'])
183
+ def view_file_content():
184
+ path = store.resolve_saved_file(request.args.get('filename'))
185
+ with open(path, 'r') as f:
186
+ return jsonify({'content': f.read()}), 200
187
+
188
+
189
+ @bp.route('/save_sql_to_file', methods=['PATCH'])
190
+ def save_sql_to_file():
191
+ data = get_json_body()
192
+ for field, label in (('author', 'Author'), ('description', 'Description'),
193
+ ('sql_query', 'SQL query'), ('filename', 'Filename')):
194
+ if not data.get(field) or not isinstance(data[field], str):
195
+ raise ApiError(f'{label} is missing')
196
+ tags = data.get('tags', [])
197
+ if not isinstance(tags, (list, str)):
198
+ raise ApiError('tags must be a string or a list')
199
+ query_parameters = get_object(data.get('query_parameters'), 'query_parameters')
200
+ connection_name = data.get('connection_name')
201
+ if connection_name is not None and not isinstance(connection_name, str):
202
+ raise ApiError('connection_name must be a string')
203
+
204
+ query_uuid, version = store.save_version(data['filename'], {
205
+ 'sql_query': data['sql_query'],
206
+ 'author': data['author'],
207
+ 'description': data['description'],
208
+ 'tags': tags,
209
+ 'query_parameters': query_parameters,
210
+ **({'connection_name': connection_name} if connection_name else {}),
211
+ })
212
+ return jsonify({'message': 'SQL query saved successfully', 'filename': data['filename'],
213
+ 'uuid': query_uuid, 'version': version}), 200
214
+
215
+
216
+ @bp.route('/saved_sql/<name>', methods=['DELETE'])
217
+ def delete_saved_query(name):
218
+ version = get_int(request.args.get('version'), 'version')
219
+ store.delete_saved(name, version)
220
+ what = f'Version {version} of {name}' if version is not None else name
221
+ return jsonify({'message': f'{what} deleted'}), 200
222
+
223
+
224
+ @bp.route('/list_files', methods=['GET'])
225
+ def list_files():
226
+ sort_by = request.args.get('sort_by', 'name')
227
+ if sort_by not in ('name', 'modified'):
228
+ raise ApiError("sort_by must be 'name' or 'modified'")
229
+ sort_order = request.args.get('sort_order', 'asc')
230
+ if sort_order not in ('asc', 'desc'):
231
+ raise ApiError("sort_order must be 'asc' or 'desc'")
232
+
233
+ files = store.list_saved()
234
+ if sort_by == 'name':
235
+ def key(f):
236
+ return f['filename'].lower()
237
+ else:
238
+ def key(f):
239
+ return (f['versions'][-1].get('last_modified_at') or '') if f['versions'] else ''
240
+ files.sort(key=key, reverse=sort_order == 'desc')
241
+ return jsonify({'files': files}), 200
242
+
243
+
244
+ # --------------------------------------------------------------------------------------
245
+ # Connections
246
+ # --------------------------------------------------------------------------------------
247
+
248
+ @bp.route('/connections', methods=['GET'])
249
+ def get_connections():
250
+ return jsonify({'connections': store.mask_passwords(store.read_connections())}), 200
251
+
252
+
253
+ @bp.route('/connections', methods=['PATCH'])
254
+ def update_connections():
255
+ connections = get_json_body().get('connections')
256
+ if not connections or not isinstance(connections, dict):
257
+ raise ApiError('Connections data is missing')
258
+ store.update_connections(connections)
259
+ return jsonify({'message': 'Connections updated successfully'}), 200
260
+
261
+
262
+ @bp.route('/connections/<name>', methods=['DELETE'])
263
+ def delete_connection(name):
264
+ store.delete_connection(name)
265
+ return jsonify({'message': f"Connection '{name}' deleted"}), 200
266
+
267
+
268
+ # --------------------------------------------------------------------------------------
269
+ # Service endpoints
270
+ # --------------------------------------------------------------------------------------
271
+
272
+ @bp.route('/', methods=['GET'])
273
+ def index():
274
+ return redirect(url_for('api.docs'))
275
+
276
+
277
+ @bp.route('/favicon.ico', methods=['GET'])
278
+ def favicon():
279
+ return '', 204
280
+
281
+
282
+ @bp.route('/health', methods=['GET'])
283
+ def health():
284
+ from flask import current_app
285
+ return jsonify({'status': 'ok', 'version': current_app.config['SQL2API_VERSION']})
286
+
287
+
288
+ @bp.route('/openapi.json', methods=['GET'])
289
+ def openapi_spec():
290
+ from flask import current_app
291
+ return jsonify(openapi.build_spec(current_app.config['SQL2API_VERSION']))
292
+
293
+
294
+ @bp.route('/docs', methods=['GET'])
295
+ def docs():
296
+ return Response(openapi.DOCS_HTML, mimetype='text/html')
sql2api/cli.py ADDED
@@ -0,0 +1,65 @@
1
+ """Command line entry point: ``sql2api serve`` and ``sql2api init``."""
2
+ import argparse
3
+ import logging
4
+ import os
5
+
6
+ from . import __version__, config, store
7
+ from .app import create_app
8
+
9
+ LOOPBACK_HOSTS = ('127.0.0.1', 'localhost', '::1')
10
+
11
+
12
+ def _serve(args):
13
+ app = create_app()
14
+ if args.host not in LOOPBACK_HOSTS and not config.api_key():
15
+ logging.getLogger('sql2api').warning(
16
+ 'Listening on %s without SQL2API_API_KEY set: anyone who can reach this port can run SQL '
17
+ 'on your active connections.', args.host)
18
+ logging.getLogger('sql2api').info('Using %s (connections: %s)', config.home(), config.connections_file().name)
19
+ app.run(host=args.host, port=args.port, debug=args.debug)
20
+ return 0
21
+
22
+
23
+ def _init(args):
24
+ home = config.home()
25
+ home.mkdir(parents=True, exist_ok=True)
26
+ (home / 'saved_sql').mkdir(exist_ok=True)
27
+ target = config.connections_file()
28
+ if target.exists():
29
+ print(f'{target} already exists - left untouched')
30
+ else:
31
+ store.write_json_atomic(str(target), config.EXAMPLE_CONNECTIONS)
32
+ print(f'Created {target}\nEdit it, set "active": true on the connections you want, then run: sql2api serve')
33
+ return 0
34
+
35
+
36
+ def build_parser():
37
+ parser = argparse.ArgumentParser(prog='sql2api', description='Expose SQL databases as a REST API.')
38
+ parser.add_argument('--version', action='version', version=f'sql2api {__version__}')
39
+ commands = parser.add_subparsers(dest='command')
40
+
41
+ serve = commands.add_parser('serve', help='start the HTTP server (default)')
42
+ serve.add_argument('--host', default=os.environ.get('SQL2API_HOST', '127.0.0.1'))
43
+ serve.add_argument('--port', type=int, default=int(os.environ.get('SQL2API_PORT', 5000)))
44
+ serve.add_argument('--debug', action='store_true', default=config.env_flag('SQL2API_DEBUG'),
45
+ help='Flask debug mode - never use on a reachable host')
46
+ serve.set_defaults(func=_serve)
47
+
48
+ init = commands.add_parser('init', help='create db_connections.json and saved_sql/ in the home folder')
49
+ init.set_defaults(func=_init)
50
+
51
+ for sub in (serve, init):
52
+ sub.add_argument('--home', help='folder holding db_connections.json and saved_sql/ '
53
+ '(default: $SQL2API_HOME or the current directory)')
54
+ return parser
55
+
56
+
57
+ def main(argv=None):
58
+ parser = build_parser()
59
+ args = parser.parse_args(argv)
60
+ if args.command is None:
61
+ args = parser.parse_args(['serve', *(argv or [])])
62
+ if getattr(args, 'home', None):
63
+ os.environ['SQL2API_HOME'] = args.home
64
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(name)s: %(message)s')
65
+ return args.func(args)
sql2api/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Runtime configuration, read from environment variables at call time."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+ SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2')
6
+ PASSWORD_MASK = '********'
7
+ CONNECT_TIMEOUT = 10 # seconds
8
+ HISTORY_LIMIT = 50 # executions remembered per saved-query version
9
+
10
+ # Ships with the package; override with SQL2API_H2_JAR to use a different H2 version.
11
+ BUNDLED_H2_JAR = Path(__file__).parent / 'lib' / 'h2-2.2.224.jar'
12
+
13
+ # Shown by `sql2api init`. Every entry starts inactive so nothing connects until you opt in.
14
+ EXAMPLE_CONNECTIONS = {
15
+ 'connections': {
16
+ 'example-sqlite': {'db': 'sqlite', 'database': 'example.db', 'active': False},
17
+ 'example-postgres': {'db': 'postgres', 'host': 'localhost', 'port': 5432, 'database': 'postgres',
18
+ 'user': 'postgres', 'password': '${POSTGRES_PASSWORD}', 'active': False},
19
+ 'example-mysql': {'db': 'mysql', 'host': 'localhost', 'port': 3306, 'database': 'mydb',
20
+ 'user': 'root', 'password': '${MYSQL_PASSWORD}', 'active': False},
21
+ 'example-clickhouse': {'db': 'clickhouse', 'host': 'localhost', 'port': 9000, 'database': 'default',
22
+ 'user': 'default', 'password': '', 'active': False},
23
+ 'example-h2': {'db': 'h2', 'host': 'localhost', 'database': 'test', 'user': 'SA', 'password': '',
24
+ 'active': False},
25
+ }
26
+ }
27
+
28
+
29
+ def env_flag(name):
30
+ return os.environ.get(name, '').strip().lower() in ('1', 'true', 'yes', 'on')
31
+
32
+
33
+ def home():
34
+ """Folder holding db_connections.json and saved_sql/ (SQL2API_HOME, default: current directory)."""
35
+ return Path(os.environ.get('SQL2API_HOME') or os.getcwd()).resolve()
36
+
37
+
38
+ def connections_file():
39
+ return home() / 'db_connections.json'
40
+
41
+
42
+ def saved_sql_dir():
43
+ return home() / 'saved_sql'
44
+
45
+
46
+ def h2_jar():
47
+ return os.environ.get('SQL2API_H2_JAR') or str(BUNDLED_H2_JAR)
48
+
49
+
50
+ def allow_writes():
51
+ return env_flag('SQL2API_ALLOW_WRITES')
52
+
53
+
54
+ def api_key():
55
+ return os.environ.get('SQL2API_API_KEY') or None
56
+
57
+
58
+ def max_page_size():
59
+ try:
60
+ return max(1, int(os.environ.get('SQL2API_MAX_PAGE_SIZE', 1000)))
61
+ except ValueError:
62
+ return 1000
sql2api/engine.py ADDED
@@ -0,0 +1,37 @@
1
+ """Ties the pieces together: look up the connection, validate the SQL, run it, wrap the page."""
2
+ import logging
3
+ import time
4
+
5
+ from . import config, store
6
+ from .errors import ApiError
7
+ from .formats import ResultSetDTO
8
+ from .runners import RUNNERS
9
+ from .sqltools import validate_sql
10
+
11
+ log = logging.getLogger('sql2api')
12
+
13
+
14
+ def execute_sql(sql, connection_name, limit, offset, params=None):
15
+ """Run ``sql`` on a named connection and return the requested page as a ResultSetDTO."""
16
+ details = store.get_connection(connection_name)
17
+ sql = validate_sql(sql)
18
+ log.info('Executing on %s (%s), limit=%s offset=%s: %s', connection_name, details['db'], limit, offset, sql)
19
+ try:
20
+ columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes())
21
+ except ApiError:
22
+ raise
23
+ except ImportError as error:
24
+ log.exception('Missing database driver')
25
+ raise ApiError(f"The driver for '{details['db']}' is not installed", 500, detail=str(error)) from error
26
+ except Exception as error:
27
+ log.exception('Query on %s failed', connection_name)
28
+ raise ApiError('An error occurred while executing the SQL query', 500, detail=str(error)) from error
29
+ has_more = len(rows) > limit
30
+ return ResultSetDTO(rows[:limit], columns, has_more=has_more)
31
+
32
+
33
+ def timed(func, *args, **kwargs):
34
+ """Call ``func`` and return (result, elapsed milliseconds)."""
35
+ started = time.monotonic()
36
+ result = func(*args, **kwargs)
37
+ return result, round((time.monotonic() - started) * 1000)
sql2api/errors.py ADDED
@@ -0,0 +1,8 @@
1
+ class ApiError(Exception):
2
+ """An error that maps directly onto an HTTP response."""
3
+
4
+ def __init__(self, message, status=400, **extra):
5
+ super().__init__(message)
6
+ self.message = message
7
+ self.status = status
8
+ self.extra = extra
sql2api/formats.py ADDED
@@ -0,0 +1,142 @@
1
+ """Result sets and their output formats (JSON, NDJSON, CSV, TSV, XML, YAML, XLSX)."""
2
+ import csv
3
+ import json
4
+ import re
5
+ import xml.etree.ElementTree as ET
6
+ from datetime import date, datetime, time
7
+ from decimal import Decimal
8
+ from io import BytesIO, StringIO
9
+
10
+ import yaml
11
+ from flask import Response, jsonify
12
+ from openpyxl import Workbook
13
+ from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
14
+
15
+
16
+ def json_default(obj):
17
+ """``default=`` hook for json.dumps covering the types database drivers return."""
18
+ if isinstance(obj, datetime):
19
+ return obj.strftime('%Y-%m-%d %H:%M:%S')
20
+ if isinstance(obj, (date, time)):
21
+ return obj.isoformat()
22
+ if isinstance(obj, Decimal):
23
+ return float(obj)
24
+ if isinstance(obj, (bytes, bytearray, memoryview)):
25
+ return bytes(obj).decode('utf-8', 'replace')
26
+ return str(obj)
27
+
28
+
29
+ def _unique(names):
30
+ """Disambiguate repeated column names (e.g. ``SELECT a.id, b.id``) so no data is lost."""
31
+ seen, result = {}, []
32
+ for name in names:
33
+ name = str(name)
34
+ seen[name] = seen.get(name, 0) + 1
35
+ result.append(name if seen[name] == 1 else f'{name}_{seen[name]}')
36
+ return result
37
+
38
+
39
+ def _cell(value):
40
+ """Coerce a value into plain built-in types that CSV, YAML and Excel writers all accept.
41
+
42
+ Some drivers (e.g. H2 through JPype) return subclasses of int/float/str, which PyYAML's safe
43
+ dumper rejects because it only recognises the exact built-in types.
44
+ """
45
+ if value is None:
46
+ return None
47
+ if isinstance(value, bool):
48
+ return bool(value)
49
+ if isinstance(value, int):
50
+ return int(value)
51
+ if isinstance(value, float):
52
+ return float(value)
53
+ if isinstance(value, str):
54
+ return str(value)
55
+ if isinstance(value, Decimal):
56
+ return float(value)
57
+ if isinstance(value, (bytes, bytearray, memoryview)):
58
+ return bytes(value).decode('utf-8', 'replace')
59
+ if isinstance(value, datetime):
60
+ return value.replace(tzinfo=None) if value.tzinfo else value
61
+ if isinstance(value, (date, time)):
62
+ return value
63
+ return str(value)
64
+
65
+
66
+ def _xml_name(name):
67
+ name = re.sub(r'[^\w.\-]', '_', name)
68
+ return name if re.match(r'[A-Za-z_]', name) else f'_{name}'
69
+
70
+
71
+ class ResultSetDTO:
72
+ """A page of query results (column names + rows) with output-format converters."""
73
+
74
+ def __init__(self, rows, columns, has_more=False):
75
+ self.columns = _unique(columns)
76
+ self.rows = [[_cell(v) for v in row] for row in rows]
77
+ self.has_more = has_more
78
+
79
+ def __bool__(self):
80
+ return bool(self.rows)
81
+
82
+ def as_dicts(self):
83
+ return [dict(zip(self.columns, row)) for row in self.rows]
84
+
85
+ def _to_delimited(self, delimiter, mimetype):
86
+ data_io = StringIO()
87
+ writer = csv.writer(data_io, delimiter=delimiter)
88
+ writer.writerow(self.columns)
89
+ writer.writerows(self.rows)
90
+ return Response(data_io.getvalue(), mimetype=mimetype)
91
+
92
+ def to_csv(self):
93
+ return self._to_delimited(',', 'text/csv')
94
+
95
+ def to_tsv(self):
96
+ return self._to_delimited('\t', 'text/tab-separated-values')
97
+
98
+ def to_json(self):
99
+ return jsonify(self.as_dicts())
100
+
101
+ def to_ndjson(self):
102
+ lines = (json.dumps(row, default=json_default) for row in self.as_dicts())
103
+ return Response('\n'.join(lines) + '\n', mimetype='application/x-ndjson')
104
+
105
+ def to_xml(self):
106
+ root = ET.Element('data')
107
+ tags = [_xml_name(c) for c in self.columns]
108
+ for row in self.rows:
109
+ item = ET.SubElement(root, 'item')
110
+ for tag, value in zip(tags, row):
111
+ ET.SubElement(item, tag).text = '' if value is None else str(value)
112
+ return Response(ET.tostring(root, encoding='unicode', method='xml'), mimetype='application/xml')
113
+
114
+ def to_yaml(self):
115
+ return Response(yaml.safe_dump(self.as_dicts(), default_flow_style=False, sort_keys=False,
116
+ allow_unicode=True),
117
+ mimetype='application/x-yaml')
118
+
119
+ def to_xlsx(self):
120
+ wb = Workbook()
121
+ ws = wb.active
122
+ ws.append(self.columns)
123
+ for row in self.rows:
124
+ ws.append([ILLEGAL_CHARACTERS_RE.sub('', v) if isinstance(v, str) else v for v in row])
125
+ excel_data = BytesIO()
126
+ wb.save(excel_data)
127
+ return Response(
128
+ excel_data.getvalue(),
129
+ mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
130
+ headers={'Content-Disposition': 'attachment;filename=result.xlsx'},
131
+ )
132
+
133
+
134
+ FORMATTERS = {
135
+ 'json': ResultSetDTO.to_json,
136
+ 'ndjson': ResultSetDTO.to_ndjson,
137
+ 'csv': ResultSetDTO.to_csv,
138
+ 'tsv': ResultSetDTO.to_tsv,
139
+ 'xml': ResultSetDTO.to_xml,
140
+ 'yaml': ResultSetDTO.to_yaml,
141
+ 'xlsx': ResultSetDTO.to_xlsx,
142
+ }
Binary file