-
Notifications
You must be signed in to change notification settings - Fork 14.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Adds the /explore endpoint to the v1 API (#20399)
* feat: Adds the /explore endpoint to the v1 API * Fixes pylint errors * Fixes tests * Changes the tests logic * Removes ABC reference * Improves indentation * Addresses review comments * Rebases code * Improves dataset and slice assertions * Fixes tests * Removes schema and table name assertions * Removes fixed IDs * Fixes datasource ID
- Loading branch information
1 parent
44f0b51
commit 2016336
Showing
10 changed files
with
746 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
# Licensed to the Apache Software Foundation (ASF) under one | ||
# or more contributor license agreements. See the NOTICE file | ||
# distributed with this work for additional information | ||
# regarding copyright ownership. The ASF licenses this file | ||
# to you under the Apache License, Version 2.0 (the | ||
# "License"); you may not use this file except in compliance | ||
# with the License. You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, | ||
# software distributed under the License is distributed on an | ||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
# KIND, either express or implied. See the License for the | ||
# specific language governing permissions and limitations | ||
# under the License. | ||
import logging | ||
|
||
from flask import g, request, Response | ||
from flask_appbuilder.api import BaseApi, expose, protect, safe | ||
|
||
from superset.charts.commands.exceptions import ChartNotFoundError | ||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod | ||
from superset.explore.commands.get import GetExploreCommand | ||
from superset.explore.commands.parameters import CommandParameters | ||
from superset.explore.exceptions import DatasetAccessDeniedError, WrongEndpointError | ||
from superset.explore.permalink.exceptions import ExplorePermalinkGetFailedError | ||
from superset.explore.schemas import ExploreContextSchema | ||
from superset.extensions import event_logger | ||
from superset.temporary_cache.commands.exceptions import ( | ||
TemporaryCacheAccessDeniedError, | ||
TemporaryCacheResourceNotFoundError, | ||
) | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class ExploreRestApi(BaseApi): | ||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP | ||
include_route_methods = {RouteMethod.GET} | ||
allow_browser_login = True | ||
class_permission_name = "Explore" | ||
resource_name = "explore" | ||
openapi_spec_tag = "Explore" | ||
openapi_spec_component_schemas = (ExploreContextSchema,) | ||
|
||
@expose("/", methods=["GET"]) | ||
@protect() | ||
@safe | ||
@event_logger.log_this_with_context( | ||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", | ||
log_to_statsd=True, | ||
) | ||
def get(self) -> Response: | ||
"""Assembles Explore related information (form_data, slice, dataset) | ||
in a single endpoint. | ||
--- | ||
get: | ||
summary: >- | ||
Assembles Explore related information (form_data, slice, dataset) | ||
in a single endpoint. | ||
description: >- | ||
Assembles Explore related information (form_data, slice, dataset) | ||
in a single endpoint.<br/><br/> | ||
The information can be assembled from:<br/> | ||
- The cache using a form_data_key<br/> | ||
- The metadata database using a permalink_key<br/> | ||
- Build from scratch using dataset or slice identifiers. | ||
parameters: | ||
- in: query | ||
schema: | ||
type: string | ||
name: form_data_key | ||
- in: query | ||
schema: | ||
type: string | ||
name: permalink_key | ||
- in: query | ||
schema: | ||
type: integer | ||
name: slice_id | ||
- in: query | ||
schema: | ||
type: integer | ||
name: dataset_id | ||
- in: query | ||
schema: | ||
type: string | ||
name: dataset_type | ||
responses: | ||
200: | ||
description: Returns the initial context. | ||
content: | ||
application/json: | ||
schema: | ||
$ref: '#/components/schemas/ExploreContextSchema' | ||
400: | ||
$ref: '#/components/responses/400' | ||
401: | ||
$ref: '#/components/responses/401' | ||
404: | ||
$ref: '#/components/responses/404' | ||
422: | ||
$ref: '#/components/responses/422' | ||
500: | ||
$ref: '#/components/responses/500' | ||
""" | ||
try: | ||
params = CommandParameters( | ||
actor=g.user, | ||
permalink_key=request.args.get("permalink_key", type=str), | ||
form_data_key=request.args.get("form_data_key", type=str), | ||
dataset_id=request.args.get("dataset_id", type=int), | ||
dataset_type=request.args.get("dataset_type", type=str), | ||
slice_id=request.args.get("slice_id", type=int), | ||
) | ||
result = GetExploreCommand(params).run() | ||
if not result: | ||
return self.response_404() | ||
return self.response(200, result=result) | ||
except ValueError as ex: | ||
return self.response(400, message=str(ex)) | ||
except DatasetAccessDeniedError as ex: | ||
return self.response( | ||
403, | ||
message=ex.message, | ||
dataset_id=ex.dataset_id, | ||
dataset_type=ex.dataset_type, | ||
) | ||
except (ChartNotFoundError, ExplorePermalinkGetFailedError) as ex: | ||
return self.response(404, message=str(ex)) | ||
except WrongEndpointError as ex: | ||
return self.response(302, redirect=ex.redirect) | ||
except TemporaryCacheAccessDeniedError as ex: | ||
return self.response(403, message=str(ex)) | ||
except TemporaryCacheResourceNotFoundError as ex: | ||
return self.response(404, message=str(ex)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
# Licensed to the Apache Software Foundation (ASF) under one | ||
# or more contributor license agreements. See the NOTICE file | ||
# distributed with this work for additional information | ||
# regarding copyright ownership. The ASF licenses this file | ||
# to you under the Apache License, Version 2.0 (the | ||
# "License"); you may not use this file except in compliance | ||
# with the License. You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, | ||
# software distributed under the License is distributed on an | ||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
# KIND, either express or implied. See the License for the | ||
# specific language governing permissions and limitations | ||
# under the License. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
# Licensed to the Apache Software Foundation (ASF) under one | ||
# or more contributor license agreements. See the NOTICE file | ||
# distributed with this work for additional information | ||
# regarding copyright ownership. The ASF licenses this file | ||
# to you under the Apache License, Version 2.0 (the | ||
# "License"); you may not use this file except in compliance | ||
# with the License. You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, | ||
# software distributed under the License is distributed on an | ||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
# KIND, either express or implied. See the License for the | ||
# specific language governing permissions and limitations | ||
# under the License. | ||
import logging | ||
from abc import ABC | ||
from typing import Any, cast, Dict, Optional | ||
|
||
import simplejson as json | ||
from flask import current_app as app | ||
from flask_babel import gettext as __, lazy_gettext as _ | ||
from sqlalchemy.exc import SQLAlchemyError | ||
|
||
from superset import db, security_manager | ||
from superset.commands.base import BaseCommand | ||
from superset.connectors.base.models import BaseDatasource | ||
from superset.connectors.sqla.models import SqlaTable | ||
from superset.datasets.commands.exceptions import DatasetNotFoundError | ||
from superset.datasource.dao import DatasourceDAO | ||
from superset.exceptions import SupersetException | ||
from superset.explore.commands.parameters import CommandParameters | ||
from superset.explore.exceptions import DatasetAccessDeniedError, WrongEndpointError | ||
from superset.explore.form_data.commands.get import GetFormDataCommand | ||
from superset.explore.form_data.commands.parameters import ( | ||
CommandParameters as FormDataCommandParameters, | ||
) | ||
from superset.explore.permalink.commands.get import GetExplorePermalinkCommand | ||
from superset.explore.permalink.exceptions import ExplorePermalinkGetFailedError | ||
from superset.models.sql_lab import Query | ||
from superset.utils import core as utils | ||
from superset.views.utils import ( | ||
get_datasource_info, | ||
get_form_data, | ||
sanitize_datasource_data, | ||
) | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class GetExploreCommand(BaseCommand, ABC): | ||
def __init__( | ||
self, | ||
params: CommandParameters, | ||
) -> None: | ||
self._actor = params.actor | ||
self._permalink_key = params.permalink_key | ||
self._form_data_key = params.form_data_key | ||
self._dataset_id = params.dataset_id | ||
self._dataset_type = params.dataset_type | ||
self._slice_id = params.slice_id | ||
|
||
# pylint: disable=too-many-locals,too-many-branches,too-many-statements | ||
def run(self) -> Optional[Dict[str, Any]]: | ||
initial_form_data = {} | ||
|
||
if self._permalink_key is not None: | ||
command = GetExplorePermalinkCommand(self._actor, self._permalink_key) | ||
permalink_value = command.run() | ||
if not permalink_value: | ||
raise ExplorePermalinkGetFailedError() | ||
state = permalink_value["state"] | ||
initial_form_data = state["formData"] | ||
url_params = state.get("urlParams") | ||
if url_params: | ||
initial_form_data["url_params"] = dict(url_params) | ||
elif self._form_data_key: | ||
parameters = FormDataCommandParameters( | ||
actor=self._actor, key=self._form_data_key | ||
) | ||
value = GetFormDataCommand(parameters).run() | ||
initial_form_data = json.loads(value) if value else {} | ||
|
||
message = None | ||
|
||
if not initial_form_data: | ||
if self._slice_id: | ||
initial_form_data["slice_id"] = self._slice_id | ||
if self._form_data_key: | ||
message = _( | ||
"Form data not found in cache, reverting to chart metadata." | ||
) | ||
elif self._dataset_id: | ||
initial_form_data["datasource"] = f"{self._dataset_id}__table" | ||
if self._form_data_key: | ||
message = _( | ||
"Form data not found in cache, reverting to dataset metadata." | ||
) | ||
|
||
form_data, slc = get_form_data( | ||
use_slice_data=True, initial_form_data=initial_form_data | ||
) | ||
try: | ||
self._dataset_id, self._dataset_type = get_datasource_info( | ||
self._dataset_id, self._dataset_type, form_data | ||
) | ||
except SupersetException: | ||
self._dataset_id = None | ||
# fallback unkonw datasource to table type | ||
self._dataset_type = SqlaTable.type | ||
|
||
dataset: Optional[BaseDatasource] = None | ||
if self._dataset_id is not None: | ||
try: | ||
dataset = DatasourceDAO.get_datasource( | ||
db.session, cast(str, self._dataset_type), self._dataset_id | ||
) | ||
except DatasetNotFoundError: | ||
pass | ||
dataset_name = dataset.name if dataset else _("[Missing Dataset]") | ||
|
||
if dataset: | ||
if app.config["ENABLE_ACCESS_REQUEST"] and ( | ||
not security_manager.can_access_datasource(dataset) | ||
): | ||
message = __(security_manager.get_datasource_access_error_msg(dataset)) | ||
raise DatasetAccessDeniedError( | ||
message=message, | ||
dataset_type=self._dataset_type, | ||
dataset_id=self._dataset_id, | ||
) | ||
|
||
viz_type = form_data.get("viz_type") | ||
if not viz_type and dataset and dataset.default_endpoint: | ||
raise WrongEndpointError(redirect=dataset.default_endpoint) | ||
|
||
form_data["datasource"] = ( | ||
str(self._dataset_id) + "__" + cast(str, self._dataset_type) | ||
) | ||
|
||
# On explore, merge legacy and extra filters into the form data | ||
utils.convert_legacy_filters_into_adhoc(form_data) | ||
utils.merge_extra_filters(form_data) | ||
|
||
dummy_dataset_data: Dict[str, Any] = { | ||
"type": self._dataset_type, | ||
"name": dataset_name, | ||
"columns": [], | ||
"metrics": [], | ||
"database": {"id": 0, "backend": ""}, | ||
} | ||
try: | ||
dataset_data = dataset.data if dataset else dummy_dataset_data | ||
except (SupersetException, SQLAlchemyError): | ||
dataset_data = dummy_dataset_data | ||
|
||
if dataset: | ||
dataset_data["owners"] = dataset.owners_data | ||
if isinstance(dataset, Query): | ||
dataset_data["columns"] = dataset.columns | ||
|
||
return { | ||
"dataset": sanitize_datasource_data(dataset_data), | ||
"form_data": form_data, | ||
"slice": slc.data if slc else None, | ||
"message": message, | ||
} | ||
|
||
def validate(self) -> None: | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
# Licensed to the Apache Software Foundation (ASF) under one | ||
# or more contributor license agreements. See the NOTICE file | ||
# distributed with this work for additional information | ||
# regarding copyright ownership. The ASF licenses this file | ||
# to you under the Apache License, Version 2.0 (the | ||
# "License"); you may not use this file except in compliance | ||
# with the License. You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, | ||
# software distributed under the License is distributed on an | ||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
# KIND, either express or implied. See the License for the | ||
# specific language governing permissions and limitations | ||
# under the License. | ||
from dataclasses import dataclass | ||
from typing import Optional | ||
|
||
from flask_appbuilder.security.sqla.models import User | ||
|
||
|
||
@dataclass | ||
class CommandParameters: | ||
actor: User | ||
permalink_key: Optional[str] | ||
form_data_key: Optional[str] | ||
dataset_id: Optional[int] | ||
dataset_type: Optional[str] | ||
slice_id: Optional[int] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
# Licensed to the Apache Software Foundation (ASF) under one | ||
# or more contributor license agreements. See the NOTICE file | ||
# distributed with this work for additional information | ||
# regarding copyright ownership. The ASF licenses this file | ||
# to you under the Apache License, Version 2.0 (the | ||
# "License"); you may not use this file except in compliance | ||
# with the License. You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, | ||
# software distributed under the License is distributed on an | ||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
# KIND, either express or implied. See the License for the | ||
# specific language governing permissions and limitations | ||
# under the License. | ||
from typing import Optional | ||
|
||
from superset.commands.exceptions import CommandException, ForbiddenError | ||
|
||
|
||
class DatasetAccessDeniedError(ForbiddenError): | ||
def __init__( | ||
self, message: str, dataset_id: Optional[int], dataset_type: Optional[str] | ||
) -> None: | ||
self.message = message | ||
self.dataset_id = dataset_id | ||
self.dataset_type = dataset_type | ||
super().__init__(self.message) | ||
|
||
|
||
class WrongEndpointError(CommandException): | ||
def __init__(self, redirect: str) -> None: | ||
self.redirect = redirect | ||
super().__init__() |
Oops, something went wrong.