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 <[email protected]>
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

Reply via email to