This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 31f406a  feat: API endpoint to validate databases using separate 
parameters (#14420)
31f406a is described below

commit 31f406a526b1ca8e7a72b8f8429c148636426ef9
Author: Beto Dealmeida <[email protected]>
AuthorDate: Wed May 12 18:32:10 2021 -0700

    feat: API endpoint to validate databases using separate parameters (#14420)
    
    * feat: new endpoint for validating database parameters
    
    * Rebase
    
    * Remove broken tests
---
 docs/src/pages/docs/Miscellaneous/issue_codes.mdx  |  24 ++++
 .../src/components/ErrorMessage/types.ts           |   5 +
 superset/config.py                                 |   2 +-
 superset/constants.py                              |   1 +
 superset/databases/api.py                          |  57 ++++++++
 superset/databases/commands/exceptions.py          |  14 +-
 superset/databases/commands/validate.py            | 127 +++++++++++++++++
 superset/databases/schemas.py                      |  32 ++++-
 superset/db_engine_specs/base.py                   |  73 ++++++++--
 superset/db_engine_specs/bigquery.py               |   5 +-
 superset/db_engine_specs/cockroachdb.py            |   1 +
 superset/db_engine_specs/mssql.py                  |   8 +-
 superset/db_engine_specs/mysql.py                  |   6 +-
 superset/db_engine_specs/postgres.py               |   7 +
 superset/db_engine_specs/presto.py                 |  10 +-
 superset/db_engine_specs/redshift.py               |   8 +-
 superset/errors.py                                 |  30 +++++
 superset/exceptions.py                             |  27 ++++
 tests/databases/api_tests.py                       | 150 ++++++++++++++++++++-
 tests/databases/commands_tests.py                  | 127 ++++++++++++++++-
 tests/db_engine_specs/base_engine_spec_tests.py    | 101 ++++++++++++++
 tests/db_engine_specs/postgres_tests.py            |  37 ++---
 22 files changed, 811 insertions(+), 41 deletions(-)

diff --git a/docs/src/pages/docs/Miscellaneous/issue_codes.mdx 
b/docs/src/pages/docs/Miscellaneous/issue_codes.mdx
index 6b4a72d..ad26c70 100644
--- a/docs/src/pages/docs/Miscellaneous/issue_codes.mdx
+++ b/docs/src/pages/docs/Miscellaneous/issue_codes.mdx
@@ -183,3 +183,27 @@ The user doesn't have the proper permissions to connect to 
the database
 ```
 
 We were unable to connect to your database. Please confirm that your service 
account has the Viewer and Job User roles on the project.
+
+## Issue 1018
+
+```
+One or more parameters needed to configure a database are missing.
+```
+
+Not all parameters required to test, create, or edit a database were present. 
Please double check which parameters are needed, and that they are present.
+
+## Issue 1019
+
+```
+The submitted payload has the incorrect format.
+```
+
+Please check that the request payload has the correct format (eg, JSON).
+
+## Issue 1020
+
+```
+The submitted payload has the incorrect schema.
+```
+
+Please check that the request payload has the expected schema.
diff --git a/superset-frontend/src/components/ErrorMessage/types.ts 
b/superset-frontend/src/components/ErrorMessage/types.ts
index 19c485b..a2ce66b 100644
--- a/superset-frontend/src/components/ErrorMessage/types.ts
+++ b/superset-frontend/src/components/ErrorMessage/types.ts
@@ -38,6 +38,7 @@ export const ErrorTypeEnum = {
   CONNECTION_UNKNOWN_DATABASE_ERROR: 'CONNECTION_UNKNOWN_DATABASE_ERROR',
   CONNECTION_DATABASE_PERMISSIONS_ERROR:
     'CONNECTION_DATABASE_PERMISSIONS_ERROR',
+  CONNECTION_MISSING_PARAMETERS_ERRORS: 'CONNECTION_MISSING_PARAMETERS_ERRORS',
 
   // Viz errors
   VIZ_GET_DF_ERROR: 'VIZ_GET_DF_ERROR',
@@ -60,6 +61,10 @@ export const ErrorTypeEnum = {
   // Generic errors
   GENERIC_COMMAND_ERROR: 'GENERIC_COMMAND_ERROR',
   GENERIC_BACKEND_ERROR: 'GENERIC_BACKEND_ERROR',
+
+  // API errors
+  INVALID_PAYLOAD_FORMAT_ERROR: 'INVALID_PAYLOAD_FORMAT_ERROR',
+  INVALID_PAYLOAD_SCHEMA_ERROR: 'INVALID_PAYLOAD_SCHEMA_ERROR',
 } as const;
 
 type ValueOf<T> = T[keyof T];
diff --git a/superset/config.py b/superset/config.py
index 89caead..9863f76 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -630,7 +630,7 @@ DISPLAY_MAX_ROW = 10000
 
 # Default row limit for SQL Lab queries. Is overridden by setting a new limit 
in
 # the SQL Lab UI
-DEFAULT_SQLLAB_LIMIT = 1000
+DEFAULT_SQLLAB_LIMIT = 10000
 
 # Maximum number of tables/views displayed in the dropdown window in SQL Lab.
 MAX_TABLE_NAMES = 3000
diff --git a/superset/constants.py b/superset/constants.py
index 5fd59bd..a4f0ad1 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -106,6 +106,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
     "select_star": "read",
     "table_metadata": "read",
     "test_connection": "read",
+    "validate_parameters": "read",
     "favorite_status": "read",
     "thumbnail": "read",
     "import_": "write",
diff --git a/superset/databases/api.py b/superset/databases/api.py
index 5b964e6..370e513 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -47,6 +47,7 @@ from superset.databases.commands.export import 
ExportDatabasesCommand
 from superset.databases.commands.importers.dispatcher import 
ImportDatabasesCommand
 from superset.databases.commands.test_connection import 
TestConnectionDatabaseCommand
 from superset.databases.commands.update import UpdateDatabaseCommand
+from superset.databases.commands.validate import 
ValidateDatabaseParametersCommand
 from superset.databases.dao import DatabaseDAO
 from superset.databases.decorators import check_datasource_access
 from superset.databases.filters import DatabaseFilter
@@ -57,6 +58,7 @@ from superset.databases.schemas import (
     DatabasePutSchema,
     DatabaseRelatedObjectsResponse,
     DatabaseTestConnectionSchema,
+    DatabaseValidateParametersSchema,
     get_export_ids_schema,
     SchemasResponseSchema,
     SelectStarResponseSchema,
@@ -65,6 +67,7 @@ from superset.databases.schemas import (
 from superset.databases.utils import get_table_metadata
 from superset.db_engine_specs import get_available_engine_specs
 from superset.db_engine_specs.base import BaseParametersMixin
+from superset.exceptions import InvalidPayloadFormatError, 
InvalidPayloadSchemaError
 from superset.extensions import security_manager
 from superset.models.core import Database
 from superset.typing import FlaskResponse
@@ -87,6 +90,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
         "related_objects",
         "function_names",
         "available",
+        "validate_parameters",
     }
     resource_name = "database"
     class_permission_name = "Database"
@@ -176,6 +180,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
         DatabaseFunctionNamesResponse,
         DatabaseRelatedObjectsResponse,
         DatabaseTestConnectionSchema,
+        DatabaseValidateParametersSchema,
         TableMetadataResponseSchema,
         SelectStarResponseSchema,
         SchemasResponseSchema,
@@ -914,3 +919,55 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
         )
 
         return self.response(200, databases=available_databases)
+
+    @expose("/validate_parameters", methods=["POST"])
+    @protect()
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
+        f".validate_parameters",
+        log_to_statsd=False,
+    )
+    def validate_parameters(  # pylint: disable=too-many-return-statements
+        self,
+    ) -> FlaskResponse:
+        """validates database connection parameters
+        ---
+        post:
+          description: >-
+            Validates parameters used to connect to a database
+          requestBody:
+            description: DB-specific parameters
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: "#/components/schemas/DatabaseValidateParametersSchema"
+          responses:
+            200:
+              description: Database Test Connection
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            400:
+              $ref: '#/components/responses/400'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            raise InvalidPayloadFormatError("Request is not JSON")
+
+        try:
+            payload = DatabaseValidateParametersSchema().load(request.json)
+        except ValidationError as error:
+            raise InvalidPayloadSchemaError(error)
+
+        command = ValidateDatabaseParametersCommand(g.user, payload)
+        command.run()
+        return self.response(200, message="OK")
diff --git a/superset/databases/commands/exceptions.py 
b/superset/databases/commands/exceptions.py
index e4236cf..c7df6bc 100644
--- a/superset/databases/commands/exceptions.py
+++ b/superset/databases/commands/exceptions.py
@@ -25,7 +25,7 @@ from superset.commands.exceptions import (
     ImportFailedError,
     UpdateFailedError,
 )
-from superset.exceptions import SupersetErrorsException
+from superset.exceptions import SupersetErrorException, SupersetErrorsException
 
 
 class DatabaseInvalidError(CommandInvalidError):
@@ -137,3 +137,15 @@ class 
DatabaseTestConnectionUnexpectedError(SupersetErrorsException):
 
 class DatabaseImportError(ImportFailedError):
     message = _("Import database failed for an unknown reason")
+
+
+class InvalidEngineError(SupersetErrorException):
+    status = 422
+
+
+class DatabaseOfflineError(SupersetErrorException):
+    status = 422
+
+
+class InvalidParametersError(SupersetErrorsException):
+    status = 422
diff --git a/superset/databases/commands/validate.py 
b/superset/databases/commands/validate.py
new file mode 100644
index 0000000..b6aa974
--- /dev/null
+++ b/superset/databases/commands/validate.py
@@ -0,0 +1,127 @@
+# 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 contextlib import closing
+from typing import Any, Dict, Optional
+
+from flask_appbuilder.security.sqla.models import User
+from flask_babel import gettext as __
+from sqlalchemy.engine.url import make_url
+
+from superset.commands.base import BaseCommand
+from superset.databases.commands.exceptions import (
+    DatabaseOfflineError,
+    DatabaseTestConnectionFailedError,
+    InvalidEngineError,
+    InvalidParametersError,
+)
+from superset.databases.dao import DatabaseDAO
+from superset.db_engine_specs import get_engine_specs
+from superset.db_engine_specs.base import BaseParametersMixin
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.models.core import Database
+
+
+class ValidateDatabaseParametersCommand(BaseCommand):
+    def __init__(self, user: User, parameters: Dict[str, Any]):
+        self._actor = user
+        self._properties = parameters.copy()
+        self._model: Optional[Database] = None
+
+    def run(self) -> None:
+        engine = self._properties["engine"]
+        engine_specs = get_engine_specs()
+        if engine not in engine_specs:
+            raise InvalidEngineError(
+                SupersetError(
+                    message=__(
+                        'Engine "%(engine)s" is not a valid engine.', 
engine=engine,
+                    ),
+                    error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                    level=ErrorLevel.ERROR,
+                    extra={"allowed": list(engine_specs), "provided": engine},
+                ),
+            )
+        engine_spec = engine_specs[engine]
+        if not issubclass(engine_spec, BaseParametersMixin):
+            raise InvalidEngineError(
+                SupersetError(
+                    message=__(
+                        'Engine "%(engine)s" cannot be configured through 
parameters.',
+                        engine=engine,
+                    ),
+                    error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                    level=ErrorLevel.ERROR,
+                    extra={
+                        "allowed": [
+                            name
+                            for name, engine_spec in engine_specs.items()
+                            if issubclass(engine_spec, BaseParametersMixin)
+                        ],
+                        "provided": engine,
+                    },
+                ),
+            )
+
+        # perform initial validation
+        errors = 
engine_spec.validate_parameters(self._properties["parameters"])
+        if errors:
+            raise InvalidParametersError(errors)
+
+        # try to connect
+        sqlalchemy_uri = engine_spec.build_sqlalchemy_uri(
+            self._properties["parameters"]  # type: ignore
+        )
+        if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
+            sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
+        database = DatabaseDAO.build_db_for_connection_test(
+            server_cert=self._properties.get("server_cert", ""),
+            extra=self._properties.get("extra", "{}"),
+            impersonate_user=self._properties.get("impersonate_user", False),
+            encrypted_extra=self._properties.get("encrypted_extra", "{}"),
+        )
+        database.set_sqlalchemy_uri(sqlalchemy_uri)
+        database.db_engine_spec.mutate_db_for_connection_test(database)
+        username = self._actor.username if self._actor is not None else None
+        engine = database.get_sqla_engine(user_name=username)
+        try:
+            with closing(engine.raw_connection()) as conn:
+                alive = engine.dialect.do_ping(conn)
+        except Exception as ex:  # pylint: disable=broad-except
+            url = make_url(sqlalchemy_uri)
+            context = {
+                "hostname": url.host,
+                "password": url.password,
+                "port": url.port,
+                "username": url.username,
+                "database": url.database,
+            }
+            errors = database.db_engine_spec.extract_errors(ex, context)
+            raise DatabaseTestConnectionFailedError(errors)
+
+        if not alive:
+            raise DatabaseOfflineError(
+                SupersetError(
+                    message=__("Database is offline."),
+                    error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+                    level=ErrorLevel.ERROR,
+                ),
+            )
+
+    def validate(self) -> None:
+        database_name = self._properties.get("database_name")
+        if database_name is not None:
+            self._model = DatabaseDAO.get_database_by_name(database_name)
diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py
index b39e2b1..ffe4a8a 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -213,9 +213,9 @@ class DatabaseParametersSchemaMixin:
     """
     Allow SQLAlchemy URI to be passed as separate parameters.
 
-    This mixing is a first step in allowing the users to test, create and
+    This mixin is a first step in allowing the users to test, create and
     edit databases without having to know how to write a SQLAlchemy URI.
-    Instead, each databases defines the parameters that it takes (eg,
+    Instead, each database defines the parameters that it takes (eg,
     username, password, host, etc.) and the SQLAlchemy URI is built from
     these parameters.
 
@@ -223,7 +223,7 @@ class DatabaseParametersSchemaMixin:
     """
 
     parameters = fields.Dict(
-        keys=fields.Str(),
+        keys=fields.String(),
         values=fields.Raw(),
         description="DB-specific parameters for configuration",
     )
@@ -270,10 +270,34 @@ class DatabaseParametersSchemaMixin:
                     ]
                 )
 
-            data["sqlalchemy_uri"] = 
engine_spec.build_sqlalchemy_url(parameters)
+            data["sqlalchemy_uri"] = 
engine_spec.build_sqlalchemy_uri(parameters)
         return data
 
 
+class DatabaseValidateParametersSchema(Schema):
+    engine = fields.String(required=True, description="SQLAlchemy engine to 
use")
+    parameters = fields.Dict(
+        keys=fields.String(),
+        values=fields.Raw(),
+        description="DB-specific parameters for configuration",
+    )
+    database_name = fields.String(
+        description=database_name_description, allow_none=True, 
validate=Length(1, 250),
+    )
+    impersonate_user = fields.Boolean(description=impersonate_user_description)
+    extra = fields.String(description=extra_description, 
validate=extra_validator)
+    encrypted_extra = fields.String(
+        description=encrypted_extra_description,
+        validate=encrypted_extra_validator,
+        allow_none=True,
+    )
+    server_cert = fields.String(
+        description=server_cert_description,
+        allow_none=True,
+        validate=server_cert_validator,
+    )
+
+
 class DatabasePostSchema(Schema, DatabaseParametersSchemaMixin):
     class Meta:  # pylint: disable=too-few-public-methods
         unknown = EXCLUDE
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index 7473029..c60e6d7 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -63,6 +63,7 @@ from superset.sql_parse import ParsedQuery, Table
 from superset.utils import core as utils
 from superset.utils.core import ColumnSpec, GenericDataType
 from superset.utils.hashing import md5_sha_from_str
+from superset.utils.network import is_hostname_valid, is_port_open
 
 if TYPE_CHECKING:
     # prevent circular imports
@@ -269,7 +270,9 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
     max_column_name_length = 0
     try_remove_schema_from_table_name = True  # pylint: disable=invalid-name
     run_multiple_statements_as_one = False
-    custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType]] = {}
+    custom_errors: Dict[
+        Pattern[str], Tuple[str, SupersetErrorType, Dict[str, Any]]
+    ] = {}
 
     @classmethod
     def get_dbapi_exception_mapping(cls) -> Dict[Type[Exception], 
Type[Exception]]:
@@ -727,16 +730,17 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
         raw_message = cls._extract_error_message(ex)
 
         context = context or {}
-        for regex, (message, error_type) in cls.custom_errors.items():
+        for regex, (message, error_type, extra) in cls.custom_errors.items():
             match = regex.search(raw_message)
             if match:
                 params = {**context, **match.groupdict()}
+                extra["engine_name"] = cls.engine_name
                 return [
                     SupersetError(
                         error_type=error_type,
                         message=message % params,
                         level=ErrorLevel.ERROR,
-                        extra={"engine_name": cls.engine_name},
+                        extra=extra,
                     )
                 ]
 
@@ -1274,13 +1278,13 @@ class BaseEngineSpec:  # pylint: 
disable=too-many-public-methods
 # schema for adding a database by providing parameters instead of the
 # full SQLAlchemy URI
 class BaseParametersSchema(Schema):
-    username = fields.String(allow_none=True, description=__("Username"))
+    username = fields.String(required=True, allow_none=True, 
description=__("Username"))
     password = fields.String(allow_none=True, description=__("Password"))
     host = fields.String(required=True, description=__("Hostname or IP 
address"))
     port = fields.Integer(required=True, description=__("Database port"))
     database = fields.String(required=True, description=__("Database name"))
     query = fields.Dict(
-        keys=fields.Str(), values=fields.Raw(), description=__("Additinal 
parameters")
+        keys=fields.Str(), values=fields.Raw(), description=__("Additional 
parameters")
     )
 
 
@@ -1318,7 +1322,7 @@ class BaseParametersMixin:
     )
 
     @classmethod
-    def build_sqlalchemy_url(cls, parameters: BaseParametersType) -> str:
+    def build_sqlalchemy_uri(cls, parameters: BaseParametersType) -> str:
         return str(
             URL(
                 cls.drivername,
@@ -1331,8 +1335,8 @@ class BaseParametersMixin:
             )
         )
 
-    @classmethod
-    def get_parameters_from_uri(cls, uri: str) -> BaseParametersType:
+    @staticmethod
+    def get_parameters_from_uri(uri: str) -> BaseParametersType:
         url = make_url(uri)
         return {
             "username": url.username,
@@ -1344,6 +1348,59 @@ class BaseParametersMixin:
         }
 
     @classmethod
+    def validate_parameters(cls, parameters: BaseParametersType) -> 
List[SupersetError]:
+        """
+        Validates any number of parameters, for progressive validation.
+
+        If only the hostname is present it will check if the name is 
resolvable. As more
+        parameters are present in the request, more validation is done.
+        """
+        errors: List[SupersetError] = []
+
+        required = {"host", "port", "username", "database"}
+        present = {key for key in parameters if parameters[key]}  # type: 
ignore
+        missing = sorted(required - present)
+
+        if missing:
+            errors.append(
+                SupersetError(
+                    message=f'One or more parameters are missing: {", 
".join(missing)}',
+                    
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
+                    level=ErrorLevel.WARNING,
+                    extra={"missing": missing},
+                ),
+            )
+
+        host = parameters["host"]
+        if not host:
+            return errors
+        if not is_hostname_valid(host):
+            errors.append(
+                SupersetError(
+                    message="The hostname provided can't be resolved.",
+                    
error_type=SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+                    level=ErrorLevel.ERROR,
+                    extra={"invalid": ["host"]},
+                ),
+            )
+            return errors
+
+        port = parameters["port"]
+        if not port:
+            return errors
+        if not is_port_open(host, port):
+            errors.append(
+                SupersetError(
+                    message="The port is closed.",
+                    error_type=SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
+                    level=ErrorLevel.ERROR,
+                    extra={"invalid": ["port"]},
+                ),
+            )
+
+        return errors
+
+    @classmethod
     def parameters_json_schema(cls) -> Any:
         """
         Return configuration parameters as OpenAPI.
diff --git a/superset/db_engine_specs/bigquery.py 
b/superset/db_engine_specs/bigquery.py
index 7b8fa84..9c52b44 100644
--- a/superset/db_engine_specs/bigquery.py
+++ b/superset/db_engine_specs/bigquery.py
@@ -16,7 +16,7 @@
 # under the License.
 import re
 from datetime import datetime
-from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
+from typing import Any, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING
 
 import pandas as pd
 from flask_babel import gettext as __
@@ -95,7 +95,7 @@ class BigQueryEngineSpec(BaseEngineSpec):
         "P1Y": "{func}({col}, YEAR)",
     }
 
-    custom_errors = {
+    custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType, Dict[str, 
Any]]] = {
         CONNECTION_DATABASE_PERMISSIONS_REGEX: (
             __(
                 "We were unable to connect to your database. Please "
@@ -103,6 +103,7 @@ class BigQueryEngineSpec(BaseEngineSpec):
                 "and Job User roles on the project."
             ),
             SupersetErrorType.CONNECTION_DATABASE_PERMISSIONS_ERROR,
+            {},
         ),
     }
 
diff --git a/superset/db_engine_specs/cockroachdb.py 
b/superset/db_engine_specs/cockroachdb.py
index f2f00c1..80b547b 100644
--- a/superset/db_engine_specs/cockroachdb.py
+++ b/superset/db_engine_specs/cockroachdb.py
@@ -20,3 +20,4 @@ from superset.db_engine_specs.postgres import 
PostgresEngineSpec
 class CockroachDbEngineSpec(PostgresEngineSpec):
     engine = "cockroachdb"
     engine_name = "CockroachDB"
+    drivername = "cockroach"
diff --git a/superset/db_engine_specs/mssql.py 
b/superset/db_engine_specs/mssql.py
index 9e089d4..c24d40d 100644
--- a/superset/db_engine_specs/mssql.py
+++ b/superset/db_engine_specs/mssql.py
@@ -17,7 +17,7 @@
 import logging
 import re
 from datetime import datetime
-from typing import Any, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Pattern, Tuple
 
 from flask_babel import gettext as __
 
@@ -64,21 +64,24 @@ class MssqlEngineSpec(BaseEngineSpec):
         "P1Y": "DATEADD(year, DATEDIFF(year, 0, {col}), 0)",
     }
 
-    custom_errors = {
+    custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType, Dict[str, 
Any]]] = {
         CONNECTION_ACCESS_DENIED_REGEX: (
             __(
                 'Either the username "%(username)s", password, '
                 'or database name "%(database)s" is incorrect.'
             ),
             SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+            {},
         ),
         CONNECTION_INVALID_HOSTNAME_REGEX: (
             __('The hostname "%(hostname)s" cannot be resolved.'),
             SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+            {},
         ),
         CONNECTION_PORT_CLOSED_REGEX: (
             __('Port %(port)s on hostname "%(hostname)s" refused the 
connection.'),
             SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
+            {},
         ),
         CONNECTION_HOST_DOWN_REGEX: (
             __(
@@ -86,6 +89,7 @@ class MssqlEngineSpec(BaseEngineSpec):
                 "reached on port %(port)s."
             ),
             SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+            {},
         ),
     }
 
diff --git a/superset/db_engine_specs/mysql.py 
b/superset/db_engine_specs/mysql.py
index a5d4a16..d5e5a5c 100644
--- a/superset/db_engine_specs/mysql.py
+++ b/superset/db_engine_specs/mysql.py
@@ -107,22 +107,26 @@ class MySQLEngineSpec(BaseEngineSpec):
 
     type_code_map: Dict[int, str] = {}  # loaded from get_datatype only if 
needed
 
-    custom_errors = {
+    custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType, Dict[str, 
Any]]] = {
         CONNECTION_ACCESS_DENIED_REGEX: (
             __('Either the username "%(username)s" or the password is 
incorrect.'),
             SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+            {},
         ),
         CONNECTION_INVALID_HOSTNAME_REGEX: (
             __('Unknown MySQL server host "%(hostname)s".'),
             SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+            {},
         ),
         CONNECTION_HOST_DOWN_REGEX: (
             __('The host "%(hostname)s" might be down and can\'t be reached.'),
             SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+            {},
         ),
         CONNECTION_UNKNOWN_DATABASE_REGEX: (
             __('Unable to connect to database "%(database)s".'),
             SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
+            {},
         ),
     }
 
diff --git a/superset/db_engine_specs/postgres.py 
b/superset/db_engine_specs/postgres.py
index abca36d..8caba10 100644
--- a/superset/db_engine_specs/postgres.py
+++ b/superset/db_engine_specs/postgres.py
@@ -104,22 +104,27 @@ class PostgresBaseEngineSpec(BaseEngineSpec):
         CONNECTION_INVALID_USERNAME_REGEX: (
             __('The username "%(username)s" does not exist.'),
             SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR,
+            {"invalid": ["username"]},
         ),
         CONNECTION_INVALID_PASSWORD_REGEX: (
             __('The password provided for username "%(username)s" is 
incorrect.'),
             SupersetErrorType.CONNECTION_INVALID_PASSWORD_ERROR,
+            {"invalid": ["username", "password"]},
         ),
         CONNECTION_INVALID_PASSWORD_NEEDED_REGEX: (
             __("Please re-enter the password."),
             SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+            {"invalid": ["password"]},
         ),
         CONNECTION_INVALID_HOSTNAME_REGEX: (
             __('The hostname "%(hostname)s" cannot be resolved.'),
             SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+            {"invalid": ["host"]},
         ),
         CONNECTION_PORT_CLOSED_REGEX: (
             __('Port %(port)s on hostname "%(hostname)s" refused the 
connection.'),
             SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
+            {"invalid": ["host", "port"]},
         ),
         CONNECTION_HOST_DOWN_REGEX: (
             __(
@@ -127,10 +132,12 @@ class PostgresBaseEngineSpec(BaseEngineSpec):
                 "reached on port %(port)s."
             ),
             SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+            {"invalid": ["host", "port"]},
         ),
         CONNECTION_UNKNOWN_DATABASE_REGEX: (
             __('Unable to connect to database "%(database)s".'),
             SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
+            {"invalid": ["database"]},
         ),
     }
 
diff --git a/superset/db_engine_specs/presto.py 
b/superset/db_engine_specs/presto.py
index 32741b1..3ffe459 100644
--- a/superset/db_engine_specs/presto.py
+++ b/superset/db_engine_specs/presto.py
@@ -165,13 +165,14 @@ class PrestoEngineSpec(BaseEngineSpec):  # pylint: 
disable=too-many-public-metho
         "date_add('day', 1, CAST({col} AS TIMESTAMP))))",
     }
 
-    custom_errors = {
+    custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType, Dict[str, 
Any]]] = {
         COLUMN_DOES_NOT_EXIST_REGEX: (
             __(
                 'We can\'t seem to resolve the column "%(column_name)s" at '
                 "line %(location)s.",
             ),
             SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR,
+            {},
         ),
         TABLE_DOES_NOT_EXIST_REGEX: (
             __(
@@ -179,6 +180,7 @@ class PrestoEngineSpec(BaseEngineSpec):  # pylint: 
disable=too-many-public-metho
                 "A valid table must be used to run this query.",
             ),
             SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR,
+            {},
         ),
         SCHEMA_DOES_NOT_EXIST_REGEX: (
             __(
@@ -186,14 +188,17 @@ class PrestoEngineSpec(BaseEngineSpec):  # pylint: 
disable=too-many-public-metho
                 "A valid schema must be used to run this query.",
             ),
             SupersetErrorType.SCHEMA_DOES_NOT_EXIST_ERROR,
+            {},
         ),
         CONNECTION_ACCESS_DENIED_REGEX: (
             __('Either the username "%(username)s" or the password is 
incorrect.'),
             SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+            {},
         ),
         CONNECTION_INVALID_HOSTNAME_REGEX: (
             __('The hostname "%(hostname)s" cannot be resolved.'),
             SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+            {},
         ),
         CONNECTION_HOST_DOWN_REGEX: (
             __(
@@ -201,14 +206,17 @@ class PrestoEngineSpec(BaseEngineSpec):  # pylint: 
disable=too-many-public-metho
                 "reached on port %(port)s."
             ),
             SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+            {},
         ),
         CONNECTION_PORT_CLOSED_REGEX: (
             __('Port %(port)s on hostname "%(hostname)s" refused the 
connection.'),
             SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
+            {},
         ),
         CONNECTION_UNKNOWN_DATABASE_ERROR: (
             __('Unable to connect to catalog named "%(catalog_name)s".'),
             SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
+            {},
         ),
     }
 
diff --git a/superset/db_engine_specs/redshift.py 
b/superset/db_engine_specs/redshift.py
index 60953f5..f2d2652 100644
--- a/superset/db_engine_specs/redshift.py
+++ b/superset/db_engine_specs/redshift.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 import re
+from typing import Any, Dict, Pattern, Tuple
 
 from flask_babel import gettext as __
 
@@ -49,18 +50,21 @@ class RedshiftEngineSpec(PostgresBaseEngineSpec):
     engine_name = "Amazon Redshift"
     max_column_name_length = 127
 
-    custom_errors = {
+    custom_errors: Dict[Pattern[str], Tuple[str, SupersetErrorType, Dict[str, 
Any]]] = {
         CONNECTION_ACCESS_DENIED_REGEX: (
             __('Either the username "%(username)s" or the password is 
incorrect.'),
             SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
+            {},
         ),
         CONNECTION_INVALID_HOSTNAME_REGEX: (
             __('The hostname "%(hostname)s" cannot be resolved.'),
             SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+            {},
         ),
         CONNECTION_PORT_CLOSED_REGEX: (
             __('Port %(port)s on hostname "%(hostname)s" refused the 
connection.'),
             SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
+            {},
         ),
         CONNECTION_HOST_DOWN_REGEX: (
             __(
@@ -68,6 +72,7 @@ class RedshiftEngineSpec(PostgresBaseEngineSpec):
                 "reached on port %(port)s."
             ),
             SupersetErrorType.CONNECTION_HOST_DOWN_ERROR,
+            {},
         ),
         CONNECTION_UNKNOWN_DATABASE_REGEX: (
             __(
@@ -75,6 +80,7 @@ class RedshiftEngineSpec(PostgresBaseEngineSpec):
                 " Please verify your database name and try again."
             ),
             SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR,
+            {},
         ),
     }
 
diff --git a/superset/errors.py b/superset/errors.py
index be05944..961cca4 100644
--- a/superset/errors.py
+++ b/superset/errors.py
@@ -48,6 +48,7 @@ class SupersetErrorType(str, Enum):
     CONNECTION_ACCESS_DENIED_ERROR = "CONNECTION_ACCESS_DENIED_ERROR"
     CONNECTION_UNKNOWN_DATABASE_ERROR = "CONNECTION_UNKNOWN_DATABASE_ERROR"
     CONNECTION_DATABASE_PERMISSIONS_ERROR = 
"CONNECTION_DATABASE_PERMISSIONS_ERROR"
+    CONNECTION_MISSING_PARAMETERS_ERROR = "CONNECTION_MISSING_PARAMETERS_ERROR"
 
     # Viz errors
     VIZ_GET_DF_ERROR = "VIZ_GET_DF_ERROR"
@@ -70,6 +71,10 @@ class SupersetErrorType(str, Enum):
     GENERIC_COMMAND_ERROR = "GENERIC_COMMAND_ERROR"
     GENERIC_BACKEND_ERROR = "GENERIC_BACKEND_ERROR"
 
+    # API errors
+    INVALID_PAYLOAD_FORMAT_ERROR = "INVALID_PAYLOAD_FORMAT_ERROR"
+    INVALID_PAYLOAD_SCHEMA_ERROR = "INVALID_PAYLOAD_SCHEMA_ERROR"
+
 
 ERROR_TYPES_TO_ISSUE_CODES_MAPPING = {
     SupersetErrorType.BACKEND_TIMEOUT_ERROR: [
@@ -220,6 +225,31 @@ ERROR_TYPES_TO_ISSUE_CODES_MAPPING = {
             "message": _("Issue 1017 - User doesn't have the proper 
permissions."),
         },
     ],
+    SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR: [
+        {
+            "code": 1018,
+            "message": _(
+                "Issue 1018 - One or more parameters needed to configure a "
+                "database are missing."
+            ),
+        },
+    ],
+    SupersetErrorType.INVALID_PAYLOAD_FORMAT_ERROR: [
+        {
+            "code": 1019,
+            "message": _(
+                "Issue 1019 - The submitted payload has the incorrect format."
+            ),
+        }
+    ],
+    SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR: [
+        {
+            "code": 1020,
+            "message": _(
+                "Issue 1020 - The submitted payload has the incorrect schema."
+            ),
+        }
+    ],
 }
 
 
diff --git a/superset/exceptions.py b/superset/exceptions.py
index aff21a6..2926817 100644
--- a/superset/exceptions.py
+++ b/superset/exceptions.py
@@ -17,6 +17,7 @@
 from typing import Any, Dict, List, Optional
 
 from flask_babel import gettext as _
+from marshmallow import ValidationError
 
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 
@@ -155,3 +156,29 @@ class DashboardImportException(SupersetException):
 
 class SerializationError(SupersetException):
     pass
+
+
+class InvalidPayloadFormatError(SupersetErrorException):
+    status = 400
+
+    def __init__(self, message: str = "Request payload has incorrect format"):
+        error = SupersetError(
+            message=message,
+            error_type=SupersetErrorType.INVALID_PAYLOAD_FORMAT_ERROR,
+            level=ErrorLevel.ERROR,
+            extra={},
+        )
+        super().__init__(error)
+
+
+class InvalidPayloadSchemaError(SupersetErrorException):
+    status = 422
+
+    def __init__(self, error: ValidationError):
+        error = SupersetError(
+            message="An error happened when validating the request",
+            error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR,
+            level=ErrorLevel.ERROR,
+            extra={"messages": error.messages},
+        )
+        super().__init__(error)
diff --git a/tests/databases/api_tests.py b/tests/databases/api_tests.py
index 1ce922f..d14f790 100644
--- a/tests/databases/api_tests.py
+++ b/tests/databases/api_tests.py
@@ -1290,7 +1290,7 @@ class TestDatabaseApi(SupersetTestCase):
                             },
                             "query": {
                                 "additionalProperties": {},
-                                "description": "Additinal parameters",
+                                "description": "Additional parameters",
                                 "type": "object",
                             },
                             "username": {
@@ -1299,7 +1299,7 @@ class TestDatabaseApi(SupersetTestCase):
                                 "type": "string",
                             },
                         },
-                        "required": ["database", "host", "port"],
+                        "required": ["database", "host", "port", "username"],
                         "type": "object",
                     },
                     "preferred": True,
@@ -1308,3 +1308,149 @@ class TestDatabaseApi(SupersetTestCase):
                 {"engine": "mysql", "name": "MySQL", "preferred": False},
             ]
         }
+
+    def test_validate_parameters_invalid_payload_format(self):
+        self.login(username="admin")
+        url = "api/v1/database/validate_parameters"
+        rv = self.client.post(url, data="INVALID", content_type="text/plain")
+        response = json.loads(rv.data.decode("utf-8"))
+
+        assert rv.status_code == 400
+        assert response == {
+            "errors": [
+                {
+                    "message": "Request is not JSON",
+                    "error_type": "INVALID_PAYLOAD_FORMAT_ERROR",
+                    "level": "error",
+                    "extra": {
+                        "issue_codes": [
+                            {
+                                "code": 1019,
+                                "message": "Issue 1019 - The submitted payload 
has the incorrect format.",
+                            }
+                        ]
+                    },
+                }
+            ]
+        }
+
+    def test_validate_parameters_invalid_payload_schema(self):
+        self.login(username="admin")
+        url = "api/v1/database/validate_parameters"
+        payload = {"foo": "bar"}
+        rv = self.client.post(url, json=payload)
+        response = json.loads(rv.data.decode("utf-8"))
+
+        assert rv.status_code == 422
+        assert response == {
+            "errors": [
+                {
+                    "message": "An error happened when validating the request",
+                    "error_type": "INVALID_PAYLOAD_SCHEMA_ERROR",
+                    "level": "error",
+                    "extra": {
+                        "messages": {
+                            "engine": ["Missing data for required field."],
+                            "foo": ["Unknown field."],
+                        },
+                        "issue_codes": [
+                            {
+                                "code": 1020,
+                                "message": "Issue 1020 - The submitted payload 
has the incorrect schema.",
+                            }
+                        ],
+                    },
+                }
+            ]
+        }
+
+    def test_validate_parameters_missing_fields(self):
+        self.login(username="admin")
+        url = "api/v1/database/validate_parameters"
+        payload = {
+            "engine": "postgresql",
+            "parameters": {
+                "host": "",
+                "port": 5432,
+                "username": "",
+                "password": "",
+                "database": "",
+                "query": {},
+            },
+        }
+        rv = self.client.post(url, json=payload)
+        response = json.loads(rv.data.decode("utf-8"))
+
+        assert rv.status_code == 422
+        assert response == {
+            "errors": [
+                {
+                    "message": "One or more parameters are missing: database, 
host, username",
+                    "error_type": "CONNECTION_MISSING_PARAMETERS_ERROR",
+                    "level": "warning",
+                    "extra": {
+                        "missing": ["database", "host", "username"],
+                        "issue_codes": [
+                            {
+                                "code": 1018,
+                                "message": "Issue 1018 - One or more 
parameters needed to configure a database are missing.",
+                            }
+                        ],
+                    },
+                }
+            ]
+        }
+
+    @mock.patch("superset.db_engine_specs.base.is_hostname_valid")
+    def test_validate_parameters_invalid_host(self, is_hostname_valid):
+        is_hostname_valid.return_value = False
+
+        self.login(username="admin")
+        url = "api/v1/database/validate_parameters"
+        payload = {
+            "engine": "postgresql",
+            "parameters": {
+                "host": "localhost",
+                "port": 5432,
+                "username": "",
+                "password": "",
+                "database": "",
+                "query": {},
+            },
+        }
+        rv = self.client.post(url, json=payload)
+        response = json.loads(rv.data.decode("utf-8"))
+
+        assert rv.status_code == 422
+        assert response == {
+            "errors": [
+                {
+                    "message": "One or more parameters are missing: database, 
username",
+                    "error_type": "CONNECTION_MISSING_PARAMETERS_ERROR",
+                    "level": "warning",
+                    "extra": {
+                        "missing": ["database", "username"],
+                        "issue_codes": [
+                            {
+                                "code": 1018,
+                                "message": "Issue 1018 - One or more 
parameters needed to configure a database are missing.",
+                            }
+                        ],
+                    },
+                },
+                {
+                    "message": "The hostname provided can't be resolved.",
+                    "error_type": "CONNECTION_INVALID_HOSTNAME_ERROR",
+                    "level": "error",
+                    "extra": {
+                        "invalid": ["host"],
+                        "issue_codes": [
+                            {
+                                "code": 1007,
+                                "message": "Issue 1007 - The hostname provided 
can't be resolved.",
+                            }
+                        ],
+                    },
+                },
+            ]
+        }
diff --git a/tests/databases/commands_tests.py 
b/tests/databases/commands_tests.py
index da475d1..e7a3806 100644
--- a/tests/databases/commands_tests.py
+++ b/tests/databases/commands_tests.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 # pylint: disable=no-self-use, invalid-name
-from unittest import mock
+from unittest import mock, skip
 from unittest.mock import patch
 
 import pytest
@@ -36,9 +36,10 @@ from superset.databases.commands.exceptions import (
 from superset.databases.commands.export import ExportDatabasesCommand
 from superset.databases.commands.importers.v1 import ImportDatabasesCommand
 from superset.databases.commands.test_connection import 
TestConnectionDatabaseCommand
+from superset.databases.commands.validate import 
ValidateDatabaseParametersCommand
 from superset.databases.schemas import DatabaseTestConnectionSchema
-from superset.errors import SupersetError, SupersetErrorType
-from superset.exceptions import SupersetSecurityException
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import SupersetErrorsException, 
SupersetSecurityException
 from superset.models.core import Database
 from superset.utils.core import backend, get_example_database
 from tests.base_tests import SupersetTestCase
@@ -53,6 +54,7 @@ from tests.fixtures.importexport import (
 
 
 class TestExportDatabasesCommand(SupersetTestCase):
+    @skip("Flaky")
     @patch("superset.security.manager.g")
     @pytest.mark.usefixtures(
         "load_birth_names_dashboard_with_slices", 
"load_energy_table_with_slice"
@@ -620,3 +622,122 @@ class TestTestConnectionDatabaseCommand(SupersetTestCase):
             )
 
         mock_event_logger.assert_called()
+
+
[email protected]("superset.db_engine_specs.base.is_hostname_valid")
[email protected]("superset.db_engine_specs.base.is_port_open")
[email protected]("superset.databases.commands.validate.DatabaseDAO")
+def test_validate(DatabaseDAO, is_port_open, is_hostname_valid, app_context):
+    """
+    Test parameter validation.
+    """
+    is_hostname_valid.return_value = True
+    is_port_open.return_value = True
+
+    payload = {
+        "engine": "postgresql",
+        "parameters": {
+            "host": "localhost",
+            "port": 5432,
+            "username": "superset",
+            "password": "superset",
+            "database": "test",
+            "query": {},
+        },
+    }
+    command = ValidateDatabaseParametersCommand(None, payload)
+    command.run()
+
+
[email protected]("superset.db_engine_specs.base.is_hostname_valid")
[email protected]("superset.db_engine_specs.base.is_port_open")
+def test_validate_partial(is_port_open, is_hostname_valid, app_context):
+    """
+    Test parameter validation when only some parameters are present.
+    """
+    is_hostname_valid.return_value = True
+    is_port_open.return_value = True
+
+    payload = {
+        "engine": "postgresql",
+        "parameters": {
+            "host": "localhost",
+            "port": 5432,
+            "username": "",
+            "password": "superset",
+            "database": "test",
+            "query": {},
+        },
+    }
+    command = ValidateDatabaseParametersCommand(None, payload)
+    with pytest.raises(SupersetErrorsException) as excinfo:
+        command.run()
+    assert excinfo.value.errors == [
+        SupersetError(
+            message="One or more parameters are missing: username",
+            error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
+            level=ErrorLevel.WARNING,
+            extra={
+                "missing": ["username"],
+                "issue_codes": [
+                    {
+                        "code": 1018,
+                        "message": "Issue 1018 - One or more parameters needed 
to configure a database are missing.",
+                    }
+                ],
+            },
+        )
+    ]
+
+
[email protected]("superset.db_engine_specs.base.is_hostname_valid")
+def test_validate_partial_invalid_hostname(is_hostname_valid, app_context):
+    """
+    Test parameter validation when only some parameters are present.
+    """
+    is_hostname_valid.return_value = False
+
+    payload = {
+        "engine": "postgresql",
+        "parameters": {
+            "host": "localhost",
+            "port": None,
+            "username": "",
+            "password": "",
+            "database": "",
+            "query": {},
+        },
+    }
+    command = ValidateDatabaseParametersCommand(None, payload)
+    with pytest.raises(SupersetErrorsException) as excinfo:
+        command.run()
+    assert excinfo.value.errors == [
+        SupersetError(
+            message="One or more parameters are missing: database, port, 
username",
+            error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
+            level=ErrorLevel.WARNING,
+            extra={
+                "missing": ["database", "port", "username"],
+                "issue_codes": [
+                    {
+                        "code": 1018,
+                        "message": "Issue 1018 - One or more parameters needed 
to configure a database are missing.",
+                    }
+                ],
+            },
+        ),
+        SupersetError(
+            message="The hostname provided can't be resolved.",
+            error_type=SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+            level=ErrorLevel.ERROR,
+            extra={
+                "invalid": ["host"],
+                "issue_codes": [
+                    {
+                        "code": 1007,
+                        "message": "Issue 1007 - The hostname provided can't 
be resolved.",
+                    }
+                ],
+            },
+        ),
+    ]
diff --git a/tests/db_engine_specs/base_engine_spec_tests.py 
b/tests/db_engine_specs/base_engine_spec_tests.py
index e1ca7d3..164b3bb 100644
--- a/tests/db_engine_specs/base_engine_spec_tests.py
+++ b/tests/db_engine_specs/base_engine_spec_tests.py
@@ -22,11 +22,13 @@ import pytest
 from superset.db_engine_specs import get_engine_specs
 from superset.db_engine_specs.base import (
     BaseEngineSpec,
+    BaseParametersMixin,
     builtin_time_grains,
     LimitMethod,
 )
 from superset.db_engine_specs.mysql import MySQLEngineSpec
 from superset.db_engine_specs.sqlite import SqliteEngineSpec
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.sql_parse import ParsedQuery
 from superset.utils.core import get_example_database
 from tests.db_engine_specs.base_tests import TestDbEngineSpec
@@ -365,3 +367,102 @@ def test_get_time_grain_with_unkown_values():
         assert list(time_grains)[-1] == "weird"
 
     app.config = config
+
+
[email protected]("superset.db_engine_specs.base.is_hostname_valid")
[email protected]("superset.db_engine_specs.base.is_port_open")
+def test_validate(is_port_open, is_hostname_valid):
+    is_hostname_valid.return_value = True
+    is_port_open.return_value = True
+
+    parameters = {
+        "host": "localhost",
+        "port": 5432,
+        "username": "username",
+        "password": "password",
+        "database": "dbname",
+        "query": {"sslmode": "verify-full"},
+    }
+    errors = BaseParametersMixin.validate_parameters(parameters)
+    assert errors == []
+
+
+def test_validate_parameters_missing():
+    parameters = {
+        "host": "",
+        "port": None,
+        "username": "",
+        "password": "",
+        "database": "",
+        "query": {},
+    }
+    errors = BaseParametersMixin.validate_parameters(parameters)
+    assert errors == [
+        SupersetError(
+            message=(
+                "One or more parameters are missing: " "database, host, port, 
username"
+            ),
+            error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
+            level=ErrorLevel.WARNING,
+            extra={"missing": ["database", "host", "port", "username"]},
+        ),
+    ]
+
+
[email protected]("superset.db_engine_specs.base.is_hostname_valid")
+def test_validate_parameters_invalid_host(is_hostname_valid):
+    is_hostname_valid.return_value = False
+
+    parameters = {
+        "host": "localhost",
+        "port": None,
+        "username": "username",
+        "password": "password",
+        "database": "dbname",
+        "query": {"sslmode": "verify-full"},
+    }
+    errors = BaseParametersMixin.validate_parameters(parameters)
+    assert errors == [
+        SupersetError(
+            message="One or more parameters are missing: port",
+            error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
+            level=ErrorLevel.WARNING,
+            extra={"missing": ["port"]},
+        ),
+        SupersetError(
+            message="The hostname provided can't be resolved.",
+            error_type=SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
+            level=ErrorLevel.ERROR,
+            extra={"invalid": ["host"]},
+        ),
+    ]
+
+
[email protected]("superset.db_engine_specs.base.is_hostname_valid")
[email protected]("superset.db_engine_specs.base.is_port_open")
+def test_validate_parameters_port_closed(is_port_open, is_hostname_valid):
+    is_hostname_valid.return_value = True
+    is_port_open.return_value = False
+
+    parameters = {
+        "host": "localhost",
+        "port": 5432,
+        "username": "username",
+        "password": "password",
+        "database": "dbname",
+        "query": {"sslmode": "verify-full"},
+    }
+    errors = BaseParametersMixin.validate_parameters(parameters)
+    assert errors == [
+        SupersetError(
+            message="The port is closed.",
+            error_type=SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
+            level=ErrorLevel.ERROR,
+            extra={
+                "invalid": ["port"],
+                "issue_codes": [
+                    {"code": 1008, "message": "Issue 1008 - The port is 
closed."}
+                ],
+            },
+        )
+    ]
diff --git a/tests/db_engine_specs/postgres_tests.py 
b/tests/db_engine_specs/postgres_tests.py
index 1dc044e..11d09f1 100644
--- a/tests/db_engine_specs/postgres_tests.py
+++ b/tests/db_engine_specs/postgres_tests.py
@@ -23,6 +23,7 @@ from sqlalchemy.dialects import postgresql
 from superset.db_engine_specs import get_engine_specs
 from superset.db_engine_specs.postgres import PostgresEngineSpec
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.utils.core import get_example_database
 from tests.db_engine_specs.base_tests import TestDbEngineSpec
 from tests.fixtures.certificates import ssl_certificate
 from tests.fixtures.database import default_db_extra
@@ -234,6 +235,7 @@ class TestPostgresDbEngineSpec(TestDbEngineSpec):
                             ),
                         },
                     ],
+                    "invalid": ["username"],
                 },
             )
         ]
@@ -257,6 +259,7 @@ class TestPostgresDbEngineSpec(TestDbEngineSpec):
                             "can't be resolved.",
                         }
                     ],
+                    "invalid": ["host"],
                 },
             )
         ]
@@ -282,6 +285,7 @@ could not connect to server: Connection refused
                     "issue_codes": [
                         {"code": 1008, "message": "Issue 1008 - The port is 
closed."}
                     ],
+                    "invalid": ["host", "port"],
                 },
             )
         ]
@@ -311,6 +315,7 @@ psql: error: could not connect to server: Operation timed 
out
                             "and can't be reached on the provided port.",
                         }
                     ],
+                    "invalid": ["host", "port"],
                 },
             )
         ]
@@ -341,6 +346,7 @@ psql: error: could not connect to server: Operation timed 
out
                             "and can't be reached on the provided port.",
                         }
                     ],
+                    "invalid": ["host", "port"],
                 },
             )
         ]
@@ -363,6 +369,7 @@ psql: error: could not connect to server: Operation timed 
out
                             ),
                         },
                     ],
+                    "invalid": ["username", "password"],
                 },
             )
         ]
@@ -385,6 +392,7 @@ psql: error: could not connect to server: Operation timed 
out
                             ),
                         }
                     ],
+                    "invalid": ["database"],
                 },
             )
         ]
@@ -393,21 +401,20 @@ psql: error: could not connect to server: Operation timed 
out
         result = PostgresEngineSpec.extract_errors(Exception(msg))
         assert result == [
             SupersetError(
-                error_type=SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
                 message="Please re-enter the password.",
+                error_type=SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR,
                 level=ErrorLevel.ERROR,
                 extra={
+                    "invalid": ["password"],
                     "engine_name": "PostgreSQL",
                     "issue_codes": [
                         {
                             "code": 1014,
-                            "message": "Issue 1014 - Either the"
-                            " username or the password is wrong.",
+                            "message": "Issue 1014 - Either the username or 
the password is wrong.",
                         },
                         {
                             "code": 1015,
-                            "message": "Issue 1015 - Either the database is "
-                            "spelled incorrectly or does not exist.",
+                            "message": "Issue 1015 - Either the database is 
spelled incorrectly or does not exist.",
                         },
                     ],
                 },
@@ -424,7 +431,7 @@ def test_base_parameters_mixin():
         "database": "dbname",
         "query": {"foo": "bar"},
     }
-    sqlalchemy_uri = PostgresEngineSpec.build_sqlalchemy_url(parameters)
+    sqlalchemy_uri = PostgresEngineSpec.build_sqlalchemy_uri(parameters)
     assert (
         sqlalchemy_uri
         == 
"postgresql+psycopg2://username:password@localhost:5432/dbname?foo=bar"
@@ -437,20 +444,20 @@ def test_base_parameters_mixin():
     assert json_schema == {
         "type": "object",
         "properties": {
+            "port": {
+                "type": "integer",
+                "format": "int32",
+                "description": "Database port",
+            },
+            "password": {"type": "string", "nullable": True, "description": 
"Password"},
             "host": {"type": "string", "description": "Hostname or IP 
address"},
             "username": {"type": "string", "nullable": True, "description": 
"Username"},
-            "password": {"type": "string", "nullable": True, "description": 
"Password"},
-            "database": {"type": "string", "description": "Database name"},
             "query": {
                 "type": "object",
-                "description": "Additinal parameters",
+                "description": "Additional parameters",
                 "additionalProperties": {},
             },
-            "port": {
-                "type": "integer",
-                "format": "int32",
-                "description": "Database port",
-            },
+            "database": {"type": "string", "description": "Database name"},
         },
-        "required": ["database", "host", "port"],
+        "required": ["database", "host", "port", "username"],
     }

Reply via email to