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

roryqi 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 989ab1f2f7 [#12379] feat(core): Support assignment values in REST and 
Java client (#12384)
989ab1f2f7 is described below

commit 989ab1f2f78bff4424d7d764d1f6af0cc6b62fc9
Author: roryqi <[email protected]>
AuthorDate: Wed Aug 12 10:57:43 2026 +0800

    [#12379] feat(core): Support assignment values in REST and Java client 
(#12384)
    
    ### What changes were proposed in this pull request?
    
    Add REST and Java client support for tag assignment values:
    
    - Support creating tags with value constraints.
    - Support associating and removing tag assignment values.
    - Support retrieving assignment values from associated tags.
    - Support listing associated metadata objects by assignment value.
    - Use the v2 REST media type for valued tag association requests while
    retaining v1 behavior for legacy requests.
    
    ### Why are the changes needed?
    
    The tag assignment value APIs added in the API and core layers need
    corresponding REST endpoints and Java client implementations.
    
    Fix: #12379
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Java client users can create tags with value constraints, associate
    values with tags, retrieve assigned values, and filter associated
    objects by value.
    
    ### How was this patch tested?
    
    - Added REST unit tests for version handling, authorization, tag
    creation, association, removal, and value filtering.
    - Added Java client unit tests for constraints, assignments, valued
    requests, and value filtering.
    - Added and passed `TagIT.testAssignmentValues`.
    - Passed the focused server and Java client regression tests.
---
 .../apache/gravitino/client/BaseSchemaCatalog.java |   6 +
 .../org/apache/gravitino/client/GenericColumn.java |   6 +
 .../apache/gravitino/client/GenericFileset.java    |   6 +
 .../apache/gravitino/client/GenericFunction.java   |   6 +
 .../org/apache/gravitino/client/GenericModel.java  |   6 +
 .../org/apache/gravitino/client/GenericSchema.java |   6 +
 .../org/apache/gravitino/client/GenericTag.java    |  39 ++++++
 .../org/apache/gravitino/client/GenericTopic.java  |   6 +
 .../org/apache/gravitino/client/GenericView.java   |   6 +
 .../apache/gravitino/client/GravitinoClient.java   |  11 ++
 .../apache/gravitino/client/GravitinoMetalake.java |  41 ++++++-
 .../client/MetadataObjectTagOperations.java        |  26 ++++
 .../apache/gravitino/client/RelationalTable.java   |   6 +
 .../apache/gravitino/client/TestGenericTag.java    |  24 ++++
 .../gravitino/client/TestGravitinoMetalake.java    |  36 ++++++
 .../apache/gravitino/client/TestSupportTags.java   |  22 ++++
 .../gravitino/client/integration/test/TagIT.java   |  50 ++++++++
 .../dto/requests/TagValuesAssociateRequest.java    |  56 +++++++--
 .../requests/TestTagValuesAssociateRequest.java    |  40 +++++++
 .../storage/relational/RelationalEntityStore.java  |  36 +++---
 .../storage/relational/service/TagMetaService.java |   8 +-
 .../java/org/apache/gravitino/tag/TagManager.java  |  64 ++++++++--
 .../relational/TestRelationalEntityStore.java      |  60 ++++++----
 .../org/apache/gravitino/tag/TestTagManager.java   |  48 ++++++++
 design-docs/tag-assignment-values.md               |   7 +-
 docs/open-api/tags.yaml                            | 118 ++++++++++++++++++-
 .../apache/gravitino/server/web/ApiVersion.java    |  11 +-
 .../gravitino/server/web/VersioningFilter.java     |  80 +++++++++----
 .../AssociateTagAuthorizationExecutor.java         |  48 ++++++--
 .../web/rest/MetadataObjectTagOperations.java      | 120 +++++++++++++++----
 .../gravitino/server/web/rest/TagOperations.java   |  37 +++++-
 .../gravitino/server/web/TestApiVersion.java       |  11 +-
 .../gravitino/server/web/TestVersioningFilter.java |  82 +++++++++++--
 .../filter/TestGravitinoInterceptionService.java   |  93 +++++++++++++++
 .../TestAssociateTagAuthorizationExecutor.java     | 128 ++++++++++++++++++++
 .../web/rest/TestMetadataObjectTagOperations.java  | 131 ++++++++++++++++++++-
 .../server/web/rest/TestTagOperations.java         |  52 ++++++--
 37 files changed, 1377 insertions(+), 156 deletions(-)

diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
index 0553abfc4b..636090dba0 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/BaseSchemaCatalog.java
@@ -58,6 +58,7 @@ import org.apache.gravitino.policy.SupportsPolicies;
 import org.apache.gravitino.rest.RESTUtils;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /**
  * BaseSchemaCatalog is the base abstract class for all the catalog with 
schema. It provides the
@@ -288,6 +289,11 @@ abstract class BaseSchemaCatalog extends CatalogDTO
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public String[] listPolicies() {
     return objectPolicyOperations.listPolicies();
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericColumn.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericColumn.java
index aacf022e95..c7845db865 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericColumn.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericColumn.java
@@ -29,6 +29,7 @@ import org.apache.gravitino.rel.expressions.Expression;
 import org.apache.gravitino.rel.types.Type;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a generic column. */
 public class GenericColumn implements Column, SupportsTags {
@@ -78,6 +79,11 @@ public class GenericColumn implements Column, SupportsTags {
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public String name() {
     return internalColumn.name();
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFileset.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFileset.java
index a6963a6a44..d51e4fea37 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFileset.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFileset.java
@@ -38,6 +38,7 @@ import org.apache.gravitino.policy.Policy;
 import org.apache.gravitino.policy.SupportsPolicies;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a generic fileset. */
 class GenericFileset
@@ -136,6 +137,11 @@ class GenericFileset
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public String[] listPolicies() {
     return objectPolicyOperations.listPolicies();
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFunction.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFunction.java
index fd3bcb459b..2bb58b7ec3 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFunction.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFunction.java
@@ -32,6 +32,7 @@ import org.apache.gravitino.function.FunctionDefinition;
 import org.apache.gravitino.function.FunctionType;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a generic function. */
 class GenericFunction implements Function, SupportsTags {
@@ -106,6 +107,11 @@ class GenericFunction implements Function, SupportsTags {
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public boolean equals(Object obj) {
     if (this == obj) {
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModel.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModel.java
index 076ed38143..14dc61a057 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModel.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericModel.java
@@ -36,6 +36,7 @@ import org.apache.gravitino.policy.Policy;
 import org.apache.gravitino.policy.SupportsPolicies;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a generic model. */
 class GenericModel implements Model, SupportsTags, SupportsPolicies {
@@ -135,6 +136,11 @@ class GenericModel implements Model, SupportsTags, 
SupportsPolicies {
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public String[] listPolicies() {
     return objectPolicyOperations.listPolicies();
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericSchema.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericSchema.java
index bbb5f84300..09ecbc8916 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericSchema.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericSchema.java
@@ -33,6 +33,7 @@ import org.apache.gravitino.policy.Policy;
 import org.apache.gravitino.policy.SupportsPolicies;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a generic schema. */
 class GenericSchema implements Schema, SupportsTags, SupportsRoles, 
SupportsPolicies {
@@ -109,6 +110,11 @@ class GenericSchema implements Schema, SupportsTags, 
SupportsRoles, SupportsPoli
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public String[] listPolicies() {
     return objectPolicyOperations.listPolicies();
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTag.java 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTag.java
index 52bfc4db37..6bf7921e39 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTag.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTag.java
@@ -18,19 +18,26 @@
  */
 package org.apache.gravitino.client;
 
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
 import java.util.Collections;
 import java.util.Map;
 import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Audit;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.dto.responses.MetadataObjectListResponse;
 import org.apache.gravitino.dto.tag.TagDTO;
 import org.apache.gravitino.rest.RESTUtils;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagAssignment;
+import org.apache.gravitino.tag.TagValueConstraint;
 
 /** Represents a generic tag. */
 class GenericTag implements Tag, Tag.AssociatedObjects {
 
+  private static final int MAX_TAG_VALUE_LENGTH = 256;
+
   private final TagDTO tagDTO;
 
   private final RESTClient restClient;
@@ -58,6 +65,16 @@ class GenericTag implements Tag, Tag.AssociatedObjects {
     return tagDTO.properties();
   }
 
+  @Override
+  public TagValueConstraint valueConstraint() {
+    return tagDTO.valueConstraint();
+  }
+
+  @Override
+  public Optional<TagAssignment> assignment() {
+    return tagDTO.assignment();
+  }
+
   @Override
   public Optional<Boolean> inherited() {
     return tagDTO.inherited();
@@ -88,6 +105,28 @@ class GenericTag implements Tag, Tag.AssociatedObjects {
     return resp.getMetadataObjects();
   }
 
+  @Override
+  public MetadataObject[] objects(String value) {
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(value), "Tag assignment value must not be null 
or empty");
+    Preconditions.checkArgument(
+        value.length() <= MAX_TAG_VALUE_LENGTH,
+        "Tag assignment value must not exceed " + MAX_TAG_VALUE_LENGTH + " 
characters");
+
+    MetadataObjectListResponse resp =
+        restClient.get(
+            String.format(
+                "api/metalakes/%s/tags/%s/objects",
+                RESTUtils.encodeString(metalake), 
RESTUtils.encodeString(name())),
+            ImmutableMap.of("value", value),
+            MetadataObjectListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+
+    resp.validate();
+    return resp.getMetadataObjects();
+  }
+
   @Override
   public boolean equals(Object obj) {
     if (this == obj) {
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTopic.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTopic.java
index a040bbbd93..a69579d15f 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTopic.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTopic.java
@@ -35,6 +35,7 @@ import org.apache.gravitino.policy.Policy;
 import org.apache.gravitino.policy.SupportsPolicies;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a generic topic. */
 class GenericTopic implements Topic, SupportsTags, SupportsRoles, 
SupportsPolicies {
@@ -113,6 +114,11 @@ class GenericTopic implements Topic, SupportsTags, 
SupportsRoles, SupportsPolici
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public String[] listPolicies() {
     return objectPolicyOperations.listPolicies();
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericView.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericView.java
index a38228806a..aa774f7539 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericView.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericView.java
@@ -33,6 +33,7 @@ import org.apache.gravitino.rel.Representation;
 import org.apache.gravitino.rel.View;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a generic view. */
 class GenericView implements View, SupportsTags {
@@ -116,6 +117,11 @@ class GenericView implements View, SupportsTags {
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public boolean equals(Object obj) {
     if (this == obj) {
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
index 16b222ba8d..cb8afa01a5 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClient.java
@@ -70,6 +70,7 @@ import org.apache.gravitino.policy.PolicyOperations;
 import org.apache.gravitino.tag.Tag;
 import org.apache.gravitino.tag.TagChange;
 import org.apache.gravitino.tag.TagOperations;
+import org.apache.gravitino.tag.TagValueConstraint;
 
 /**
  * Apache Gravitino Client for a user to interact with the Gravitino API, 
allowing the client to
@@ -595,6 +596,16 @@ public class GravitinoClient extends GravitinoClientBase
     return getMetalake().createTag(name, comment, properties);
   }
 
+  @Override
+  public Tag createTag(
+      String name,
+      String comment,
+      Map<String, String> properties,
+      TagValueConstraint valueConstraint)
+      throws TagAlreadyExistsException {
+    return getMetalake().createTag(name, comment, properties, valueConstraint);
+  }
+
   @Override
   public Tag alterTag(String name, TagChange... changes)
       throws NoSuchTagException, IllegalArgumentException, 
TagAlreadyExistsException {
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
index f88a33a227..eed967128d 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java
@@ -131,6 +131,7 @@ import org.apache.gravitino.rest.RESTUtils;
 import org.apache.gravitino.tag.Tag;
 import org.apache.gravitino.tag.TagChange;
 import org.apache.gravitino.tag.TagOperations;
+import org.apache.gravitino.tag.TagValueConstraint;
 
 /**
  * Apache Gravitino Metalake is the top-level metadata repository for users. 
It contains a list of
@@ -510,8 +511,46 @@ public class GravitinoMetalake extends MetalakeDTO
   @Override
   public Tag createTag(String name, String comment, Map<String, String> 
properties)
       throws TagAlreadyExistsException {
+    return createTag(name, comment, properties, TagValueConstraint.anyValue());
+  }
+
+  /**
+   * Create a tag under the current metalake with an assignment value 
constraint.
+   *
+   * @param name The name of the tag.
+   * @param comment The comment of the tag.
+   * @param properties The properties of the tag.
+   * @param valueConstraint The assignment value constraint of the tag.
+   * @return The created tag.
+   * @throws TagAlreadyExistsException If the tag already exists.
+   */
+  @Override
+  public Tag createTag(
+      String name,
+      String comment,
+      Map<String, String> properties,
+      TagValueConstraint valueConstraint)
+      throws TagAlreadyExistsException {
     Preconditions.checkArgument(StringUtils.isNotBlank(name), "tag name must 
not be null or empty");
-    TagCreateRequest req = new TagCreateRequest(name, comment, properties);
+    TagValueConstraint normalizedConstraint =
+        valueConstraint == null ? TagValueConstraint.anyValue() : 
valueConstraint;
+    String[] allowedValues;
+    switch (normalizedConstraint.type()) {
+      case ANY_VALUE:
+        allowedValues = null;
+        break;
+      case NO_VALUE:
+        allowedValues = new String[0];
+        break;
+      case ALLOWED_VALUES:
+        allowedValues = normalizedConstraint.allowedValues();
+        break;
+      default:
+        throw new IllegalArgumentException(
+            "Unknown tag value constraint: " + normalizedConstraint.type());
+    }
+
+    TagCreateRequest req = new TagCreateRequest(name, comment, properties, 
allowedValues);
     req.validate();
 
     TagResponse resp =
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/MetadataObjectTagOperations.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/MetadataObjectTagOperations.java
index 29ec00512b..c504c9a212 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/MetadataObjectTagOperations.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/MetadataObjectTagOperations.java
@@ -25,6 +25,7 @@ import java.util.Collections;
 import java.util.Locale;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
 import org.apache.gravitino.dto.requests.TagsAssociateRequest;
 import org.apache.gravitino.dto.responses.NameListResponse;
 import org.apache.gravitino.dto.responses.TagListResponse;
@@ -33,6 +34,8 @@ import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.rest.RESTUtils;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
+import org.apache.hc.core5.http.HttpHeaders;
 
 /**
  * The implementation of {@link SupportsTags}. This interface will be 
composited into catalog,
@@ -40,6 +43,8 @@ import org.apache.gravitino.tag.Tag;
  */
 class MetadataObjectTagOperations implements SupportsTags {
 
+  private static final String TAG_VALUES_MEDIA_TYPE = 
"application/vnd.gravitino.v2+json";
+
   private final String metalakeName;
 
   private final RESTClient restClient;
@@ -118,4 +123,25 @@ class MetadataObjectTagOperations implements SupportsTags {
     resp.validate();
     return resp.getNames();
   }
+
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    TagValuesAssociateRequest request = new 
TagValuesAssociateRequest(tagsToAdd, tagsToRemove);
+    request.validate();
+
+    NameListResponse resp =
+        restClient.post(
+            tagRequestPath,
+            request,
+            NameListResponse.class,
+            ImmutableMap.of(
+                HttpHeaders.ACCEPT,
+                TAG_VALUES_MEDIA_TYPE,
+                HttpHeaders.CONTENT_TYPE,
+                TAG_VALUES_MEDIA_TYPE),
+            ErrorHandlers.tagErrorHandler());
+
+    resp.validate();
+    return resp.getNames();
+  }
 }
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalTable.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalTable.java
index fac38e76ac..4b4b6c0aac 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalTable.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalTable.java
@@ -69,6 +69,7 @@ import org.apache.gravitino.stats.SupportsPartitionStatistics;
 import org.apache.gravitino.stats.SupportsStatistics;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
 
 /** Represents a relational table. */
 class RelationalTable
@@ -392,6 +393,11 @@ class RelationalTable
     return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
   }
 
+  @Override
+  public String[] associateTags(TagValue[] tagsToAdd, TagValue[] tagsToRemove) 
{
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   @Override
   public String[] listPolicies() {
     return objectPolicyOperations.listPolicies();
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericTag.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericTag.java
index 20463ddb86..5af79c9def 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericTag.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericTag.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.client;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.collect.ImmutableMap;
 import java.time.Instant;
 import java.util.Collections;
 import org.apache.gravitino.MetadataObject;
@@ -31,6 +32,7 @@ import org.apache.gravitino.dto.tag.MetadataObjectDTO;
 import org.apache.gravitino.dto.tag.TagDTO;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValueConstraint;
 import org.apache.hc.core5.http.HttpStatus;
 import org.apache.hc.core5.http.Method;
 import org.junit.jupiter.api.AfterAll;
@@ -47,6 +49,8 @@ public class TestGenericTag extends TestBase {
           .withName("tag1")
           .withComment("comment1")
           .withProperties(Collections.emptyMap())
+          .withAllowedValues(new String[] {"finance", "risk"})
+          .withAssignmentValues(new String[] {"finance"})
           
.withAudit(AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build())
           .build();
 
@@ -79,6 +83,16 @@ public class TestGenericTag extends TestBase {
     gravitinoClient.close();
   }
 
+  @Test
+  public void testValueConstraintAndAssignment() {
+    Tag tag = new GenericTag(tagDTO, gravitinoClient.restClient(), 
metalakeName);
+
+    Assertions.assertEquals(
+        TagValueConstraint.ofAllowedValues("finance", "risk"), 
tag.valueConstraint());
+    Assertions.assertTrue(tag.assignment().isPresent());
+    Assertions.assertArrayEquals(new String[] {"finance"}, 
tag.assignment().get().values());
+  }
+
   @Test
   public void testAssociatedObjects() throws JsonProcessingException {
     Tag tag = new GenericTag(tagDTO, gravitinoClient.restClient(), 
metalakeName);
@@ -121,6 +135,16 @@ public class TestGenericTag extends TestBase {
       Assertions.assertEquals(object.type(), actualObject.type());
     }
 
+    buildMockResource(
+        Method.GET, path, ImmutableMap.of("value", "finance"), null, resp, 
HttpStatus.SC_OK);
+    MetadataObject[] valueObjects = tag.associatedObjects().objects("finance");
+    Assertions.assertEquals(objects.length, valueObjects.length);
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
tag.associatedObjects().objects(""));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> tag.associatedObjects().objects(String.join("", 
Collections.nCopies(257, "a"))));
+
     // Test return empty array
     buildMockResource(
         Method.GET,
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoMetalake.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoMetalake.java
index 0484cbc6b6..09ba0761b7 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoMetalake.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoMetalake.java
@@ -74,6 +74,7 @@ import org.apache.gravitino.policy.PolicyChange;
 import org.apache.gravitino.policy.PolicyContents;
 import org.apache.gravitino.tag.Tag;
 import org.apache.gravitino.tag.TagChange;
+import org.apache.gravitino.tag.TagValueConstraint;
 import org.apache.hc.core5.http.HttpStatus;
 import org.apache.hc.core5.http.Method;
 import org.junit.jupiter.api.AfterAll;
@@ -579,6 +580,41 @@ public class TestGravitinoMetalake extends TestBase {
     Assertions.assertNull(tag.comment());
     Assertions.assertNull(tag.properties());
 
+    TagValueConstraint valueConstraint = 
TagValueConstraint.ofAllowedValues("finance", "risk");
+    TagCreateRequest constrainedRequest =
+        new TagCreateRequest(tagName, null, null, new String[] {"finance", 
"risk"});
+    TagDTO constrainedTagDTO =
+        TagDTO.builder()
+            .withName(tagName)
+            .withAllowedValues(new String[] {"finance", "risk"})
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    buildMockResource(
+        Method.POST,
+        path,
+        constrainedRequest,
+        new TagResponse(constrainedTagDTO),
+        HttpStatus.SC_OK);
+
+    Tag constrainedTag = gravitinoClient.createTag(tagName, null, null, 
valueConstraint);
+    Assertions.assertEquals(valueConstraint, constrainedTag.valueConstraint());
+
+    TagValueConstraint noValueConstraint = TagValueConstraint.noValue();
+    TagCreateRequest noValueRequest = new TagCreateRequest(tagName, null, 
null, new String[0]);
+    TagDTO noValueTagDTO =
+        TagDTO.builder()
+            .withName(tagName)
+            .withAllowedValues(new String[0])
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    buildMockResource(
+        Method.POST, path, noValueRequest, new TagResponse(noValueTagDTO), 
HttpStatus.SC_OK);
+
+    Tag noValueTag = gravitinoClient.createTag(tagName, null, null, 
noValueConstraint);
+    Assertions.assertEquals(noValueConstraint, noValueTag.valueConstraint());
+
     // Test with null name
     Throwable ex =
         Assertions.assertThrows(
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportTags.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportTags.java
index 7ee356ebae..6f73848ed5 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportTags.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportTags.java
@@ -42,6 +42,7 @@ import org.apache.gravitino.dto.rel.ColumnDTO;
 import org.apache.gravitino.dto.rel.SQLRepresentationDTO;
 import org.apache.gravitino.dto.rel.TableDTO;
 import org.apache.gravitino.dto.rel.ViewDTO;
+import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
 import org.apache.gravitino.dto.requests.TagsAssociateRequest;
 import org.apache.gravitino.dto.responses.ErrorResponse;
 import org.apache.gravitino.dto.responses.NameListResponse;
@@ -61,10 +62,14 @@ import org.apache.gravitino.rel.View;
 import org.apache.gravitino.rel.types.Types;
 import org.apache.gravitino.tag.SupportsTags;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagValue;
+import org.apache.hc.core5.http.HttpHeaders;
 import org.apache.hc.core5.http.Method;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.mockserver.model.HttpRequest;
+import org.mockserver.verify.VerificationTimes;
 
 public class TestSupportTags extends TestBase {
 
@@ -640,6 +645,23 @@ public class TestSupportTags extends TestBase {
     String[] actualTags = supportsTags.associateTags(tagsToAdd, tagsToRemove);
     Assertions.assertArrayEquals(tagsToAdd, actualTags);
 
+    TagValue[] tagValuesToAdd =
+        new TagValue[] {TagValue.noValue("tag1"), TagValue.of("tag2", 
"finance")};
+    TagValue[] tagValuesToRemove = new TagValue[] {TagValue.of("tag3", 
"risk")};
+    TagValuesAssociateRequest valueRequest =
+        new TagValuesAssociateRequest(tagValuesToAdd, tagValuesToRemove);
+    buildMockResource(Method.POST, path, valueRequest, resp, SC_OK);
+
+    String[] actualValueTags = supportsTags.associateTags(tagValuesToAdd, 
tagValuesToRemove);
+    Assertions.assertArrayEquals(tagsToAdd, actualValueTags);
+    mockServer.verify(
+        HttpRequest.request(path)
+            .withMethod(Method.POST.name())
+            .withHeader(HttpHeaders.ACCEPT, 
"application/vnd.gravitino.v2+json")
+            .withHeader(HttpHeaders.CONTENT_TYPE, 
"application/vnd.gravitino.v2+json")
+            .withBody(MAPPER.writeValueAsString(valueRequest)),
+        VerificationTimes.once());
+
     // Test throw internal error
     ErrorResponse errorResp1 = ErrorResponse.internalError("mock error");
     buildMockResource(Method.POST, path, request, errorResp1, 
SC_INTERNAL_SERVER_ERROR);
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
index ffa59da404..676484a72f 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
@@ -26,6 +26,7 @@ import java.util.Set;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Schema;
 import org.apache.gravitino.client.GravitinoMetalake;
@@ -54,6 +55,8 @@ import org.apache.gravitino.rel.View;
 import org.apache.gravitino.rel.types.Types;
 import org.apache.gravitino.tag.Tag;
 import org.apache.gravitino.tag.TagChange;
+import org.apache.gravitino.tag.TagValue;
+import org.apache.gravitino.tag.TagValueConstraint;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
@@ -298,6 +301,53 @@ public class TagIT extends BaseIT {
     Assertions.assertEquals(tag2, tag3);
   }
 
+  @Test
+  public void testAssignmentValues() {
+    String tagName = GravitinoITUtils.genRandomName("tag_it_assignment_value");
+    TagValueConstraint constraint = 
TagValueConstraint.ofAllowedValues("finance", "risk");
+    Tag tag = metalake.createTag(tagName, "comment", Collections.emptyMap(), 
constraint);
+    Assertions.assertEquals(constraint, tag.valueConstraint());
+
+    TagValue[] values =
+        new TagValue[] {TagValue.of(tagName, "finance"), TagValue.of(tagName, 
"risk")};
+    Assertions.assertArrayEquals(
+        new String[] {tagName}, table.supportsTags().associateTags(values, 
null));
+
+    Tag assignedTag = table.supportsTags().getTag(tagName);
+    Assertions.assertEquals(constraint, assignedTag.valueConstraint());
+    Assertions.assertTrue(assignedTag.assignment().isPresent());
+    Assertions.assertArrayEquals(valuesToStrings(values), 
assignedTag.assignment().get().values());
+    Assertions.assertFalse(assignedTag.inherited().get());
+
+    MetadataObject expectedTable =
+        MetadataObjects.of(
+            relationalCatalog.name() + "." + schema.name(),
+            table.name(),
+            MetadataObject.Type.TABLE);
+    MetadataObject[] financeObjects = 
tag.associatedObjects().objects("finance");
+    Assertions.assertEquals(1, financeObjects.length);
+    Assertions.assertEquals(expectedTable.fullName(), 
financeObjects[0].fullName());
+    Assertions.assertEquals(expectedTable.type(), financeObjects[0].type());
+    Assertions.assertEquals(0, 
tag.associatedObjects().objects("unknown").length);
+
+    String noValueTagName = 
GravitinoITUtils.genRandomName("tag_it_no_value_assignment");
+    metalake.createTag(noValueTagName, "comment", Collections.emptyMap());
+    TagValue[] noValueValues = new TagValue[] 
{TagValue.noValue(noValueTagName)};
+    Assertions.assertDoesNotThrow(() -> 
table.supportsTags().associateTags(noValueValues, null));
+    Assertions.assertDoesNotThrow(() -> 
table.supportsTags().associateTags(noValueValues, null));
+
+    Tag noValueAssignedTag = table.supportsTags().getTag(noValueTagName);
+    Assertions.assertTrue(noValueAssignedTag.assignment().isPresent());
+    Assertions.assertEquals(0, 
noValueAssignedTag.assignment().get().values().length);
+    table.supportsTags().associateTags(null, noValueValues);
+
+    Assertions.assertEquals(0, table.supportsTags().associateTags(null, 
values).length);
+  }
+
+  private static String[] valuesToStrings(TagValue[] values) {
+    return Arrays.stream(values).map(value -> 
value.value().get()).toArray(String[]::new);
+  }
+
   @Test
   public void testNullableComment() {
     String tagName = GravitinoITUtils.genRandomName("tag_it_tag");
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/requests/TagValuesAssociateRequest.java
 
b/common/src/main/java/org/apache/gravitino/dto/requests/TagValuesAssociateRequest.java
index 8eaf2720bc..56bec53d12 100644
--- 
a/common/src/main/java/org/apache/gravitino/dto/requests/TagValuesAssociateRequest.java
+++ 
b/common/src/main/java/org/apache/gravitino/dto/requests/TagValuesAssociateRequest.java
@@ -19,8 +19,12 @@
 package org.apache.gravitino.dto.requests;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSetter;
+import com.fasterxml.jackson.annotation.Nulls;
 import com.google.common.base.Preconditions;
 import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
 import javax.annotation.Nullable;
 import lombok.EqualsAndHashCode;
 import lombok.ToString;
@@ -34,9 +38,11 @@ public class TagValuesAssociateRequest implements 
RESTRequest {
   private static final int MAX_TAG_VALUE_LENGTH = 256;
 
   @JsonProperty("tagsToAdd")
+  @JsonSetter(nulls = Nulls.AS_EMPTY)
   private final RequestTagValue[] tagsToAdd;
 
   @JsonProperty("tagsToRemove")
+  @JsonSetter(nulls = Nulls.AS_EMPTY)
   private final RequestTagValue[] tagsToRemove;
 
   /**
@@ -52,8 +58,7 @@ public class TagValuesAssociateRequest implements RESTRequest 
{
 
   /** This is the constructor that is used by Jackson deserializer */
   public TagValuesAssociateRequest() {
-    this.tagsToAdd = null;
-    this.tagsToRemove = null;
+    this(null, null);
   }
 
   /**
@@ -74,6 +79,24 @@ public class TagValuesAssociateRequest implements 
RESTRequest {
     return toTagValues(tagsToRemove);
   }
 
+  /**
+   * Returns the tag names to add without validating assignment values.
+   *
+   * @return The tag names to add.
+   */
+  public String[] tagNamesToAdd() {
+    return tagNames(tagsToAdd);
+  }
+
+  /**
+   * Returns the tag names to remove without validating assignment values.
+   *
+   * @return The tag names to remove.
+   */
+  public String[] tagNamesToRemove() {
+    return tagNames(tagsToRemove);
+  }
+
   /**
    * Validates the request.
    *
@@ -82,32 +105,39 @@ public class TagValuesAssociateRequest implements 
RESTRequest {
   @Override
   public void validate() throws IllegalArgumentException {
     Preconditions.checkArgument(
-        tagsToAdd != null || tagsToRemove != null,
-        "tagsToAdd and tagsToRemove cannot both be null");
+        tagsToAdd.length > 0 || tagsToRemove.length > 0,
+        "tagsToAdd and tagsToRemove cannot both be empty");
 
     validateTagValues(tagsToAdd, "tagsToAdd");
     validateTagValues(tagsToRemove, "tagsToRemove");
+    validateNoIntersection(tagsToAdd, tagsToRemove);
   }
 
   private static RequestTagValue[] toRequestTagValues(TagValue[] tagValues) {
     if (tagValues == null) {
-      return null;
+      return new RequestTagValue[0];
     }
     return 
Arrays.stream(tagValues).map(RequestTagValue::new).toArray(RequestTagValue[]::new);
   }
 
   private static TagValue[] toTagValues(RequestTagValue[] tagValues) {
     if (tagValues == null) {
-      return null;
+      return new TagValue[0];
     }
     return 
Arrays.stream(tagValues).map(RequestTagValue::toTagValue).toArray(TagValue[]::new);
   }
 
-  private static void validateTagValues(RequestTagValue[] tagValues, String 
fieldName) {
+  private static String[] tagNames(RequestTagValue[] tagValues) {
     if (tagValues == null) {
-      return;
+      return new String[0];
     }
+    return Arrays.stream(tagValues)
+        .filter(tagValue -> tagValue != null)
+        .map(tagValue -> tagValue.name)
+        .toArray(String[]::new);
+  }
 
+  private static void validateTagValues(RequestTagValue[] tagValues, String 
fieldName) {
     for (RequestTagValue tagValue : tagValues) {
       Preconditions.checkArgument(
           tagValue != null, "%s must not contain null tag values", fieldName);
@@ -128,6 +158,16 @@ public class TagValuesAssociateRequest implements 
RESTRequest {
       }
     }
   }
+
+  private static void validateNoIntersection(
+      RequestTagValue[] tagsToAdd, RequestTagValue[] tagsToRemove) {
+    Set<RequestTagValue> tagsToAddSet = new 
LinkedHashSet<>(Arrays.asList(tagsToAdd));
+    for (RequestTagValue tagToRemove : tagsToRemove) {
+      Preconditions.checkArgument(
+          !tagsToAddSet.contains(tagToRemove), "tagsToAdd and tagsToRemove 
must not overlap");
+    }
+  }
+
   /**
    * Compares this request with another object.
    *
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/requests/TestTagValuesAssociateRequest.java
 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestTagValuesAssociateRequest.java
index 970ca6ca2a..fd5c1b1cb4 100644
--- 
a/common/src/test/java/org/apache/gravitino/dto/requests/TestTagValuesAssociateRequest.java
+++ 
b/common/src/test/java/org/apache/gravitino/dto/requests/TestTagValuesAssociateRequest.java
@@ -48,6 +48,26 @@ public class TestTagValuesAssociateRequest {
     TagValuesAssociateRequest validRequest =
         new TagValuesAssociateRequest(new TagValue[] 
{TagValue.of("data_domain", "finance")}, null);
     Assertions.assertDoesNotThrow(validRequest::validate);
+    Assertions.assertArrayEquals(new TagValue[0], 
validRequest.tagValuesToRemove());
+
+    TagValuesAssociateRequest emptyRequest = new 
TagValuesAssociateRequest(null, null);
+    Assertions.assertThrows(IllegalArgumentException.class, 
emptyRequest::validate);
+
+    TagValuesAssociateRequest nullFieldRequest =
+        JsonUtils.objectMapper()
+            .readValue(
+                
"{\"tagsToAdd\":[{\"name\":\"data_domain\"}],\"tagsToRemove\":null}",
+                TagValuesAssociateRequest.class);
+    Assertions.assertDoesNotThrow(nullFieldRequest::validate);
+    Assertions.assertArrayEquals(new TagValue[0], 
nullFieldRequest.tagValuesToRemove());
+
+    TagValuesAssociateRequest nullFieldsRequest =
+        JsonUtils.objectMapper()
+            .readValue(
+                "{\"tagsToAdd\":null,\"tagsToRemove\":null}", 
TagValuesAssociateRequest.class);
+    Assertions.assertArrayEquals(new TagValue[0], 
nullFieldsRequest.tagValuesToAdd());
+    Assertions.assertArrayEquals(new TagValue[0], 
nullFieldsRequest.tagValuesToRemove());
+    Assertions.assertThrows(IllegalArgumentException.class, 
nullFieldsRequest::validate);
 
     TagValuesAssociateRequest blankNameRequest =
         JsonUtils.objectMapper()
@@ -64,6 +84,26 @@ public class TestTagValuesAssociateRequest {
     Assertions.assertThrows(IllegalArgumentException.class, 
blankValueRequest::validate);
   }
 
+  @Test
+  public void testTagValuesAssociateRequestRejectsOverlappingPairs() {
+    TagValuesAssociateRequest exactPairOverlap =
+        new TagValuesAssociateRequest(
+            new TagValue[] {TagValue.of("data_domain", "finance"), 
TagValue.noValue("owner")},
+            new TagValue[] {TagValue.of("data_domain", "finance")});
+    Assertions.assertThrows(IllegalArgumentException.class, 
exactPairOverlap::validate);
+
+    TagValuesAssociateRequest noValueOverlap =
+        new TagValuesAssociateRequest(
+            new TagValue[] {TagValue.noValue("owner")}, new TagValue[] 
{TagValue.noValue("owner")});
+    Assertions.assertThrows(IllegalArgumentException.class, 
noValueOverlap::validate);
+
+    TagValuesAssociateRequest differentValue =
+        new TagValuesAssociateRequest(
+            new TagValue[] {TagValue.of("data_domain", "finance")},
+            new TagValue[] {TagValue.of("data_domain", "risk")});
+    Assertions.assertDoesNotThrow(differentValue::validate);
+  }
+
   @Test
   public void testTagValuesAssociateRequestRejectsV1Shape() {
     Assertions.assertThrows(
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
index fb175d5a1e..333e88b024 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
@@ -437,13 +437,26 @@ public class RelationalEntityStore
       NameIdentifier[] destEntitiesToAdd,
       NameIdentifier[] destEntitiesToRemove)
       throws IOException, NoSuchEntityException, EntityAlreadyExistsException {
-    return updateEntityRelations(
+    RelationUpdate update =
         RelationUpdate.of(
             relType,
             srcEntityIdent,
             srcEntityType,
             toRelationEdgeTargets(relType, destEntitiesToAdd),
-            toRelationEdgeTargets(relType, destEntitiesToRemove)));
+            toRelationEdgeTargets(relType, destEntitiesToRemove));
+    if (relType != SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL) {
+      return updateEntityRelations(update);
+    }
+
+    List<E> result =
+        backend.updateEntityRelations(
+            relType, srcEntityIdent, srcEntityType, destEntitiesToAdd, 
destEntitiesToRemove);
+    Entity.EntityType targetEntityType = relationUpdateTargetType(relType);
+    cache.invalidate(srcEntityIdent, srcEntityType);
+    invalidateRelationTargetCache(targetEntityType, update.targetsToAdd());
+    invalidateRelationTargetCache(targetEntityType, update.targetsToRemove());
+
+    return result;
   }
 
   @Override
@@ -467,18 +480,7 @@ public class RelationalEntityStore
 
     RelationEdgeTarget[] targetsToAdd = update.targetsToAdd();
     RelationEdgeTarget[] targetsToRemove = update.targetsToRemove();
-    List<E> result;
-    if (update.hasRelationValues()) {
-      result = backend.updateEntityRelations(update);
-    } else {
-      result =
-          backend.updateEntityRelations(
-              update.relationType(),
-              update.sourceIdentifier(),
-              update.sourceEntityType(),
-              toNameIdentifiers(targetsToAdd),
-              toNameIdentifiers(targetsToRemove));
-    }
+    List<E> result = backend.updateEntityRelations(update);
 
     // Invalidate after the backend write, not before: invalidating first 
opens a window where a
     // concurrent read could repopulate the cache with stale pre-commit data.
@@ -540,12 +542,6 @@ public class RelationalEntityStore
         .toArray(RelationEdgeTarget[]::new);
   }
 
-  private static NameIdentifier[] toNameIdentifiers(RelationEdgeTarget[] 
relationTargets) {
-    return Arrays.stream(relationTargets)
-        .map(RelationEdgeTarget::nameIdentifier)
-        .toArray(NameIdentifier[]::new);
-  }
-
   private static Entity.EntityType relationUpdateTargetType(Type relType) {
     switch (relType) {
       case POLICY_METADATA_OBJECT_REL:
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
index 36806449c4..92b6362379 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
@@ -392,9 +392,15 @@ public class TagMetaService {
               .remove(tagValueToRemove.value());
           tagRelsToRemove.add(
               tagRelForValue(tagPO, metadataObjectId, metadataObject, 
tagValueToRemove));
-        } else {
+        } else if (failOnDuplicateValuelessAssignment) {
           activeValuesByTagId.remove(tagPO.getTagId());
           tagIdsToRemove.add(tagPO.getTagId());
+        } else {
+          activeValuesByTagId
+              .computeIfAbsent(tagPO.getTagId(), ignored -> new 
LinkedHashSet<>())
+              .remove(Optional.empty());
+          tagRelsToRemove.add(
+              tagRelForValue(tagPO, metadataObjectId, metadataObject, 
tagValueToRemove));
         }
       }
 
diff --git a/core/src/main/java/org/apache/gravitino/tag/TagManager.java 
b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
index 3122dd9997..9e2cf23b83 100644
--- a/core/src/main/java/org/apache/gravitino/tag/TagManager.java
+++ b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
@@ -341,13 +341,33 @@ public class TagManager implements TagDispatcher {
       String metalake, MetadataObject metadataObject, String[] tagsToAdd, 
String[] tagsToRemove)
       throws NoSuchMetadataObjectException, TagAlreadyAssociatedException {
     return associateTagValuesForMetadataObject(
-        metalake, metadataObject, toNoValue(tagsToAdd), 
toNoValue(tagsToRemove));
+        metalake,
+        metadataObject,
+        toNoValue(tagsToAdd),
+        toNoValue(tagsToRemove),
+        TagAssociationMode.TAG_NAMES);
   }
 
   @Override
   public String[] associateTagValuesForMetadataObject(
       String metalake, MetadataObject metadataObject, TagValue[] tagsToAdd, 
TagValue[] tagsToRemove)
       throws NoSuchMetadataObjectException, TagAlreadyAssociatedException {
+    return associateTagValuesForMetadataObject(
+        metalake, metadataObject, tagsToAdd, tagsToRemove, 
TagAssociationMode.TAG_VALUES);
+  }
+
+  private enum TagAssociationMode {
+    TAG_NAMES,
+    TAG_VALUES
+  }
+
+  private String[] associateTagValuesForMetadataObject(
+      String metalake,
+      MetadataObject metadataObject,
+      TagValue[] tagsToAdd,
+      TagValue[] tagsToRemove,
+      TagAssociationMode mode)
+      throws NoSuchMetadataObjectException, TagAlreadyAssociatedException {
     Preconditions.checkArgument(
         
SUPPORTED_METADATA_OBJECT_TYPES_FOR_TAGS.contains(metadataObject.type()),
         "Cannot associate tags for unsupported metadata object type %s",
@@ -382,17 +402,29 @@ public class TagManager implements TagDispatcher {
                 LockType.WRITE,
                 () -> {
                   try {
-                    List<TagEntity> tags =
-                        entityStore
-                            .relationOperations()
-                            .updateEntityRelations(
-                                RelationUpdate.of(
-                                    
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
-                                    entityIdent,
-                                    entityType,
-                                    toRelationEdgeTargets(metalake, 
tagValuesToAdd),
-                                    toRelationEdgeTargets(metalake, 
tagValuesToRemove)));
-
+                    List<TagEntity> tags;
+                    if (mode == TagAssociationMode.TAG_VALUES) {
+                      tags =
+                          entityStore
+                              .relationOperations()
+                              .updateEntityRelations(
+                                  RelationUpdate.of(
+                                      
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+                                      entityIdent,
+                                      entityType,
+                                      toRelationEdgeTargets(metalake, 
tagValuesToAdd),
+                                      toRelationEdgeTargets(metalake, 
tagValuesToRemove)));
+                    } else {
+                      tags =
+                          entityStore
+                              .relationOperations()
+                              .updateEntityRelations(
+                                  
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL,
+                                  entityIdent,
+                                  entityType,
+                                  toNameIdentifiers(metalake, tagValuesToAdd),
+                                  toNameIdentifiers(metalake, 
tagValuesToRemove));
+                    }
                     return 
tags.stream().map(Tag::name).distinct().toArray(String[]::new);
                   } catch (NoSuchEntityException e) {
                     throw new NoSuchMetadataObjectException(
@@ -459,12 +491,18 @@ public class TagManager implements TagDispatcher {
 
   private static TagValue[] toNoValue(String[] tags) {
     if (tags == null) {
-      return null;
+      return new TagValue[0];
     }
 
     return Arrays.stream(tags).map(TagValue::noValue).toArray(TagValue[]::new);
   }
 
+  private static NameIdentifier[] toNameIdentifiers(String metalake, 
TagValue[] tagValues) {
+    return Arrays.stream(tagValues)
+        .map(tagValue -> NameIdentifierUtil.ofTag(metalake, tagValue.name()))
+        .toArray(NameIdentifier[]::new);
+  }
+
   private static RelationEdgeTarget[] toRelationEdgeTargets(String metalake, 
TagValue[] tagValues) {
     return Arrays.stream(tagValues)
         .map(
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
index edc1cb9ebe..2aca05b3c1 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
@@ -161,33 +161,49 @@ public class TestRelationalEntityStore {
     NameIdentifier[] destEntitiesToRemove = new NameIdentifier[] 
{destToRemove};
     NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
 
-    Mockito.doAnswer(
-            invocation -> {
-              Mockito.verify(cache, Mockito.never()).invalidate(src, 
Entity.EntityType.TABLE);
-              Mockito.verify(cache, Mockito.never()).invalidate(destToAdd, 
destinationType);
-              Mockito.verify(cache, Mockito.never()).invalidate(destToRemove, 
destinationType);
-              return List.of();
-            })
-        .when(backend)
-        .updateEntityRelations(
-            eq(relationType),
-            eq(src),
-            eq(Entity.EntityType.TABLE),
-            any(NameIdentifier[].class),
-            any(NameIdentifier[].class));
+    if (relationType == 
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL) {
+      Mockito.doAnswer(
+              invocation -> {
+                Mockito.verify(cache, Mockito.never()).invalidate(src, 
Entity.EntityType.TABLE);
+                Mockito.verify(cache, Mockito.never()).invalidate(destToAdd, 
destinationType);
+                Mockito.verify(cache, 
Mockito.never()).invalidate(destToRemove, destinationType);
+                return List.of();
+              })
+          .when(backend)
+          .updateEntityRelations(
+              eq(relationType),
+              eq(src),
+              eq(Entity.EntityType.TABLE),
+              any(NameIdentifier[].class),
+              any(NameIdentifier[].class));
+    } else {
+      Mockito.doAnswer(
+              invocation -> {
+                Mockito.verify(cache, Mockito.never()).invalidate(src, 
Entity.EntityType.TABLE);
+                Mockito.verify(cache, Mockito.never()).invalidate(destToAdd, 
destinationType);
+                Mockito.verify(cache, 
Mockito.never()).invalidate(destToRemove, destinationType);
+                return List.of();
+              })
+          .when(backend)
+          .updateEntityRelations(any(RelationUpdate.class));
+    }
 
     store.updateEntityRelations(
         relationType, src, Entity.EntityType.TABLE, destEntitiesToAdd, 
destEntitiesToRemove);
 
     InOrder inOrder = Mockito.inOrder(backend, cache);
-    inOrder
-        .verify(backend)
-        .updateEntityRelations(
-            eq(relationType),
-            eq(src),
-            eq(Entity.EntityType.TABLE),
-            any(NameIdentifier[].class),
-            any(NameIdentifier[].class));
+    if (relationType == 
SupportsRelationOperations.Type.TAG_METADATA_OBJECT_REL) {
+      inOrder
+          .verify(backend)
+          .updateEntityRelations(
+              eq(relationType),
+              eq(src),
+              eq(Entity.EntityType.TABLE),
+              any(NameIdentifier[].class),
+              any(NameIdentifier[].class));
+    } else {
+      inOrder.verify(backend).updateEntityRelations(any(RelationUpdate.class));
+    }
     inOrder.verify(cache).invalidate(src, Entity.EntityType.TABLE);
     inOrder.verify(cache).invalidate(destToAdd, destinationType);
     inOrder.verify(cache).invalidate(destToRemove, destinationType);
diff --git a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java 
b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
index ff2b33046c..a683561823 100644
--- a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
+++ b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
@@ -700,6 +700,16 @@ public class TestTagManager {
     Assertions.assertEquals(
         0, tagManager.listMetadataObjectsForTag(METALAKE, tag.name(), 
"pii").length);
 
+    tagManager.associateTagValuesForMetadataObject(
+        METALAKE, tableObject, null, new TagValue[] 
{TagValue.noValue(tag.name())});
+    Tag dataDomainInfo = tagManager.getTagForMetadataObject(METALAKE, 
tableObject, tag.name());
+    Assertions.assertArrayEquals(
+        new String[] {"finance", "risk"}, 
dataDomainInfo.assignment().get().values());
+
+    Assertions.assertArrayEquals(
+        new MetadataObject[] {tableObject},
+        tagManager.listMetadataObjectsForTag(METALAKE, tag.name(), "finance"));
+
     Tag ownerTag = tagManager.createTag(METALAKE, "owner", null, null);
     tagManager.associateTagValuesForMetadataObject(
         METALAKE, tableObject, new TagValue[] 
{TagValue.noValue(ownerTag.name())}, null);
@@ -714,12 +724,50 @@ public class TestTagManager {
         new MetadataObject[] {tableObject},
         tagManager.listMetadataObjectsForTag(METALAKE, ownerTag.name(), 
"team-a"));
 
+    Tag noValueIdempotentTag = tagManager.createTag(METALAKE, 
"no_value_idempotent", null, null);
+    tagManager.associateTagValuesForMetadataObject(
+        METALAKE,
+        tableObject,
+        new TagValue[] {TagValue.noValue(noValueIdempotentTag.name())},
+        null);
+    Assertions.assertDoesNotThrow(
+        () ->
+            tagManager.associateTagValuesForMetadataObject(
+                METALAKE,
+                tableObject,
+                new TagValue[] {TagValue.noValue(noValueIdempotentTag.name())},
+                null));
+
     Assertions.assertTrue(
         tagInfos[0].valueConstraint().type() == 
TagValueConstraint.Type.ALLOWED_VALUES);
     Assertions.assertArrayEquals(
         new String[] {"finance", "risk"}, 
tagInfos[0].valueConstraint().allowedValues());
   }
 
+  @Test
+  public void testV1RemoveValuedTagByName() {
+    Tag tag =
+        tagManager.createTag(
+            METALAKE, "v1_remove_valued", null, null, 
TagValueConstraint.ofAllowedValues("dev"));
+    MetadataObject tableObject =
+        NameIdentifierUtil.toMetadataObject(
+            NameIdentifierUtil.ofTable(METALAKE, CATALOG, SCHEMA, TABLE), 
Entity.EntityType.TABLE);
+
+    tagManager.associateTagValuesForMetadataObject(
+        METALAKE, tableObject, new TagValue[] {TagValue.of(tag.name(), 
"dev")}, null);
+    Assertions.assertArrayEquals(
+        new MetadataObject[] {tableObject},
+        tagManager.listMetadataObjectsForTag(METALAKE, tag.name(), "dev"));
+
+    tagManager.associateTagsForMetadataObject(
+        METALAKE, tableObject, null, new String[] {tag.name()});
+
+    Assertions.assertEquals(
+        0, tagManager.listTagsInfoForMetadataObject(METALAKE, 
tableObject).length);
+    Assertions.assertEquals(
+        0, tagManager.listMetadataObjectsForTag(METALAKE, tag.name(), 
"dev").length);
+  }
+
   @Test
   public void testRejectNullTagValueToRemove() {
     MetadataObject tableObject =
diff --git a/design-docs/tag-assignment-values.md 
b/design-docs/tag-assignment-values.md
index 1bf0375b72..705cd82325 100644
--- a/design-docs/tag-assignment-values.md
+++ b/design-docs/tag-assignment-values.md
@@ -730,7 +730,8 @@ pair-level delta instead of replacing the complete value 
list:
 Adding a non-null pair to a tag with an active null row is a changed logical 
assignment: the null row
 is soft-deleted and the non-null row is inserted in the same transaction. 
Adding a null pair to a tag
 with active non-null rows is accepted only when the request's removals remove 
all remaining
-non-null rows for that tag; otherwise it fails with `409 Conflict`.
+non-null rows for that tag; otherwise it fails with `400 Bad Request` as an 
invalid assignment
+transition.
 
 Name-only reads must use distinct tag names because a valued assignment can 
now have multiple
 physical rows. Detailed reads must group relation rows by logical assignment 
before building
@@ -811,9 +812,9 @@ TagValuePair[] tagsToRemove();
 | Assignment value is not in the tag's configured allowed values | `400 Bad 
Request` |
 | `tagsToAdd` and `tagsToRemove` contain the same exact pair | `400 Bad 
Request` |
 | `tagsToAdd` contains both a valueless pair and non-null value pairs for the 
same tag | `400 Bad Request` |
-| Adding a valueless pair while non-null values remain active for the same tag 
| `409 Conflict` |
+| Adding a valueless pair while non-null values remain active for the same tag 
| `400 Bad Request` |
 | `value` query parameter is blank or longer than 256 characters | `400 Bad 
Request` |
-| `tagsToAdd` or `tagsToRemove` references a tag that does not exist | `404 
Not Found` |
+| `tagsToAdd` or `tagsToRemove` references a tag that does not exist | `200 
OK`; no relation row is inserted or deleted |
 | `tagsToAdd` targets an already active pair | `200 OK`; no relation row is 
inserted |
 | `tagsToRemove` targets a missing pair | `200 OK`; no relation row is deleted 
|
 
diff --git a/docs/open-api/tags.yaml b/docs/open-api/tags.yaml
index c2e39cd489..c9ea86cbf8 100644
--- a/docs/open-api/tags.yaml
+++ b/docs/open-api/tags.yaml
@@ -232,6 +232,12 @@ paths:
             examples:
               TagAssociate:
                 $ref: "#/components/examples/TagAssociate"
+          application/vnd.gravitino.v2+json:
+            schema:
+              $ref: "#/components/requests/TagValuesAssociateRequest"
+            examples:
+              TagValuesAssociate:
+                $ref: "#/components/examples/TagValuesAssociate"
       responses:
         "200":
           description: Returns the list of tag names associated with the 
specified metadata object
@@ -242,6 +248,21 @@ paths:
               examples:
                 NameListResponse:
                   $ref: "#/components/examples/NameListResponse"
+            application/vnd.gravitino.v2+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/NameListResponse"
+              examples:
+                NameListResponse:
+                  $ref: "#/components/examples/NameListResponse"
+        "400":
+          description: Bad Request - The request body is malformed or violates 
tag association validation rules
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+            application/vnd.gravitino.v2+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
         "404":
           description: Not Found - The specified metalake does not exist
           content:
@@ -251,6 +272,12 @@ paths:
               examples:
                 NoSuchMetalakeException:
                   $ref: 
"./metalakes.yaml#/components/examples/NoSuchMetalakeException"
+            application/vnd.gravitino.v2+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                NoSuchMetalakeException:
+                  $ref: 
"./metalakes.yaml#/components/examples/NoSuchMetalakeException"
         "409":
           description: Conflict - The target tag already associated with the 
specified metadata object
           content:
@@ -260,8 +287,21 @@ paths:
               examples:
                 TagAlreadyAssociatedException:
                   $ref: "#/components/examples/TagAlreadyAssociatedException"
+            application/vnd.gravitino.v2+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                TagAlreadyAssociatedException:
+                  $ref: "#/components/examples/TagAlreadyAssociatedException"
         "5xx":
-          $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
+          description: A server-side problem that might not be addressable 
from the client side
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+            application/vnd.gravitino.v2+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
 
 
   
/metalakes/{metalake}/objects/{metadataObjectType}/{metadataObjectFullName}/tags/{tag}:
@@ -313,6 +353,8 @@ paths:
         - tag
       summary: list metadata objects for tag
       operationId: listTagObjects
+      parameters:
+        - $ref: "#/components/parameters/tagValue"
       responses:
         "200":
           description: Returns the list of metadata objects associated with 
specified tag
@@ -349,6 +391,15 @@ components:
         type: boolean
         default: false
 
+    tagValue:
+      name: value
+      in: query
+      description: Exact assignment value used to filter objects associated 
with the tag
+      required: false
+      schema:
+        type: string
+        maxLength: 256
+
   schemas:
 
     Tag:
@@ -377,6 +428,20 @@ components:
           type: boolean
           description: Whether the tag is inherited from the parent metadata 
object
           nullable: true
+        allowedValues:
+          type: array
+          description: Allowed assignment values. Null means any value is 
allowed, an empty array means the tag can only be assigned without a value.
+          nullable: true
+          items:
+            type: string
+            maxLength: 256
+        assignmentValues:
+          type: array
+          description: Assignment values in the current metadata object 
context. Null means no assignment context, an empty array means assigned 
without a value.
+          nullable: true
+          items:
+            type: string
+            maxLength: 256
 
     MetadataObject:
       type: object
@@ -421,6 +486,13 @@ components:
           default: { }
           additionalProperties:
             type: string
+        allowedValues:
+          type: array
+          description: Allowed assignment values. Null means any value is 
allowed, an empty array means the tag can only be assigned without a value.
+          nullable: true
+          items:
+            type: string
+            maxLength: 256
 
     TagUpdatesRequest:
       type: object
@@ -539,6 +611,36 @@ components:
           description: The tags to remove
           nullable: true
 
+    TagValuesAssociateRequest:
+      type: object
+      properties:
+        tagsToAdd:
+          type: array
+          items:
+            $ref: "#/components/requests/TagValue"
+          description: The tag values to add
+          nullable: true
+        tagsToRemove:
+          type: array
+          items:
+            $ref: "#/components/requests/TagValue"
+          description: The tag values to remove
+          nullable: true
+
+    TagValue:
+      type: object
+      required:
+        - name
+      properties:
+        name:
+          type: string
+          description: The tag name
+        value:
+          type: string
+          description: The optional assignment value
+          nullable: true
+          maxLength: 256
+
   responses:
     TagListResponse:
       type: object
@@ -628,7 +730,8 @@ components:
         "properties": {
           "key1": "value1",
           "key2": "value2"
-        }
+        },
+        "allowedValues": ["finance", "engineering"]
       }
 
     TagAssociate:
@@ -637,6 +740,17 @@ components:
         "tagsToRemove": ["my_tag3"]
       }
 
+    TagValuesAssociate:
+      value: {
+        "tagsToAdd": [
+          {"name": "my_tag1", "value": "finance"},
+          {"name": "my_tag2"}
+        ],
+        "tagsToRemove": [
+          {"name": "my_tag3", "value": "engineering"}
+        ]
+      }
+
     TagResponse:
       value: {
         "code": 0,
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/ApiVersion.java 
b/server/src/main/java/org/apache/gravitino/server/web/ApiVersion.java
index 5740e9d791..87009dd978 100644
--- a/server/src/main/java/org/apache/gravitino/server/web/ApiVersion.java
+++ b/server/src/main/java/org/apache/gravitino/server/web/ApiVersion.java
@@ -18,17 +18,19 @@
  */
 package org.apache.gravitino.server.web;
 
+import java.util.Arrays;
 import java.util.Comparator;
 import java.util.TreeSet;
 
 public enum ApiVersion {
-  V_1(1);
+  V_1(1),
+  V_2(2);
 
   private static final TreeSet<ApiVersion> VERSIONS =
       new TreeSet<>(Comparator.comparingInt(o -> o.version));
 
   static {
-    VERSIONS.add(V_1);
+    VERSIONS.addAll(Arrays.asList(values()));
   }
 
   private final int version;
@@ -45,6 +47,11 @@ public enum ApiVersion {
     return VERSIONS.last();
   }
 
+  /** Returns the API version used when a request does not specify one. */
+  public static ApiVersion defaultVersion() {
+    return V_1;
+  }
+
   public static boolean isSupportedVersion(int version) {
     for (ApiVersion v : ApiVersion.values()) {
       if (v.version == version) {
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java 
b/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java
index 7c2c71b32d..98e132f89c 100644
--- a/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java
+++ b/server/src/main/java/org/apache/gravitino/server/web/VersioningFilter.java
@@ -65,6 +65,15 @@ public class VersioningFilter implements Filter {
       return ((HttpServletRequest) getRequest()).getHeader(name);
     }
 
+    @Override
+    public Enumeration<String> getHeaders(String name) {
+      String headerValue = customHeaders.get(name);
+      if (headerValue != null) {
+        return Collections.enumeration(Collections.singletonList(headerValue));
+      }
+      return ((HttpServletRequest) getRequest()).getHeaders(name);
+    }
+
     @Override
     public Enumeration<String> getHeaderNames() {
       List<String> combinedHeaderNames = new 
ArrayList<>(customHeaders.keySet());
@@ -78,9 +87,10 @@ public class VersioningFilter implements Filter {
     }
   }
 
-  private static final Pattern ACCEPT_VERSION_REGEX =
+  private static final Pattern VERSIONED_JSON_MEDIA_TYPE_REGEX =
       Pattern.compile("application/vnd\\.gravitino\\.v(\\d+)\\+json");
   private static final String ACCEPT_VERSION_HEADER = "Accept";
+  private static final String CONTENT_TYPE_HEADER = "Content-Type";
 
   private static String getAcceptVersion(int version) {
     return String.format("application/vnd.gravitino.v%d+json", version);
@@ -93,34 +103,62 @@ public class VersioningFilter implements Filter {
   public void doFilter(ServletRequest request, ServletResponse response, 
FilterChain chain)
       throws IOException, ServletException {
     HttpServletRequest req = (HttpServletRequest) request;
-    Enumeration<String> acceptHeader = req.getHeaders(ACCEPT_VERSION_HEADER);
-    while (acceptHeader.hasMoreElements()) {
-      String value = acceptHeader.nextElement();
+    Integer acceptVersion = 
versionFromHeaders(req.getHeaders(ACCEPT_VERSION_HEADER));
+    if (acceptVersion != null) {
+      if (isUnsupportedVersion(acceptVersion, response)) {
+        return;
+      }
+
+      chain.doFilter(request, response);
+      return;
+    }
 
-      // If version accept header is set, then we need to check if it is 
supported.
-      Matcher m = ACCEPT_VERSION_REGEX.matcher(value);
-      if (m.find()) {
-        int version = Integer.parseInt(m.group(1));
+    MutableHttpServletRequest mutableRequest = new 
MutableHttpServletRequest(req);
+    Integer contentTypeVersion = 
versionFromHeader(req.getHeader(CONTENT_TYPE_HEADER));
+    if (contentTypeVersion != null) {
+      if (isUnsupportedVersion(contentTypeVersion, response)) {
+        return;
+      }
 
-        if (!ApiVersion.isSupportedVersion(version)) {
-          LOG.error("Unsupported version v{} in Request Header {}.", version, 
value);
+      mutableRequest.putHeader(ACCEPT_VERSION_HEADER, 
getAcceptVersion(contentTypeVersion));
+    } else {
+      ApiVersion defaultVersion = ApiVersion.defaultVersion();
+      mutableRequest.putHeader(ACCEPT_VERSION_HEADER, 
getAcceptVersion(defaultVersion.version()));
+    }
 
-          HttpServletResponse resp = (HttpServletResponse) response;
-          resp.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, "Unsupported 
version");
-        } else {
-          chain.doFilter(request, response);
-        }
+    chain.doFilter(mutableRequest, response);
+  }
 
-        return;
+  private static Integer versionFromHeaders(Enumeration<String> headers) {
+    while (headers.hasMoreElements()) {
+      Integer version = versionFromHeader(headers.nextElement());
+      if (version != null) {
+        return version;
       }
     }
 
-    // If no version accept header not is set, then we need to set the latest 
version.
-    MutableHttpServletRequest mutableRequest = new 
MutableHttpServletRequest(req);
-    ApiVersion latest = ApiVersion.latestVersion();
-    mutableRequest.putHeader(ACCEPT_VERSION_HEADER, 
getAcceptVersion(latest.version()));
+    return null;
+  }
 
-    chain.doFilter(mutableRequest, response);
+  private static Integer versionFromHeader(String value) {
+    if (value == null) {
+      return null;
+    }
+
+    Matcher matcher = VERSIONED_JSON_MEDIA_TYPE_REGEX.matcher(value);
+    return matcher.find() ? Integer.parseInt(matcher.group(1)) : null;
+  }
+
+  private static boolean isUnsupportedVersion(int version, ServletResponse 
response)
+      throws IOException {
+    if (ApiVersion.isSupportedVersion(version)) {
+      return false;
+    }
+
+    LOG.error("Unsupported version v{} in request header.", version);
+    HttpServletResponse resp = (HttpServletResponse) response;
+    resp.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, "Unsupported 
version");
+    return true;
   }
 
   @Override
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AssociateTagAuthorizationExecutor.java
 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AssociateTagAuthorizationExecutor.java
index 2fef4e9ee3..f18ab38334 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AssociateTagAuthorizationExecutor.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AssociateTagAuthorizationExecutor.java
@@ -22,14 +22,18 @@ import static 
org.apache.gravitino.server.web.filter.ParameterUtil.extractFromPa
 
 import com.google.common.base.Preconditions;
 import java.lang.reflect.Parameter;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
 import org.apache.gravitino.dto.requests.TagsAssociateRequest;
 import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
+import org.apache.gravitino.tag.TagValue;
 
 /**
  * Metadata object authorization for {@link
@@ -63,37 +67,49 @@ public class AssociateTagAuthorizationExecutor extends 
CommonAuthorizerExecutor
     context.setOriginalAuthorizationExpression(expression);
     Entity.EntityType targetType =
         Entity.EntityType.TAG; // Tags are the only supported batch target here
+
     Preconditions.checkArgument(
-        request instanceof TagsAssociateRequest,
+        request instanceof TagsAssociateRequest || request instanceof 
TagValuesAssociateRequest,
         "Only tag can use AssociateTagAuthorizationExecutor, please contact 
the administrator.");
-    TagsAssociateRequest tagsAssociateRequest = (TagsAssociateRequest) request;
-    tagsAssociateRequest.validate();
+
+    TagValue[] tagsToAdd;
+    TagValue[] tagsToRemove;
+    if (request instanceof TagsAssociateRequest) {
+      TagsAssociateRequest tagsAssociateRequest = (TagsAssociateRequest) 
request;
+      tagsToAdd = toNoValue(tagsAssociateRequest.getTagsToAdd());
+      tagsToRemove = toNoValue(tagsAssociateRequest.getTagsToRemove());
+    } else {
+      TagValuesAssociateRequest tagValuesAssociateRequest = 
(TagValuesAssociateRequest) request;
+      tagsToAdd = toNoValue(tagValuesAssociateRequest.tagNamesToAdd());
+      tagsToRemove = toNoValue(tagValuesAssociateRequest.tagNamesToRemove());
+    }
+
     // Authorize both 'tagsToAdd' and 'tagsToRemove' fields.
-    return authorizeTag(tagsAssociateRequest.getTagsToAdd(), context, 
targetType)
-        && authorizeTag(tagsAssociateRequest.getTagsToRemove(), context, 
targetType);
+    return authorizeTag(tagsToAdd, context, targetType)
+        && authorizeTag(tagsToRemove, context, targetType);
   }
 
   /**
    * Performs batch authorization for a given field (e.g., "tagsToAdd" or 
"tagsToRemove") containing
-   * an array of tag names.
+   * an array of tag values.
    *
-   * @param tagNames tagNames
+   * @param tagValues tag values
    * @param context The shared authorization request context.
    * @param targetType The entity type being authorized (expected to be TAG).
    * @return {@code true} if all tags in the field pass authorization; {@code 
false} otherwise.
    */
   private boolean authorizeTag(
-      String[] tagNames, AuthorizationRequestContext context, 
Entity.EntityType targetType) {
+      TagValue[] tagValues, AuthorizationRequestContext context, 
Entity.EntityType targetType) {
 
     // Treat null or empty arrays as no-op (implicitly authorized)
-    if (tagNames == null) {
+    if (tagValues == null) {
       return true;
     }
 
-    for (String tagName : tagNames) {
+    for (TagValue tagValue : tagValues) {
       // Use a fresh context copy for each tag to avoid cross-contamination
       Map<Entity.EntityType, NameIdentifier> currentContext = new 
HashMap<>(this.metadataContext);
-      buildNameIdentifierForBatchAuthorization(currentContext, tagName, 
targetType);
+      buildNameIdentifierForBatchAuthorization(currentContext, 
tagValue.name(), targetType);
 
       boolean authorized =
           authorizationExpressionEvaluator.evaluate(
@@ -105,4 +121,14 @@ public class AssociateTagAuthorizationExecutor extends 
CommonAuthorizerExecutor
     }
     return true;
   }
+
+  private static TagValue[] toNoValue(String[] tags) {
+    if (tags == null) {
+      return new TagValue[0];
+    }
+    return Arrays.stream(tags)
+        .filter(StringUtils::isNotBlank)
+        .map(TagValue::noValue)
+        .toArray(TagValue[]::new);
+  }
 }
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/MetadataObjectTagOperations.java
 
b/server/src/main/java/org/apache/gravitino/server/web/rest/MetadataObjectTagOperations.java
index d96e655d30..a77584b3cb 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/MetadataObjectTagOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/MetadataObjectTagOperations.java
@@ -23,14 +23,14 @@ import static 
org.apache.gravitino.server.authorization.expression.Authorization
 
 import com.codahale.metrics.annotation.ResponseMetered;
 import com.codahale.metrics.annotation.Timed;
-import com.google.common.collect.Sets;
 import java.util.Arrays;
-import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Optional;
-import java.util.Set;
 import javax.inject.Inject;
 import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.Consumes;
 import javax.ws.rs.DefaultValue;
 import javax.ws.rs.GET;
 import javax.ws.rs.POST;
@@ -44,6 +44,7 @@ import org.apache.commons.lang3.ArrayUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
 import org.apache.gravitino.dto.requests.TagsAssociateRequest;
 import org.apache.gravitino.dto.responses.NameListResponse;
 import org.apache.gravitino.dto.responses.TagListResponse;
@@ -71,6 +72,8 @@ import org.slf4j.LoggerFactory;
 public class MetadataObjectTagOperations {
   private static final Logger LOG = 
LoggerFactory.getLogger(MetadataObjectTagOperations.class);
 
+  private static final String TAG_VALUES_MEDIA_TYPE = 
"application/vnd.gravitino.v2+json";
+
   private final TagDispatcher tagDispatcher;
 
   @Context private HttpServletRequest httpRequest;
@@ -183,14 +186,12 @@ public class MetadataObjectTagOperations {
                 MetadataObjects.parse(
                     fullName, 
MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT)));
 
-            Set<TagDTO> tags = Sets.newHashSet();
+            Map<String, TagDTO> tags = new LinkedHashMap<>();
             Tag[] nonInheritedTags = 
tagDispatcher.listTagsInfoForMetadataObject(metalake, object);
             if (ArrayUtils.isNotEmpty(nonInheritedTags)) {
-              Collections.addAll(
-                  tags,
-                  Arrays.stream(nonInheritedTags)
-                      .map(t -> DTOConverters.toDTO(t, Optional.of(false)))
-                      .toArray(TagDTO[]::new));
+              Arrays.stream(nonInheritedTags)
+                  .map(t -> DTOConverters.toDTO(t, Optional.of(false)))
+                  .forEach(tag -> tags.putIfAbsent(tag.name(), tag));
             }
 
             for (MetadataObject parentObject :
@@ -198,11 +199,9 @@ public class MetadataObjectTagOperations {
               Tag[] inheritedTags =
                   tagDispatcher.listTagsInfoForMetadataObject(metalake, 
parentObject);
               if (ArrayUtils.isNotEmpty(inheritedTags)) {
-                Collections.addAll(
-                    tags,
-                    Arrays.stream(inheritedTags)
-                        .map(t -> DTOConverters.toDTO(t, Optional.of(true)))
-                        .toArray(TagDTO[]::new));
+                Arrays.stream(inheritedTags)
+                    .map(t -> DTOConverters.toDTO(t, Optional.of(true)))
+                    .forEach(tag -> tags.putIfAbsent(tag.name(), tag));
               }
             }
 
@@ -213,7 +212,7 @@ public class MetadataObjectTagOperations {
                   type,
                   fullName,
                   metalake);
-              TagDTO[] tagDTOS = tags.toArray(new TagDTO[0]);
+              TagDTO[] tagDTOS = tags.values().toArray(new TagDTO[0]);
               tagDTOS =
                   MetadataAuthzHelper.filterByExpression(
                       metalake,
@@ -224,8 +223,7 @@ public class MetadataObjectTagOperations {
               return Utils.ok(new TagListResponse(tagDTOS));
 
             } else {
-              // We have used Set to avoid duplicate tag names
-              String[] tagNames = 
tags.stream().map(TagDTO::name).toArray(String[]::new);
+              String[] tagNames = tags.keySet().toArray(new String[0]);
               tagNames =
                   MetadataAuthzHelper.filterByExpression(
                       metalake,
@@ -249,6 +247,7 @@ public class MetadataObjectTagOperations {
   }
 
   @POST
+  @Consumes("application/json")
   @Produces("application/vnd.gravitino.v1+json")
   @Timed(name = "associate-object-tags." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
   @ResponseMetered(name = "associate-object-tags", absolute = true)
@@ -260,6 +259,36 @@ public class MetadataObjectTagOperations {
       @PathParam("fullName") @AuthorizationFullName String fullName,
       @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.ASSOCIATE_TAG)
           TagsAssociateRequest request) {
+    return associateTagsForObjectInternal(metalake, type, fullName, request);
+  }
+
+  /**
+   * Associates tag values with a metadata object using the v2 request 
representation.
+   *
+   * @param metalake The metalake name.
+   * @param type The metadata object type.
+   * @param fullName The metadata object full name.
+   * @param request The tag values association request.
+   * @return The response containing associated tag names.
+   */
+  @POST
+  @Consumes(TAG_VALUES_MEDIA_TYPE)
+  @Produces(TAG_VALUES_MEDIA_TYPE)
+  @Timed(name = "associate-object-tags." + MetricNames.HTTP_PROCESS_DURATION, 
absolute = true)
+  @ResponseMetered(name = "associate-object-tags", absolute = true)
+  @AuthorizationExpression(expression = CAN_ACCESS_METADATA_AND_TAG)
+  public Response associateTagValuesForObject(
+      @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
+          String metalake,
+      @PathParam("type") @AuthorizationObjectType String type,
+      @PathParam("fullName") @AuthorizationFullName String fullName,
+      @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.ASSOCIATE_TAG)
+          TagValuesAssociateRequest request) {
+    return associateTagValuesForObjectInternal(metalake, type, fullName, 
request);
+  }
+
+  private Response associateTagsForObjectInternal(
+      String metalake, String type, String fullName, TagsAssociateRequest 
request) {
     LOG.info(
         "Received associate tags request for object type: {}, full name: {} 
under metalake: {}",
         type,
@@ -270,19 +299,12 @@ public class MetadataObjectTagOperations {
           httpRequest,
           () -> {
             request.validate();
-            MetadataObject object =
-                MetadataObjects.parse(
-                    fullName, 
MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT)));
+            MetadataObject object = parseMetadataObject(type, fullName);
             String[] tagNames =
                 tagDispatcher.associateTagsForMetadataObject(
                     metalake, object, request.getTagsToAdd(), 
request.getTagsToRemove());
             tagNames = tagNames == null ? new String[0] : tagNames;
-            LOG.info(
-                "Associated tags: {} for object type: {}, full name: {} under 
metalake: {}",
-                Arrays.toString(tagNames),
-                type,
-                fullName,
-                metalake);
+            logAssociatedTags(type, fullName, metalake, tagNames);
             return Utils.ok(new NameListResponse(tagNames));
           });
     } catch (Exception e) {
@@ -290,6 +312,52 @@ public class MetadataObjectTagOperations {
     }
   }
 
+  private Response associateTagValuesForObjectInternal(
+      String metalake, String type, String fullName, TagValuesAssociateRequest 
request) {
+    LOG.info(
+        "Received associate tag values request for object type: {}, full name: 
{} under metalake: {}",
+        type,
+        fullName,
+        metalake);
+    try {
+      return Utils.doAs(
+          httpRequest,
+          () -> {
+            request.validate();
+            MetadataObject object = parseMetadataObject(type, fullName);
+            String[] tagNames =
+                tagDispatcher.associateTagValuesForMetadataObject(
+                    metalake, object, request.tagValuesToAdd(), 
request.tagValuesToRemove());
+            tagNames = tagNames == null ? new String[0] : tagNames;
+            logAssociatedTags(type, fullName, metalake, tagNames);
+            return Response.ok(new NameListResponse(tagNames), 
TAG_VALUES_MEDIA_TYPE).build();
+          });
+    } catch (Exception e) {
+      return withMediaType(
+          ExceptionHandlers.handleTagException(OperationType.ASSOCIATE, "", 
fullName, e),
+          TAG_VALUES_MEDIA_TYPE);
+    }
+  }
+
+  private static MetadataObject parseMetadataObject(String type, String 
fullName) {
+    return MetadataObjects.parse(
+        fullName, MetadataObject.Type.valueOf(type.toUpperCase(Locale.ROOT)));
+  }
+
+  private static void logAssociatedTags(
+      String type, String fullName, String metalake, String[] tagNames) {
+    LOG.info(
+        "Associated tags: {} for object type: {}, full name: {} under 
metalake: {}",
+        Arrays.toString(tagNames),
+        type,
+        fullName,
+        metalake);
+  }
+
+  private static Response withMediaType(Response response, String mediaType) {
+    return Response.fromResponse(response).type(mediaType).build();
+  }
+
   private Optional<Tag> getTagForObject(String metalake, MetadataObject 
object, String tagName) {
     try {
       return 
Optional.ofNullable(tagDispatcher.getTagForMetadataObject(metalake, object, 
tagName));
diff --git 
a/server/src/main/java/org/apache/gravitino/server/web/rest/TagOperations.java 
b/server/src/main/java/org/apache/gravitino/server/web/rest/TagOperations.java
index c69b3a3432..de6460fa2a 100644
--- 
a/server/src/main/java/org/apache/gravitino/server/web/rest/TagOperations.java
+++ 
b/server/src/main/java/org/apache/gravitino/server/web/rest/TagOperations.java
@@ -39,6 +39,7 @@ import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.Response;
 import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.dto.requests.TagCreateRequest;
@@ -74,6 +75,8 @@ public class TagOperations {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(TagOperations.class);
 
+  private static final int MAX_TAG_VALUE_LENGTH = 256;
+
   private final TagDispatcher tagDispatcher;
 
   @Context private HttpServletRequest httpRequest;
@@ -157,7 +160,11 @@ public class TagOperations {
             request.validate();
             Tag tag =
                 tagDispatcher.createTag(
-                    metalake, request.getName(), request.getComment(), 
request.getProperties());
+                    metalake,
+                    request.getName(),
+                    request.getComment(),
+                    request.getProperties(),
+                    request.valueConstraint());
 
             LOG.info("Created tag: {} under metalake: {}", tag.name(), 
metalake);
             return Utils.ok(new TagResponse(DTOConverters.toDTO(tag, 
Optional.empty())));
@@ -268,14 +275,21 @@ public class TagOperations {
   public Response listMetadataObjectsForTag(
       @PathParam("metalake") @AuthorizationMetadata(type = 
Entity.EntityType.METALAKE)
           String metalake,
-      @PathParam("tag") @AuthorizationMetadata(type = Entity.EntityType.TAG) 
String tagName) {
-    LOG.info("Received list objects for tag: {} under metalake: {}", tagName, 
metalake);
+      @PathParam("tag") @AuthorizationMetadata(type = Entity.EntityType.TAG) 
String tagName,
+      @QueryParam("value") String value) {
+    LOG.info(
+        "Received list objects for tag: {} and value: {} under metalake: {}",
+        tagName,
+        value,
+        metalake);
 
     try {
+      validateTagAssignmentValueFilter(value);
       return Utils.doAs(
           httpRequest,
           () -> {
-            MetadataObject[] objects = 
tagDispatcher.listMetadataObjectsForTag(metalake, tagName);
+            MetadataObject[] objects =
+                tagDispatcher.listMetadataObjectsForTag(metalake, tagName, 
value);
             objects = objects == null ? new MetadataObject[0] : objects;
 
             LOG.info(
@@ -295,6 +309,21 @@ public class TagOperations {
     }
   }
 
+  private static void validateTagAssignmentValueFilter(String value) {
+    if (value == null) {
+      return;
+    }
+
+    if (StringUtils.isBlank(value)) {
+      throw new IllegalArgumentException("Tag assignment value must not be 
null or empty");
+    }
+
+    if (value.length() > MAX_TAG_VALUE_LENGTH) {
+      throw new IllegalArgumentException(
+          "Tag assignment value must not exceed " + MAX_TAG_VALUE_LENGTH + " 
characters");
+    }
+  }
+
   /**
    * @deprecated This API has moved to {@code
    *     /api/metalakes/{metalake}/objects/{type}/{fullName}/tags}.
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/TestApiVersion.java 
b/server/src/test/java/org/apache/gravitino/server/web/TestApiVersion.java
index 55f21ba60b..2aa59752e0 100644
--- a/server/src/test/java/org/apache/gravitino/server/web/TestApiVersion.java
+++ b/server/src/test/java/org/apache/gravitino/server/web/TestApiVersion.java
@@ -29,12 +29,19 @@ public class TestApiVersion {
   @Test
   public void testLatestVersion() {
     ApiVersion latest = ApiVersion.latestVersion();
-    assertEquals(ApiVersion.V_1, latest);
+    assertEquals(ApiVersion.V_2, latest);
+  }
+
+  @Test
+  public void testDefaultVersion() {
+    ApiVersion defaultVersion = ApiVersion.defaultVersion();
+    assertEquals(ApiVersion.V_1, defaultVersion);
   }
 
   @Test
   public void testIsSupportedVersion() {
     assertTrue(ApiVersion.isSupportedVersion(1));
-    assertFalse(ApiVersion.isSupportedVersion(2));
+    assertTrue(ApiVersion.isSupportedVersion(2));
+    assertFalse(ApiVersion.isSupportedVersion(3));
   }
 }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/TestVersioningFilter.java
 
b/server/src/test/java/org/apache/gravitino/server/web/TestVersioningFilter.java
index 6da270ea4f..2d5f3dc1f0 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/TestVersioningFilter.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/TestVersioningFilter.java
@@ -73,7 +73,7 @@ public class TestVersioningFilter {
 
     when(mockRequest.getHeaders("Accept"))
         .thenReturn(
-            new 
Vector<>(Collections.singletonList("application/vnd.gravitino.v2+json"))
+            new 
Vector<>(Collections.singletonList("application/vnd.gravitino.v3+json"))
                 .elements());
 
     filter.doFilter(mockRequest, mockResponse, mockChain);
@@ -102,7 +102,7 @@ public class TestVersioningFilter {
         ArgumentCaptor.forClass(MutableHttpServletRequest.class);
     verify(mockChain).doFilter(captor.capture(), any());
     assertEquals(
-        String.format("application/vnd.gravitino.v%d+json", 
ApiVersion.latestVersion().version()),
+        String.format("application/vnd.gravitino.v%d+json", 
ApiVersion.defaultVersion().version()),
         captor.getValue().getHeader("Accept"));
   }
 
@@ -124,10 +124,53 @@ public class TestVersioningFilter {
         ArgumentCaptor.forClass(MutableHttpServletRequest.class);
     verify(mockChain).doFilter(captor.capture(), any());
     assertEquals(
-        String.format("application/vnd.gravitino.v%d+json", 
ApiVersion.latestVersion().version()),
+        String.format("application/vnd.gravitino.v%d+json", 
ApiVersion.defaultVersion().version()),
         captor.getValue().getHeader("Accept"));
   }
 
+  @Test
+  public void testContentTypeVersionWithoutAccept() throws ServletException, 
IOException {
+    VersioningFilter filter = new VersioningFilter();
+    FilterChain mockChain = mock(FilterChain.class);
+    HttpServletRequest mockRequest = mock(HttpServletRequest.class);
+    HttpServletResponse mockResponse = mock(HttpServletResponse.class);
+
+    
when(mockRequest.getHeaders("Accept")).thenReturn(Collections.emptyEnumeration());
+    
when(mockRequest.getHeader("Content-Type")).thenReturn("application/vnd.gravitino.v2+json");
+
+    filter.doFilter(mockRequest, mockResponse, mockChain);
+
+    verify(mockChain).doFilter(any(), any());
+    verify(mockResponse, never()).sendError(anyInt(), anyString());
+
+    ArgumentCaptor<MutableHttpServletRequest> captor =
+        ArgumentCaptor.forClass(MutableHttpServletRequest.class);
+    verify(mockChain).doFilter(captor.capture(), any());
+    assertEquals("application/vnd.gravitino.v2+json", 
captor.getValue().getHeader("Accept"));
+  }
+
+  @Test
+  public void testContentTypeVersionWithWildcardAccept() throws 
ServletException, IOException {
+    VersioningFilter filter = new VersioningFilter();
+    FilterChain mockChain = mock(FilterChain.class);
+    HttpServletRequest mockRequest = mock(HttpServletRequest.class);
+    HttpServletResponse mockResponse = mock(HttpServletResponse.class);
+
+    when(mockRequest.getHeaders("Accept"))
+        .thenReturn(new Vector<>(Collections.singletonList("*/*")).elements());
+    
when(mockRequest.getHeader("Content-Type")).thenReturn("application/vnd.gravitino.v2+json");
+
+    filter.doFilter(mockRequest, mockResponse, mockChain);
+
+    verify(mockChain).doFilter(any(), any());
+    verify(mockResponse, never()).sendError(anyInt(), anyString());
+
+    ArgumentCaptor<MutableHttpServletRequest> captor =
+        ArgumentCaptor.forClass(MutableHttpServletRequest.class);
+    verify(mockChain).doFilter(captor.capture(), any());
+    assertEquals("application/vnd.gravitino.v2+json", 
captor.getValue().getHeader("Accept"));
+  }
+
   @Test
   public void testDoFilterWithMultipleAcceptHeaders() throws ServletException, 
IOException {
     VersioningFilter filter = new VersioningFilter();
@@ -147,8 +190,8 @@ public class TestVersioningFilter {
     reset(mockChain, mockResponse);
 
     filter.doFilter(mockRequest, mockResponse, mockChain);
-    verify(mockChain, never()).doFilter(any(), any());
-    verify(mockResponse).sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, 
"Unsupported version");
+    verify(mockChain).doFilter(any(), any());
+    verify(mockResponse, never()).sendError(anyInt(), anyString());
   }
 
   @Test
@@ -168,7 +211,7 @@ public class TestVersioningFilter {
         ArgumentCaptor.forClass(MutableHttpServletRequest.class);
     verify(mockChain).doFilter(captor.capture(), any());
     assertEquals(
-        String.format("application/vnd.gravitino.v%d+json", 
ApiVersion.latestVersion().version()),
+        String.format("application/vnd.gravitino.v%d+json", 
ApiVersion.defaultVersion().version()),
         captor.getValue().getHeader("Accept"));
   }
 
@@ -231,8 +274,8 @@ public class TestVersioningFilter {
     reset(mockChain, mockResponse);
 
     filter.doFilter(mockRequest, mockResponse, mockChain);
-    verify(mockChain, never()).doFilter(any(), any());
-    verify(mockResponse).sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, 
"Unsupported version");
+    verify(mockChain).doFilter(any(), any());
+    verify(mockResponse, never()).sendError(anyInt(), anyString());
 
     reset(mockChain, mockResponse);
 
@@ -260,6 +303,25 @@ public class TestVersioningFilter {
     assertTrue(actualHeaderNames.contains("CustomHeader"));
   }
 
+  @Test
+  public void testGetHeaders() {
+    HttpServletRequest mockRequest = mock(HttpServletRequest.class);
+    when(mockRequest.getHeaders("Header1"))
+        .thenReturn(new 
Vector<>(Collections.singletonList("Value1")).elements());
+
+    VersioningFilter.MutableHttpServletRequest mutableRequest =
+        new VersioningFilter.MutableHttpServletRequest(mockRequest);
+    mutableRequest.putHeader("Accept", "application/vnd.gravitino.v1+json");
+
+    List<String> customHeaderValues = 
Collections.list(mutableRequest.getHeaders("Accept"));
+    assertEquals(1, customHeaderValues.size());
+    assertEquals("application/vnd.gravitino.v1+json", 
customHeaderValues.get(0));
+
+    List<String> delegatedHeaderValues = 
Collections.list(mutableRequest.getHeaders("Header1"));
+    assertEquals(1, delegatedHeaderValues.size());
+    assertEquals("Value1", delegatedHeaderValues.get(0));
+  }
+
   @Test
   public void testDoFilterWithHeaderContainingValidVersionAsSubstring() throws 
Exception {
     VersioningFilter filter = new VersioningFilter();
@@ -286,8 +348,8 @@ public class TestVersioningFilter {
                 .elements());
 
     filter.doFilter(mockRequest, mockResponse, mockChain);
-    verify(mockChain, never()).doFilter(any(), any());
-    verify(mockResponse).sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, 
"Unsupported version");
+    verify(mockChain).doFilter(any(), any());
+    verify(mockResponse, never()).sendError(anyInt(), anyString());
 
     reset(mockChain, mockResponse);
 
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 3a74305ce5..5e9f60ac36 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -17,7 +17,9 @@
 
 package org.apache.gravitino.server.web.filter;
 
+import static 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.CAN_ACCESS_METADATA_AND_TAG;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockStatic;
 import static org.mockito.Mockito.never;
@@ -30,9 +32,11 @@ import java.lang.reflect.Method;
 import java.security.Principal;
 import java.util.Collections;
 import java.util.List;
+import javax.servlet.http.HttpServletRequest;
 import javax.ws.rs.core.Response;
 import org.aopalliance.intercept.MethodInterceptor;
 import org.aopalliance.intercept.MethodInvocation;
+import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.GravitinoEnv;
@@ -44,16 +48,23 @@ import 
org.apache.gravitino.authorization.AuthorizationRequestContext;
 import org.apache.gravitino.authorization.AuthorizationUtils;
 import org.apache.gravitino.authorization.GravitinoAuthorizer;
 import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
 import org.apache.gravitino.dto.responses.ErrorResponse;
 import org.apache.gravitino.exceptions.ForbiddenException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.json.JsonUtils;
 import org.apache.gravitino.listener.EventBus;
 import 
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
 import org.apache.gravitino.metalake.MetalakeManager;
 import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationFullName;
 import 
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationObjectType;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
 import org.apache.gravitino.server.web.Utils;
+import org.apache.gravitino.server.web.rest.MetadataObjectTagOperations;
+import org.apache.gravitino.tag.TagDispatcher;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.gravitino.utils.RequestContext;
 import org.junit.jupiter.api.AfterEach;
@@ -317,6 +328,75 @@ public class TestGravitinoInterceptionService {
     }
   }
 
+  @Test
+  public void 
testInvalidV2TagAssociationBodyReturnsBadRequestAfterAuthorization()
+      throws Throwable {
+    try (MockedStatic<PrincipalUtils> principalUtilsMocked = 
mockStatic(PrincipalUtils.class);
+        MockedStatic<GravitinoAuthorizerProvider> authorizerMocked =
+            mockStatic(GravitinoAuthorizerProvider.class);
+        MockedStatic<AuthorizationUtils> authUtilsMocked = 
mockStatic(AuthorizationUtils.class)) {
+
+      principalUtilsMocked
+          .when(PrincipalUtils::getCurrentPrincipal)
+          .thenReturn(new UserPrincipal("tester"));
+      principalUtilsMocked
+          .when(() -> PrincipalUtils.doAs(ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenCallRealMethod();
+      
principalUtilsMocked.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+      GravitinoAuthorizerProvider mockedProvider = 
mock(GravitinoAuthorizerProvider.class);
+      
authorizerMocked.when(GravitinoAuthorizerProvider::getInstance).thenReturn(mockedProvider);
+      GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+      when(mockedProvider.getGravitinoAuthorizer()).thenReturn(authorizer);
+      when(authorizer.authorize(any(), any(), any(), any(), 
any())).thenReturn(true);
+      when(authorizer.deny(any(), any(), any(), any(), 
any())).thenReturn(false);
+      when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(true);
+      authUtilsMocked
+          .when(
+              () ->
+                  AuthorizationUtils.checkCurrentUser(
+                      ArgumentMatchers.any(), ArgumentMatchers.any(), 
ArgumentMatchers.any()))
+          .thenAnswer(invocation -> null);
+
+      TagValuesAssociateRequest request =
+          JsonUtils.objectMapper()
+              .readValue(
+                  "{\"tagsToAdd\":[{\"name\":\"data_domain\",\"value\":\" 
\"}]}",
+                  TagValuesAssociateRequest.class);
+      Method method =
+          TestMetadataObjectTagAssociationOperations.class.getMethod(
+              "associateTagValuesForObject",
+              String.class,
+              String.class,
+              String.class,
+              TagValuesAssociateRequest.class);
+      Object[] args = new Object[] {"testMetalake", "catalog", "object1", 
request};
+      TagDispatcher tagDispatcher = mock(TagDispatcher.class);
+      MetadataObjectTagOperations operations = new 
MetadataObjectTagOperations(tagDispatcher);
+      HttpServletRequest httpRequest = mock(HttpServletRequest.class);
+      FieldUtils.writeField(operations, "httpRequest", httpRequest, true);
+      Response invalidBodyResponse =
+          operations.associateTagValuesForObject(
+              (String) args[0],
+              (String) args[1],
+              (String) args[2],
+              (TagValuesAssociateRequest) args[3]);
+
+      MethodInvocation methodInvocation = mock(MethodInvocation.class);
+      when(methodInvocation.getMethod()).thenReturn(method);
+      when(methodInvocation.getArguments()).thenReturn(args);
+      when(methodInvocation.proceed()).thenReturn(invalidBodyResponse);
+
+      MethodInterceptor methodInterceptor =
+          new 
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+      Response response = (Response) 
methodInterceptor.invoke(methodInvocation);
+
+      assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+      verify(tagDispatcher, never())
+          .associateTagValuesForMetadataObject(any(), any(), any(), any());
+    }
+  }
+
   /**
    * When the authorization executor returns {@code false}, {@code 
buildNoAuthResponse} is called
    * with {@code emitEvent=true}. Verify that:
@@ -489,6 +569,19 @@ public class TestGravitinoInterceptionService {
     }
   }
 
+  public static class TestMetadataObjectTagAssociationOperations {
+
+    @AuthorizationExpression(expression = CAN_ACCESS_METADATA_AND_TAG)
+    public Response associateTagValuesForObject(
+        @AuthorizationMetadata(type = Entity.EntityType.METALAKE) String 
metalake,
+        @AuthorizationObjectType String type,
+        @AuthorizationFullName String fullName,
+        @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.ASSOCIATE_TAG)
+            TagValuesAssociateRequest request) {
+      return Utils.ok("unused");
+    }
+  }
+
   public static class TestOperations {
 
     @AuthorizationExpression(
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestAssociateTagAuthorizationExecutor.java
 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestAssociateTagAuthorizationExecutor.java
new file mode 100644
index 0000000000..184b29ad1c
--- /dev/null
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestAssociateTagAuthorizationExecutor.java
@@ -0,0 +1,128 @@
+/*
+ * 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.filter.authorization;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
+import org.apache.gravitino.dto.requests.TagsAssociateRequest;
+import org.apache.gravitino.json.JsonUtils;
+import 
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import 
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import org.apache.gravitino.tag.TagValue;
+import org.junit.jupiter.api.Test;
+
+public class TestAssociateTagAuthorizationExecutor {
+
+  @Test
+  public void testAuthorizesV1TagNames() throws Exception {
+    TagsAssociateRequest request =
+        new TagsAssociateRequest(new String[] {"pii"}, new String[] 
{"data_domain"});
+    assertAuthorizesAllTags("associateV1", TagsAssociateRequest.class, 
request);
+  }
+
+  @Test
+  public void testAuthorizesV2TagValues() throws Exception {
+    TagValuesAssociateRequest request =
+        new TagValuesAssociateRequest(
+            new TagValue[] {TagValue.noValue("pii")},
+            new TagValue[] {TagValue.of("data_domain", "finance")});
+    assertAuthorizesAllTags("associateV2", TagValuesAssociateRequest.class, 
request);
+  }
+
+  @Test
+  public void testAuthorizesV2TagNamesWithoutValidatingValues() throws 
Exception {
+    TagValuesAssociateRequest request =
+        JsonUtils.objectMapper()
+            .readValue(
+                "{\"tagsToAdd\":[{\"name\":\"data_domain\",\"value\":\" \"}]}",
+                TagValuesAssociateRequest.class);
+    Method method = TestOperations.class.getDeclaredMethod("associateV2", 
request.getClass());
+    Map<Entity.EntityType, NameIdentifier> metadataContext = new HashMap<>();
+    metadataContext.put(Entity.EntityType.METALAKE, 
NameIdentifier.of("metalake"));
+    AssociateTagAuthorizationExecutor executor =
+        new AssociateTagAuthorizationExecutor(
+            "TAG::OWNER",
+            method.getParameters(),
+            new Object[] {request},
+            metadataContext,
+            Collections.emptyMap(),
+            Optional.empty());
+
+    AuthorizationExpressionEvaluator evaluator = 
mock(AuthorizationExpressionEvaluator.class);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    when(evaluator.evaluate(anyMap(), anyMap(), any(), 
any())).thenReturn(true);
+    executor.authorizationExpressionEvaluator = evaluator;
+
+    assertTrue(executor.execute(context));
+    verify(evaluator, times(1)).evaluate(anyMap(), anyMap(), any(), any());
+  }
+
+  private static void assertAuthorizesAllTags(
+      String methodName, Class<?> requestType, Object request) throws 
Exception {
+    Method method = TestOperations.class.getDeclaredMethod(methodName, 
requestType);
+    Map<Entity.EntityType, NameIdentifier> metadataContext = new HashMap<>();
+    metadataContext.put(Entity.EntityType.METALAKE, 
NameIdentifier.of("metalake"));
+    AssociateTagAuthorizationExecutor executor =
+        new AssociateTagAuthorizationExecutor(
+            "TAG::OWNER",
+            method.getParameters(),
+            new Object[] {request},
+            metadataContext,
+            Collections.emptyMap(),
+            Optional.empty());
+
+    AuthorizationExpressionEvaluator evaluator = 
mock(AuthorizationExpressionEvaluator.class);
+    AuthorizationRequestContext context = new AuthorizationRequestContext();
+    when(evaluator.evaluate(anyMap(), anyMap(), any(), 
any())).thenReturn(true);
+    executor.authorizationExpressionEvaluator = evaluator;
+
+    assertTrue(executor.execute(context));
+    verify(evaluator, times(2)).evaluate(anyMap(), anyMap(), any(), any());
+  }
+
+  private static class TestOperations {
+    private void associateV1(
+        @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.ASSOCIATE_TAG)
+            TagsAssociateRequest request) {
+      request.validate();
+    }
+
+    private void associateV2(
+        @AuthorizationRequest(type = 
AuthorizationRequest.RequestType.ASSOCIATE_TAG)
+            TagValuesAssociateRequest request) {
+      request.validate();
+    }
+  }
+}
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
index e9e7796521..1a291c305e 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestMetadataObjectTagOperations.java
@@ -38,6 +38,7 @@ import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.dto.requests.TagValuesAssociateRequest;
 import org.apache.gravitino.dto.requests.TagsAssociateRequest;
 import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.dto.responses.ErrorResponse;
@@ -50,8 +51,10 @@ import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.TagEntity;
 import org.apache.gravitino.rest.RESTUtils;
 import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagAssignment;
 import org.apache.gravitino.tag.TagDispatcher;
 import org.apache.gravitino.tag.TagManager;
+import org.apache.gravitino.tag.TagValue;
 import org.glassfish.jersey.internal.inject.AbstractBinder;
 import org.glassfish.jersey.server.ResourceConfig;
 import org.glassfish.jersey.test.TestProperties;
@@ -502,6 +505,58 @@ public class TestMetadataObjectTagOperations extends 
BaseOperationsTest {
     Assertions.assertFalse(resultTags8.get("tag0").inherited().get());
   }
 
+  @Test
+  public void testListTagsDeduplicatesDifferentAssignmentValues() {
+    MetadataObject catalog = MetadataObjects.parse("object1", 
MetadataObject.Type.CATALOG);
+    MetadataObject schema = MetadataObjects.parse("object1.object2", 
MetadataObject.Type.SCHEMA);
+    MetadataObject table =
+        MetadataObjects.parse("object1.object2.object3", 
MetadataObject.Type.TABLE);
+    Tag directTag =
+        TagEntity.builder()
+            .withName("tag1")
+            .withId(1L)
+            .withAuditInfo(testAuditInfo1)
+            .withAssignment(TagAssignment.ofValues("finance"))
+            .build();
+    Tag inheritedTag =
+        TagEntity.builder()
+            .withName("tag1")
+            .withId(1L)
+            .withAuditInfo(testAuditInfo1)
+            .withAssignment(TagAssignment.ofValues("engineering"))
+            .build();
+    when(tagManager.listTagsInfoForMetadataObject(metalake, table))
+        .thenReturn(new Tag[] {directTag});
+    when(tagManager.listTagsInfoForMetadataObject(metalake, schema))
+        .thenReturn(new Tag[] {inheritedTag});
+    when(tagManager.listTagsInfoForMetadataObject(metalake, 
catalog)).thenReturn(new Tag[0]);
+
+    Response detailedResponse =
+        target(basePath(metalake))
+            .path(table.type().toString())
+            .path(table.fullName())
+            .path("tags")
+            .queryParam("details", true)
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+    TagListResponse detailedResult = 
detailedResponse.readEntity(TagListResponse.class);
+    Assertions.assertEquals(1, detailedResult.getTags().length);
+    Assertions.assertFalse(detailedResult.getTags()[0].inherited().get());
+    Assertions.assertArrayEquals(
+        new String[] {"finance"}, 
detailedResult.getTags()[0].assignment().get().values());
+
+    Response namesResponse =
+        target(basePath(metalake))
+            .path(table.type().toString())
+            .path(table.fullName())
+            .path("tags")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+    Assertions.assertEquals(1, 
namesResponse.readEntity(NameListResponse.class).getNames().length);
+  }
+
   @Test
   public void testListTagsForObjectUnderHierarchicalSchema() {
     // Hierarchical (multi-level) schema "a:b:c" using the default separator 
":". Its ancestor
@@ -915,7 +970,6 @@ public class TestMetadataObjectTagOperations extends 
BaseOperationsTest {
   public void testAssociateTagsForObject() {
     String[] tagsToAdd = new String[] {"tag1", "tag2"};
     String[] tagsToRemove = new String[] {"tag3", "tag4"};
-
     MetadataObject catalog = MetadataObjects.parse("object1", 
MetadataObject.Type.CATALOG);
     when(tagManager.associateTagsForMetadataObject(metalake, catalog, 
tagsToAdd, tagsToRemove))
         .thenReturn(tagsToAdd);
@@ -1017,7 +1071,8 @@ public class TestMetadataObjectTagOperations extends 
BaseOperationsTest {
     // Test throw RuntimeException
     doThrow(new RuntimeException("mock error"))
         .when(tagManager)
-        .associateTagsForMetadataObject(any(), any(), any(), any());
+        .associateTagsForMetadataObject(
+            any(String.class), any(MetadataObject.class), any(String[].class), 
any(String[].class));
 
     Response response5 =
         target(basePath(metalake))
@@ -1036,6 +1091,78 @@ public class TestMetadataObjectTagOperations extends 
BaseOperationsTest {
     Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResponse1.getType());
   }
 
+  @Test
+  public void testAssociateTagValuesForObjectV2() {
+    MetadataObject catalog = MetadataObjects.parse("object1", 
MetadataObject.Type.CATALOG);
+    TagValue[] tagsToAdd = {TagValue.noValue("pii"), 
TagValue.of("data_domain", "finance")};
+    TagValue[] tagsToRemove = {TagValue.of("data_domain", "old")};
+    when(tagManager.associateTagValuesForMetadataObject(metalake, catalog, 
tagsToAdd, tagsToRemove))
+        .thenReturn(new String[] {"pii", "data_domain"});
+
+    TagValuesAssociateRequest request = new 
TagValuesAssociateRequest(tagsToAdd, tagsToRemove);
+    Response response =
+        target(basePath(metalake))
+            .path(catalog.type().toString())
+            .path(catalog.fullName())
+            .path("tags")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v2+json")
+            .post(Entity.entity(request, "application/vnd.gravitino.v2+json"));
+
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
response.getStatus());
+    Assertions.assertEquals(
+        MediaType.valueOf("application/vnd.gravitino.v2+json"), 
response.getMediaType());
+    Assertions.assertArrayEquals(
+        new String[] {"pii", "data_domain"},
+        response.readEntity(NameListResponse.class).getNames());
+  }
+
+  @Test
+  public void testV2ErrorMediaType() {
+    MetadataObject catalog = MetadataObjects.parse("object1", 
MetadataObject.Type.CATALOG);
+    TagValuesAssociateRequest request = new TagValuesAssociateRequest(null, 
null);
+
+    Response response =
+        target(basePath(metalake))
+            .path(catalog.type().toString())
+            .path(catalog.fullName())
+            .path("tags")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v2+json")
+            .post(Entity.entity(request, "application/vnd.gravitino.v2+json"));
+
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+    Assertions.assertEquals(
+        MediaType.valueOf("application/vnd.gravitino.v2+json"), 
response.getMediaType());
+  }
+
+  @Test
+  public void testAssociateTagsRejectsRequestShapeFromOtherVersion() {
+    MetadataObject catalog = MetadataObjects.parse("object1", 
MetadataObject.Type.CATALOG);
+    String v1Json = "{\"tagsToAdd\":[\"data_domain\"]}";
+    String v2Json = 
"{\"tagsToAdd\":[{\"name\":\"data_domain\",\"value\":\"finance\"}]}";
+
+    Response v1WithV2Shape =
+        target(basePath(metalake))
+            .path(catalog.type().toString())
+            .path(catalog.fullName())
+            .path("tags")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(v2Json, MediaType.APPLICATION_JSON_TYPE));
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
v1WithV2Shape.getStatus());
+
+    Response v2WithV1Shape =
+        target(basePath(metalake))
+            .path(catalog.type().toString())
+            .path(catalog.fullName())
+            .path("tags")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v2+json")
+            .post(Entity.entity(v1Json, "application/vnd.gravitino.v2+json"));
+    Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
v2WithV1Shape.getStatus());
+  }
+
   private String basePath(String metalake) {
     return "/metalakes/" + metalake + "/objects";
   }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestTagOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestTagOperations.java
index 14c19791ba..6127bb77e5 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestTagOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestTagOperations.java
@@ -67,6 +67,7 @@ import org.apache.gravitino.tag.Tag;
 import org.apache.gravitino.tag.TagChange;
 import org.apache.gravitino.tag.TagDispatcher;
 import org.apache.gravitino.tag.TagManager;
+import org.apache.gravitino.tag.TagValueConstraint;
 import org.glassfish.jersey.internal.inject.AbstractBinder;
 import org.glassfish.jersey.server.ResourceConfig;
 import org.glassfish.jersey.test.TestProperties;
@@ -280,7 +281,9 @@ public class TestTagOperations extends BaseOperationsTest {
             .withComment("tag1 comment")
             .withAuditInfo(testAuditInfo1)
             .build();
-    when(tagManager.createTag(metalake, "tag1", "tag1 comment", 
null)).thenReturn(tag1);
+    when(tagManager.createTag(
+            metalake, "tag1", "tag1 comment", null, 
TagValueConstraint.anyValue()))
+        .thenReturn(tag1);
 
     TagCreateRequest request = new TagCreateRequest("tag1", "tag1 comment", 
null);
     Response resp =
@@ -303,7 +306,7 @@ public class TestTagOperations extends BaseOperationsTest {
     // Test throw TagAlreadyExistsException
     doThrow(new TagAlreadyExistsException("mock error"))
         .when(tagManager)
-        .createTag(any(), any(), any(), any());
+        .createTag(any(), any(), any(), any(), any());
     Response resp1 =
         target(tagPath(metalake))
             .request(MediaType.APPLICATION_JSON_TYPE)
@@ -319,7 +322,7 @@ public class TestTagOperations extends BaseOperationsTest {
     // Test throw RuntimeException
     doThrow(new RuntimeException("mock error"))
         .when(tagManager)
-        .createTag(any(), any(), any(), any());
+        .createTag(any(), any(), any(), any(), any());
 
     Response resp2 =
         target(tagPath(metalake))
@@ -953,7 +956,6 @@ public class TestTagOperations extends BaseOperationsTest {
   public void testAssociateTagsForObject() {
     String[] tagsToAdd = new String[] {"tag1", "tag2"};
     String[] tagsToRemove = new String[] {"tag3", "tag4"};
-
     MetadataObject catalog = MetadataObjects.parse("object1", 
MetadataObject.Type.CATALOG);
     when(tagManager.associateTagsForMetadataObject(metalake, catalog, 
tagsToAdd, tagsToRemove))
         .thenReturn(tagsToAdd);
@@ -1015,7 +1017,8 @@ public class TestTagOperations extends BaseOperationsTest 
{
     // Test throw RuntimeException
     doThrow(new RuntimeException("mock error"))
         .when(tagManager)
-        .associateTagsForMetadataObject(any(), any(), any(), any());
+        .associateTagsForMetadataObject(
+            any(String.class), any(MetadataObject.class), any(String[].class), 
any(String[].class));
 
     Response response3 =
         target(tagPath(metalake))
@@ -1043,7 +1046,7 @@ public class TestTagOperations extends BaseOperationsTest 
{
           MetadataObjects.parse("object1.object2.object3.object4", 
MetadataObject.Type.COLUMN)
         };
 
-    when(tagManager.listMetadataObjectsForTag(metalake, 
"tag1")).thenReturn(objects);
+    when(tagManager.listMetadataObjectsForTag(metalake, "tag1", 
null)).thenReturn(objects);
 
     Response response =
         target(tagPath(metalake))
@@ -1068,10 +1071,43 @@ public class TestTagOperations extends 
BaseOperationsTest {
       Assertions.assertEquals(objects[i].fullName(), 
respObjects[i].fullName());
     }
 
+    when(tagManager.listMetadataObjectsForTag(metalake, "tag1", 
"finance")).thenReturn(objects);
+    Response filteredResponse =
+        target(tagPath(metalake))
+            .path("tag1")
+            .path("objects")
+            .queryParam("value", "finance")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+    Assertions.assertEquals(Response.Status.OK.getStatusCode(), 
filteredResponse.getStatus());
+
+    Response emptyValueResponse =
+        target(tagPath(metalake))
+            .path("tag1")
+            .path("objects")
+            .queryParam("value", "")
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+    Assertions.assertEquals(
+        Response.Status.BAD_REQUEST.getStatusCode(), 
emptyValueResponse.getStatus());
+
+    Response tooLongValueResponse =
+        target(tagPath(metalake))
+            .path("tag1")
+            .path("objects")
+            .queryParam("value", "v".repeat(257))
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .get();
+    Assertions.assertEquals(
+        Response.Status.BAD_REQUEST.getStatusCode(), 
tooLongValueResponse.getStatus());
+
     // Test throw NoSuchTagException
     doThrow(new NoSuchTagException("mock error"))
         .when(tagManager)
-        .listMetadataObjectsForTag(metalake, "tag1");
+        .listMetadataObjectsForTag(metalake, "tag1", null);
 
     Response response1 =
         target(tagPath(metalake))
@@ -1090,7 +1126,7 @@ public class TestTagOperations extends BaseOperationsTest 
{
     // Test throw RuntimeException
     doThrow(new RuntimeException("mock error"))
         .when(tagManager)
-        .listMetadataObjectsForTag(any(), any());
+        .listMetadataObjectsForTag(any(), any(), any());
 
     Response response2 =
         target(tagPath(metalake))

Reply via email to