diff --git a/superset/charts/api.py b/superset/charts/api.py index fb31064f9f853..fb78cd9d739e3 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -17,7 +17,8 @@ import logging from typing import Any -from flask import g, request, Response +import simplejson +from flask import g, make_response, request, Response from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext @@ -41,8 +42,12 @@ ChartPutSchema, get_delete_ids_schema, ) +from superset.common.query_context import QueryContext from superset.constants import RouteMethod +from superset.exceptions import SupersetSecurityException +from superset.extensions import event_logger, security_manager from superset.models.slice import Slice +from superset.utils.core import json_int_dttm_ser from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter from superset.views.filters import FilterRelatedOwners @@ -59,6 +64,7 @@ class ChartRestApi(BaseSupersetModelRestApi): RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined + "data", } class_permission_name = "SliceModelView" show_columns = [ @@ -348,3 +354,112 @@ def bulk_delete( return self.response_403() except ChartBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) + + @expose("/data", methods=["POST"]) + @event_logger.log_this + @protect() + @safe + def data(self) -> Response: + """ + Takes a query context constructed in the client and returns payload + data response for the given query. + --- + post: + description: >- + Takes a query context constructed in the client and returns payload data + response for the given query. + requestBody: + description: Query context schema + required: true + content: + application/json: + schema: + type: object + properties: + datasource: + type: object + description: The datasource where the query will run + properties: + id: + type: integer + type: + type: string + queries: + type: array + items: + type: object + properties: + granularity: + type: string + groupby: + type: array + items: + type: string + metrics: + type: array + items: + type: object + filters: + type: array + items: + type: string + row_limit: + type: integer + responses: + 200: + description: Query result + content: + application/json: + schema: + type: array + items: + type: object + properties: + cache_key: + type: string + cached_dttm: + type: string + cache_timeout: + type: integer + error: + type: string + is_cached: + type: boolean + query: + type: string + status: + type: string + stacktrace: + type: string + rowcount: + type: integer + data: + type: array + items: + type: object + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + if not request.is_json: + return self.response_400(message="Request is not JSON") + try: + query_context = QueryContext(**request.json) + except KeyError: + return self.response_400(message="Request is incorrect") + try: + security_manager.assert_query_context_permission(query_context) + except SupersetSecurityException: + return self.response_401() + payload_json = query_context.get_payload() + response_data = simplejson.dumps( + payload_json, default=json_int_dttm_ser, ignore_nan=True + ) + resp = make_response(response_data, 200) + resp.headers["Content-Type"] = "application/json; charset=utf-8" + return resp diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 4faad91fab3ed..d0c027dac67c8 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -84,6 +84,7 @@ class BaseSupersetModelRestApi(ModelRestApi): "info": "list", "related": "list", "refresh": "edit", + "data": "list", } order_rel_fields: Dict[str, Tuple[str, str]] = {} diff --git a/tests/charts/api_tests.py b/tests/charts/api_tests.py index d885e0b8321d1..450e99379b0b1 100644 --- a/tests/charts/api_tests.py +++ b/tests/charts/api_tests.py @@ -16,7 +16,7 @@ # under the License. """Unit tests for Superset""" import json -from typing import List, Optional +from typing import Any, Dict, List, Optional import prison from sqlalchemy.sql import func @@ -69,6 +69,22 @@ def insert_chart( db.session.commit() return slice + def _get_query_context(self) -> Dict[str, Any]: + self.login(username="admin") + slc = self.get_slice("Girl Name Cloud", db.session) + return { + "datasource": {"id": slc.datasource_id, "type": slc.datasource_type}, + "queries": [ + { + "granularity": "ds", + "groupby": ["name"], + "metrics": [{"label": "sum__num"}], + "filters": [], + "row_limit": 100, + } + ], + } + def test_delete_chart(self): """ Chart API: Test delete @@ -580,3 +596,25 @@ def test_get_charts_no_data_access(self): self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 0) + + def test_chart_data(self): + """ + Query API: Test chart data query + """ + self.login(username="admin") + query_context = self._get_query_context() + uri = "api/v1/chart/data" + rv = self.client.post(uri, json=query_context) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data[0]["rowcount"], 100) + + def test_query_exec_not_allowed(self): + """ + Query API: Test chart data query not allowed + """ + self.login(username="gamma") + query_context = self._get_query_context() + uri = "api/v1/chart/data" + rv = self.client.post(uri, json=query_context) + self.assertEqual(rv.status_code, 401) diff --git a/tests/queries/api_tests.py b/tests/queries/api_tests.py index 616178fd07ee4..4f43c77335008 100644 --- a/tests/queries/api_tests.py +++ b/tests/queries/api_tests.py @@ -17,9 +17,9 @@ # isort:skip_file """Unit tests for Superset""" import json -import uuid import random import string +from typing import Dict, Any import prison from sqlalchemy.sql import func