diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 0a5a655a06..a8827bbc28 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -1185,6 +1185,26 @@ export function persistEditorHeight(queryEditor, northPercent, southPercent) { }; } +export function popPermalink(key) { + return function (dispatch) { + return SupersetClient.get({ endpoint: `/api/v1/sqllab/permalink/${key}` }) + .then(({ json }) => + dispatch( + addQueryEditor({ + name: json.name ? json.name : t('Shared query'), + dbId: json.dbId ? parseInt(json.dbId, 10) : null, + catalog: json.catalog ? json.catalog : null, + schema: json.schema ? json.schema : null, + autorun: json.autorun ? json.autorun : false, + sql: json.sql ? json.sql : 'SELECT ...', + templateParams: json.templateParams, + }), + ), + ) + .catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY))); + }; +} + export function popStoredQuery(urlId) { return function (dispatch) { return SupersetClient.get({ endpoint: `/kv/${urlId}` }) diff --git a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx index a1251e8bcf..30b07202b0 100644 --- a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx +++ b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx @@ -23,7 +23,7 @@ import fetchMock from 'fetch-mock'; import * as uiCore from '@superset-ui/core'; import { Provider } from 'react-redux'; import { supersetTheme, ThemeProvider } from '@superset-ui/core'; -import { render, screen, act } from '@testing-library/react'; +import { render, screen, act, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import userEvent from '@testing-library/user-event'; import * as utils from 'src/utils/common'; @@ -92,20 +92,24 @@ const standardProviderWithUnsaved: FC = ({ children }) => ( ); describe('ShareSqlLabQuery', () => { - const storeQueryUrl = 'glob:*/kv/store/'; - const storeQueryMockId = '123'; + const storeQueryUrl = 'glob:*/api/v1/sqllab/permalink'; + const storeQueryMockId = 'ci39c3'; beforeEach(async () => { - fetchMock.post(storeQueryUrl, () => ({ id: storeQueryMockId }), { - overwriteRoutes: true, - }); + fetchMock.post( + storeQueryUrl, + () => ({ key: storeQueryMockId, url: `/p/${storeQueryMockId}` }), + { + overwriteRoutes: true, + }, + ); fetchMock.resetHistory(); jest.clearAllMocks(); }); afterAll(fetchMock.reset); - describe('via /kv/store', () => { + describe('via permalink api', () => { beforeAll(() => { isFeatureEnabledMock = jest .spyOn(uiCore, 'isFeatureEnabled') @@ -124,11 +128,13 @@ describe('ShareSqlLabQuery', () => { }); const button = screen.getByRole('button'); const { id, remoteId, ...expected } = mockQueryEditor; - const storeQuerySpy = jest.spyOn(utils, 'storeQuery'); userEvent.click(button); - expect(storeQuerySpy.mock.calls).toHaveLength(1); - expect(storeQuerySpy).toBeCalledWith(expected); - storeQuerySpy.mockRestore(); + await waitFor(() => + expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1), + ); + expect( + JSON.parse(fetchMock.calls(storeQueryUrl)[0][1]?.body as string), + ).toEqual(expected); }); it('calls storeQuery() with unsaved changes', async () => { @@ -139,11 +145,13 @@ describe('ShareSqlLabQuery', () => { }); const button = screen.getByRole('button'); const { id, ...expected } = unsavedQueryEditor; - const storeQuerySpy = jest.spyOn(utils, 'storeQuery'); userEvent.click(button); - expect(storeQuerySpy.mock.calls).toHaveLength(1); - expect(storeQuerySpy).toBeCalledWith(expected); - storeQuerySpy.mockRestore(); + await waitFor(() => + expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1), + ); + expect( + JSON.parse(fetchMock.calls(storeQueryUrl)[0][1]?.body as string), + ).toEqual(expected); }); }); diff --git a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx index a9a82c43f0..9f555532fd 100644 --- a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx +++ b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/index.tsx @@ -23,12 +23,12 @@ import { useTheme, isFeatureEnabled, getClientErrorObject, + SupersetClient, } from '@superset-ui/core'; import Button from 'src/components/Button'; import Icons from 'src/components/Icons'; import withToasts from 'src/components/MessageToasts/withToasts'; import CopyToClipboard from 'src/components/CopyToClipboard'; -import { storeQuery } from 'src/utils/common'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; interface ShareSqlLabQueryProps { @@ -63,12 +63,16 @@ const ShareSqlLabQuery = ({ 'templateParams', ]); - const getCopyUrlForKvStore = (callback: Function) => { + const getCopyUrlForPermalink = (callback: Function) => { const sharedQuery = { dbId, name, schema, autorun, sql, templateParams }; - return storeQuery(sharedQuery) - .then(shortUrl => { - callback(shortUrl); + return SupersetClient.post({ + endpoint: '/api/v1/sqllab/permalink', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sharedQuery), + }) + .then(({ json }) => { + callback(json.url); }) .catch(response => { getClientErrorObject(response).then(() => { @@ -92,7 +96,7 @@ const ShareSqlLabQuery = ({ }; const getCopyUrl = (callback: Function) => { if (isFeatureEnabled(FeatureFlag.ShareQueriesViaKvStore)) { - return getCopyUrlForKvStore(callback); + return getCopyUrlForPermalink(callback); } return getCopyUrlForSavedQuery(callback); }; diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx index d4474fba28..37a50b1c01 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx @@ -71,6 +71,27 @@ describe('componentDidMount', () => { '/sqllab', ); }); + test('should handle permalink', async () => { + const key = '9sadkfl'; + fetchMock.get(`glob:*/api/v1/sqllab/permalink/${key}`, { + label: 'test permalink', + sql: 'SELECT * FROM test_table', + dbId: 1, + }); + uriStub.mockReturnValue({ p: key }); + setup(store); + await waitFor(() => + expect( + fetchMock.calls(`glob:*/api/v1/sqllab/permalink/${key}`), + ).toHaveLength(1), + ); + expect(replaceState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + '/sqllab', + ); + fetchMock.reset(); + }); test('should handle savedQueryId', () => { uriStub.mockReturnValue({ savedQueryId: 1 }); setup(store); diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index b26067556c..c4c28b3b32 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx @@ -79,6 +79,7 @@ class TabbedSqlEditors extends PureComponent { const { id, name, + p, sql, savedQueryId, datasourceKey, @@ -97,8 +98,10 @@ class TabbedSqlEditors extends PureComponent { } as Record; // Popping a new tab based on the querystring - if (id || sql || savedQueryId || datasourceKey || queryId) { - if (id) { + if (p || id || sql || savedQueryId || datasourceKey || queryId) { + if (p) { + this.props.actions.popPermalink(p); + } else if (id) { this.props.actions.popStoredQuery(id); } else if (savedQueryId) { this.props.actions.popSavedQuery(savedQueryId); diff --git a/superset/commands/sql_lab/permalink/__init__.py b/superset/commands/sql_lab/permalink/__init__.py new file mode 100644 index 0000000000..13a83393a9 --- /dev/null +++ b/superset/commands/sql_lab/permalink/__init__.py @@ -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. diff --git a/superset/commands/sql_lab/permalink/base.py b/superset/commands/sql_lab/permalink/base.py new file mode 100644 index 0000000000..1619ddb91d --- /dev/null +++ b/superset/commands/sql_lab/permalink/base.py @@ -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 abc import ABC + +from superset.commands.base import BaseCommand +from superset.key_value.shared_entries import get_permalink_salt +from superset.key_value.types import ( + KeyValueResource, + MarshmallowKeyValueCodec, + SharedKey, +) +from superset.sqllab.permalink.schemas import SqlLabPermalinkSchema + + +class BaseSqlLabPermalinkCommand(BaseCommand, ABC): + resource: KeyValueResource = KeyValueResource.SQLLAB_PERMALINK + codec = MarshmallowKeyValueCodec(SqlLabPermalinkSchema()) + + @property + def salt(self) -> str: + return get_permalink_salt(SharedKey.SQLLAB_PERMALINK_SALT) diff --git a/superset/commands/sql_lab/permalink/create.py b/superset/commands/sql_lab/permalink/create.py new file mode 100644 index 0000000000..18839099b2 --- /dev/null +++ b/superset/commands/sql_lab/permalink/create.py @@ -0,0 +1,49 @@ +# 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 typing import Any + +from superset.commands.key_value.create import CreateKeyValueCommand +from superset.commands.sql_lab.permalink.base import BaseSqlLabPermalinkCommand +from superset.key_value.exceptions import KeyValueCodecEncodeException +from superset.key_value.utils import encode_permalink_key +from superset.sqllab.permalink.exceptions import SqlLabPermalinkCreateFailedError + +logger = logging.getLogger(__name__) + + +class CreateSqlLabPermalinkCommand(BaseSqlLabPermalinkCommand): + def __init__(self, state: dict[str, Any]): + self._properties = state.copy() + + def run(self) -> str: + self.validate() + try: + command = CreateKeyValueCommand( + resource=self.resource, + value=self._properties, + codec=self.codec, + ) + key = command.run() + if key.id is None: + raise SqlLabPermalinkCreateFailedError("Unexpected missing key id") + return encode_permalink_key(key=key.id, salt=self.salt) + except KeyValueCodecEncodeException as ex: + raise SqlLabPermalinkCreateFailedError(str(ex)) from ex + + def validate(self) -> None: + pass diff --git a/superset/commands/sql_lab/permalink/get.py b/superset/commands/sql_lab/permalink/get.py new file mode 100644 index 0000000000..7cc58865b0 --- /dev/null +++ b/superset/commands/sql_lab/permalink/get.py @@ -0,0 +1,58 @@ +# 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 typing import Optional + +from superset.commands.key_value.get import GetKeyValueCommand +from superset.commands.sql_lab.permalink.base import BaseSqlLabPermalinkCommand +from superset.key_value.exceptions import ( + KeyValueCodecDecodeException, + KeyValueGetFailedError, + KeyValueParseKeyError, +) +from superset.key_value.utils import decode_permalink_id +from superset.sqllab.permalink.exceptions import SqlLabPermalinkGetFailedError +from superset.sqllab.permalink.types import SqlLabPermalinkValue + +logger = logging.getLogger(__name__) + + +class GetSqlLabPermalinkCommand(BaseSqlLabPermalinkCommand): + def __init__(self, key: str): + self.key = key + + def run(self) -> Optional[SqlLabPermalinkValue]: + self.validate() + try: + key = decode_permalink_id(self.key, salt=self.salt) + value: Optional[SqlLabPermalinkValue] = GetKeyValueCommand( + resource=self.resource, + key=key, + codec=self.codec, + ).run() + if value: + return value + return None + except ( + KeyValueCodecDecodeException, + KeyValueGetFailedError, + KeyValueParseKeyError, + ) as ex: + raise SqlLabPermalinkGetFailedError(message=ex.message) from ex + + def validate(self) -> None: + pass diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 65e518b7c9..f10f488abe 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -154,6 +154,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.row_level_security.api import RLSRestApi from superset.security.api import SecurityRestApi from superset.sqllab.api import SqlLabRestApi + from superset.sqllab.permalink.api import SqlLabPermalinkRestApi from superset.tags.api import TagRestApi from superset.views.alerts import AlertView, ReportView from superset.views.all_entities import TaggedObjectsModelView @@ -221,6 +222,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_api(SavedQueryRestApi) appbuilder.add_api(TagRestApi) appbuilder.add_api(SqlLabRestApi) + appbuilder.add_api(SqlLabPermalinkRestApi) # # Setup regular views # diff --git a/superset/key_value/types.py b/superset/key_value/types.py index 7b0130c0e6..c3f54ea1ee 100644 --- a/superset/key_value/types.py +++ b/superset/key_value/types.py @@ -50,11 +50,13 @@ class KeyValueResource(StrEnum): EXPLORE_PERMALINK = "explore_permalink" METASTORE_CACHE = "superset_metastore_cache" LOCK = "lock" + SQLLAB_PERMALINK = "sqllab_permalink" class SharedKey(StrEnum): DASHBOARD_PERMALINK_SALT = "dashboard_permalink_salt" EXPLORE_PERMALINK_SALT = "explore_permalink_salt" + SQLLAB_PERMALINK_SALT = "sqllab_permalink_salt" class KeyValueCodec(ABC): diff --git a/superset/sqllab/permalink/__init__.py b/superset/sqllab/permalink/__init__.py new file mode 100644 index 0000000000..13a83393a9 --- /dev/null +++ b/superset/sqllab/permalink/__init__.py @@ -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. diff --git a/superset/sqllab/permalink/api.py b/superset/sqllab/permalink/api.py new file mode 100644 index 0000000000..755326c991 --- /dev/null +++ b/superset/sqllab/permalink/api.py @@ -0,0 +1,144 @@ +# 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 request, Response +from flask_appbuilder.api import expose, protect, safe +from marshmallow import ValidationError + +from superset.commands.sql_lab.permalink.create import CreateSqlLabPermalinkCommand +from superset.commands.sql_lab.permalink.get import GetSqlLabPermalinkCommand +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP +from superset.extensions import event_logger +from superset.key_value.exceptions import KeyValueAccessDeniedError +from superset.sqllab.permalink.exceptions import SqlLabPermalinkInvalidStateError +from superset.sqllab.permalink.schemas import SqlLabPermalinkSchema +from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics + +logger = logging.getLogger(__name__) + + +class SqlLabPermalinkRestApi(BaseSupersetApi): + add_model_schema = SqlLabPermalinkSchema() + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + allow_browser_login = True + class_permission_name = "SqlLabPermalinkRestApi" + resource_name = "sqllab" + openapi_spec_tag = "SQL Lab Permanent Link" + openapi_spec_component_schemas = (SqlLabPermalinkSchema,) + + @expose("/permalink", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post", + log_to_statsd=False, + ) + @requires_json + def post(self) -> Response: + """Create a new permanent link for SQL Lab editor + --- + post: + summary: Create a new permanent link + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExplorePermalinkStateSchema' + responses: + 201: + description: The permanent link was stored successfully. + content: + application/json: + schema: + type: object + properties: + key: + type: string + description: The key to retrieve the permanent link data. + url: + type: string + description: permanent link. + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + state = self.add_model_schema.load(request.json) + key = CreateSqlLabPermalinkCommand(state=state).run() + http_origin = request.headers.environ.get("HTTP_ORIGIN") + url = f"{http_origin}/sqllab?p={key}" + return self.response(201, key=key, url=url) + except ValidationError as ex: + return self.response(400, message=ex.messages) + except KeyValueAccessDeniedError as ex: + return self.response(403, message=str(ex)) + + @expose("/permalink/", methods=("GET",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", + log_to_statsd=False, + ) + def get(self, key: str) -> Response: + """Get chart's permanent link state. + --- + get: + summary: Get chart's permanent link state + parameters: + - in: path + schema: + type: string + name: key + responses: + 200: + description: Returns the stored form_data. + content: + application/json: + schema: + type: object + properties: + state: + type: object + description: The stored state + 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: + value = GetSqlLabPermalinkCommand(key=key).run() + if not value: + return self.response_404() + return self.response(200, **value) + except SqlLabPermalinkInvalidStateError as ex: + return self.response(400, message=str(ex)) diff --git a/superset/sqllab/permalink/exceptions.py b/superset/sqllab/permalink/exceptions.py new file mode 100644 index 0000000000..5d8dd7a9f4 --- /dev/null +++ b/superset/sqllab/permalink/exceptions.py @@ -0,0 +1,31 @@ +# 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 flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import CommandException, CreateFailedError + + +class SqlLabPermalinkInvalidStateError(CreateFailedError): + message = _("Invalid state.") + + +class SqlLabPermalinkCreateFailedError(CreateFailedError): + message = _("An error occurred while creating the copy link.") + + +class SqlLabPermalinkGetFailedError(CommandException): + message = _("An error occurred while accessing the copy link.") diff --git a/superset/sqllab/permalink/schemas.py b/superset/sqllab/permalink/schemas.py new file mode 100644 index 0000000000..d2b7961e65 --- /dev/null +++ b/superset/sqllab/permalink/schemas.py @@ -0,0 +1,46 @@ +# 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 marshmallow import fields, Schema + + +class SqlLabPermalinkSchema(Schema): + autorun = fields.Boolean() + dbId = fields.Integer( + required=True, + allow_none=False, + metadata={"description": "The id of the database"}, + ) + name = fields.String( + required=True, + allow_none=False, + metadata={"description": "The label of the editor tab"}, + ) + schema = fields.String( + required=False, + allow_none=True, + metadata={"description": "The schema name of the query"}, + ) + sql = fields.String( + required=True, + allow_none=False, + metadata={"description": "SQL query text"}, + ) + templateParams = fields.String( + required=False, + allow_none=True, + metadata={"description": "stringfied JSON string for template parameters"}, + ) diff --git a/superset/sqllab/permalink/types.py b/superset/sqllab/permalink/types.py new file mode 100644 index 0000000000..adc127da11 --- /dev/null +++ b/superset/sqllab/permalink/types.py @@ -0,0 +1,26 @@ +# 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, TypedDict + + +class SqlLabPermalinkValue(TypedDict): + dbId: int + name: str + schema: Optional[str] + sql: str + autorun: bool + templateParams: Optional[str] diff --git a/tests/integration_tests/sql_lab/permalink/__init__.py b/tests/integration_tests/sql_lab/permalink/__init__.py new file mode 100644 index 0000000000..13a83393a9 --- /dev/null +++ b/tests/integration_tests/sql_lab/permalink/__init__.py @@ -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. diff --git a/tests/integration_tests/sql_lab/permalink/api_tests.py b/tests/integration_tests/sql_lab/permalink/api_tests.py new file mode 100644 index 0000000000..22777323c2 --- /dev/null +++ b/tests/integration_tests/sql_lab/permalink/api_tests.py @@ -0,0 +1,69 @@ +# 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 json + +from superset import db +from superset.key_value.models import KeyValueEntry +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.constants import ( + GAMMA_SQLLAB_USERNAME, +) + + +class TestSqlLabPermalinkApi(SupersetTestCase): + data = { + "dbId": 1, + "name": "Untitled Query 1", + "schema": "main", + "sql": "SELECT 'foo' as bar", + "autorun": False, + "templateParams": '{"param1": "value1"}', + } + + def test_post(self): + self.login(GAMMA_SQLLAB_USERNAME) + resp = self.client.post("api/v1/sqllab/permalink", json=self.data) + assert resp.status_code == 201 + data = resp.json + key = data["key"] + url = data["url"] + assert key in url + db.session.query(KeyValueEntry).filter_by(id=key).delete() + db.session.commit() + + def test_post_access_denied(self): + resp = self.client.post("api/v1/sqllab/permalink", json=self.data) + assert resp.status_code == 401 + + def test_post_invalid_schema(self): + self.login(GAMMA_SQLLAB_USERNAME) + resp = self.client.post( + "api/v1/sqllab/permalink", json={"name": "Untitled Query 1", "sql": "Test"} + ) + assert resp.status_code == 400 + + def test_get(self): + self.login(GAMMA_SQLLAB_USERNAME) + resp = self.client.post("api/v1/sqllab/permalink", json=self.data) + data = resp.json + key = data["key"] + resp = self.client.get(f"api/v1/sqllab/permalink/{key}") + assert resp.status_code == 200 + result = json.loads(resp.data.decode("utf-8")) + assert result == self.data + db.session.query(KeyValueEntry).filter_by(id=key).delete() + db.session.commit()