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

mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 38cd71f9b9 [#12607] feat(server): Add Semantic Model create and load 
REST APIs (#12625)
38cd71f9b9 is described below

commit 38cd71f9b9b3f50caace4020f2318181ba83d270
Author: mchades <[email protected]>
AuthorDate: Mon Sep 28 18:29:28 2026 +0800

    [#12607] feat(server): Add Semantic Model create and load REST APIs (#12625)
    
    ### What changes were proposed in this pull request?
    
    This PR adds Semantic Model REST APIs for create and load, including
    request and response DTOs, DTO conversion, server routing, exception
    mapping, and focused tests.
    
    This draft is stacked on the unmerged Semantic Model Core create/load
    work for #12604 and Definition DTO PR #12624. The feature commit itself
    contains only create/load changes under common and server.
    
    ### Why are the changes needed?
    
    These APIs expose Semantic Model creation and retrieval through the
    Gravitino REST server while preserving structured definitions,
    null-versus-empty values, request validation, and Semantic
    Model-specific errors.
    
    Fix: #12607
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It adds POST create and GET load REST operations for schema-scoped
    Semantic Models. List, alter, drop, OpenAPI, and client changes are
    outside this feature commit.
    
    ### How was this patch tested?
    
    The following local checks passed:
    
    - Semantic Model create/load DTO and REST focused tests
    - ./gradlew :common:check :server:check -PskipITs
    - ./gradlew :common:spotlessApply :server:spotlessApply
    - git diff --check
---
 .../dto/requests/SemanticModelCreateRequest.java   | 102 +++++
 .../dto/responses/SemanticModelResponse.java       |  66 +++
 .../gravitino/dto/semantic/SemanticModelDTO.java   | 206 +++++++++
 .../apache/gravitino/dto/util/DTOConverters.java   |  19 +
 .../requests/TestSemanticModelCreateRequest.java   | 164 +++++++
 .../dto/responses/TestSemanticModelResponse.java   | 126 ++++++
 .../dto/semantic/TestSemanticModelDTO.java         | 184 ++++++++
 .../dto/util/TestSemanticModelDTOConverter.java    | 136 ++++++
 .../apache/gravitino/server/GravitinoServer.java   |   4 +
 .../server/web/rest/ExceptionHandlers.java         |  58 +++
 .../server/web/rest/SemanticModelOperations.java   | 160 +++++++
 .../web/rest/TestSemanticModelOperations.java      | 495 +++++++++++++++++++++
 12 files changed, 1720 insertions(+)

diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/SemanticModelCreateRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/SemanticModelCreateRequest.java
new file mode 100644
index 0000000000..63e2248daf
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/SemanticModelCreateRequest.java
@@ -0,0 +1,102 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.dto.requests;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.google.common.base.Preconditions;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import lombok.extern.jackson.Jacksonized;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO;
+import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+
+/** Represents a request to create a Semantic Model. */
+@Getter
+@EqualsAndHashCode
+@ToString
+@Builder
+@Jacksonized
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"name", "comment", "definition", "properties"})
+public class SemanticModelCreateRequest implements RESTRequest {
+
+  @JsonProperty("name")
+  private final String name;
+
+  @Nullable
+  @JsonProperty("comment")
+  private final String comment;
+
+  @JsonProperty("definition")
+  private final SemanticModelDefinitionDTO definition;
+
+  @Nullable
+  @JsonProperty("properties")
+  private final Map<String, String> properties;
+
+  /** Default constructor for Jackson deserialization. */
+  public SemanticModelCreateRequest() {
+    this(null, null, null, null);
+  }
+
+  /**
+   * Creates a Semantic Model create request.
+   *
+   * @param name The Semantic Model name.
+   * @param comment The comment, or {@code null} if it is not set.
+   * @param definition The required Semantic Model definition.
+   * @param properties The Gravitino-specific properties, or {@code null} if 
none are set.
+   */
+  public SemanticModelCreateRequest(
+      String name,
+      @Nullable String comment,
+      SemanticModelDefinitionDTO definition,
+      @Nullable Map<String, String> properties) {
+    this.name = name;
+    this.comment = comment;
+    this.definition = definition;
+    this.properties = properties;
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(name), "\"name\" field is required and cannot 
be empty");
+    Preconditions.checkArgument(
+        definition != null, "\"definition\" field is required and cannot be 
null");
+  }
+
+  /**
+   * Converts the definition in this request to an API definition.
+   *
+   * @return The Semantic Model definition.
+   * @throws IllegalArgumentException If a definition field is invalid.
+   */
+  public SemanticModelDefinition toDefinition() {
+    return definition.toDefinition();
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/responses/SemanticModelResponse.java
 
b/common/src/main/java/org/apache/gravitino/dto/responses/SemanticModelResponse.java
new file mode 100644
index 0000000000..d3f3b59b84
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/responses/SemanticModelResponse.java
@@ -0,0 +1,66 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.dto.responses;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Preconditions;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.dto.semantic.SemanticModelDTO;
+
+/** Represents a response containing one Semantic Model. */
+@Getter
+@EqualsAndHashCode(callSuper = true)
+@ToString
+public class SemanticModelResponse extends BaseResponse {
+
+  @JsonProperty("semanticModel")
+  private final SemanticModelDTO semanticModel;
+
+  /** Default constructor for Jackson deserialization. */
+  public SemanticModelResponse() {
+    super();
+    this.semanticModel = null;
+  }
+
+  /**
+   * Creates a successful Semantic Model response.
+   *
+   * @param semanticModel The Semantic Model DTO.
+   */
+  public SemanticModelResponse(SemanticModelDTO semanticModel) {
+    super(0);
+    this.semanticModel = semanticModel;
+  }
+
+  @Override
+  public void validate() throws IllegalArgumentException {
+    super.validate();
+    Preconditions.checkArgument(semanticModel != null, "semanticModel must not 
be null");
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(semanticModel.name()),
+        "semanticModel 'name' must not be null or empty");
+    Preconditions.checkArgument(
+        semanticModel.hasDefinition(), "semanticModel 'definition' must not be 
null");
+    Preconditions.checkArgument(
+        semanticModel.auditInfo() != null, "semanticModel 'audit' must not be 
null");
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticModelDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticModelDTO.java
new file mode 100644
index 0000000000..850b90b8b3
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/dto/semantic/SemanticModelDTO.java
@@ -0,0 +1,206 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.dto.semantic;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.google.common.base.Preconditions;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.semantic.SemanticModel;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+
+/** DTO for a schema-scoped Semantic Model. */
+@EqualsAndHashCode
+@ToString
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@JsonPropertyOrder({"name", "comment", "definition", "properties", "audit"})
+public class SemanticModelDTO implements SemanticModel {
+
+  @JsonProperty("name")
+  private String name;
+
+  @Nullable
+  @JsonProperty("comment")
+  private String comment;
+
+  @JsonProperty("definition")
+  private SemanticModelDefinitionDTO definition;
+
+  @JsonProperty("properties")
+  private Map<String, String> properties;
+
+  @JsonProperty("audit")
+  private AuditDTO audit;
+
+  private SemanticModelDTO() {}
+
+  private SemanticModelDTO(
+      String name,
+      @Nullable String comment,
+      SemanticModelDefinitionDTO definition,
+      @Nullable Map<String, String> properties,
+      AuditDTO audit) {
+    this.name = name;
+    this.comment = comment;
+    this.definition = definition;
+    this.properties = immutableProperties(properties);
+    this.audit = audit;
+  }
+
+  @Override
+  public String name() {
+    return name;
+  }
+
+  @Override
+  @Nullable
+  public String comment() {
+    return comment;
+  }
+
+  /**
+   * Returns whether this DTO contains a Semantic Model definition.
+   *
+   * @return {@code true} if the definition is present, otherwise {@code 
false}.
+   */
+  public boolean hasDefinition() {
+    return definition != null;
+  }
+
+  @Override
+  public SemanticModelDefinition definition() {
+    Preconditions.checkArgument(definition != null, "definition must not be 
null");
+    return definition.toDefinition();
+  }
+
+  @Override
+  public Map<String, String> properties() {
+    return immutableProperties(properties);
+  }
+
+  @Override
+  public AuditDTO auditInfo() {
+    return audit;
+  }
+
+  /**
+   * Creates a builder for a Semantic Model DTO.
+   *
+   * @return A new builder.
+   */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Builder for {@link SemanticModelDTO}. */
+  public static final class Builder {
+
+    private String name;
+    @Nullable private String comment;
+    private SemanticModelDefinitionDTO definition;
+    @Nullable private Map<String, String> properties;
+    private AuditDTO audit;
+
+    private Builder() {}
+
+    /**
+     * Sets the Semantic Model name.
+     *
+     * @param name The Semantic Model name.
+     * @return This builder.
+     */
+    public Builder withName(String name) {
+      this.name = name;
+      return this;
+    }
+
+    /**
+     * Sets the Semantic Model comment.
+     *
+     * @param comment The comment, or {@code null} if it is not set.
+     * @return This builder.
+     */
+    public Builder withComment(@Nullable String comment) {
+      this.comment = comment;
+      return this;
+    }
+
+    /**
+     * Sets the Semantic Model definition.
+     *
+     * @param definition The complete definition DTO.
+     * @return This builder.
+     */
+    public Builder withDefinition(SemanticModelDefinitionDTO definition) {
+      this.definition = definition;
+      return this;
+    }
+
+    /**
+     * Sets the Gravitino-specific Semantic Model properties.
+     *
+     * @param properties The properties, or {@code null} if none are set.
+     * @return This builder.
+     */
+    public Builder withProperties(@Nullable Map<String, String> properties) {
+      this.properties = properties;
+      return this;
+    }
+
+    /**
+     * Sets the Semantic Model audit information.
+     *
+     * @param audit The audit information.
+     * @return This builder.
+     */
+    public Builder withAudit(AuditDTO audit) {
+      this.audit = audit;
+      return this;
+    }
+
+    /**
+     * Builds a Semantic Model DTO.
+     *
+     * @return The Semantic Model DTO.
+     * @throws IllegalArgumentException If a required field is missing.
+     */
+    public SemanticModelDTO build() {
+      Preconditions.checkArgument(StringUtils.isNotBlank(name), "name cannot 
be null or empty");
+      Preconditions.checkArgument(definition != null, "definition cannot be 
null");
+      Preconditions.checkArgument(audit != null, "audit cannot be null");
+
+      return new SemanticModelDTO(name, comment, definition, properties, 
audit);
+    }
+  }
+
+  private static Map<String, String> immutableProperties(@Nullable Map<String, 
String> properties) {
+    if (properties == null || properties.isEmpty()) {
+      return Collections.emptyMap();
+    }
+    return Collections.unmodifiableMap(new LinkedHashMap<>(properties));
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java 
b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
index c9d36b951e..6d3c4881f2 100644
--- a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
+++ b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
@@ -89,6 +89,8 @@ import 
org.apache.gravitino.dto.rel.partitions.IdentityPartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.ListPartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.PartitionDTO;
 import org.apache.gravitino.dto.rel.partitions.RangePartitionDTO;
+import org.apache.gravitino.dto.semantic.SemanticModelDTO;
+import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO;
 import org.apache.gravitino.dto.stats.StatisticDTO;
 import org.apache.gravitino.dto.tag.MetadataObjectDTO;
 import org.apache.gravitino.dto.tag.TagDTO;
@@ -130,6 +132,7 @@ import org.apache.gravitino.rel.partitions.Partition;
 import org.apache.gravitino.rel.partitions.Partitions;
 import org.apache.gravitino.rel.partitions.RangePartition;
 import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.semantic.SemanticModel;
 import org.apache.gravitino.stats.Statistic;
 import org.apache.gravitino.tag.Tag;
 import org.apache.gravitino.tag.TagValueConstraint;
@@ -318,6 +321,22 @@ public class DTOConverters {
         .build();
   }
 
+  /**
+   * Converts a {@link SemanticModel} implementation to a {@link 
SemanticModelDTO}.
+   *
+   * @param semanticModel The Semantic Model implementation.
+   * @return The Semantic Model DTO.
+   */
+  public static SemanticModelDTO toDTO(SemanticModel semanticModel) {
+    return SemanticModelDTO.builder()
+        .withName(semanticModel.name())
+        .withComment(semanticModel.comment())
+        
.withDefinition(SemanticModelDefinitionDTO.fromDefinition(semanticModel.definition()))
+        .withProperties(semanticModel.properties())
+        .withAudit(toDTO(semanticModel.auditInfo()))
+        .build();
+  }
+
   /**
    * Converts a {@link Representation} implementation to a {@link 
RepresentationDTO}.
    *
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/requests/TestSemanticModelCreateRequest.java
 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestSemanticModelCreateRequest.java
new file mode 100644
index 0000000000..4c4d398852
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestSemanticModelCreateRequest.java
@@ -0,0 +1,164 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.dto.requests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.junit.jupiter.api.Test;
+
+public class TestSemanticModelCreateRequest {
+
+  @Test
+  public void testWrappedDefinitionJsonAndConversion() throws 
JsonProcessingException {
+    String json =
+        "{"
+            + "\"name\":\"sales_model\","
+            + "\"comment\":\"Governed sales definitions\","
+            + "\"definition\":{"
+            + "\"aiContext\":\"Use governed metrics\","
+            + "\"datasets\":[{\"name\":\"orders\","
+            + 
"\"source\":{\"namespace\":[\"sales\",\"mart\"],\"name\":\"orders\"}}],"
+            + "\"relationships\":[],"
+            + "\"metrics\":[],"
+            + "\"customExtensions\":[]},"
+            + "\"properties\":{}"
+            + "}";
+
+    SemanticModelCreateRequest request =
+        JsonUtils.objectMapper().readValue(json, 
SemanticModelCreateRequest.class);
+    request.validate();
+    SemanticModelDefinition definition = request.toDefinition();
+
+    assertEquals("sales_model", request.getName());
+    assertEquals("Use governed metrics", definition.aiContext().text());
+    assertEquals(1, definition.datasets().length);
+    assertEquals("orders", definition.datasets()[0].name());
+    assertEquals(0, definition.relationships().length);
+    assertEquals(0, definition.metrics().length);
+    assertEquals(0, definition.customExtensions().length);
+
+    JsonNode serialized =
+        
JsonUtils.objectMapper().readTree(JsonUtils.objectMapper().writeValueAsString(request));
+    assertTrue(serialized.has("definition"));
+    assertTrue(serialized.path("definition").has("aiContext"));
+    assertTrue(serialized.path("definition").has("datasets"));
+    assertTrue(serialized.has("properties"));
+    assertFalse(serialized.has("datasets"));
+  }
+
+  @Test
+  public void testRequiredFieldValidation() {
+    SemanticModelDefinitionDTO definition =
+        SemanticModelDefinitionDTO.fromDefinition(
+            SemanticModelDefinition.builder().withDatasets(new Dataset[] 
{dataset()}).build());
+
+    IllegalArgumentException missingName =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new SemanticModelCreateRequest(null, null, definition, 
Map.of()).validate());
+    assertEquals("\"name\" field is required and cannot be empty", 
missingName.getMessage());
+
+    IllegalArgumentException missingDefinition =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new SemanticModelCreateRequest("sales_model", null, null, 
Map.of()).validate());
+    assertEquals(
+        "\"definition\" field is required and cannot be null", 
missingDefinition.getMessage());
+  }
+
+  @Test
+  public void testOmittedPropertiesAreAccepted() throws 
JsonProcessingException {
+    String json =
+        "{"
+            + "\"name\":\"sales_model\","
+            + "\"definition\":{\"datasets\":[{\"name\":\"orders\","
+            + 
"\"source\":{\"namespace\":[\"sales\",\"mart\"],\"name\":\"orders\"}}]}"
+            + "}";
+
+    SemanticModelCreateRequest request =
+        JsonUtils.objectMapper().readValue(json, 
SemanticModelCreateRequest.class);
+    request.validate();
+
+    assertNull(request.getProperties());
+  }
+
+  @Test
+  public void testUnknownDefinitionFieldsAreRejected() {
+    String unknownTopLevel =
+        "{"
+            + "\"name\":\"sales_model\","
+            + "\"definition\":{\"datasets\":[{\"name\":\"orders\","
+            + 
"\"source\":{\"namespace\":[\"sales\",\"mart\"],\"name\":\"orders\"}}]},"
+            + "\"properties\":{},"
+            + "\"unknown\":true"
+            + "}";
+    String unknownDataset =
+        "{"
+            + "\"name\":\"sales_model\","
+            + "\"definition\":{\"datasets\":[{\"name\":\"orders\","
+            + 
"\"source\":{\"namespace\":[\"sales\",\"mart\"],\"name\":\"orders\"},"
+            + "\"unknown\":true}]},"
+            + "\"properties\":{}"
+            + "}";
+
+    assertThrows(
+        JsonProcessingException.class,
+        () ->
+            JsonUtils.objectMapper().readValue(unknownTopLevel, 
SemanticModelCreateRequest.class));
+    assertThrows(
+        JsonProcessingException.class,
+        () -> JsonUtils.objectMapper().readValue(unknownDataset, 
SemanticModelCreateRequest.class));
+  }
+
+  @Test
+  public void testNestedNullMembersAreRejected() throws 
JsonProcessingException {
+    String json =
+        "{"
+            + "\"name\":\"sales_model\","
+            + "\"definition\":{\"datasets\":[null]},"
+            + "\"properties\":{}"
+            + "}";
+    SemanticModelCreateRequest request =
+        JsonUtils.objectMapper().readValue(json, 
SemanticModelCreateRequest.class);
+
+    request.validate();
+    IllegalArgumentException exception =
+        assertThrows(IllegalArgumentException.class, request::toDefinition);
+    assertEquals("datasets[0] must not be null", exception.getMessage());
+  }
+
+  private static Dataset dataset() {
+    return Dataset.builder()
+        .withName("orders")
+        .withSource(NameIdentifier.of("sales", "mart", "orders"))
+        .build();
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/responses/TestSemanticModelResponse.java
 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestSemanticModelResponse.java
new file mode 100644
index 0000000000..24e37582e0
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/responses/TestSemanticModelResponse.java
@@ -0,0 +1,126 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.dto.responses;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.time.Instant;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.semantic.SemanticModelDTO;
+import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.junit.jupiter.api.Test;
+
+public class TestSemanticModelResponse {
+
+  @Test
+  public void testResponseSerDeAndValidate() throws JsonProcessingException {
+    SemanticModelResponse response = new 
SemanticModelResponse(semanticModelDTO());
+
+    String json = JsonUtils.objectMapper().writeValueAsString(response);
+    JsonNode root = JsonUtils.objectMapper().readTree(json);
+    JsonNode semanticModel = root.path("semanticModel");
+    assertTrue(root.has("semanticModel"));
+    assertFalse(root.has("semantic_model"));
+    assertTrue(semanticModel.has("definition"));
+    assertTrue(semanticModel.path("definition").has("datasets"));
+    assertFalse(semanticModel.has("datasets"));
+
+    SemanticModelResponse deserialized =
+        JsonUtils.objectMapper().readValue(json, SemanticModelResponse.class);
+    deserialized.validate();
+    assertEquals("sales_model", deserialized.getSemanticModel().name());
+    assertEquals("orders", 
deserialized.getSemanticModel().definition().datasets()[0].name());
+  }
+
+  @Test
+  public void testResponseRequiresSemanticModel() {
+    IllegalArgumentException exception =
+        assertThrows(IllegalArgumentException.class, new 
SemanticModelResponse()::validate);
+    assertEquals("semanticModel must not be null", exception.getMessage());
+  }
+
+  @Test
+  public void testResponseValidatesSemanticModelMembers() throws 
JsonProcessingException {
+    String definition =
+        "{\"datasets\":[{\"name\":\"orders\",\"source\":{"
+            + "\"namespace\":[\"sales\",\"mart\"],\"name\":\"orders\"}}]}";
+    SemanticModelResponse missingName =
+        readResponse(
+            "{\"code\":0,\"semanticModel\":{\"definition\":" + definition + 
",\"audit\":{}}}");
+    SemanticModelResponse missingDefinition =
+        
readResponse("{\"code\":0,\"semanticModel\":{\"name\":\"sales_model\",\"audit\":{}}}");
+    SemanticModelResponse missingAudit =
+        readResponse(
+            
"{\"code\":0,\"semanticModel\":{\"name\":\"sales_model\",\"definition\":"
+                + definition
+                + "}}");
+
+    assertEquals(
+        "semanticModel 'name' must not be null or empty",
+        assertThrows(IllegalArgumentException.class, 
missingName::validate).getMessage());
+    assertEquals(
+        "semanticModel 'definition' must not be null",
+        assertThrows(IllegalArgumentException.class, 
missingDefinition::validate).getMessage());
+    assertEquals(
+        "semanticModel 'audit' must not be null",
+        assertThrows(IllegalArgumentException.class, 
missingAudit::validate).getMessage());
+
+    SemanticModelResponse invalidNestedDefinition =
+        readResponse(
+            "{\"code\":0,\"semanticModel\":{\"name\":\"sales_model\","
+                + "\"definition\":{\"datasets\":[null]},\"audit\":{}}}");
+    assertDoesNotThrow(invalidNestedDefinition::validate);
+  }
+
+  private static SemanticModelDTO semanticModelDTO() {
+    Dataset dataset =
+        Dataset.builder()
+            .withName("orders")
+            .withSource(NameIdentifier.of("sales", "mart", "orders"))
+            .build();
+    SemanticModelDefinition definition =
+        SemanticModelDefinition.builder().withDatasets(new Dataset[] 
{dataset}).build();
+    AuditDTO audit =
+        AuditDTO.builder()
+            .withCreator("tester")
+            .withCreateTime(Instant.parse("2026-08-11T00:00:00Z"))
+            .build();
+    return SemanticModelDTO.builder()
+        .withName("sales_model")
+        .withDefinition(SemanticModelDefinitionDTO.fromDefinition(definition))
+        .withProperties(Map.of())
+        .withAudit(audit)
+        .build();
+  }
+
+  private static SemanticModelResponse readResponse(String json) throws 
JsonProcessingException {
+    return JsonUtils.objectMapper().readValue(json, 
SemanticModelResponse.class);
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticModelDTO.java
 
b/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticModelDTO.java
new file mode 100644
index 0000000000..4bdfdfea9c
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/semantic/TestSemanticModelDTO.java
@@ -0,0 +1,184 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.dto.semantic;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.Relationship;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.junit.jupiter.api.Test;
+
+public class TestSemanticModelDTO {
+
+  @Test
+  public void testNestedDefinitionJsonRoundTrip() throws 
JsonProcessingException {
+    SemanticModelDefinition definition =
+        SemanticModelDefinition.builder()
+            .withAIContext(AIContext.of("Use governed metrics"))
+            .withDatasets(new Dataset[] {dataset("orders")})
+            .withRelationships(new Relationship[0])
+            .withMetrics(new Metric[0])
+            .withCustomExtensions(new CustomExtension[0])
+            .build();
+    SemanticModelDTO dto =
+        SemanticModelDTO.builder()
+            .withName("sales_model")
+            .withComment("Governed sales definitions")
+            
.withDefinition(SemanticModelDefinitionDTO.fromDefinition(definition))
+            .withProperties(Map.of("owner", "finance"))
+            .withAudit(audit())
+            .build();
+
+    String json = JsonUtils.objectMapper().writeValueAsString(dto);
+    JsonNode root = JsonUtils.objectMapper().readTree(json);
+
+    assertEquals("sales_model", root.path("name").textValue());
+    assertTrue(root.has("definition"));
+    assertEquals("Use governed metrics", 
root.path("definition").path("aiContext").textValue());
+    assertTrue(root.path("definition").has("datasets"));
+    assertTrue(root.path("definition").has("relationships"));
+    assertTrue(root.has("properties"));
+    assertTrue(root.has("audit"));
+    assertFalse(root.has("datasets"));
+
+    SemanticModelDTO deserialized =
+        JsonUtils.objectMapper().readValue(json, SemanticModelDTO.class);
+    assertEquals("sales_model", deserialized.name());
+    assertEquals("Governed sales definitions", deserialized.comment());
+    assertEquals(definition, deserialized.definition());
+    assertEquals(Map.of("owner", "finance"), deserialized.properties());
+    assertNotNull(deserialized.auditInfo());
+  }
+
+  @Test
+  public void testDefinitionAndPropertiesAreDefensive() {
+    Dataset orders = dataset("orders");
+    DatasetDTO[] datasets = {DatasetDTO.fromDataset(orders)};
+    SemanticModelDefinitionDTO definition =
+        SemanticModelDefinitionDTO.builder().withDatasets(datasets).build();
+    Map<String, String> properties = new LinkedHashMap<>();
+    properties.put("owner", "finance");
+
+    SemanticModelDTO dto =
+        SemanticModelDTO.builder()
+            .withName("sales_model")
+            .withDefinition(definition)
+            .withProperties(properties)
+            .withAudit(audit())
+            .build();
+
+    datasets[0] = DatasetDTO.fromDataset(dataset("customers"));
+    properties.put("owner", "sales");
+    Dataset[] returnedDatasets = dto.definition().datasets();
+    returnedDatasets[0] = dataset("customers");
+
+    assertEquals(orders, dto.definition().datasets()[0]);
+    assertEquals(Map.of("owner", "finance"), dto.properties());
+    assertThrows(UnsupportedOperationException.class, () -> 
dto.properties().put("k", "v"));
+  }
+
+  @Test
+  public void testAbsentDefinitionCollectionsRemainAbsent() throws 
JsonProcessingException {
+    String json =
+        "{"
+            + "\"name\":\"sales_model\","
+            + "\"definition\":{\"datasets\":[{\"name\":\"orders\","
+            + 
"\"source\":{\"namespace\":[\"sales\",\"mart\"],\"name\":\"orders\"}}]},"
+            + "\"audit\":{}"
+            + "}";
+
+    SemanticModelDTO dto = JsonUtils.objectMapper().readValue(json, 
SemanticModelDTO.class);
+    assertNull(dto.definition().relationships());
+    assertNull(dto.definition().metrics());
+    assertNull(dto.definition().customExtensions());
+    assertTrue(dto.properties().isEmpty());
+
+    String unknown = json.substring(0, json.length() - 1) + 
",\"unknown\":true}";
+    assertThrows(
+        JsonProcessingException.class,
+        () -> JsonUtils.objectMapper().readValue(unknown, 
SemanticModelDTO.class));
+  }
+
+  @Test
+  public void testBuilderValidation() {
+    SemanticModelDefinitionDTO definition = definitionDTO("orders");
+    IllegalArgumentException missingName =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> 
SemanticModelDTO.builder().withDefinition(definition).withAudit(audit()).build());
+    assertEquals("name cannot be null or empty", missingName.getMessage());
+
+    IllegalArgumentException missingDefinition =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> 
SemanticModelDTO.builder().withName("sales_model").withAudit(audit()).build());
+    assertEquals("definition cannot be null", missingDefinition.getMessage());
+
+    SemanticModelDefinitionDTO invalidDefinition =
+        SemanticModelDefinitionDTO.builder().withDatasets(new DatasetDTO[] 
{null}).build();
+    SemanticModelDTO dto =
+        assertDoesNotThrow(
+            () ->
+                SemanticModelDTO.builder()
+                    .withName("sales_model")
+                    .withDefinition(invalidDefinition)
+                    .withAudit(audit())
+                    .build());
+    assertTrue(dto.hasDefinition());
+  }
+
+  private static SemanticModelDefinitionDTO definitionDTO(String datasetName) {
+    return SemanticModelDefinitionDTO.fromDefinition(
+        SemanticModelDefinition.builder()
+            .withDatasets(new Dataset[] {dataset(datasetName)})
+            .build());
+  }
+
+  private static Dataset dataset(String name) {
+    return Dataset.builder()
+        .withName(name)
+        .withSource(NameIdentifier.of("sales", "mart", name))
+        .build();
+  }
+
+  private static AuditDTO audit() {
+    return AuditDTO.builder()
+        .withCreator("tester")
+        .withCreateTime(Instant.parse("2026-08-11T00:00:00Z"))
+        .build();
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/util/TestSemanticModelDTOConverter.java
 
b/common/src/test/java/org/apache/gravitino/dto/util/TestSemanticModelDTOConverter.java
new file mode 100644
index 0000000000..b33ab40588
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/util/TestSemanticModelDTOConverter.java
@@ -0,0 +1,136 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.dto.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.time.Instant;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.gravitino.Audit;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.semantic.SemanticModelDTO;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.DialectExpression;
+import org.apache.gravitino.semantic.Dialects;
+import org.apache.gravitino.semantic.Expression;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.Relationship;
+import org.apache.gravitino.semantic.SemanticModel;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.junit.jupiter.api.Test;
+
+public class TestSemanticModelDTOConverter {
+
+  @Test
+  public void testConvertSemanticModelToDTO() {
+    Dataset orders = dataset("orders");
+    Dataset customers = dataset("customers");
+    Relationship relationship =
+        Relationship.builder()
+            .withName("orders_to_customers")
+            .withFrom("orders")
+            .withTo("customers")
+            .withFromColumns(new String[] {"customer_id"})
+            .withToColumns(new String[] {"id"})
+            .build();
+    Metric metric =
+        Metric.builder()
+            .withName("revenue")
+            .withExpression(expression("SUM(orders.amount)"))
+            .build();
+    CustomExtension extension =
+        
CustomExtension.builder().withVendorName("example").withData("{}").build();
+    SemanticModelDefinition definition =
+        SemanticModelDefinition.builder()
+            .withAIContext(AIContext.of("Use governed metrics"))
+            .withDatasets(new Dataset[] {orders, customers})
+            .withRelationships(new Relationship[] {relationship})
+            .withMetrics(new Metric[] {metric})
+            .withCustomExtensions(new CustomExtension[] {extension})
+            .build();
+    AuditDTO audit = audit();
+
+    SemanticModel semanticModel =
+        new SemanticModel() {
+          @Override
+          public String name() {
+            return "sales_model";
+          }
+
+          @Override
+          @Nullable
+          public String comment() {
+            return "Governed sales definitions";
+          }
+
+          @Override
+          public SemanticModelDefinition definition() {
+            return definition;
+          }
+
+          @Override
+          public Map<String, String> properties() {
+            return Map.of("owner", "finance");
+          }
+
+          @Override
+          public Audit auditInfo() {
+            return audit;
+          }
+        };
+
+    SemanticModelDTO dto = DTOConverters.toDTO(semanticModel);
+
+    assertEquals("sales_model", dto.name());
+    assertEquals("Governed sales definitions", dto.comment());
+    assertEquals(definition, dto.definition());
+    assertEquals(Map.of("owner", "finance"), dto.properties());
+    assertEquals(audit, dto.auditInfo());
+  }
+
+  private static Dataset dataset(String name) {
+    return Dataset.builder()
+        .withName(name)
+        .withSource(NameIdentifier.of("sales", "mart", name))
+        .build();
+  }
+
+  private static Expression expression(String value) {
+    return Expression.builder()
+        .withDialects(
+            new DialectExpression[] {
+              DialectExpression.builder()
+                  .withDialect(Dialects.ANSI_SQL)
+                  .withExpression(value)
+                  .build()
+            })
+        .build();
+  }
+
+  private static AuditDTO audit() {
+    return AuditDTO.builder()
+        .withCreator("tester")
+        .withCreateTime(Instant.parse("2026-08-11T00:00:00Z"))
+        .build();
+  }
+}
diff --git 
a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java 
b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
index 8b9a21844a..6900fc6f9f 100644
--- a/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
+++ b/server/src/main/java/org/apache/gravitino/server/GravitinoServer.java
@@ -36,6 +36,7 @@ import org.apache.gravitino.catalog.FunctionDispatcher;
 import org.apache.gravitino.catalog.ModelDispatcher;
 import org.apache.gravitino.catalog.PartitionDispatcher;
 import org.apache.gravitino.catalog.SchemaDispatcher;
+import org.apache.gravitino.catalog.SemanticModelDispatcher;
 import org.apache.gravitino.catalog.TableDispatcher;
 import org.apache.gravitino.catalog.TopicDispatcher;
 import org.apache.gravitino.catalog.ViewDispatcher;
@@ -183,6 +184,9 @@ public class GravitinoServer extends ResourceConfig {
             
bind(gravitinoEnv.secretProviderRegistry()).to(SecretProviderRegistry.class).ranked(1);
             
bind(gravitinoEnv.modelDispatcher()).to(ModelDispatcher.class).ranked(1);
             
bind(gravitinoEnv.functionDispatcher()).to(FunctionDispatcher.class).ranked(1);
+            bind(gravitinoEnv.semanticModelDispatcher())
+                .to(SemanticModelDispatcher.class)
+                .ranked(1);
             bind(lineageService).to(LineageDispatcher.class).ranked(1);
             
bind(gravitinoEnv.jobOperationDispatcher()).to(JobOperationDispatcher.class).ranked(1);
             
bind(gravitinoEnv.statisticDispatcher()).to(StatisticDispatcher.class).ranked(1);
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
index 0c87f998b1..0f54d4506a 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
@@ -49,6 +49,7 @@ import 
org.apache.gravitino.exceptions.PolicyAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.PolicyAlreadyExistsException;
 import org.apache.gravitino.exceptions.RoleAlreadyExistsException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
+import org.apache.gravitino.exceptions.SemanticModelAlreadyExistsException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
 import org.apache.gravitino.exceptions.TagAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.TagAlreadyExistsException;
@@ -161,6 +162,20 @@ public class ExceptionHandlers {
     return FunctionExceptionHandler.INSTANCE.handle(op, function, schema, e);
   }
 
+  /**
+   * Handles an exception raised by a Semantic Model REST operation.
+   *
+   * @param op The operation type.
+   * @param semanticModel The Semantic Model name, or an empty string for a 
list operation.
+   * @param schema The parent schema name.
+   * @param e The exception to handle.
+   * @return The mapped REST response.
+   */
+  public static Response handleSemanticModelException(
+      OperationType op, String semanticModel, String schema, Exception e) {
+    return SemanticModelExceptionHandler.INSTANCE.handle(op, semanticModel, 
schema, e);
+  }
+
   public static Response handleJobTemplateException(
       OperationType op, String jobTemplate, String metalake, Exception e) {
     return JobTemplateExceptionHandler.INSTANCE.handle(op, jobTemplate, 
metalake, e);
@@ -990,6 +1005,49 @@ public class ExceptionHandlers {
     }
   }
 
+  private static class SemanticModelExceptionHandler extends 
BaseExceptionHandler {
+    private static final ExceptionHandler INSTANCE = new 
SemanticModelExceptionHandler();
+
+    private static String getSemanticModelErrorMsg(
+        String semanticModel, String operation, String schema, String reason) {
+      return String.format(
+          "Failed to operate Semantic Model(s)%s operation [%s] under schema 
[%s], reason [%s]",
+          semanticModel, operation, schema, reason);
+    }
+
+    @Override
+    public Response handle(OperationType op, String semanticModel, String 
schema, Exception e) {
+      String formatted = StringUtil.isBlank(semanticModel) ? "" : " [" + 
semanticModel + "]";
+      String errorMsg = getSemanticModelErrorMsg(formatted, op.name(), schema, 
getErrorMsg(e));
+      LOG.warn(errorMsg, e);
+
+      if (e instanceof IllegalArgumentException) {
+        return Utils.illegalArguments(errorMsg, e);
+
+      } else if (e instanceof NotFoundException) {
+        return Utils.notFound(errorMsg, e);
+
+      } else if (e instanceof SemanticModelAlreadyExistsException) {
+        return Utils.alreadyExists(errorMsg, e);
+
+      } else if (e instanceof ForbiddenException) {
+        return Utils.forbidden(errorMsg, e);
+
+      } else if (e instanceof UnsupportedOperationException) {
+        return Utils.unsupportedOperation(errorMsg, e);
+
+      } else if (e instanceof ConnectionFailedException) {
+        return Utils.connectionFailed(errorMsg, e);
+
+      } else if (e instanceof NotInUseException) {
+        return Utils.notInUse(errorMsg, e);
+
+      } else {
+        return super.handle(op, semanticModel, schema, e);
+      }
+    }
+  }
+
   private static class JobTemplateExceptionHandler extends 
BaseExceptionHandler {
 
     private static final ExceptionHandler INSTANCE = new 
JobTemplateExceptionHandler();
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
new file mode 100644
index 0000000000..1c011be250
--- /dev/null
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/SemanticModelOperations.java
@@ -0,0 +1,160 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.server.web.rest;
+
+import com.codahale.metrics.annotation.ResponseMetered;
+import com.codahale.metrics.annotation.Timed;
+import java.util.Collections;
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.catalog.SemanticModelDispatcher;
+import org.apache.gravitino.dto.requests.SemanticModelCreateRequest;
+import org.apache.gravitino.dto.responses.SemanticModelResponse;
+import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.metrics.MetricNames;
+import org.apache.gravitino.semantic.SemanticModel;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.server.web.Utils;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** REST create and load operations for schema-scoped Semantic Models. */
+@Path("metalakes/{metalake}/catalogs/{catalog}/schemas/{schema}/semantic-models")
+public class SemanticModelOperations {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(SemanticModelOperations.class);
+
+  private final SemanticModelDispatcher dispatcher;
+
+  @Context private HttpServletRequest httpRequest;
+
+  /**
+   * Creates Semantic Model REST operations.
+   *
+   * @param dispatcher The Semantic Model dispatcher.
+   */
+  @Inject
+  public SemanticModelOperations(SemanticModelDispatcher dispatcher) {
+    this.dispatcher = dispatcher;
+  }
+
+  /**
+   * Creates a Semantic Model.
+   *
+   * @param metalake The metalake name.
+   * @param catalog The catalog name.
+   * @param schema The schema name.
+   * @param request The structured Semantic Model create request.
+   * @return A response containing the created Semantic Model.
+   */
+  @POST
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "create-semantic-model." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "create-semantic-model", absolute = true)
+  public Response createSemanticModel(
+      @PathParam("metalake") String metalake,
+      @PathParam("catalog") String catalog,
+      @PathParam("schema") String schema,
+      SemanticModelCreateRequest request) {
+    String name = request == null ? "" : request.getName();
+    LOG.info(
+        "Received create Semantic Model request: {}.{}.{}.{}", metalake, 
catalog, schema, name);
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            if (request == null) {
+              throw new IllegalArgumentException("Request body must not be 
null");
+            }
+            request.validate();
+            SemanticModelDefinition definition = request.toDefinition();
+            NameIdentifier ident =
+                NameIdentifierUtil.ofSemanticModel(metalake, catalog, schema, 
request.getName());
+            SemanticModel semanticModel =
+                dispatcher.createSemanticModel(
+                    ident,
+                    request.getComment(),
+                    definition,
+                    request.getProperties() == null
+                        ? Collections.emptyMap()
+                        : request.getProperties());
+            LOG.info(
+                "Semantic Model created: {}.{}.{}.{}",
+                metalake,
+                catalog,
+                schema,
+                semanticModel.name());
+            return Utils.ok(new 
SemanticModelResponse(DTOConverters.toDTO(semanticModel)));
+          });
+    } catch (Exception e) {
+      return 
ExceptionHandlers.handleSemanticModelException(OperationType.CREATE, name, 
schema, e);
+    }
+  }
+
+  /**
+   * Loads a Semantic Model.
+   *
+   * @param metalake The metalake name.
+   * @param catalog The catalog name.
+   * @param schema The schema name.
+   * @param semanticModel The Semantic Model name.
+   * @return A response containing the loaded Semantic Model.
+   */
+  @GET
+  @Path("{semanticModel}")
+  @Produces("application/vnd.gravitino.v1+json")
+  @Timed(name = "load-semantic-model." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "load-semantic-model", absolute = true)
+  public Response loadSemanticModel(
+      @PathParam("metalake") String metalake,
+      @PathParam("catalog") String catalog,
+      @PathParam("schema") String schema,
+      @PathParam("semanticModel") String semanticModel) {
+    LOG.info(
+        "Received load Semantic Model request: {}.{}.{}.{}",
+        metalake,
+        catalog,
+        schema,
+        semanticModel);
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            NameIdentifier ident =
+                NameIdentifierUtil.ofSemanticModel(metalake, catalog, schema, 
semanticModel);
+            SemanticModel loaded = dispatcher.loadSemanticModel(ident);
+            LOG.info(
+                "Semantic Model loaded: {}.{}.{}.{}", metalake, catalog, 
schema, semanticModel);
+            return Utils.ok(new 
SemanticModelResponse(DTOConverters.toDTO(loaded)));
+          });
+    } catch (Exception e) {
+      return ExceptionHandlers.handleSemanticModelException(
+          OperationType.LOAD, semanticModel, schema, e);
+    }
+  }
+}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
new file mode 100644
index 0000000000..a463c33775
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
@@ -0,0 +1,495 @@
+/*
+ * 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.
+ */
+package org.apache.gravitino.server.web.rest;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.catalog.SemanticModelDispatcher;
+import org.apache.gravitino.dto.requests.SemanticModelCreateRequest;
+import org.apache.gravitino.dto.responses.ErrorConstants;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.SemanticModelResponse;
+import org.apache.gravitino.dto.semantic.DatasetDTO;
+import org.apache.gravitino.dto.semantic.SemanticModelDefinitionDTO;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.IllegalSemanticModelException;
+import org.apache.gravitino.exceptions.MetalakeNotInUseException;
+import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.apache.gravitino.exceptions.NoSuchSemanticModelException;
+import org.apache.gravitino.exceptions.SemanticModelAlreadyExistsException;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.SemanticModelEntity;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.semantic.AIContext;
+import org.apache.gravitino.semantic.CustomExtension;
+import org.apache.gravitino.semantic.DataType;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.DialectExpression;
+import org.apache.gravitino.semantic.Dialects;
+import org.apache.gravitino.semantic.Expression;
+import org.apache.gravitino.semantic.Field;
+import org.apache.gravitino.semantic.Metric;
+import org.apache.gravitino.semantic.SemanticModel;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.glassfish.jersey.internal.inject.AbstractBinder;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.TestProperties;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/** Tests the Semantic Model create/load REST resource and its error mappings. 
*/
+public class TestSemanticModelOperations extends BaseOperationsTest {
+
+  private static final String VND_V1_JSON = 
"application/vnd.gravitino.v1+json";
+
+  private static class MockServletRequestFactory extends 
ServletRequestFactoryBase {
+    @Override
+    public HttpServletRequest get() {
+      return mock(HttpServletRequest.class);
+    }
+  }
+
+  private final SemanticModelDispatcher dispatcher = 
mock(SemanticModelDispatcher.class);
+  private final String metalake = "semantic_model_metalake";
+  private final String catalog = "semantic_model_catalog";
+  private final String schema = "semantic_model_schema";
+  private final Namespace namespace = NamespaceUtil.ofSemanticModel(metalake, 
catalog, schema);
+
+  /** {@inheritDoc} */
+  @Override
+  protected Application configure() {
+    try {
+      forceSet(
+          TestProperties.CONTAINER_PORT, 
String.valueOf(RESTUtils.findAvailablePort(2000, 3000)));
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+
+    ResourceConfig resourceConfig = new ResourceConfig();
+    resourceConfig.register(SemanticModelOperations.class);
+    resourceConfig.register(
+        new AbstractBinder() {
+          @Override
+          protected void configure() {
+            bind(dispatcher).to(SemanticModelDispatcher.class).ranked(2);
+            
bindFactory(MockServletRequestFactory.class).to(HttpServletRequest.class);
+          }
+        });
+    return resourceConfig;
+  }
+
+  @BeforeEach
+  void resetDispatcher() {
+    reset(dispatcher);
+  }
+
+  @Test
+  void testLoadSemanticModelReturnsCompleteDefinitionWithoutWrites() {
+    NameIdentifier ident = semanticModelIdentifier("sales");
+    SemanticModel semanticModel = semanticModel("sales", "Sales definitions");
+    when(dispatcher.loadSemanticModel(ident)).thenReturn(semanticModel);
+
+    Response response = get(semanticModelPath() + "/sales");
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    SemanticModelResponse body = 
response.readEntity(SemanticModelResponse.class);
+    body.validate();
+    Assertions.assertEquals("sales", body.getSemanticModel().name());
+    Assertions.assertEquals("Sales definitions", 
body.getSemanticModel().comment());
+    Assertions.assertEquals(semanticModel.definition(), 
body.getSemanticModel().definition());
+    Assertions.assertEquals("orders", 
body.getSemanticModel().definition().datasets()[0].name());
+    Assertions.assertEquals(
+        "order_total", 
body.getSemanticModel().definition().metrics()[0].name());
+    Assertions.assertEquals(
+        "acme", 
body.getSemanticModel().definition().customExtensions()[0].vendorName());
+    Assertions.assertEquals(Map.of("domain", "sales"), 
body.getSemanticModel().properties());
+    Assertions.assertEquals("tester", 
body.getSemanticModel().auditInfo().creator());
+    verify(dispatcher).loadSemanticModel(ident);
+    verifyNoMoreInteractions(dispatcher);
+  }
+
+  @Test
+  void testLoadSemanticModelNotFound() {
+    NameIdentifier ident = semanticModelIdentifier("sales");
+    doThrow(new NoSuchSemanticModelException("sales does not exist"))
+        .when(dispatcher)
+        .loadSemanticModel(ident);
+
+    assertError(
+        get(semanticModelPath() + "/sales"),
+        Response.Status.NOT_FOUND,
+        ErrorConstants.NOT_FOUND_CODE,
+        NoSuchSemanticModelException.class.getSimpleName(),
+        "sales does not exist");
+  }
+
+  @Test
+  void testLoadSemanticModelNotInUse() {
+    NameIdentifier ident = semanticModelIdentifier("sales");
+    doThrow(new MetalakeNotInUseException("metalake is not in use"))
+        .when(dispatcher)
+        .loadSemanticModel(ident);
+
+    assertError(
+        get(semanticModelPath() + "/sales"),
+        Response.Status.CONFLICT,
+        ErrorConstants.NOT_IN_USE_CODE,
+        MetalakeNotInUseException.class.getSimpleName(),
+        "metalake is not in use");
+  }
+
+  @Test
+  void testCreateSemanticModelConvertsStructuredDefinition() {
+    NameIdentifier ident = semanticModelIdentifier("sales");
+    SemanticModelDefinition definition = semanticModelDefinition("TRINO");
+    SemanticModelCreateRequest request = createRequest("sales", "Sales 
definitions", definition);
+    SemanticModel semanticModel = semanticModel("sales", "Sales definitions");
+    when(dispatcher.createSemanticModel(
+            eq(ident),
+            eq("Sales definitions"),
+            any(SemanticModelDefinition.class),
+            eq(Map.of("domain", "sales"))))
+        .thenReturn(semanticModel);
+
+    Response response = post(semanticModelPath(), request);
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    SemanticModelResponse body = 
response.readEntity(SemanticModelResponse.class);
+    body.validate();
+    Assertions.assertEquals("sales", body.getSemanticModel().name());
+    Assertions.assertEquals(semanticModel.definition(), 
body.getSemanticModel().definition());
+
+    ArgumentCaptor<SemanticModelDefinition> definitionCaptor =
+        ArgumentCaptor.forClass(SemanticModelDefinition.class);
+    verify(dispatcher)
+        .createSemanticModel(
+            eq(ident),
+            eq("Sales definitions"),
+            definitionCaptor.capture(),
+            eq(Map.of("domain", "sales")));
+    Assertions.assertEquals(definition, definitionCaptor.getValue());
+    Assertions.assertEquals(
+        "TRINO", 
definitionCaptor.getValue().metrics()[0].expression().dialects()[0].dialect());
+    Assertions.assertEquals(DataType.DECIMAL, 
definitionCaptor.getValue().metrics()[0].datatype());
+    Assertions.assertEquals(
+        NameIdentifier.of(catalog, schema, "orders"),
+        definitionCaptor.getValue().datasets()[0].source());
+  }
+
+  @Test
+  void testCreateSemanticModelDefaultsOmittedProperties() {
+    NameIdentifier ident = semanticModelIdentifier("sales");
+    SemanticModelCreateRequest request =
+        new SemanticModelCreateRequest(
+            "sales",
+            "Sales definitions",
+            
SemanticModelDefinitionDTO.fromDefinition(semanticModelDefinition()),
+            null);
+    SemanticModel semanticModel = semanticModel("sales", "Sales definitions");
+    when(dispatcher.createSemanticModel(
+            eq(ident), eq("Sales definitions"), 
any(SemanticModelDefinition.class), eq(Map.of())))
+        .thenReturn(semanticModel);
+
+    Response response = post(semanticModelPath(), request);
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    verify(dispatcher)
+        .createSemanticModel(
+            eq(ident), eq("Sales definitions"), 
any(SemanticModelDefinition.class), eq(Map.of()));
+  }
+
+  @Test
+  void testCreateSemanticModelErrors() {
+    SemanticModelDefinition definition = semanticModelDefinition();
+    SemanticModelCreateRequest request = createRequest("sales", "Sales 
definitions", definition);
+    NameIdentifier ident = semanticModelIdentifier("sales");
+
+    SemanticModelCreateRequest invalidRequest =
+        new SemanticModelCreateRequest("sales", null, null, Map.of());
+    assertError(
+        post(semanticModelPath(), invalidRequest),
+        Response.Status.BAD_REQUEST,
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+        IllegalArgumentException.class.getSimpleName(),
+        "definition");
+
+    SemanticModelCreateRequest nullDatasetRequest =
+        new SemanticModelCreateRequest(
+            "sales",
+            null,
+            SemanticModelDefinitionDTO.builder().withDatasets(new DatasetDTO[] 
{null}).build(),
+            Map.of());
+    assertError(
+        post(semanticModelPath(), nullDatasetRequest),
+        Response.Status.BAD_REQUEST,
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+        IllegalArgumentException.class.getSimpleName(),
+        "datasets[0] must not be null");
+
+    SemanticModelCreateRequest emptyDatasetsRequest =
+        new SemanticModelCreateRequest(
+            "sales",
+            null,
+            SemanticModelDefinitionDTO.builder().withDatasets(new 
DatasetDTO[0]).build(),
+            Map.of());
+    assertError(
+        post(semanticModelPath(), emptyDatasetsRequest),
+        Response.Status.BAD_REQUEST,
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+        IllegalArgumentException.class.getSimpleName(),
+        "datasets must not be null or empty");
+
+    doThrow(new NoSuchSchemaException("schema is missing"))
+        .when(dispatcher)
+        .createSemanticModel(
+            eq(ident),
+            eq("Sales definitions"),
+            any(SemanticModelDefinition.class),
+            eq(Map.of("domain", "sales")));
+    assertError(
+        post(semanticModelPath(), request),
+        Response.Status.NOT_FOUND,
+        ErrorConstants.NOT_FOUND_CODE,
+        NoSuchSchemaException.class.getSimpleName(),
+        "schema is missing");
+
+    doThrow(new IllegalSemanticModelException("source column is missing"))
+        .when(dispatcher)
+        .createSemanticModel(
+            eq(ident),
+            eq("Sales definitions"),
+            any(SemanticModelDefinition.class),
+            eq(Map.of("domain", "sales")));
+    assertError(
+        post(semanticModelPath(), request),
+        Response.Status.BAD_REQUEST,
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+        IllegalSemanticModelException.class.getSimpleName(),
+        "source column is missing");
+
+    doThrow(new SemanticModelAlreadyExistsException("sales already exists"))
+        .when(dispatcher)
+        .createSemanticModel(
+            eq(ident),
+            eq("Sales definitions"),
+            any(SemanticModelDefinition.class),
+            eq(Map.of("domain", "sales")));
+    assertError(
+        post(semanticModelPath(), request),
+        Response.Status.CONFLICT,
+        ErrorConstants.ALREADY_EXISTS_CODE,
+        SemanticModelAlreadyExistsException.class.getSimpleName(),
+        "sales already exists");
+
+    doThrow(new ForbiddenException("source metadata is not visible"))
+        .when(dispatcher)
+        .createSemanticModel(
+            eq(ident),
+            eq("Sales definitions"),
+            any(SemanticModelDefinition.class),
+            eq(Map.of("domain", "sales")));
+    assertError(
+        post(semanticModelPath(), request),
+        Response.Status.FORBIDDEN,
+        ErrorConstants.FORBIDDEN_CODE,
+        ForbiddenException.class.getSimpleName(),
+        "source metadata is not visible");
+
+    doThrow(new ConnectionFailedException("source catalog is unavailable"))
+        .when(dispatcher)
+        .createSemanticModel(
+            eq(ident),
+            eq("Sales definitions"),
+            any(SemanticModelDefinition.class),
+            eq(Map.of("domain", "sales")));
+    ErrorResponse unavailable =
+        assertError(
+            post(semanticModelPath(), request),
+            Response.Status.BAD_GATEWAY,
+            ErrorConstants.CONNECTION_FAILED_CODE,
+            ConnectionFailedException.class.getSimpleName(),
+            "source catalog is unavailable");
+    Assertions.assertNotNull(unavailable.getStack());
+    Assertions.assertFalse(unavailable.getStack().isEmpty());
+  }
+
+  @Test
+  void testCreateSemanticModelRejectsNullBody() {
+    assertError(
+        postJson(semanticModelPath(), "null"),
+        Response.Status.BAD_REQUEST,
+        ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
+        IllegalArgumentException.class.getSimpleName(),
+        "Request body must not be null");
+    verifyNoMoreInteractions(dispatcher);
+  }
+
+  @Test
+  void testCreateSemanticModelMapsUnsupportedCatalog() {
+    SemanticModelCreateRequest request =
+        createRequest("sales", "Sales definitions", semanticModelDefinition());
+    doThrow(new UnsupportedOperationException("catalog is not relational"))
+        .when(dispatcher)
+        .createSemanticModel(
+            eq(semanticModelIdentifier("sales")),
+            eq("Sales definitions"),
+            any(SemanticModelDefinition.class),
+            eq(Map.of("domain", "sales")));
+
+    assertError(
+        post(semanticModelPath(), request),
+        Response.Status.NOT_IMPLEMENTED,
+        ErrorConstants.UNSUPPORTED_OPERATION_CODE,
+        UnsupportedOperationException.class.getSimpleName(),
+        "catalog is not relational");
+  }
+
+  private SemanticModelCreateRequest createRequest(
+      String name, String comment, SemanticModelDefinition definition) {
+    return new SemanticModelCreateRequest(
+        name,
+        comment,
+        SemanticModelDefinitionDTO.fromDefinition(definition),
+        Map.of("domain", "sales"));
+  }
+
+  private SemanticModel semanticModel(String name, String comment) {
+    return SemanticModelEntity.builder()
+        .withId(1L)
+        .withName(name)
+        .withNamespace(namespace)
+        .withComment(comment)
+        .withDefinition(semanticModelDefinition())
+        .withProperties(Map.of("domain", "sales"))
+        .withAuditInfo(
+            AuditInfo.builder()
+                .withCreator("tester")
+                .withCreateTime(Instant.parse("2026-08-25T00:00:00Z"))
+                .build())
+        .build();
+  }
+
+  private SemanticModelDefinition semanticModelDefinition() {
+    return semanticModelDefinition(Dialects.ANSI_SQL);
+  }
+
+  private SemanticModelDefinition semanticModelDefinition(String dialect) {
+    Field field =
+        Field.builder()
+            .withName("order_id")
+            .withExpression(expression(dialect, "orders.order_id"))
+            .withDatatype(DataType.STRING)
+            .build();
+    Dataset dataset =
+        Dataset.builder()
+            .withName("orders")
+            .withSource(NameIdentifier.of(catalog, schema, "orders"))
+            .withPrimaryKey(new String[] {"order_id"})
+            .withFields(new Field[] {field})
+            .build();
+    Metric metric =
+        Metric.builder()
+            .withName("order_total")
+            .withExpression(expression(dialect, "SUM(orders.amount)"))
+            .withDatatype(DataType.DECIMAL)
+            .build();
+    CustomExtension customExtension =
+        
CustomExtension.builder().withVendorName("acme").withData("{\"certified\":true}").build();
+    return SemanticModelDefinition.builder()
+        .withAIContext(AIContext.of("Use certified metrics"))
+        .withDatasets(new Dataset[] {dataset})
+        .withMetrics(new Metric[] {metric})
+        .withCustomExtensions(new CustomExtension[] {customExtension})
+        .build();
+  }
+
+  private static Expression expression(String dialect, String value) {
+    DialectExpression dialectExpression =
+        
DialectExpression.builder().withDialect(dialect).withExpression(value).build();
+    return Expression.builder().withDialects(new DialectExpression[] 
{dialectExpression}).build();
+  }
+
+  private NameIdentifier semanticModelIdentifier(String name) {
+    return NameIdentifierUtil.ofSemanticModel(metalake, catalog, schema, name);
+  }
+
+  private String semanticModelPath() {
+    return "/metalakes/"
+        + metalake
+        + "/catalogs/"
+        + catalog
+        + "/schemas/"
+        + schema
+        + "/semantic-models";
+  }
+
+  private Response get(String path) {
+    return 
target(path).request(MediaType.APPLICATION_JSON_TYPE).accept(VND_V1_JSON).get();
+  }
+
+  private Response post(String path, SemanticModelCreateRequest request) {
+    return target(path)
+        .request(MediaType.APPLICATION_JSON_TYPE)
+        .accept(VND_V1_JSON)
+        .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));
+  }
+
+  private Response postJson(String path, String json) {
+    return target(path)
+        .request(MediaType.APPLICATION_JSON_TYPE)
+        .accept(VND_V1_JSON)
+        .post(Entity.entity(json, MediaType.APPLICATION_JSON_TYPE));
+  }
+
+  private static ErrorResponse assertError(
+      Response response,
+      Response.Status expectedStatus,
+      int expectedCode,
+      String expectedType,
+      String expectedMessageFragment) {
+    Assertions.assertEquals(expectedStatus.getStatusCode(), 
response.getStatus());
+    ErrorResponse error = response.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(expectedCode, error.getCode());
+    Assertions.assertEquals(expectedType, error.getType());
+    
Assertions.assertTrue(error.getMessage().contains(expectedMessageFragment));
+    return error;
+  }
+}

Reply via email to