diff --git a/superset/charts/api.py b/superset/charts/api.py index d3c1d33a377..19de171ce84 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -18,7 +18,6 @@ import logging from typing import Any, Dict import simplejson -from apispec import APISpec from flask import g, make_response, redirect, request, Response, url_for from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface @@ -48,6 +47,7 @@ from superset.charts.schemas import ( ChartPostSchema, ChartPutSchema, get_delete_ids_schema, + openapi_spec_methods_override, thumbnail_query_schema, ) from superset.constants import RouteMethod @@ -145,14 +145,21 @@ class ChartRestApi(BaseSupersetModelRestApi): edit_model_schema = ChartPutSchema() openapi_spec_tag = "Charts" + """ Override the name set for this collection of endpoints """ + openapi_spec_component_schemas = CHART_DATA_SCHEMAS + """ Add extra schemas to the OpenAPI components schema section """ + openapi_spec_methods = openapi_spec_methods_override + """ Overrides GET methods OpenApi descriptions """ order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } + related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) } + allowed_rel_fields = {"owners"} def __init__(self) -> None: @@ -169,7 +176,7 @@ class ChartRestApi(BaseSupersetModelRestApi): --- post: description: >- - Create a new Chart + Create a new Chart. requestBody: description: Chart schema required: true @@ -224,7 +231,7 @@ class ChartRestApi(BaseSupersetModelRestApi): --- put: description: >- - Changes a Chart + Changes a Chart. parameters: - in: path schema: @@ -290,7 +297,7 @@ class ChartRestApi(BaseSupersetModelRestApi): --- delete: description: >- - Deletes a Chart + Deletes a Chart. parameters: - in: path schema: @@ -340,7 +347,7 @@ class ChartRestApi(BaseSupersetModelRestApi): --- delete: description: >- - Deletes multiple Charts in a bulk operation + Deletes multiple Charts in a bulk operation. parameters: - in: query name: q @@ -457,7 +464,7 @@ class ChartRestApi(BaseSupersetModelRestApi): """Get Chart thumbnail --- get: - description: Compute or get already computed chart thumbnail from cache + description: Compute or get already computed chart thumbnail from cache. parameters: - in: path schema: @@ -509,13 +516,6 @@ class ChartRestApi(BaseSupersetModelRestApi): FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True ) - def add_apispec_components(self, api_spec: APISpec) -> None: - for chart_type in CHART_DATA_SCHEMAS: - api_spec.components.schema( - chart_type.__name__, schema=chart_type, - ) - super().add_apispec_components(api_spec) - @expose("/datasources", methods=["GET"]) @protect() @safe @@ -523,6 +523,7 @@ class ChartRestApi(BaseSupersetModelRestApi): """Get available datasources --- get: + description: Get available datasources. responses: 200: description: charts unique datasource data diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 5bb3fe6b4a3..8302f0cfb94 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -23,12 +23,69 @@ from superset.common.query_context import QueryContext from superset.exceptions import SupersetException from superset.utils import core as utils +# +# RISON/JSON schemas for query parameters +# get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} thumbnail_query_schema = { "type": "object", "properties": {"force": {"type": "boolean"}}, } +# +# Column schema descriptions +# +slice_name_description = "The name of the chart." +description_description = "A description of the chart propose." +viz_type_description = "The type of chart visualization used." +owners_description = ( + "Owner are users ids allowed to delete or change this chart. " + "If left empty you will be one of the owners of the chart." +) +params_description = ( + "Parameters are generated dynamically when clicking the save " + "or overwrite button in the explore view. " + "This JSON object for power users who may want to alter specific parameters." +) +cache_timeout_description = ( + "Duration (in seconds) of the caching timeout " + "for this chart. Note this defaults to the datasource/table" + " timeout if undefined." +) +datasource_id_description = ( + "The id of the dataset/datasource this new chart will use. " + "A complete datasource identification needs `datasouce_id` " + "and `datasource_type`." +) +datasource_type_description = ( + "The type of dataset/datasource identified on `datasource_id`." +) +datasource_name_description = "The datasource name." +dashboards_description = "A list of dashboards to include this new chart to." + +# +# OpenAPI method specification overrides +# +openapi_spec_methods_override = { + "get": {"get": {"description": "Get a chart detail information."}}, + "get_list": { + "get": { + "description": "Get a list of charts, use Rison or JSON query " + "parameters for filtering, sorting, pagination and " + " for selecting specific columns and metadata.", + } + }, + "info": { + "get": { + "description": "Several metadata information about chart API endpoints.", + } + }, + "related": { + "get": {"description": "Get a list of all possible owners for a chart."} + }, +} +""" Overrides GET methods OpenApi descriptions """ + def validate_json(value: Union[bytes, bytearray, str]) -> None: try: @@ -38,35 +95,74 @@ def validate_json(value: Union[bytes, bytearray, str]) -> None: class ChartPostSchema(Schema): - slice_name = fields.String(required=True, validate=Length(1, 250)) - description = fields.String(allow_none=True) - viz_type = fields.String(allow_none=True, validate=Length(0, 250)) - owners = fields.List(fields.Integer()) - params = fields.String(allow_none=True, validate=validate_json) - cache_timeout = fields.Integer(allow_none=True) - datasource_id = fields.Integer(required=True) - datasource_type = fields.String(required=True) - datasource_name = fields.String(allow_none=True) - dashboards = fields.List(fields.Integer()) + """ + Schema to add a new chart. + """ + + slice_name = fields.String( + description=slice_name_description, required=True, validate=Length(1, 250) + ) + description = fields.String(description=description_description, allow_none=True) + viz_type = fields.String( + description=viz_type_description, + validate=Length(0, 250), + example=["bar", "line_multi", "area", "table"], + ) + owners = fields.List(fields.Integer(description=owners_description)) + params = fields.String( + description=params_description, allow_none=True, validate=validate_json + ) + cache_timeout = fields.Integer( + description=cache_timeout_description, allow_none=True + ) + datasource_id = fields.Integer(description=datasource_id_description, required=True) + datasource_type = fields.String( + description=datasource_type_description, + validate=validate.OneOf(choices=("druid", "table", "view")), + required=True, + ) + datasource_name = fields.String( + description=datasource_name_description, allow_none=True + ) + dashboards = fields.List(fields.Integer(description=dashboards_description)) class ChartPutSchema(Schema): - slice_name = fields.String(allow_none=True, validate=Length(0, 250)) - description = fields.String(allow_none=True) - viz_type = fields.String(allow_none=True, validate=Length(0, 250)) - owners = fields.List(fields.Integer()) - params = fields.String(allow_none=True) - cache_timeout = fields.Integer(allow_none=True) - datasource_id = fields.Integer(allow_none=True) - datasource_type = fields.String(allow_none=True) - dashboards = fields.List(fields.Integer()) + """ + Schema to update or patch a chart + """ + + slice_name = fields.String( + description=slice_name_description, allow_none=True, validate=Length(0, 250) + ) + description = fields.String(description=description_description, allow_none=True) + viz_type = fields.String( + description=viz_type_description, + allow_none=True, + validate=Length(0, 250), + example=["bar", "line_multi", "area", "table"], + ) + owners = fields.List(fields.Integer(description=owners_description)) + params = fields.String(description=params_description, allow_none=True) + cache_timeout = fields.Integer( + description=cache_timeout_description, allow_none=True + ) + datasource_id = fields.Integer( + description=datasource_id_description, allow_none=True + ) + datasource_type = fields.String( + description=datasource_type_description, + validate=validate.OneOf(choices=("druid", "table", "view")), + allow_none=True, + ) + dashboards = fields.List(fields.Integer(description=dashboards_description)) class ChartDataColumnSchema(Schema): column_name = fields.String( description="The name of the target column", example="mycol", ) - type = fields.String(description="Type of target column", example="BIGINT",) + type = fields.String(description="Type of target column", example="BIGINT") class ChartDataAdhocMetricSchema(Schema): diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 3806bbf3069..21d4e7959fe 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -45,6 +45,7 @@ from superset.dashboards.schemas import ( DashboardPutSchema, get_delete_ids_schema, get_export_ids_schema, + openapi_spec_methods_override, thumbnail_query_schema, ) from superset.models.dashboard import Dashboard @@ -145,6 +146,9 @@ class DashboardRestApi(BaseSupersetModelRestApi): } allowed_rel_fields = {"owners"} + openapi_spec_methods = openapi_spec_methods_override + """ Overrides GET methods OpenApi descriptions """ + def __init__(self) -> None: if is_feature_enabled("THUMBNAILS"): self.include_route_methods = self.include_route_methods | {"thumbnail"} @@ -159,7 +163,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): --- post: description: >- - Create a new Dashboard + Create a new Dashboard. requestBody: description: Dashboard schema required: true @@ -216,7 +220,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): --- put: description: >- - Changes a Dashboard + Changes a Dashboard. parameters: - in: path schema: @@ -282,7 +286,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): --- delete: description: >- - Deletes a Dashboard + Deletes a Dashboard. parameters: - in: path schema: @@ -332,7 +336,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): --- delete: description: >- - Deletes multiple Dashboards in a bulk operation + Deletes multiple Dashboards in a bulk operation. parameters: - in: query name: q @@ -391,7 +395,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): --- get: description: >- - Exports multiple Dashboards and downloads them as YAML files + Exports multiple Dashboards and downloads them as YAML files. parameters: - in: query name: q @@ -444,7 +448,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): --- get: description: >- - Compute async or get already computed dashboard thumbnail from cache + Compute async or get already computed dashboard thumbnail from cache. parameters: - in: path schema: diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 201c4ca49d8..0d8c60ad170 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -31,6 +31,52 @@ thumbnail_query_schema = { "properties": {"force": {"type": "boolean"}}, } +dashboard_title_description = "A title for the dashboard." +slug_description = "Unique identifying part for the web address of the dashboard." +owners_description = ( + "Owner are users ids allowed to delete or change this dashboard. " + "If left empty you will be one of the owners of the dashboard." +) +position_json_description = ( + "This json object describes the positioning of the widgets " + "in the dashboard. It is dynamically generated when " + "adjusting the widgets size and positions by using " + "drag & drop in the dashboard view" +) +css_description = "Override CSS for the dashboard." +json_metadata_description = ( + "This JSON object is generated dynamically when clicking " + "the save or overwrite button in the dashboard view. " + "It is exposed here for reference and for power users who may want to alter " + " specific parameters." +) +published_description = ( + "Determines whether or not this dashboard is visible in " + "the list of all dashboards." +) + + +openapi_spec_methods_override = { + "get": {"get": {"description": "Get a dashboard detail information."}}, + "get_list": { + "get": { + "description": "Get a list of dashboards, use Rison or JSON query " + "parameters for filtering, sorting, pagination and " + " for selecting specific columns and metadata.", + } + }, + "info": { + "get": { + "description": "Several metadata information about dashboard API " + "endpoints.", + } + }, + "related": { + "get": {"description": "Get a list of all possible owners for a dashboard."} + }, +} +""" Overrides GET methods OpenApi descriptions """ + def validate_json(value: Union[bytes, bytearray, str]) -> None: try: @@ -73,20 +119,44 @@ class BaseDashboardSchema(Schema): class DashboardPostSchema(BaseDashboardSchema): - dashboard_title = fields.String(allow_none=True, validate=Length(0, 500)) - slug = fields.String(allow_none=True, validate=[Length(1, 255)]) - owners = fields.List(fields.Integer()) - position_json = fields.String(validate=validate_json) + dashboard_title = fields.String( + description=dashboard_title_description, + allow_none=True, + validate=Length(0, 500), + ) + slug = fields.String( + description=slug_description, allow_none=True, validate=[Length(1, 255)] + ) + owners = fields.List(fields.Integer(description=owners_description)) + position_json = fields.String( + description=position_json_description, validate=validate_json + ) css = fields.String() - json_metadata = fields.String(validate=validate_json_metadata) - published = fields.Boolean() + json_metadata = fields.String( + description=json_metadata_description, validate=validate_json_metadata + ) + published = fields.Boolean(description=published_description) class DashboardPutSchema(BaseDashboardSchema): - dashboard_title = fields.String(allow_none=True, validate=Length(0, 500)) - slug = fields.String(allow_none=True, validate=Length(0, 255)) - owners = fields.List(fields.Integer(allow_none=True)) - position_json = fields.String(allow_none=True, validate=validate_json) - css = fields.String(allow_none=True) - json_metadata = fields.String(allow_none=True, validate=validate_json_metadata) - published = fields.Boolean(allow_none=True) + dashboard_title = fields.String( + description=dashboard_title_description, + allow_none=True, + validate=Length(0, 500), + ) + slug = fields.String( + description=slug_description, allow_none=True, validate=Length(0, 255) + ) + owners = fields.List( + fields.Integer(description=owners_description, allow_none=True) + ) + position_json = fields.String( + description=position_json_description, allow_none=True, validate=validate_json + ) + css = fields.String(description=css_description, allow_none=True) + json_metadata = fields.String( + description=json_metadata_description, + allow_none=True, + validate=validate_json_metadata, + ) + published = fields.Boolean(description=published_description, allow_none=True) diff --git a/superset/queries/api.py b/superset/queries/api.py index 092989f5eff..0d49bc39c87 100644 --- a/superset/queries/api.py +++ b/superset/queries/api.py @@ -21,6 +21,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface from superset.constants import RouteMethod from superset.models.sql_lab import Query from superset.queries.filters import QueryFilter +from superset.queries.schemas import openapi_spec_methods_override from superset.views.base_api import BaseSupersetModelRestApi logger = logging.getLogger(__name__) @@ -70,3 +71,4 @@ class QueryRestApi(BaseSupersetModelRestApi): base_order = ("changed_on", "desc") openapi_spec_tag = "Queries" + openapi_spec_methods = openapi_spec_methods_override diff --git a/superset/queries/schemas.py b/superset/queries/schemas.py new file mode 100644 index 00000000000..04c003e1040 --- /dev/null +++ b/superset/queries/schemas.py @@ -0,0 +1,28 @@ +# 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. + +openapi_spec_methods_override = { + "get": {"get": {"description": "Get query detail information."}}, + "get_list": { + "get": { + "description": "Get a list of queries, use Rison or JSON query " + "parameters for filtering, sorting, pagination and " + " for selecting specific columns and metadata.", + } + }, +} +""" Overrides GET methods OpenApi descriptions """ diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 60f9d29527c..5675506dc55 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -18,11 +18,13 @@ import functools import logging from typing import Any, cast, Dict, Optional, Set, Tuple, Type, Union +from apispec import APISpec from flask import Response from flask_appbuilder import ModelRestApi from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.filters import BaseFilter, Filters from flask_appbuilder.models.sqla.filters import FilterStartsWith +from marshmallow import Schema from superset.stats_logger import BaseStatsLogger from superset.utils.core import time_function @@ -109,10 +111,23 @@ class BaseSupersetModelRestApi(ModelRestApi): """ # pylint: disable=pointless-string-statement allowed_rel_fields: Set[str] = set() + openapi_spec_component_schemas: Tuple[Schema, ...] = tuple() + """ + Add extra schemas to the OpenAPI component schemas section + """ # pylint: disable=pointless-string-statement + def __init__(self) -> None: super().__init__() self.stats_logger = BaseStatsLogger() + def add_apispec_components(self, api_spec: APISpec) -> None: + + for schema in self.openapi_spec_component_schemas: + api_spec.components.schema( + schema.__name__, schema=schema, + ) + super().add_apispec_components(api_spec) + def create_blueprint(self, appbuilder, *args, **kwargs): self.stats_logger = self.appbuilder.get_app.config["STATS_LOGGER"] return super().create_blueprint(appbuilder, *args, **kwargs) diff --git a/tests/charts/api_tests.py b/tests/charts/api_tests.py index 558e2e9d539..3e9769c6355 100644 --- a/tests/charts/api_tests.py +++ b/tests/charts/api_tests.py @@ -333,10 +333,10 @@ class ChartApiTests(SupersetTestCase, ApiOwnersTestCaseMixin): } uri = f"api/v1/chart/" rv = self.post_assert_metric(uri, chart_data, "post") - self.assertEqual(rv.status_code, 422) + self.assertEqual(rv.status_code, 400) response = json.loads(rv.data.decode("utf-8")) self.assertEqual( - response, {"message": {"datasource_id": ["Datasource does not exist"]}} + response, {"message": {"datasource_type": ["Not a valid choice."]}} ) chart_data = { "slice_name": "title1", @@ -439,10 +439,10 @@ class ChartApiTests(SupersetTestCase, ApiOwnersTestCaseMixin): chart_data = {"datasource_id": 1, "datasource_type": "unknown"} uri = f"api/v1/chart/{chart.id}" rv = self.put_assert_metric(uri, chart_data, "put") - self.assertEqual(rv.status_code, 422) + self.assertEqual(rv.status_code, 400) response = json.loads(rv.data.decode("utf-8")) self.assertEqual( - response, {"message": {"datasource_id": ["Datasource does not exist"]}} + response, {"message": {"datasource_type": ["Not a valid choice."]}} ) chart_data = {"datasource_id": 0, "datasource_type": "table"} uri = f"api/v1/chart/{chart.id}"