You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2026/04/23 17:37:24 UTC

(superset) 01/33: feat: API for semantic layers

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

betodealmeida pushed a commit to branch backup/semantic-layer-dynamic-columns-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit d6d8e094453aa655062fe64d6daca2c122f3cbfc
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Feb 11 16:02:37 2026 -0500

    feat: API for semantic layers
---
 pyproject.toml                                     |   1 +
 superset/commands/semantic_layer/create.py         |  64 ++
 superset/commands/semantic_layer/delete.py         |  56 ++
 superset/commands/semantic_layer/exceptions.py     |  27 +
 superset/commands/semantic_layer/update.py         |  41 +-
 superset/daos/semantic_layer.py                    |  15 +
 superset/initialization/__init__.py                |   6 +-
 superset/semantic_layers/api.py                    | 363 +++++++++++-
 superset/semantic_layers/schemas.py                |  15 +
 .../commands/semantic_layer/create_test.py         | 148 +++++
 .../commands/semantic_layer/delete_test.py         |  50 ++
 .../commands/semantic_layer/exceptions_test.py     |  43 ++
 .../commands/semantic_layer/update_test.py         | 117 +++-
 tests/unit_tests/semantic_layers/api_test.py       | 651 +++++++++++++++++++++
 tests/unit_tests/semantic_layers/schemas_test.py   | 131 ++++-
 15 files changed, 1719 insertions(+), 9 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 70feeeb4d3e..d222e3ccece 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -285,6 +285,7 @@ module = [
     "superset.tags.filters",
     "superset.commands.security.update",
     "superset.commands.security.create",
+    "superset.semantic_layers.api",
 ]
 warn_unused_ignores = false
 
diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py
new file mode 100644
index 00000000000..48ec0603c2a
--- /dev/null
+++ b/superset/commands/semantic_layer/create.py
@@ -0,0 +1,64 @@
+# 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 __future__ import annotations
+
+import logging
+from functools import partial
+from typing import Any
+
+from flask_appbuilder.models.sqla import Model
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.commands.base import BaseCommand
+from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerInvalidError,
+)
+from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.semantic_layers.registry import registry
+from superset.utils.decorators import on_error, transaction
+
+logger = logging.getLogger(__name__)
+
+
+class CreateSemanticLayerCommand(BaseCommand):
+    def __init__(self, data: dict[str, Any]):
+        self._properties = data.copy()
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError, ValueError),
+            reraise=SemanticLayerCreateFailedError,
+        )
+    )
+    def run(self) -> Model:
+        self.validate()
+        return SemanticLayerDAO.create(attributes=self._properties)
+
+    def validate(self) -> None:
+        sl_type = self._properties.get("type")
+        if sl_type not in registry:
+            raise SemanticLayerInvalidError(f"Unknown type: {sl_type}")
+
+        name: str = self._properties.get("name", "")
+        if not SemanticLayerDAO.validate_uniqueness(name):
+            raise SemanticLayerInvalidError(f"Name already exists: {name}")
+
+        # Validate configuration against the plugin
+        cls = registry[sl_type]
+        cls.from_configuration(self._properties["configuration"])
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
new file mode 100644
index 00000000000..677126221a8
--- /dev/null
+++ b/superset/commands/semantic_layer/delete.py
@@ -0,0 +1,56 @@
+# 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 __future__ import annotations
+
+import logging
+from functools import partial
+
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.commands.base import BaseCommand
+from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerDeleteFailedError,
+    SemanticLayerNotFoundError,
+)
+from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.semantic_layers.models import SemanticLayer
+from superset.utils.decorators import on_error, transaction
+
+logger = logging.getLogger(__name__)
+
+
+class DeleteSemanticLayerCommand(BaseCommand):
+    def __init__(self, uuid: str):
+        self._uuid = uuid
+        self._model: SemanticLayer | None = None
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError,),
+            reraise=SemanticLayerDeleteFailedError,
+        )
+    )
+    def run(self) -> None:
+        self.validate()
+        assert self._model
+        SemanticLayerDAO.delete([self._model])
+
+    def validate(self) -> None:
+        self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
+        if not self._model:
+            raise SemanticLayerNotFoundError()
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index 3e2fad94752..1b82a65b841 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -19,6 +19,8 @@ from flask_babel import lazy_gettext as _
 from superset.commands.exceptions import (
     CommandException,
     CommandInvalidError,
+    CreateFailedError,
+    DeleteFailedError,
     ForbiddenError,
     UpdateFailedError,
 )
@@ -39,3 +41,28 @@ class SemanticViewInvalidError(CommandInvalidError):
 
 class SemanticViewUpdateFailedError(UpdateFailedError):
     message = _("Semantic view could not be updated.")
+
+
+class SemanticLayerNotFoundError(CommandException):
+    status = 404
+    message = _("Semantic layer does not exist")
+
+
+class SemanticLayerForbiddenError(ForbiddenError):
+    message = _("Changing this semantic layer is forbidden")
+
+
+class SemanticLayerInvalidError(CommandInvalidError):
+    message = _("Semantic layer parameters are invalid.")
+
+
+class SemanticLayerCreateFailedError(CreateFailedError):
+    message = _("Semantic layer could not be created.")
+
+
+class SemanticLayerUpdateFailedError(UpdateFailedError):
+    message = _("Semantic layer could not be updated.")
+
+
+class SemanticLayerDeleteFailedError(DeleteFailedError):
+    message = _("Semantic layer could not be deleted.")
diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/update.py
index 55fbff34125..7487e17996a 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/update.py
@@ -26,13 +26,17 @@ from sqlalchemy.exc import SQLAlchemyError
 from superset import security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewNotFoundError,
     SemanticViewUpdateFailedError,
 )
-from superset.daos.semantic_layer import SemanticViewDAO
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
 from superset.exceptions import SupersetSecurityException
-from superset.semantic_layers.models import SemanticView
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.semantic_layers.registry import registry
 from superset.utils import json
 from superset.utils.decorators import on_error, transaction
 
@@ -83,3 +87,36 @@ class UpdateSemanticViewCommand(BaseCommand):
                 f"A semantic view with name '{name}' and the same "
                 "configuration already exists in this semantic layer."
             )
+
+
+class UpdateSemanticLayerCommand(BaseCommand):
+    def __init__(self, uuid: str, data: dict[str, Any]):
+        self._uuid = uuid
+        self._properties = data.copy()
+        self._model: SemanticLayer | None = None
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError, ValueError),
+            reraise=SemanticLayerUpdateFailedError,
+        )
+    )
+    def run(self) -> Model:
+        self.validate()
+        assert self._model
+        return SemanticLayerDAO.update(self._model, attributes=self._properties)
+
+    def validate(self) -> None:
+        self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
+        if not self._model:
+            raise SemanticLayerNotFoundError()
+
+        name = self._properties.get("name")
+        if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
+            raise SemanticLayerInvalidError(f"Name already exists: {name}")
+
+        if configuration := self._properties.get("configuration"):
+            sl_type = self._model.type
+            cls = registry[sl_type]
+            cls.from_configuration(configuration)
diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py
index 3ae4d97c431..8782973561c 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -38,6 +38,21 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO):
 
     model_cls = SemanticLayer
 
+    @staticmethod
+    def find_by_uuid(uuid_str: str) -> SemanticLayer | None:
+        try:
+            return (
+                db.session.query(SemanticLayer)
+                .filter(SemanticLayer.uuid == uuid_str)
+                .one_or_none()
+            )
+        except ValueError:
+            return None
+
+    @classmethod
+    def find_all(cls, skip_base_filter: bool = False) -> list[SemanticLayer]:
+        return db.session.query(SemanticLayer).all()
+
     @classmethod
     def validate_uniqueness(cls, name: str) -> bool:
         """
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 62075dac5bc..c8ec9bbe516 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -269,8 +269,12 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         appbuilder.add_api(RLSRestApi)
         appbuilder.add_api(SavedQueryRestApi)
         if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
-            from superset.semantic_layers.api import SemanticViewRestApi
+            from superset.semantic_layers.api import (
+                SemanticLayerRestApi,
+                SemanticViewRestApi,
+            )
 
+            appbuilder.add_api(SemanticLayerRestApi)
             appbuilder.add_api(SemanticViewRestApi)
         appbuilder.add_api(TagRestApi)
         appbuilder.add_api(SqlLabRestApi)
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 6cf5ea0e5e9..eb10205106a 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -17,24 +17,43 @@
 from __future__ import annotations
 
 import logging
+from typing import Any
 
 from flask import request, Response
-from flask_appbuilder.api import expose, protect
+from flask_appbuilder.api import expose, protect, safe
 from flask_appbuilder.models.sqla.interface import SQLAInterface
 from marshmallow import ValidationError
 
 from superset import event_logger
+from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
+from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
     SemanticViewUpdateFailedError,
 )
-from superset.commands.semantic_layer.update import UpdateSemanticViewCommand
+from superset.commands.semantic_layer.update import (
+    UpdateSemanticLayerCommand,
+    UpdateSemanticViewCommand,
+)
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
-from superset.semantic_layers.models import SemanticView
-from superset.semantic_layers.schemas import SemanticViewPutSchema
+from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.semantic_layers.registry import registry
+from superset.semantic_layers.schemas import (
+    SemanticLayerPostSchema,
+    SemanticLayerPutSchema,
+    SemanticViewPutSchema,
+)
+from superset.superset_typing import FlaskResponse
 from superset.views.base_api import (
+    BaseSupersetApi,
     BaseSupersetModelRestApi,
     requires_json,
     statsd_metrics,
@@ -43,6 +62,16 @@ from superset.views.base_api import (
 logger = logging.getLogger(__name__)
 
 
+def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]:
+    return {
+        "uuid": str(layer.uuid),
+        "name": layer.name,
+        "description": layer.description,
+        "type": layer.type,
+        "cache_timeout": layer.cache_timeout,
+    }
+
+
 class SemanticViewRestApi(BaseSupersetModelRestApi):
     datamodel = SQLAInterface(SemanticView)
 
@@ -126,3 +155,329 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
             )
             response = self.response_422(message=str(ex))
         return response
+
+
+class SemanticLayerRestApi(BaseSupersetApi):
+    resource_name = "semantic_layer"
+    allow_browser_login = True
+    class_permission_name = "SemanticLayer"
+    method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
+    openapi_spec_tag = "Semantic Layers"
+
+    @expose("/types", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def types(self) -> FlaskResponse:
+        """List available semantic layer types.
+        ---
+        get:
+          summary: List available semantic layer types
+          responses:
+            200:
+              description: A list of semantic layer types
+            401:
+              $ref: '#/components/responses/401'
+        """
+        result = [
+            {"id": key, "name": cls.name, "description": cls.description}  # type: ignore[attr-defined]
+            for key, cls in registry.items()
+        ]
+        return self.response(200, result=result)
+
+    @expose("/schema/configuration", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @requires_json
+    def configuration_schema(self) -> FlaskResponse:
+        """Get configuration schema for a semantic layer type.
+        ---
+        post:
+          summary: Get configuration schema for a semantic layer type
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    type:
+                      type: string
+                    configuration:
+                      type: object
+          responses:
+            200:
+              description: Configuration JSON Schema
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+        """
+        body = request.json or {}
+        sl_type = body.get("type")
+
+        cls = registry.get(sl_type)  # type: ignore[arg-type]
+        if not cls:
+            return self.response_400(message=f"Unknown type: {sl_type}")
+
+        parsed_config = None
+        if config := body.get("configuration"):
+            try:
+                parsed_config = cls.from_configuration(config).configuration  # type: ignore[attr-defined]
+            except Exception:  # pylint: disable=broad-except
+                parsed_config = None
+
+        schema = cls.get_configuration_schema(parsed_config)
+        return self.response(200, result=schema)
+
+    @expose("/<uuid>/schema/runtime", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def runtime_schema(self, uuid: str) -> FlaskResponse:
+        """Get runtime schema for a stored semantic layer.
+        ---
+        post:
+          summary: Get runtime schema for a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    runtime_data:
+                      type: object
+          responses:
+            200:
+              description: Runtime JSON Schema
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+
+        body = request.get_json(silent=True) or {}
+        runtime_data = body.get("runtime_data")
+
+        cls = registry.get(layer.type)
+        if not cls:
+            return self.response_400(message=f"Unknown type: {layer.type}")
+
+        try:
+            schema = cls.get_runtime_schema(
+                layer.implementation.configuration,  # type: ignore[attr-defined]
+                runtime_data,
+            )
+        except Exception as ex:  # pylint: disable=broad-except
+            return self.response_400(message=str(ex))
+
+        return self.response(200, result=schema)
+
+    @expose("/", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @requires_json
+    def post(self) -> FlaskResponse:
+        """Create a semantic layer.
+        ---
+        post:
+          summary: Create a semantic layer
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                    description:
+                      type: string
+                    type:
+                      type: string
+                    configuration:
+                      type: object
+                    cache_timeout:
+                      type: integer
+          responses:
+            201:
+              description: Semantic layer created
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            item = SemanticLayerPostSchema().load(request.json)
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+
+        try:
+            new_model = CreateSemanticLayerCommand(item).run()
+            return self.response(201, result={"uuid": str(new_model.uuid)})
+        except SemanticLayerInvalidError as ex:
+            return self.response_422(message=str(ex))
+        except SemanticLayerCreateFailedError as ex:
+            logger.error(
+                "Error creating semantic layer: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<uuid>", methods=("PUT",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @requires_json
+    def put(self, uuid: str) -> FlaskResponse:
+        """Update a semantic layer.
+        ---
+        put:
+          summary: Update a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                    description:
+                      type: string
+                    configuration:
+                      type: object
+                    cache_timeout:
+                      type: integer
+          responses:
+            200:
+              description: Semantic layer updated
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            item = SemanticLayerPutSchema().load(request.json)
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+
+        try:
+            changed_model = UpdateSemanticLayerCommand(uuid, item).run()
+            return self.response(200, result={"uuid": str(changed_model.uuid)})
+        except SemanticLayerNotFoundError:
+            return self.response_404()
+        except SemanticLayerInvalidError as ex:
+            return self.response_422(message=str(ex))
+        except SemanticLayerUpdateFailedError as ex:
+            logger.error(
+                "Error updating semantic layer: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<uuid>", methods=("DELETE",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def delete(self, uuid: str) -> FlaskResponse:
+        """Delete a semantic layer.
+        ---
+        delete:
+          summary: Delete a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          responses:
+            200:
+              description: Semantic layer deleted
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            DeleteSemanticLayerCommand(uuid).run()
+            return self.response(200, message="OK")
+        except SemanticLayerNotFoundError:
+            return self.response_404()
+        except SemanticLayerDeleteFailedError as ex:
+            logger.error(
+                "Error deleting semantic layer: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def get_list(self) -> FlaskResponse:
+        """List all semantic layers.
+        ---
+        get:
+          summary: List all semantic layers
+          responses:
+            200:
+              description: A list of semantic layers
+            401:
+              $ref: '#/components/responses/401'
+        """
+        layers = SemanticLayerDAO.find_all()
+        result = [_serialize_layer(layer) for layer in layers]
+        return self.response(200, result=result)
+
+    @expose("/<uuid>", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def get(self, uuid: str) -> FlaskResponse:
+        """Get a single semantic layer.
+        ---
+        get:
+          summary: Get a semantic layer by UUID
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          responses:
+            200:
+              description: A semantic layer
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+        return self.response(200, result=_serialize_layer(layer))
diff --git a/superset/semantic_layers/schemas.py b/superset/semantic_layers/schemas.py
index b86a5398937..d10e0fd28fb 100644
--- a/superset/semantic_layers/schemas.py
+++ b/superset/semantic_layers/schemas.py
@@ -20,3 +20,18 @@ from marshmallow import fields, Schema
 class SemanticViewPutSchema(Schema):
     description = fields.String(allow_none=True)
     cache_timeout = fields.Integer(allow_none=True)
+
+
+class SemanticLayerPostSchema(Schema):
+    name = fields.String(required=True)
+    description = fields.String(allow_none=True)
+    type = fields.String(required=True)
+    configuration = fields.Dict(required=True)
+    cache_timeout = fields.Integer(allow_none=True)
+
+
+class SemanticLayerPutSchema(Schema):
+    name = fields.String()
+    description = fields.String(allow_none=True)
+    configuration = fields.Dict()
+    cache_timeout = fields.Integer(allow_none=True)
diff --git a/tests/unit_tests/commands/semantic_layer/create_test.py b/tests/unit_tests/commands/semantic_layer/create_test.py
new file mode 100644
index 00000000000..3beceebc3b8
--- /dev/null
+++ b/tests/unit_tests/commands/semantic_layer/create_test.py
@@ -0,0 +1,148 @@
+# 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 unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
+from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerInvalidError,
+)
+
+
+def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
+    """Test successful creation of a semantic layer."""
+    new_model = MagicMock()
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = True
+    dao.create.return_value = new_model
+
+    mock_cls = MagicMock()
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": mock_cls},
+    )
+
+    data = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    result = CreateSemanticLayerCommand(data).run()
+
+    assert result == new_model
+    dao.create.assert_called_once_with(attributes=data)
+    mock_cls.from_configuration.assert_called_once_with({"account": "test"})
+
+
+def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerInvalidError is raised for unknown type."""
+    mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {},
+        clear=True,
+    )
+
+    data = {
+        "name": "My Layer",
+        "type": "nonexistent",
+        "configuration": {},
+    }
+    with pytest.raises(SemanticLayerInvalidError):
+        CreateSemanticLayerCommand(data).run()
+
+
+def test_create_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerInvalidError is raised for duplicate names."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = False
+
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": MagicMock()},
+    )
+
+    data = {
+        "name": "Duplicate",
+        "type": "snowflake",
+        "configuration": {},
+    }
+    with pytest.raises(SemanticLayerInvalidError):
+        CreateSemanticLayerCommand(data).run()
+
+
+def test_create_semantic_layer_invalid_configuration(
+    mocker: MockerFixture,
+) -> None:
+    """Test that invalid configuration is caught by the @transaction decorator."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = True
+
+    mock_cls = MagicMock()
+    mock_cls.from_configuration.side_effect = ValueError("bad config")
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": mock_cls},
+    )
+
+    data = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"bad": "data"},
+    }
+    with pytest.raises(SemanticLayerCreateFailedError):
+        CreateSemanticLayerCommand(data).run()
+
+
+def test_create_semantic_layer_copies_data(mocker: MockerFixture) -> None:
+    """Test that the command copies input data and does not mutate it."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = True
+    dao.create.return_value = MagicMock()
+
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": MagicMock()},
+    )
+
+    original_data = {
+        "name": "Original",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    CreateSemanticLayerCommand(original_data).run()
+
+    assert original_data == {
+        "name": "Original",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
new file mode 100644
index 00000000000..456af8e390a
--- /dev/null
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -0,0 +1,50 @@
+# 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 unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
+from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
+
+
+def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
+    """Test successful deletion of a semantic layer."""
+    mock_model = MagicMock()
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+
+    DeleteSemanticLayerCommand("some-uuid").run()
+
+    dao.find_by_uuid.assert_called_once_with("some-uuid")
+    dao.delete.assert_called_once_with([mock_model])
+
+
+def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerNotFoundError is raised when model is missing."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = None
+
+    with pytest.raises(SemanticLayerNotFoundError):
+        DeleteSemanticLayerCommand("missing-uuid").run()
diff --git a/tests/unit_tests/commands/semantic_layer/exceptions_test.py b/tests/unit_tests/commands/semantic_layer/exceptions_test.py
index fb6728a36ab..91aa4329228 100644
--- a/tests/unit_tests/commands/semantic_layer/exceptions_test.py
+++ b/tests/unit_tests/commands/semantic_layer/exceptions_test.py
@@ -16,6 +16,12 @@
 # under the License.
 
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerForbiddenError,
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -46,3 +52,40 @@ def test_semantic_view_update_failed_error() -> None:
     """Test SemanticViewUpdateFailedError has correct message."""
     error = SemanticViewUpdateFailedError()
     assert str(error.message) == "Semantic view could not be updated."
+
+
+def test_semantic_layer_not_found_error() -> None:
+    """Test SemanticLayerNotFoundError has correct status and message."""
+    error = SemanticLayerNotFoundError()
+    assert error.status == 404
+    assert str(error.message) == "Semantic layer does not exist"
+
+
+def test_semantic_layer_forbidden_error() -> None:
+    """Test SemanticLayerForbiddenError has correct message."""
+    error = SemanticLayerForbiddenError()
+    assert str(error.message) == "Changing this semantic layer is forbidden"
+
+
+def test_semantic_layer_invalid_error() -> None:
+    """Test SemanticLayerInvalidError has correct message."""
+    error = SemanticLayerInvalidError()
+    assert str(error.message) == "Semantic layer parameters are invalid."
+
+
+def test_semantic_layer_create_failed_error() -> None:
+    """Test SemanticLayerCreateFailedError has correct message."""
+    error = SemanticLayerCreateFailedError()
+    assert str(error.message) == "Semantic layer could not be created."
+
+
+def test_semantic_layer_update_failed_error() -> None:
+    """Test SemanticLayerUpdateFailedError has correct message."""
+    error = SemanticLayerUpdateFailedError()
+    assert str(error.message) == "Semantic layer could not be updated."
+
+
+def test_semantic_layer_delete_failed_error() -> None:
+    """Test SemanticLayerDeleteFailedError has correct message."""
+    error = SemanticLayerDeleteFailedError()
+    assert str(error.message) == "Semantic layer could not be deleted."
diff --git a/tests/unit_tests/commands/semantic_layer/update_test.py b/tests/unit_tests/commands/semantic_layer/update_test.py
index 467926fc2c5..1b144428e89 100644
--- a/tests/unit_tests/commands/semantic_layer/update_test.py
+++ b/tests/unit_tests/commands/semantic_layer/update_test.py
@@ -21,10 +21,15 @@ import pytest
 from pytest_mock import MockerFixture
 
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
     SemanticViewForbiddenError,
     SemanticViewNotFoundError,
 )
-from superset.commands.semantic_layer.update import UpdateSemanticViewCommand
+from superset.commands.semantic_layer.update import (
+    UpdateSemanticLayerCommand,
+    UpdateSemanticViewCommand,
+)
 from superset.exceptions import SupersetSecurityException
 
 
@@ -106,6 +111,116 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
     assert original_data == {"description": "Original"}
 
 
+# =============================================================================
+# UpdateSemanticLayerCommand tests
+# =============================================================================
+
+
+def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
+    """Test successful update of a semantic layer."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    data = {"name": "Updated", "description": "New desc"}
+    result = UpdateSemanticLayerCommand("some-uuid", data).run()
+
+    assert result == mock_model
+    dao.find_by_uuid.assert_called_once_with("some-uuid")
+    dao.update.assert_called_once_with(mock_model, attributes=data)
+
+
+def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerNotFoundError is raised when model is missing."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = None
+
+    with pytest.raises(SemanticLayerNotFoundError):
+        UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
+
+
+def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerInvalidError is raised for duplicate names."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.validate_update_uniqueness.return_value = False
+
+    with pytest.raises(SemanticLayerInvalidError):
+        UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
+
+
+def test_update_semantic_layer_validates_configuration(
+    mocker: MockerFixture,
+) -> None:
+    """Test that configuration is validated against the plugin."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    mock_cls = MagicMock()
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.update.registry",
+        {"snowflake": mock_cls},
+    )
+
+    config = {"account": "test"}
+    UpdateSemanticLayerCommand("some-uuid", {"configuration": config}).run()
+
+    mock_cls.from_configuration.assert_called_once_with(config)
+
+
+def test_update_semantic_layer_skips_name_check_when_no_name(
+    mocker: MockerFixture,
+) -> None:
+    """Test that name uniqueness is not checked when name is not provided."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
+
+    dao.validate_update_uniqueness.assert_not_called()
+
+
+def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
+    """Test that the command copies input data and does not mutate it."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    original_data = {"description": "Original"}
+    UpdateSemanticLayerCommand("some-uuid", original_data).run()
+
+    assert original_data == {"description": "Original"}
+
+
 def _make_view_model(
     uuid: str = "view-uuid-1",
     name: str = "my_view",
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index db77ec9670f..08e1a1ab796 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import uuid as uuid_lib
 from typing import Any
 from unittest.mock import MagicMock
 
@@ -22,6 +23,11 @@ import pytest
 from pytest_mock import MockerFixture
 
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -259,3 +265,648 @@ def test_put_semantic_view_empty_payload(
     )
 
     assert response.status_code == 200
+
+
+# =============================================================================
+# SemanticLayerRestApi tests
+# =============================================================================
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_types(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /types returns registered semantic layer types."""
+    mock_cls = MagicMock()
+    mock_cls.name = "Snowflake Semantic Layer"
+    mock_cls.description = "Connect to Snowflake."
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.get("/api/v1/semantic_layer/types")
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert len(result) == 1
+    assert result[0] == {
+        "id": "snowflake",
+        "name": "Snowflake Semantic Layer",
+        "description": "Connect to Snowflake.",
+    }
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_types_empty(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /types returns empty list when no types registered."""
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {},
+        clear=True,
+    )
+
+    response = client.get("/api/v1/semantic_layer/types")
+
+    assert response.status_code == 200
+    assert response.json["result"] == []
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /schema/configuration returns schema without partial config."""
+    mock_cls = MagicMock()
+    mock_cls.get_configuration_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "snowflake"},
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"] == {"type": "object"}
+    mock_cls.get_configuration_schema.assert_called_once_with(None)
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_with_partial_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /schema/configuration enriches schema with partial config."""
+    mock_instance = MagicMock()
+    mock_instance.configuration = {"account": "test"}
+
+    mock_cls = MagicMock()
+    mock_cls.from_configuration.return_value = mock_instance
+    mock_cls.get_configuration_schema.return_value = {
+        "type": "object",
+        "properties": {"database": {"enum": ["db1", "db2"]}},
+    }
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "snowflake", "configuration": {"account": "test"}},
+    )
+
+    assert response.status_code == 200
+    mock_cls.get_configuration_schema.assert_called_once_with({"account": "test"})
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_with_invalid_partial_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test /schema/configuration returns schema when partial config fails."""
+    mock_cls = MagicMock()
+    mock_cls.from_configuration.side_effect = ValueError("bad config")
+    mock_cls.get_configuration_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "snowflake", "configuration": {"bad": "data"}},
+    )
+
+    assert response.status_code == 200
+    mock_cls.get_configuration_schema.assert_called_once_with(None)
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_unknown_type(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /schema/configuration returns 400 for unknown type."""
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "nonexistent"},
+    )
+
+    assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns runtime schema."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "snowflake"
+    mock_layer.implementation.configuration = {"account": "test"}
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mock_cls = MagicMock()
+    mock_cls.get_runtime_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+        json={"runtime_data": {"database": "mydb"}},
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"] == {"type": "object"}
+    mock_cls.get_runtime_schema.assert_called_once_with(
+        {"account": "test"}, {"database": "mydb"}
+    )
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_no_body(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime works without a request body."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "snowflake"
+    mock_layer.implementation.configuration = {"account": "test"}
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mock_cls = MagicMock()
+    mock_cls.get_runtime_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+    )
+
+    assert response.status_code == 200
+    mock_cls.get_runtime_schema.assert_called_once_with({"account": "test"}, None)
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns 404 when layer not found."""
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = None
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}/schema/runtime",
+    )
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_unknown_type(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns 400 for unknown type."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "unknown_type"
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+    )
+
+    assert response.status_code == 400
+    assert "Unknown type" in response.json["message"]
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_exception(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns 400 when schema raises."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "snowflake"
+    mock_layer.implementation.configuration = {"account": "test"}
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mock_cls = MagicMock()
+    mock_cls.get_runtime_schema.side_effect = ValueError("Bad config")
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+    )
+
+    assert response.status_code == 400
+    assert "Bad config" in response.json["message"]
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / creates a semantic layer."""
+    test_uuid = uuid_lib.uuid4()
+    new_model = MagicMock()
+    new_model.uuid = test_uuid
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.return_value = new_model
+
+    payload = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    response = client.post("/api/v1/semantic_layer/", json=payload)
+
+    assert response.status_code == 201
+    assert response.json["result"]["uuid"] == str(test_uuid)
+    mock_command.assert_called_once_with(payload)
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer_invalid(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / returns 422 when validation fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerInvalidError(
+        "Unknown type: bad"
+    )
+
+    payload = {
+        "name": "My Layer",
+        "type": "bad",
+        "configuration": {},
+    }
+    response = client.post("/api/v1/semantic_layer/", json=payload)
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer_create_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / returns 422 when creation fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerCreateFailedError()
+
+    payload = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    response = client.post("/api/v1/semantic_layer/", json=payload)
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer_missing_required_fields(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / returns 400 when required fields are missing."""
+    mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/",
+        json={"name": "Only name"},
+    )
+
+    assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> updates a semantic layer."""
+    test_uuid = uuid_lib.uuid4()
+    changed_model = MagicMock()
+    changed_model.uuid = test_uuid
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.return_value = changed_model
+
+    payload = {"name": "Updated Name"}
+    response = client.put(
+        f"/api/v1/semantic_layer/{test_uuid}",
+        json=payload,
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"]["uuid"] == str(test_uuid)
+    mock_command.assert_called_once_with(str(test_uuid), payload)
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> returns 404 when layer not found."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerNotFoundError()
+
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"name": "New"},
+    )
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_invalid(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> returns 422 when validation fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerInvalidError(
+        "Name already exists"
+    )
+
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"name": "Duplicate"},
+    )
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_update_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> returns 422 when update fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerUpdateFailedError()
+
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"name": "Test"},
+    )
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_validation_error(
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """Test PUT /<uuid> returns 400 when payload fails schema validation."""
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"cache_timeout": "not_a_number"},
+    )
+
+    assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<uuid> deletes a semantic layer."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticLayerCommand",
+    )
+    mock_command.return_value.run.return_value = None
+
+    response = client.delete(f"/api/v1/semantic_layer/{test_uuid}")
+
+    assert response.status_code == 200
+    mock_command.assert_called_once_with(test_uuid)
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_layer_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<uuid> returns 404 when layer not found."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerNotFoundError()
+
+    response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_layer_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<uuid> returns 422 when deletion fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerDeleteFailedError()
+
+    response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_list_semantic_layers(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET / returns list of semantic layers."""
+    layer1 = MagicMock()
+    layer1.uuid = uuid_lib.uuid4()
+    layer1.name = "Layer 1"
+    layer1.description = "First"
+    layer1.type = "snowflake"
+    layer1.cache_timeout = None
+
+    layer2 = MagicMock()
+    layer2.uuid = uuid_lib.uuid4()
+    layer2.name = "Layer 2"
+    layer2.description = None
+    layer2.type = "snowflake"
+    layer2.cache_timeout = 300
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_all.return_value = [layer1, layer2]
+
+    response = client.get("/api/v1/semantic_layer/")
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert len(result) == 2
+    assert result[0]["name"] == "Layer 1"
+    assert result[1]["name"] == "Layer 2"
+    assert result[1]["cache_timeout"] == 300
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_list_semantic_layers_empty(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET / returns empty list when no layers exist."""
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_all.return_value = []
+
+    response = client.get("/api/v1/semantic_layer/")
+
+    assert response.status_code == 200
+    assert response.json["result"] == []
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /<uuid> returns a single semantic layer."""
+    test_uuid = uuid_lib.uuid4()
+    layer = MagicMock()
+    layer.uuid = test_uuid
+    layer.name = "My Layer"
+    layer.description = "A layer"
+    layer.type = "snowflake"
+    layer.cache_timeout = 600
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = layer
+
+    response = client.get(f"/api/v1/semantic_layer/{test_uuid}")
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert result["uuid"] == str(test_uuid)
+    assert result["name"] == "My Layer"
+    assert result["type"] == "snowflake"
+    assert result["cache_timeout"] == 600
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_semantic_layer_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /<uuid> returns 404 when layer not found."""
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = None
+
+    response = client.get(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
+
+    assert response.status_code == 404
diff --git a/tests/unit_tests/semantic_layers/schemas_test.py b/tests/unit_tests/semantic_layers/schemas_test.py
index 739544cdadc..76529621042 100644
--- a/tests/unit_tests/semantic_layers/schemas_test.py
+++ b/tests/unit_tests/semantic_layers/schemas_test.py
@@ -18,7 +18,11 @@
 import pytest
 from marshmallow import ValidationError
 
-from superset.semantic_layers.schemas import SemanticViewPutSchema
+from superset.semantic_layers.schemas import (
+    SemanticLayerPostSchema,
+    SemanticLayerPutSchema,
+    SemanticViewPutSchema,
+)
 
 
 def test_semantic_view_put_schema_both_fields() -> None:
@@ -77,3 +81,128 @@ def test_semantic_view_put_schema_unknown_field() -> None:
     with pytest.raises(ValidationError) as exc_info:
         schema.load({"unknown_field": "value"})
     assert "unknown_field" in exc_info.value.messages
+
+
+# =============================================================================
+# SemanticLayerPostSchema tests
+# =============================================================================
+
+
+def test_post_schema_all_fields() -> None:
+    """Test loading all fields."""
+    schema = SemanticLayerPostSchema()
+    result = schema.load(
+        {
+            "name": "My Layer",
+            "description": "A layer",
+            "type": "snowflake",
+            "configuration": {"account": "test"},
+            "cache_timeout": 300,
+        }
+    )
+    assert result["name"] == "My Layer"
+    assert result["type"] == "snowflake"
+    assert result["configuration"] == {"account": "test"}
+    assert result["cache_timeout"] == 300
+
+
+def test_post_schema_required_fields_only() -> None:
+    """Test loading with only required fields."""
+    schema = SemanticLayerPostSchema()
+    result = schema.load(
+        {
+            "name": "My Layer",
+            "type": "snowflake",
+            "configuration": {"account": "test"},
+        }
+    )
+    assert result["name"] == "My Layer"
+    assert "description" not in result
+    assert "cache_timeout" not in result
+
+
+def test_post_schema_missing_name() -> None:
+    """Test that missing name raises ValidationError."""
+    schema = SemanticLayerPostSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"type": "snowflake", "configuration": {}})
+    assert "name" in exc_info.value.messages
+
+
+def test_post_schema_missing_type() -> None:
+    """Test that missing type raises ValidationError."""
+    schema = SemanticLayerPostSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"name": "My Layer", "configuration": {}})
+    assert "type" in exc_info.value.messages
+
+
+def test_post_schema_missing_configuration() -> None:
+    """Test that missing configuration raises ValidationError."""
+    schema = SemanticLayerPostSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"name": "My Layer", "type": "snowflake"})
+    assert "configuration" in exc_info.value.messages
+
+
+def test_post_schema_null_description() -> None:
+    """Test that description accepts None."""
+    schema = SemanticLayerPostSchema()
+    result = schema.load(
+        {
+            "name": "My Layer",
+            "type": "snowflake",
+            "configuration": {},
+            "description": None,
+        }
+    )
+    assert result["description"] is None
+
+
+# =============================================================================
+# SemanticLayerPutSchema tests
+# =============================================================================
+
+
+def test_put_schema_all_fields() -> None:
+    """Test loading all fields."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load(
+        {
+            "name": "Updated",
+            "description": "New desc",
+            "configuration": {"account": "new"},
+            "cache_timeout": 600,
+        }
+    )
+    assert result["name"] == "Updated"
+    assert result["configuration"] == {"account": "new"}
+
+
+def test_put_schema_empty() -> None:
+    """Test loading empty payload."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load({})
+    assert result == {}
+
+
+def test_put_schema_name_only() -> None:
+    """Test loading with only name."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load({"name": "New Name"})
+    assert result == {"name": "New Name"}
+
+
+def test_put_schema_configuration_only() -> None:
+    """Test loading with only configuration."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load({"configuration": {"key": "value"}})
+    assert result == {"configuration": {"key": "value"}}
+
+
+def test_put_schema_unknown_field() -> None:
+    """Test that unknown fields raise ValidationError."""
+    schema = SemanticLayerPutSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"unknown_field": "value"})
+    assert "unknown_field" in exc_info.value.messages