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/02/11 21:34:32 UTC

(superset) branch semantic-layer-api-semantic-layer created (now b58a2d1f8f1)

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

beto pushed a change to branch semantic-layer-api-semantic-layer
in repository https://gitbox.apache.org/repos/asf/superset.git


      at b58a2d1f8f1 feat: API for semantic layers

This branch includes the following new commits:

     new b58a2d1f8f1 feat: API for semantic layers

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



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

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch semantic-layer-api-semantic-layer
in repository https://gitbox.apache.org/repos/asf/superset.git

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

    feat: API for semantic layers
---
 .../semantic_layer/{update.py => create.py}        |  39 +-
 .../semantic_layer/{update.py => delete.py}        |  39 +-
 superset/commands/semantic_layer/exceptions.py     |  27 +
 superset/commands/semantic_layer/update.py         |  41 +-
 superset/daos/semantic_layer.py                    |  12 +
 superset/initialization/__init__.py                |   6 +-
 superset/semantic_layers/api.py                    | 362 +++++++++++++-
 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       | 553 +++++++++++++++++++++
 tests/unit_tests/semantic_layers/schemas_test.py   | 123 ++++-
 14 files changed, 1520 insertions(+), 55 deletions(-)

diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/create.py
similarity index 59%
copy from superset/commands/semantic_layer/update.py
copy to superset/commands/semantic_layer/create.py
index 4ce8da07646..b0778fe8084 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/create.py
@@ -23,45 +23,42 @@ from typing import Any
 from flask_appbuilder.models.sqla import Model
 from sqlalchemy.exc import SQLAlchemyError
 
-from superset import security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
-    SemanticViewForbiddenError,
-    SemanticViewNotFoundError,
-    SemanticViewUpdateFailedError,
+    SemanticLayerCreateFailedError,
+    SemanticLayerInvalidError,
 )
-from superset.daos.semantic_layer import SemanticViewDAO
-from superset.exceptions import SupersetSecurityException
-from superset.semantic_layers.models import SemanticView
+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 UpdateSemanticViewCommand(BaseCommand):
-    def __init__(self, model_id: int, data: dict[str, Any]):
-        self._model_id = model_id
+class CreateSemanticLayerCommand(BaseCommand):
+    def __init__(self, data: dict[str, Any]):
         self._properties = data.copy()
-        self._model: SemanticView | None = None
 
     @transaction(
         on_error=partial(
             on_error,
             catches=(SQLAlchemyError, ValueError),
-            reraise=SemanticViewUpdateFailedError,
+            reraise=SemanticLayerCreateFailedError,
         )
     )
     def run(self) -> Model:
         self.validate()
-        assert self._model
-        return SemanticViewDAO.update(self._model, attributes=self._properties)
+        return SemanticLayerDAO.create(attributes=self._properties)
 
     def validate(self) -> None:
-        self._model = SemanticViewDAO.find_by_id(self._model_id)
-        if not self._model:
-            raise SemanticViewNotFoundError()
+        sl_type = self._properties.get("type")
+        if sl_type not in registry:
+            raise SemanticLayerInvalidError(f"Unknown type: {sl_type}")
 
-        try:
-            security_manager.raise_for_ownership(self._model)
-        except SupersetSecurityException as ex:
-            raise SemanticViewForbiddenError() from ex
+        name = 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/update.py b/superset/commands/semantic_layer/delete.py
similarity index 54%
copy from superset/commands/semantic_layer/update.py
copy to superset/commands/semantic_layer/delete.py
index 4ce8da07646..677126221a8 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -18,50 +18,39 @@ 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 import security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
-    SemanticViewForbiddenError,
-    SemanticViewNotFoundError,
-    SemanticViewUpdateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerNotFoundError,
 )
-from superset.daos.semantic_layer import SemanticViewDAO
-from superset.exceptions import SupersetSecurityException
-from superset.semantic_layers.models import SemanticView
+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 UpdateSemanticViewCommand(BaseCommand):
-    def __init__(self, model_id: int, data: dict[str, Any]):
-        self._model_id = model_id
-        self._properties = data.copy()
-        self._model: SemanticView | None = None
+class DeleteSemanticLayerCommand(BaseCommand):
+    def __init__(self, uuid: str):
+        self._uuid = uuid
+        self._model: SemanticLayer | None = None
 
     @transaction(
         on_error=partial(
             on_error,
-            catches=(SQLAlchemyError, ValueError),
-            reraise=SemanticViewUpdateFailedError,
+            catches=(SQLAlchemyError,),
+            reraise=SemanticLayerDeleteFailedError,
         )
     )
-    def run(self) -> Model:
+    def run(self) -> None:
         self.validate()
         assert self._model
-        return SemanticViewDAO.update(self._model, attributes=self._properties)
+        SemanticLayerDAO.delete([self._model])
 
     def validate(self) -> None:
-        self._model = SemanticViewDAO.find_by_id(self._model_id)
+        self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
         if not self._model:
-            raise SemanticViewNotFoundError()
-
-        try:
-            security_manager.raise_for_ownership(self._model)
-        except SupersetSecurityException as ex:
-            raise SemanticViewForbiddenError() from ex
+            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 4ce8da07646..5242406af8c 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.decorators import on_error, transaction
 
 logger = logging.getLogger(__name__)
@@ -65,3 +69,36 @@ class UpdateSemanticViewCommand(BaseCommand):
             security_manager.raise_for_ownership(self._model)
         except SupersetSecurityException as ex:
             raise SemanticViewForbiddenError() from ex
+
+
+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 9c591e4a7a4..13ce777567a 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -29,6 +29,18 @@ class SemanticLayerDAO(BaseDAO[SemanticLayer]):
     Data Access Object for SemanticLayer model.
     """
 
+    @staticmethod
+    def find_by_uuid(uuid_str: str) -> SemanticLayer | None:
+        return (
+            db.session.query(SemanticLayer)
+            .filter(SemanticLayer.uuid == uuid_str)
+            .one_or_none()
+        )
+
+    @staticmethod
+    def find_all() -> list[SemanticLayer]:
+        return db.session.query(SemanticLayer).all()
+
     @staticmethod
     def validate_uniqueness(name: str) -> bool:
         """
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 2f90db3d308..44b5c4f0777 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -178,12 +178,15 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         from superset.reports.api import ReportScheduleRestApi
         from superset.reports.logs.api import ReportExecutionLogRestApi
         from superset.row_level_security.api import RLSRestApi
-        from superset.semantic_layers.api import SemanticViewRestApi
         from superset.security.api import (
             RoleRestAPI,
             SecurityRestApi,
             UserRegistrationsRestAPI,
         )
+        from superset.semantic_layers.api import (
+            SemanticLayerRestApi,
+            SemanticViewRestApi,
+        )
         from superset.sqllab.api import SqlLabRestApi
         from superset.sqllab.permalink.api import SqlLabPermalinkRestApi
         from superset.tags.api import TagRestApi
@@ -266,6 +269,7 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         appbuilder.add_api(ReportExecutionLogRestApi)
         appbuilder.add_api(RLSRestApi)
         appbuilder.add_api(SavedQueryRestApi)
+        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..f07054d86f6 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,328 @@ 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}
+            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)
+        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
+            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, 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 4c0ad47f8b9..e014ea3dc5a 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
 
 
@@ -102,3 +107,113 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
 
     # The original dict should not have been modified
     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"}
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index 67faf78d8de..8f1fb350a70 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -15,12 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import uuid as uuid_lib
 from typing import Any
 from unittest.mock import MagicMock
 
 from pytest_mock import MockerFixture
 
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -242,3 +248,550 @@ def test_put_semantic_view_empty_payload(
     )
 
     assert response.status_code == 200
+
+
+# =============================================================================
+# SemanticLayerRestApi tests
+# =============================================================================
+
+
+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.",
+    }
+
+
+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"] == []
+
+
+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)
+
+
+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"})
+
+
+def test_configuration_schema_with_invalid_partial_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /schema/configuration still 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)
+
+
+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
+
+
+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"}
+    )
+
+
+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)
+
+
+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
+
+
+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)
+
+
+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
+
+
+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
+
+
+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
+
+
+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)
+
+
+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
+
+
+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
+
+
+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
+
+
+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)
+
+
+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
+
+
+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
+
+
+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
+
+
+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"] == []
+
+
+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
+
+
+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..33873ef5489 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,120 @@ 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