Skip to content

Commit

Permalink
feat: Adds the /explore endpoint to the v1 API (#20399)
Browse files Browse the repository at this point in the history
* 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
michael-s-molina authored Jun 24, 2022
1 parent 44f0b51 commit 2016336
Show file tree
Hide file tree
Showing 10 changed files with 746 additions and 2 deletions.
137 changes: 137 additions & 0 deletions superset/explore/api.py
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))
16 changes: 16 additions & 0 deletions superset/explore/commands/__init__.py
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.
171 changes: 171 additions & 0 deletions superset/explore/commands/get.py
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
30 changes: 30 additions & 0 deletions superset/explore/commands/parameters.py
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]
35 changes: 35 additions & 0 deletions superset/explore/exceptions.py
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__()
Loading

0 comments on commit 2016336

Please sign in to comment.