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

yuqi4733 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 525a53f64 [#4172] feat(client-java): Add client side Java REST API for 
Tag System (#4235)
525a53f64 is described below

commit 525a53f64a1ce694cef7761d77e5daaa1568dacc
Author: Jerry Shao <[email protected]>
AuthorDate: Wed Jul 24 15:20:13 2024 +0800

    [#4172] feat(client-java): Add client side Java REST API for Tag System 
(#4235)
    
    ### What changes were proposed in this pull request?
    
    This PR proposes to add client side Java REST API for tag system.
    
    ### Why are the changes needed?
    
    This is a part of work for tag system.
    
    Fix: #4172
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    ### How was this patch tested?
    
    UT added.
---
 .../main/java/org/apache/gravitino/Catalog.java    |   9 +
 api/src/main/java/org/apache/gravitino/Schema.java |   9 +
 .../java/org/apache/gravitino/file/Fileset.java    |   9 +
 .../java/org/apache/gravitino/messaging/Topic.java |   9 +
 .../main/java/org/apache/gravitino/rel/Table.java  |   9 +
 .../org/apache/gravitino/tag/TagOperations.java    |   8 +-
 .../apache/gravitino/client/BaseSchemaCatalog.java |  46 +-
 .../org/apache/gravitino/client/DTOConverters.java |  25 +
 .../org/apache/gravitino/client/ErrorHandlers.java |  56 +++
 .../apache/gravitino/client/FilesetCatalog.java    |   9 +-
 .../apache/gravitino/client/GenericFileset.java    | 124 +++++
 .../org/apache/gravitino/client/GenericSchema.java | 107 +++++
 .../org/apache/gravitino/client/GenericTag.java    | 105 +++++
 .../org/apache/gravitino/client/GenericTopic.java  | 112 +++++
 .../apache/gravitino/client/GravitinoClient.java   |  40 +-
 .../gravitino/client/GravitinoClientBase.java      |   6 +
 .../apache/gravitino/client/GravitinoMetalake.java | 160 ++++++-
 .../apache/gravitino/client/MessagingCatalog.java  |   8 +-
 .../client/MetadataObjectTagOperations.java        | 119 +++++
 .../apache/gravitino/client/RelationalCatalog.java |   2 +-
 .../apache/gravitino/client/RelationalTable.java   |  60 ++-
 .../apache/gravitino/client/TestGenericTag.java    | 147 ++++++
 ...noClient.java => TestGravitinoAdminClient.java} |   2 +-
 .../gravitino/client/TestGravitinoMetalake.java    | 311 +++++++++++++
 .../apache/gravitino/client/TestSupportTags.java   | 503 +++++++++++++++++++++
 .../java/org/apache/gravitino/dto/SchemaDTO.java   |   2 +
 .../org/apache/gravitino/dto/file/FilesetDTO.java  |   2 +
 .../java/org/apache/gravitino/dto/tag/TagDTO.java  |  24 +-
 28 files changed, 1990 insertions(+), 33 deletions(-)

diff --git a/api/src/main/java/org/apache/gravitino/Catalog.java 
b/api/src/main/java/org/apache/gravitino/Catalog.java
index 4809c7ead..bbcc0cb7d 100644
--- a/api/src/main/java/org/apache/gravitino/Catalog.java
+++ b/api/src/main/java/org/apache/gravitino/Catalog.java
@@ -23,6 +23,7 @@ import org.apache.gravitino.annotation.Evolving;
 import org.apache.gravitino.file.FilesetCatalog;
 import org.apache.gravitino.messaging.TopicCatalog;
 import org.apache.gravitino.rel.TableCatalog;
+import org.apache.gravitino.tag.SupportsTags;
 
 /**
  * The interface of a catalog. The catalog is the second level entity in the 
Gravitino system,
@@ -152,4 +153,12 @@ public interface Catalog extends Auditable {
   default TopicCatalog asTopicCatalog() throws UnsupportedOperationException {
     throw new UnsupportedOperationException("Catalog does not support topic 
operations");
   }
+
+  /**
+   * @return the {@link SupportsTags} if the catalog supports tag operations.
+   * @throws UnsupportedOperationException if the catalog does not support tag 
operations.
+   */
+  default SupportsTags supportsTags() throws UnsupportedOperationException {
+    throw new UnsupportedOperationException("Catalog does not support tag 
operations");
+  }
 }
diff --git a/api/src/main/java/org/apache/gravitino/Schema.java 
b/api/src/main/java/org/apache/gravitino/Schema.java
index e835ee685..872b0a25e 100644
--- a/api/src/main/java/org/apache/gravitino/Schema.java
+++ b/api/src/main/java/org/apache/gravitino/Schema.java
@@ -22,6 +22,7 @@ import java.util.Collections;
 import java.util.Map;
 import javax.annotation.Nullable;
 import org.apache.gravitino.annotation.Evolving;
+import org.apache.gravitino.tag.SupportsTags;
 
 /**
  * An interface representing a schema in the {@link Catalog}. A Schema is a 
basic container of
@@ -47,4 +48,12 @@ public interface Schema extends Auditable {
   default Map<String, String> properties() {
     return Collections.emptyMap();
   }
+
+  /**
+   * @return the {@link SupportsTags} if the schema supports tag operations.
+   * @throws UnsupportedOperationException if the schema does not support tag 
operations.
+   */
+  default SupportsTags supportsTags() {
+    throw new UnsupportedOperationException("Schema does not support tag 
operations.");
+  }
 }
diff --git a/api/src/main/java/org/apache/gravitino/file/Fileset.java 
b/api/src/main/java/org/apache/gravitino/file/Fileset.java
index 884cb20a9..ccff039da 100644
--- a/api/src/main/java/org/apache/gravitino/file/Fileset.java
+++ b/api/src/main/java/org/apache/gravitino/file/Fileset.java
@@ -24,6 +24,7 @@ import javax.annotation.Nullable;
 import org.apache.gravitino.Auditable;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.annotation.Evolving;
+import org.apache.gravitino.tag.SupportsTags;
 
 /**
  * An interface representing a fileset under a schema {@link Namespace}. A 
fileset is a virtual
@@ -105,4 +106,12 @@ public interface Fileset extends Auditable {
   default Map<String, String> properties() {
     return Collections.emptyMap();
   }
+
+  /**
+   * @return The {@link SupportsTags} if the fileset supports tag operations.
+   * @throws UnsupportedOperationException If the fileset does not support tag 
operations.
+   */
+  default SupportsTags supportsTags() {
+    throw new UnsupportedOperationException("Fileset does not support tag 
operations.");
+  }
 }
diff --git a/api/src/main/java/org/apache/gravitino/messaging/Topic.java 
b/api/src/main/java/org/apache/gravitino/messaging/Topic.java
index b086f12a0..78607f486 100644
--- a/api/src/main/java/org/apache/gravitino/messaging/Topic.java
+++ b/api/src/main/java/org/apache/gravitino/messaging/Topic.java
@@ -24,6 +24,7 @@ import javax.annotation.Nullable;
 import org.apache.gravitino.Auditable;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.annotation.Evolving;
+import org.apache.gravitino.tag.SupportsTags;
 
 /**
  * An interface representing a topic under a schema {@link Namespace}. A topic 
is a message queue
@@ -49,4 +50,12 @@ public interface Topic extends Auditable {
   default Map<String, String> properties() {
     return Collections.emptyMap();
   }
+
+  /**
+   * @return the {@link SupportsTags} if the topic supports tag operations.
+   * @throws UnsupportedOperationException if the topic does not support tag 
operations.
+   */
+  default SupportsTags supportsTags() {
+    throw new UnsupportedOperationException("Topic does not support tag 
operations.");
+  }
 }
diff --git a/api/src/main/java/org/apache/gravitino/rel/Table.java 
b/api/src/main/java/org/apache/gravitino/rel/Table.java
index 8d542fed7..c6bafb97a 100644
--- a/api/src/main/java/org/apache/gravitino/rel/Table.java
+++ b/api/src/main/java/org/apache/gravitino/rel/Table.java
@@ -31,6 +31,7 @@ import 
org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.expressions.transforms.Transforms;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.indexes.Indexes;
+import org.apache.gravitino.tag.SupportsTags;
 
 /**
  * An interface representing a table in a {@link Namespace}. It defines the 
basic properties of a
@@ -94,4 +95,12 @@ public interface Table extends Auditable {
   default SupportsPartitions supportPartitions() throws 
UnsupportedOperationException {
     throw new UnsupportedOperationException("Table does not support partition 
operations.");
   }
+
+  /**
+   * @return The {@link SupportsTags} if the table supports tag operations.
+   * @throws UnsupportedOperationException If the table does not support tag 
operations.
+   */
+  default SupportsTags supportsTags() {
+    throw new UnsupportedOperationException("Table does not support tag 
operations.");
+  }
 }
diff --git a/api/src/main/java/org/apache/gravitino/tag/TagOperations.java 
b/api/src/main/java/org/apache/gravitino/tag/TagOperations.java
index 3674e6cc7..1745efb23 100644
--- a/api/src/main/java/org/apache/gravitino/tag/TagOperations.java
+++ b/api/src/main/java/org/apache/gravitino/tag/TagOperations.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.tag;
 
 import java.util.Map;
 import org.apache.gravitino.annotation.Evolving;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
 import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.TagAlreadyExistsException;
 
@@ -36,16 +37,17 @@ public interface TagOperations {
    * List all the tag names under a metalake.
    *
    * @return The list of tag names.
+   * @throws NoSuchMetalakeException If the metalake does not exist.
    */
-  String[] listTags();
+  String[] listTags() throws NoSuchMetalakeException;
 
   /**
    * List all the tags with detailed information under a metalake.
    *
-   * @param extended If true, the extended information of the tag will be 
included.
    * @return The list of tags.
+   * @throws NoSuchMetalakeException If the metalake does not exist.
    */
-  Tag[] listTagsInfo(boolean extended);
+  Tag[] listTagsInfo() throws NoSuchMetalakeException;
 
   /**
    * Get a tag by its name under a metalake.
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 96313c848..7d46af3a5 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
@@ -24,6 +24,8 @@ import java.util.List;
 import java.util.Map;
 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.Namespace;
 import org.apache.gravitino.Schema;
@@ -42,19 +44,24 @@ import 
org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
 import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.tag.SupportsTags;
+import org.apache.gravitino.tag.Tag;
 
 /**
  * BaseSchemaCatalog is the base abstract class for all the catalog with 
schema. It provides the
  * common methods for managing schemas in a catalog. With {@link 
BaseSchemaCatalog}, users can list,
  * create, load, alter and drop a schema with specified identifier.
  */
-abstract class BaseSchemaCatalog extends CatalogDTO implements Catalog, 
SupportsSchemas {
+abstract class BaseSchemaCatalog extends CatalogDTO
+    implements Catalog, SupportsSchemas, SupportsTags {
   /** The REST client to send the requests. */
   protected final RESTClient restClient;
 
   /** The namespace of current catalog, which is the metalake name. */
   private final Namespace catalogNamespace;
 
+  private final MetadataObjectTagOperations objectTagOperations;
+
   BaseSchemaCatalog(
       Namespace catalogNamespace,
       String name,
@@ -65,12 +72,18 @@ abstract class BaseSchemaCatalog extends CatalogDTO 
implements Catalog, Supports
       AuditDTO auditDTO,
       RESTClient restClient) {
     super(name, type, provider, comment, properties, auditDTO);
+
     this.restClient = restClient;
     Namespace.check(
         catalogNamespace != null && catalogNamespace.length() == 1,
         "Catalog namespace must be non-null and have 1 level, the input 
namespace is %s",
         catalogNamespace);
     this.catalogNamespace = catalogNamespace;
+
+    MetadataObject metadataObject =
+        MetadataObjects.of(null, this.name(), MetadataObject.Type.CATALOG);
+    this.objectTagOperations =
+        new MetadataObjectTagOperations(catalogNamespace.level(0), 
metadataObject, restClient);
   }
 
   @Override
@@ -78,6 +91,11 @@ abstract class BaseSchemaCatalog extends CatalogDTO 
implements Catalog, Supports
     return this;
   }
 
+  @Override
+  public SupportsTags supportsTags() throws UnsupportedOperationException {
+    return this;
+  }
+
   /**
    * List all the schemas under the given catalog namespace.
    *
@@ -125,7 +143,7 @@ abstract class BaseSchemaCatalog extends CatalogDTO 
implements Catalog, Supports
             ErrorHandlers.schemaErrorHandler());
     resp.validate();
 
-    return resp.getSchema();
+    return new GenericSchema(resp.getSchema(), restClient, 
catalogNamespace.level(0), this.name());
   }
 
   /**
@@ -146,7 +164,7 @@ abstract class BaseSchemaCatalog extends CatalogDTO 
implements Catalog, Supports
             ErrorHandlers.schemaErrorHandler());
     resp.validate();
 
-    return resp.getSchema();
+    return new GenericSchema(resp.getSchema(), restClient, 
catalogNamespace.level(0), this.name());
   }
 
   /**
@@ -177,7 +195,7 @@ abstract class BaseSchemaCatalog extends CatalogDTO 
implements Catalog, Supports
             ErrorHandlers.schemaErrorHandler());
     resp.validate();
 
-    return resp.getSchema();
+    return new GenericSchema(resp.getSchema(), restClient, 
catalogNamespace.level(0), this.name());
   }
 
   /**
@@ -201,6 +219,26 @@ abstract class BaseSchemaCatalog extends CatalogDTO 
implements Catalog, Supports
     return resp.dropped();
   }
 
+  @Override
+  public String[] listTags() {
+    return objectTagOperations.listTags();
+  }
+
+  @Override
+  public Tag[] listTagsInfo() {
+    return objectTagOperations.listTagsInfo();
+  }
+
+  @Override
+  public Tag getTag(String name) {
+    return objectTagOperations.getTag(name);
+  }
+
+  @Override
+  public String[] associateTags(String[] tagsToAdd, String[] tagsToRemove) {
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
   /**
    * Get the namespace of the current catalog, which is "metalake".
    *
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/DTOConverters.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/DTOConverters.java
index 4e60ed079..6b259405d 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/DTOConverters.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/DTOConverters.java
@@ -36,12 +36,14 @@ import 
org.apache.gravitino.dto.requests.FilesetUpdateRequest;
 import org.apache.gravitino.dto.requests.MetalakeUpdateRequest;
 import org.apache.gravitino.dto.requests.SchemaUpdateRequest;
 import org.apache.gravitino.dto.requests.TableUpdateRequest;
+import org.apache.gravitino.dto.requests.TagUpdateRequest;
 import org.apache.gravitino.dto.requests.TopicUpdateRequest;
 import org.apache.gravitino.file.FilesetChange;
 import org.apache.gravitino.messaging.TopicChange;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.expressions.Expression;
+import org.apache.gravitino.tag.TagChange;
 
 class DTOConverters {
   private DTOConverters() {}
@@ -312,4 +314,27 @@ class DTOConverters {
                 .toArray(PrivilegeDTO[]::new))
         .build();
   }
+
+  static TagUpdateRequest toTagUpdateRequest(TagChange change) {
+    if (change instanceof TagChange.RenameTag) {
+      return new TagUpdateRequest.RenameTagRequest(((TagChange.RenameTag) 
change).getNewName());
+
+    } else if (change instanceof TagChange.UpdateTagComment) {
+      return new TagUpdateRequest.UpdateTagCommentRequest(
+          ((TagChange.UpdateTagComment) change).getNewComment());
+
+    } else if (change instanceof TagChange.SetProperty) {
+      return new TagUpdateRequest.SetTagPropertyRequest(
+          ((TagChange.SetProperty) change).getProperty(),
+          ((TagChange.SetProperty) change).getValue());
+
+    } else if (change instanceof TagChange.RemoveProperty) {
+      return new TagUpdateRequest.RemoveTagPropertyRequest(
+          ((TagChange.RemoveProperty) change).getProperty());
+
+    } else {
+      throw new IllegalArgumentException(
+          "Unknown change type: " + change.getClass().getSimpleName());
+    }
+  }
 }
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
index ae87cf6e1..f98ee7708 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
@@ -25,6 +25,7 @@ import java.util.function.Consumer;
 import org.apache.gravitino.dto.responses.ErrorConstants;
 import org.apache.gravitino.dto.responses.ErrorResponse;
 import org.apache.gravitino.dto.responses.OAuth2ErrorResponse;
+import org.apache.gravitino.exceptions.AlreadyExistsException;
 import org.apache.gravitino.exceptions.BadRequestException;
 import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
 import org.apache.gravitino.exceptions.ConnectionFailedException;
@@ -39,6 +40,7 @@ import 
org.apache.gravitino.exceptions.NoSuchPartitionException;
 import org.apache.gravitino.exceptions.NoSuchRoleException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.NoSuchTopicException;
 import org.apache.gravitino.exceptions.NoSuchUserException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
@@ -48,6 +50,8 @@ import org.apache.gravitino.exceptions.RESTException;
 import org.apache.gravitino.exceptions.RoleAlreadyExistsException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
+import org.apache.gravitino.exceptions.TagAlreadyAssociatedException;
+import org.apache.gravitino.exceptions.TagAlreadyExistsException;
 import org.apache.gravitino.exceptions.TopicAlreadyExistsException;
 import org.apache.gravitino.exceptions.UnauthorizedException;
 import org.apache.gravitino.exceptions.UserAlreadyExistsException;
@@ -175,6 +179,15 @@ public class ErrorHandlers {
     return PermissionOperationErrorHandler.INSTANCE;
   }
 
+  /**
+   * Creates an error handler specific to Tag operations.
+   *
+   * @return A Consumer representing the Tag error handler.
+   */
+  public static Consumer<ErrorResponse> tagErrorHandler() {
+    return TagErrorHandler.INSTANCE;
+  }
+
   private ErrorHandlers() {}
 
   /**
@@ -657,6 +670,49 @@ public class ErrorHandlers {
     }
   }
 
+  /** Error handler specific to Tag operations. */
+  @SuppressWarnings("FormatStringAnnotation")
+  private static class TagErrorHandler extends RestErrorHandler {
+
+    private static final TagErrorHandler INSTANCE = new TagErrorHandler();
+
+    @Override
+    public void accept(ErrorResponse errorResponse) {
+      String errorMessage = formatErrorMessage(errorResponse);
+
+      switch (errorResponse.getCode()) {
+        case ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+          throw new IllegalArgumentException(errorMessage);
+
+        case ErrorConstants.NOT_FOUND_CODE:
+          if 
(errorResponse.getType().equals(NoSuchMetalakeException.class.getSimpleName())) 
{
+            throw new NoSuchMetalakeException(errorMessage);
+          } else if 
(errorResponse.getType().equals(NoSuchTagException.class.getSimpleName())) {
+            throw new NoSuchTagException(errorMessage);
+          } else {
+            throw new NotFoundException(errorMessage);
+          }
+
+        case ErrorConstants.ALREADY_EXISTS_CODE:
+          if 
(errorResponse.getType().equals(TagAlreadyExistsException.class.getSimpleName()))
 {
+            throw new TagAlreadyExistsException(errorMessage);
+          } else if (errorResponse
+              .getType()
+              .equals(TagAlreadyAssociatedException.class.getSimpleName())) {
+            throw new TagAlreadyAssociatedException(errorMessage);
+          } else {
+            throw new AlreadyExistsException(errorMessage);
+          }
+
+        case ErrorConstants.INTERNAL_ERROR_CODE:
+          throw new RuntimeException(errorMessage);
+
+        default:
+          super.accept(errorResponse);
+      }
+    }
+  }
+
   /** Generic error handler for REST requests. */
   private static class RestErrorHandler extends ErrorHandler {
     private static final ErrorHandler INSTANCE = new RestErrorHandler();
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
index 6aa462470..3f0d49530 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
@@ -49,8 +49,7 @@ import org.apache.gravitino.rest.RESTUtils;
  * example, schemas and filesets list, creation, update and deletion. A 
Fileset catalog is under the
  * metalake.
  */
-public class FilesetCatalog extends BaseSchemaCatalog
-    implements org.apache.gravitino.file.FilesetCatalog {
+class FilesetCatalog extends BaseSchemaCatalog implements 
org.apache.gravitino.file.FilesetCatalog {
 
   FilesetCatalog(
       Namespace namespace,
@@ -116,7 +115,7 @@ public class FilesetCatalog extends BaseSchemaCatalog
             ErrorHandlers.filesetErrorHandler());
     resp.validate();
 
-    return resp.getFileset();
+    return new GenericFileset(resp.getFileset(), restClient, fullNamespace);
   }
 
   /**
@@ -165,7 +164,7 @@ public class FilesetCatalog extends BaseSchemaCatalog
             ErrorHandlers.filesetErrorHandler());
     resp.validate();
 
-    return resp.getFileset();
+    return new GenericFileset(resp.getFileset(), restClient, fullNamespace);
   }
 
   /**
@@ -199,7 +198,7 @@ public class FilesetCatalog extends BaseSchemaCatalog
             ErrorHandlers.filesetErrorHandler());
     resp.validate();
 
-    return resp.getFileset();
+    return new GenericFileset(resp.getFileset(), restClient, fullNamespace);
   }
 
   /**
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
new file mode 100644
index 000000000..32e1d7392
--- /dev/null
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericFileset.java
@@ -0,0 +1,124 @@
+/*
+ * 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.client;
+
+import com.google.common.collect.Lists;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.gravitino.Audit;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.dto.file.FilesetDTO;
+import org.apache.gravitino.exceptions.NoSuchTagException;
+import org.apache.gravitino.file.Fileset;
+import org.apache.gravitino.tag.SupportsTags;
+import org.apache.gravitino.tag.Tag;
+
+/** Represents a generic fileset. */
+class GenericFileset implements Fileset, SupportsTags {
+
+  private final FilesetDTO filesetDTO;
+
+  private final MetadataObjectTagOperations objectTagOperations;
+
+  GenericFileset(FilesetDTO filesetDTO, RESTClient restClient, Namespace 
filesetNs) {
+    this.filesetDTO = filesetDTO;
+    List<String> filesetFullName =
+        Lists.newArrayList(filesetNs.level(1), filesetNs.level(2), 
filesetDTO.name());
+    MetadataObject filesetObject = MetadataObjects.of(filesetFullName, 
MetadataObject.Type.FILESET);
+    this.objectTagOperations =
+        new MetadataObjectTagOperations(filesetNs.level(0), filesetObject, 
restClient);
+  }
+
+  @Override
+  public Audit auditInfo() {
+    return filesetDTO.auditInfo();
+  }
+
+  @Override
+  public String name() {
+    return filesetDTO.name();
+  }
+
+  @Nullable
+  @Override
+  public String comment() {
+    return filesetDTO.comment();
+  }
+
+  @Override
+  public Type type() {
+    return filesetDTO.type();
+  }
+
+  @Override
+  public String storageLocation() {
+    return filesetDTO.storageLocation();
+  }
+
+  @Override
+  public Map<String, String> properties() {
+    return filesetDTO.properties();
+  }
+
+  @Override
+  public SupportsTags supportsTags() {
+    return this;
+  }
+
+  @Override
+  public String[] listTags() {
+    return objectTagOperations.listTags();
+  }
+
+  @Override
+  public Tag[] listTagsInfo() {
+    return objectTagOperations.listTagsInfo();
+  }
+
+  @Override
+  public Tag getTag(String name) throws NoSuchTagException {
+    return objectTagOperations.getTag(name);
+  }
+
+  @Override
+  public String[] associateTags(String[] tagsToAdd, String[] tagsToRemove) {
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (!(obj instanceof GenericFileset)) {
+      return false;
+    }
+
+    GenericFileset that = (GenericFileset) obj;
+    return filesetDTO.equals(that.filesetDTO);
+  }
+
+  @Override
+  public int hashCode() {
+    return filesetDTO.hashCode();
+  }
+}
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
new file mode 100644
index 000000000..e595a53ab
--- /dev/null
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericSchema.java
@@ -0,0 +1,107 @@
+/*
+ * 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.client;
+
+import java.util.Map;
+import org.apache.gravitino.Audit;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.dto.SchemaDTO;
+import org.apache.gravitino.exceptions.NoSuchTagException;
+import org.apache.gravitino.tag.SupportsTags;
+import org.apache.gravitino.tag.Tag;
+
+/** Represents a generic schema. */
+class GenericSchema implements Schema, SupportsTags {
+
+  private final SchemaDTO schemaDTO;
+
+  private final MetadataObjectTagOperations objectTagOperations;
+
+  GenericSchema(SchemaDTO schemaDTO, RESTClient restClient, String metalake, 
String catalog) {
+    this.schemaDTO = schemaDTO;
+    MetadataObject schemaObject =
+        MetadataObjects.of(catalog, schemaDTO.name(), 
MetadataObject.Type.SCHEMA);
+    this.objectTagOperations = new MetadataObjectTagOperations(metalake, 
schemaObject, restClient);
+  }
+
+  @Override
+  public SupportsTags supportsTags() {
+    return this;
+  }
+
+  @Override
+  public String name() {
+    return schemaDTO.name();
+  }
+
+  @Override
+  public String comment() {
+    return schemaDTO.comment();
+  }
+
+  @Override
+  public Map<String, String> properties() {
+    return schemaDTO.properties();
+  }
+
+  @Override
+  public Audit auditInfo() {
+    return schemaDTO.auditInfo();
+  }
+
+  @Override
+  public String[] listTags() {
+    return objectTagOperations.listTags();
+  }
+
+  @Override
+  public Tag[] listTagsInfo() {
+    return objectTagOperations.listTagsInfo();
+  }
+
+  @Override
+  public Tag getTag(String name) throws NoSuchTagException {
+    return objectTagOperations.getTag(name);
+  }
+
+  @Override
+  public String[] associateTags(String[] tagsToAdd, String[] tagsToRemove) {
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (!(obj instanceof GenericSchema)) {
+      return false;
+    }
+
+    GenericSchema that = (GenericSchema) obj;
+    return schemaDTO.equals(that.schemaDTO);
+  }
+
+  @Override
+  public int hashCode() {
+    return schemaDTO.hashCode();
+  }
+}
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
new file mode 100644
index 000000000..19ca417b3
--- /dev/null
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTag.java
@@ -0,0 +1,105 @@
+/*
+ * 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.client;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+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.tag.Tag;
+
+/** Represents a generic tag. */
+class GenericTag implements Tag, Tag.AssociatedObjects {
+
+  private final TagDTO tagDTO;
+
+  private final RESTClient restClient;
+
+  private final String metalake;
+
+  GenericTag(TagDTO tagDTO, RESTClient restClient, String metalake) {
+    this.tagDTO = tagDTO;
+    this.restClient = restClient;
+    this.metalake = metalake;
+  }
+
+  @Override
+  public String name() {
+    return tagDTO.name();
+  }
+
+  @Override
+  public String comment() {
+    return tagDTO.comment();
+  }
+
+  @Override
+  public Map<String, String> properties() {
+    return tagDTO.properties();
+  }
+
+  @Override
+  public Optional<Boolean> inherited() {
+    return tagDTO.inherited();
+  }
+
+  @Override
+  public Audit auditInfo() {
+    return tagDTO.auditInfo();
+  }
+
+  @Override
+  public AssociatedObjects associatedObjects() {
+    return this;
+  }
+
+  @Override
+  public MetadataObject[] objects() {
+    MetadataObjectListResponse resp =
+        restClient.get(
+            String.format("api/metalakes/%s/tags/%s/objects", metalake, 
name()),
+            MetadataObjectListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+
+    resp.validate();
+    return resp.getMetadataObjects();
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (!(obj instanceof GenericTag)) {
+      return false;
+    }
+
+    GenericTag that = (GenericTag) obj;
+    return tagDTO.equals(that.tagDTO);
+  }
+
+  @Override
+  public int hashCode() {
+    return tagDTO.hashCode();
+  }
+}
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
new file mode 100644
index 000000000..e317df069
--- /dev/null
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericTopic.java
@@ -0,0 +1,112 @@
+/*
+ * 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.client;
+
+import com.google.common.collect.Lists;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.Audit;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.dto.messaging.TopicDTO;
+import org.apache.gravitino.exceptions.NoSuchTagException;
+import org.apache.gravitino.messaging.Topic;
+import org.apache.gravitino.tag.SupportsTags;
+import org.apache.gravitino.tag.Tag;
+
+/** Represents a generic topic. */
+class GenericTopic implements Topic, SupportsTags {
+
+  private final TopicDTO topicDTO;
+
+  private final MetadataObjectTagOperations objectTagOperations;
+
+  GenericTopic(TopicDTO topicDTO, RESTClient restClient, Namespace topicNs) {
+    this.topicDTO = topicDTO;
+    List<String> topicFullName =
+        Lists.newArrayList(topicNs.level(1), topicNs.level(2), 
topicDTO.name());
+    MetadataObject topicObject = MetadataObjects.of(topicFullName, 
MetadataObject.Type.TOPIC);
+    this.objectTagOperations =
+        new MetadataObjectTagOperations(topicNs.level(0), topicObject, 
restClient);
+  }
+
+  @Override
+  public Audit auditInfo() {
+    return null;
+  }
+
+  @Override
+  public String name() {
+    return topicDTO.name();
+  }
+
+  @Override
+  public String comment() {
+    return topicDTO.comment();
+  }
+
+  @Override
+  public Map<String, String> properties() {
+    return topicDTO.properties();
+  }
+
+  @Override
+  public SupportsTags supportsTags() {
+    return this;
+  }
+
+  @Override
+  public String[] listTags() {
+    return objectTagOperations.listTags();
+  }
+
+  @Override
+  public Tag[] listTagsInfo() {
+    return objectTagOperations.listTagsInfo();
+  }
+
+  @Override
+  public Tag getTag(String name) throws NoSuchTagException {
+    return objectTagOperations.getTag(name);
+  }
+
+  @Override
+  public String[] associateTags(String[] tagsToAdd, String[] tagsToRemove) {
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (this == obj) {
+      return true;
+    }
+    if (!(obj instanceof GenericTopic)) {
+      return false;
+    }
+
+    GenericTopic that = (GenericTopic) obj;
+    return topicDTO.equals(that.topicDTO);
+  }
+
+  @Override
+  public int hashCode() {
+    return topicDTO.hashCode();
+  }
+}
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 1311930b8..5a233ee11 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
@@ -27,6 +27,11 @@ import org.apache.gravitino.SupportsCatalogs;
 import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NoSuchTagException;
+import org.apache.gravitino.exceptions.TagAlreadyExistsException;
+import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagChange;
+import org.apache.gravitino.tag.TagOperations;
 
 /**
  * Apache Gravitino Client for an user to interact with the Gravitino API, 
allowing the client to
@@ -35,7 +40,8 @@ import 
org.apache.gravitino.exceptions.NoSuchMetalakeException;
  * <p>It uses an underlying {@link RESTClient} to send HTTP requests and 
receive responses from the
  * API.
  */
-public class GravitinoClient extends GravitinoClientBase implements 
SupportsCatalogs {
+public class GravitinoClient extends GravitinoClientBase
+    implements SupportsCatalogs, TagOperations {
 
   private final GravitinoMetalake metalake;
 
@@ -139,6 +145,38 @@ public class GravitinoClient extends GravitinoClientBase 
implements SupportsCata
     getMetalake().testConnection(catalogName, type, provider, comment, 
properties);
   }
 
+  @Override
+  public String[] listTags() throws NoSuchMetalakeException {
+    return getMetalake().listTags();
+  }
+
+  @Override
+  public Tag[] listTagsInfo() throws NoSuchMetalakeException {
+    return getMetalake().listTagsInfo();
+  }
+
+  @Override
+  public Tag getTag(String name) throws NoSuchTagException {
+    return getMetalake().getTag(name);
+  }
+
+  @Override
+  public Tag createTag(String name, String comment, Map<String, String> 
properties)
+      throws TagAlreadyExistsException {
+    return getMetalake().createTag(name, comment, properties);
+  }
+
+  @Override
+  public Tag alterTag(String name, TagChange... changes)
+      throws NoSuchTagException, IllegalArgumentException {
+    return getMetalake().alterTag(name, changes);
+  }
+
+  @Override
+  public boolean deleteTag(String name) {
+    return getMetalake().deleteTag(name);
+  }
+
   /** Builder class for constructing a GravitinoClient. */
   public static class ClientBuilder extends 
GravitinoClientBase.Builder<GravitinoClient> {
 
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientBase.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientBase.java
index de18b535d..26ab962d6 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientBase.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientBase.java
@@ -20,6 +20,7 @@
 package org.apache.gravitino.client;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableMap;
 import com.google.errorprone.annotations.InlineMe;
 import java.io.Closeable;
@@ -188,6 +189,11 @@ public abstract class GravitinoClientBase implements 
Closeable {
     }
   }
 
+  @VisibleForTesting
+  RESTClient restClient() {
+    return restClient;
+  }
+
   /** Builder class for constructing a GravitinoClient. */
   public abstract static class Builder<T> {
     /** The base URI for the Gravitino API. */
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 f8a95aa9b..0c908a506 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
@@ -19,6 +19,7 @@
 package org.apache.gravitino.client;
 
 import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
@@ -35,23 +36,36 @@ import org.apache.gravitino.dto.MetalakeDTO;
 import org.apache.gravitino.dto.requests.CatalogCreateRequest;
 import org.apache.gravitino.dto.requests.CatalogUpdateRequest;
 import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
+import org.apache.gravitino.dto.requests.TagCreateRequest;
+import org.apache.gravitino.dto.requests.TagUpdateRequest;
+import org.apache.gravitino.dto.requests.TagUpdatesRequest;
 import org.apache.gravitino.dto.responses.CatalogListResponse;
 import org.apache.gravitino.dto.responses.CatalogResponse;
 import org.apache.gravitino.dto.responses.DropResponse;
 import org.apache.gravitino.dto.responses.EntityListResponse;
 import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.NameListResponse;
+import org.apache.gravitino.dto.responses.TagListResponse;
+import org.apache.gravitino.dto.responses.TagResponse;
 import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NoSuchTagException;
+import org.apache.gravitino.exceptions.TagAlreadyExistsException;
+import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagChange;
+import org.apache.gravitino.tag.TagOperations;
 
 /**
  * Apache Gravitino Metalake is the top-level metadata repository for users. 
It contains a list of
  * catalogs as sub-level metadata collections. With {@link GravitinoMetalake}, 
users can list,
  * create, load, alter and drop a catalog with specified identifier.
  */
-public class GravitinoMetalake extends MetalakeDTO implements SupportsCatalogs 
{
+public class GravitinoMetalake extends MetalakeDTO implements 
SupportsCatalogs, TagOperations {
   private static final String API_METALAKES_CATALOGS_PATH = 
"api/metalakes/%s/catalogs/%s";
 
+  private static final String API_METALAKES_TAGS_PATH = 
"api/metalakes/%s/tags";
+
   private final RESTClient restClient;
 
   GravitinoMetalake(
@@ -149,7 +163,6 @@ public class GravitinoMetalake extends MetalakeDTO 
implements SupportsCatalogs {
       String comment,
       Map<String, String> properties)
       throws NoSuchMetalakeException, CatalogAlreadyExistsException {
-
     CatalogCreateRequest req =
         new CatalogCreateRequest(catalogName, type, provider, comment, 
properties);
     req.validate();
@@ -178,7 +191,6 @@ public class GravitinoMetalake extends MetalakeDTO 
implements SupportsCatalogs {
   @Override
   public Catalog alterCatalog(String catalogName, CatalogChange... changes)
       throws NoSuchCatalogException, IllegalArgumentException {
-
     List<CatalogUpdateRequest> reqs =
         Arrays.stream(changes)
             .map(DTOConverters::toCatalogUpdateRequest)
@@ -206,7 +218,6 @@ public class GravitinoMetalake extends MetalakeDTO 
implements SupportsCatalogs {
    */
   @Override
   public boolean dropCatalog(String catalogName) {
-
     DropResponse resp =
         restClient.delete(
             String.format(API_METALAKES_CATALOGS_PATH, this.name(), 
catalogName),
@@ -259,6 +270,147 @@ public class GravitinoMetalake extends MetalakeDTO 
implements SupportsCatalogs {
     ErrorHandlers.catalogErrorHandler().accept(resp);
   }
 
+  /*
+   * List all the tag names under a metalake.
+   *
+   * @return A list of the tag names under the current metalake.
+   * @throws NoSuchMetalakeException If the metalake does not exist.
+   */
+  @Override
+  public String[] listTags() throws NoSuchMetalakeException {
+    NameListResponse resp =
+        restClient.get(
+            String.format(API_METALAKES_TAGS_PATH, this.name()),
+            NameListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+    resp.validate();
+    return resp.getNames();
+  }
+
+  /**
+   * List all the tags with detailed information under the current metalake.
+   *
+   * @return A list of {@link Tag} under the current metalake.
+   * @throws NoSuchMetalakeException If the metalake does not exist.
+   */
+  @Override
+  public Tag[] listTagsInfo() throws NoSuchMetalakeException {
+    Map<String, String> params = ImmutableMap.of("details", "true");
+    TagListResponse resp =
+        restClient.get(
+            String.format(API_METALAKES_TAGS_PATH, this.name()),
+            params,
+            TagListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+    resp.validate();
+
+    return Arrays.stream(resp.getTags())
+        .map(t -> new GenericTag(t, restClient, this.name()))
+        .toArray(Tag[]::new);
+  }
+
+  /**
+   * Get a tag by its name under the current metalake.
+   *
+   * @param name The name of the tag.
+   * @return The tag.
+   * @throws NoSuchTagException If the tag does not exist.
+   */
+  @Override
+  public Tag getTag(String name) throws NoSuchTagException {
+    Preconditions.checkArgument(StringUtils.isNotBlank(name), "tag name must 
not be null or empty");
+
+    TagResponse resp =
+        restClient.get(
+            String.format(API_METALAKES_TAGS_PATH, this.name()) + "/" + name,
+            TagResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+    resp.validate();
+
+    return new GenericTag(resp.getTag(), restClient, this.name());
+  }
+
+  /**
+   * Create a tag under the current metalake.
+   *
+   * @param name The name of the tag.
+   * @param comment The comment of the tag.
+   * @param properties The properties 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)
+      throws TagAlreadyExistsException {
+    Preconditions.checkArgument(StringUtils.isNotBlank(name), "tag name must 
not be null or empty");
+    TagCreateRequest req = new TagCreateRequest(name, comment, properties);
+    req.validate();
+
+    TagResponse resp =
+        restClient.post(
+            String.format(API_METALAKES_TAGS_PATH, this.name()),
+            req,
+            TagResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+    resp.validate();
+
+    return new GenericTag(resp.getTag(), restClient, this.name());
+  }
+
+  /**
+   * Alter a tag under the current metalake.
+   *
+   * @param name The name of the tag.
+   * @param changes The changes to apply to the tag.
+   * @return The altered tag.
+   * @throws NoSuchTagException If the tag does not exist.
+   * @throws IllegalArgumentException If the changes cannot be applied to the 
tag.
+   */
+  @Override
+  public Tag alterTag(String name, TagChange... changes)
+      throws NoSuchTagException, IllegalArgumentException {
+    Preconditions.checkArgument(StringUtils.isNotBlank(name), "tag name must 
not be null or empty");
+    List<TagUpdateRequest> updates =
+        
Arrays.stream(changes).map(DTOConverters::toTagUpdateRequest).collect(Collectors.toList());
+    TagUpdatesRequest req = new TagUpdatesRequest(updates);
+    req.validate();
+
+    TagResponse resp =
+        restClient.put(
+            String.format(API_METALAKES_TAGS_PATH, this.name()) + "/" + name,
+            req,
+            TagResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+    resp.validate();
+
+    return new GenericTag(resp.getTag(), restClient, this.name());
+  }
+
+  /**
+   * Delete a tag under the current metalake.
+   *
+   * @param name The name of the tag.
+   * @return True if the tag is deleted, false if the tag does not exist.
+   */
+  @Override
+  public boolean deleteTag(String name) {
+    Preconditions.checkArgument(StringUtils.isNotBlank(name), "tag name must 
not be null or empty");
+
+    DropResponse resp =
+        restClient.delete(
+            String.format(API_METALAKES_TAGS_PATH, this.name()) + "/" + name,
+            DropResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+    resp.validate();
+    return resp.dropped();
+  }
+
   static class Builder extends MetalakeDTO.Builder<Builder> {
     private RESTClient restClient;
 
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/MessagingCatalog.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/MessagingCatalog.java
index 9ff625644..c4e4199c4 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/MessagingCatalog.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/MessagingCatalog.java
@@ -50,7 +50,7 @@ import org.apache.gravitino.messaging.TopicChange;
  * for example, topics list, creation, update and deletion. A Messaging 
catalog is under the
  * metalake.
  */
-public class MessagingCatalog extends BaseSchemaCatalog implements 
TopicCatalog {
+class MessagingCatalog extends BaseSchemaCatalog implements TopicCatalog {
 
   MessagingCatalog(
       Namespace namespace,
@@ -121,7 +121,7 @@ public class MessagingCatalog extends BaseSchemaCatalog 
implements TopicCatalog
             ErrorHandlers.topicErrorHandler());
     resp.validate();
 
-    return resp.getTopic();
+    return new GenericTopic(resp.getTopic(), restClient, fullNamespace);
   }
 
   /**
@@ -160,7 +160,7 @@ public class MessagingCatalog extends BaseSchemaCatalog 
implements TopicCatalog
             ErrorHandlers.topicErrorHandler());
     resp.validate();
 
-    return resp.getTopic();
+    return new GenericTopic(resp.getTopic(), restClient, fullNamespace);
   }
 
   /**
@@ -194,7 +194,7 @@ public class MessagingCatalog extends BaseSchemaCatalog 
implements TopicCatalog
             ErrorHandlers.topicErrorHandler());
     resp.validate();
 
-    return resp.getTopic();
+    return new GenericTopic(resp.getTopic(), restClient, fullNamespace);
   }
 
   /**
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
new file mode 100644
index 000000000..a61e272ca
--- /dev/null
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/MetadataObjectTagOperations.java
@@ -0,0 +1,119 @@
+/*
+ * 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.client;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+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.TagsAssociateRequest;
+import org.apache.gravitino.dto.responses.NameListResponse;
+import org.apache.gravitino.dto.responses.TagListResponse;
+import org.apache.gravitino.dto.responses.TagResponse;
+import org.apache.gravitino.exceptions.NoSuchTagException;
+import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.tag.SupportsTags;
+import org.apache.gravitino.tag.Tag;
+
+/**
+ * The implementation of {@link SupportsTags}. This interface will be 
composited into catalog,
+ * schema, table, fileset and topic to provide tag operations for these 
metadata objects
+ */
+class MetadataObjectTagOperations implements SupportsTags {
+
+  private final String metalakeName;
+
+  private final RESTClient restClient;
+
+  private final String tagRequestPath;
+
+  MetadataObjectTagOperations(
+      String metalakeName, MetadataObject metadataObject, RESTClient 
restClient) {
+    this.metalakeName = metalakeName;
+    this.restClient = restClient;
+    this.tagRequestPath =
+        String.format(
+            "api/metalakes/%s/tags/%s/%s",
+            metalakeName,
+            metadataObject.type().name().toLowerCase(Locale.ROOT),
+            metadataObject.fullName());
+  }
+
+  @Override
+  public String[] listTags() {
+    NameListResponse resp =
+        restClient.get(
+            tagRequestPath,
+            NameListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+
+    resp.validate();
+    return resp.getNames();
+  }
+
+  @Override
+  public Tag[] listTagsInfo() {
+    TagListResponse resp =
+        restClient.get(
+            tagRequestPath,
+            TagListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+
+    resp.validate();
+    return Arrays.stream(resp.getTags())
+        .map(tagDTO -> new GenericTag(tagDTO, restClient, metalakeName))
+        .toArray(Tag[]::new);
+  }
+
+  @Override
+  public Tag getTag(String name) throws NoSuchTagException {
+    Preconditions.checkArgument(StringUtils.isNotBlank(name), "Tag name must 
not be null or empty");
+
+    TagResponse resp =
+        restClient.get(
+            tagRequestPath + "/" + RESTUtils.encodeString(name),
+            TagResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+
+    resp.validate();
+    return new GenericTag(resp.getTag(), restClient, metalakeName);
+  }
+
+  @Override
+  public String[] associateTags(String[] tagsToAdd, String[] tagsToRemove) {
+    TagsAssociateRequest request = new TagsAssociateRequest(tagsToAdd, 
tagsToRemove);
+    request.validate();
+
+    NameListResponse resp =
+        restClient.post(
+            tagRequestPath,
+            request,
+            NameListResponse.class,
+            Collections.emptyMap(),
+            ErrorHandlers.tagErrorHandler());
+
+    resp.validate();
+    return resp.getNames();
+  }
+}
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalCatalog.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalCatalog.java
index c20752de0..4ae92f932 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalCatalog.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/RelationalCatalog.java
@@ -59,7 +59,7 @@ import org.apache.gravitino.rest.RESTUtils;
  * operations, for example, schemas and tables list, creation, update and 
deletion. A Relational
  * catalog is under the metalake.
  */
-public class RelationalCatalog extends BaseSchemaCatalog implements 
TableCatalog {
+class RelationalCatalog extends BaseSchemaCatalog implements TableCatalog {
 
   RelationalCatalog(
       Namespace namespace,
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 4a147914f..af7e094b1 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
@@ -22,12 +22,15 @@ import static 
org.apache.gravitino.dto.util.DTOConverters.fromDTO;
 import static org.apache.gravitino.dto.util.DTOConverters.toDTO;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import javax.annotation.Nullable;
 import lombok.SneakyThrows;
 import org.apache.gravitino.Audit;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.dto.rel.TableDTO;
 import org.apache.gravitino.dto.rel.partitions.PartitionDTO;
@@ -37,6 +40,7 @@ import 
org.apache.gravitino.dto.responses.PartitionListResponse;
 import org.apache.gravitino.dto.responses.PartitionNameListResponse;
 import org.apache.gravitino.dto.responses.PartitionResponse;
 import org.apache.gravitino.exceptions.NoSuchPartitionException;
+import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.PartitionAlreadyExistsException;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.SupportsPartitions;
@@ -47,9 +51,21 @@ import 
org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.rel.partitions.Partition;
 import org.apache.gravitino.rest.RESTUtils;
+import org.apache.gravitino.tag.SupportsTags;
+import org.apache.gravitino.tag.Tag;
 
 /** Represents a relational table. */
-public class RelationalTable implements Table, SupportsPartitions {
+class RelationalTable implements Table, SupportsPartitions, SupportsTags {
+
+  private static final Joiner DOT_JOINER = Joiner.on(".");
+
+  private final Table table;
+
+  private final RESTClient restClient;
+
+  private final Namespace namespace;
+
+  private final MetadataObjectTagOperations objectTagOperations;
 
   /**
    * Creates a new RelationalTable.
@@ -59,15 +75,10 @@ public class RelationalTable implements Table, 
SupportsPartitions {
    * @param restClient The REST client.
    * @return A new RelationalTable.
    */
-  public static RelationalTable from(
-      Namespace namespace, TableDTO tableDTO, RESTClient restClient) {
+  static RelationalTable from(Namespace namespace, TableDTO tableDTO, 
RESTClient restClient) {
     return new RelationalTable(namespace, tableDTO, restClient);
   }
 
-  private final Table table;
-  private final RESTClient restClient;
-  private final Namespace namespace;
-
   /**
    * Creates a new RelationalTable.
    *
@@ -79,6 +90,10 @@ public class RelationalTable implements Table, 
SupportsPartitions {
     this.namespace = namespace;
     this.restClient = restClient;
     this.table = fromDTO(tableDTO);
+    MetadataObject tableObject =
+        MetadataObjects.parse(tableFullName(namespace, tableDTO.name()), 
MetadataObject.Type.TABLE);
+    this.objectTagOperations =
+        new MetadataObjectTagOperations(namespace.level(0), tableObject, 
restClient);
   }
 
   /**
@@ -154,7 +169,7 @@ public class RelationalTable implements Table, 
SupportsPartitions {
 
   /** @return The partition request path. */
   @VisibleForTesting
-  public String getPartitionRequestPath() {
+  String getPartitionRequestPath() {
     return "api/metalakes/"
         + namespace.level(0)
         + "/catalogs/"
@@ -263,4 +278,33 @@ public class RelationalTable implements Table, 
SupportsPartitions {
   protected static String formatPartitionRequestPath(String prefix, String 
partitionName) {
     return prefix + "/" + RESTUtils.encodeString(partitionName);
   }
+
+  @Override
+  public SupportsTags supportsTags() {
+    return this;
+  }
+
+  private static String tableFullName(Namespace tableNS, String tableName) {
+    return DOT_JOINER.join(tableNS.level(1), tableNS.level(2), tableName);
+  }
+
+  @Override
+  public String[] listTags() {
+    return objectTagOperations.listTags();
+  }
+
+  @Override
+  public Tag[] listTagsInfo() {
+    return objectTagOperations.listTagsInfo();
+  }
+
+  @Override
+  public Tag getTag(String name) throws NoSuchTagException {
+    return objectTagOperations.getTag(name);
+  }
+
+  @Override
+  public String[] associateTags(String[] tagsToAdd, String[] tagsToRemove) {
+    return objectTagOperations.associateTags(tagsToAdd, tagsToRemove);
+  }
 }
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
new file mode 100644
index 000000000..0e86e9bf6
--- /dev/null
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGenericTag.java
@@ -0,0 +1,147 @@
+/*
+ * 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.client;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import java.time.Instant;
+import java.util.Collections;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.MetalakeDTO;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.MetadataObjectListResponse;
+import org.apache.gravitino.dto.responses.MetalakeResponse;
+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.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+public class TestGenericTag extends TestBase {
+
+  private static String metalakeName = "metalake1";
+
+  private static TagDTO tagDTO =
+      TagDTO.builder()
+          .withName("tag1")
+          .withComment("comment1")
+          .withProperties(Collections.emptyMap())
+          
.withAudit(AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build())
+          .build();
+
+  private static GravitinoClient gravitinoClient;
+
+  @BeforeAll
+  public static void setUp() throws Exception {
+    TestBase.setUp();
+
+    MetalakeDTO mockMetalake =
+        MetalakeDTO.builder()
+            .withName(metalakeName)
+            .withComment("comment")
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    MetalakeResponse resp = new MetalakeResponse(mockMetalake);
+    buildMockResource(Method.GET, "/api/metalakes/" + metalakeName, null, 
resp, HttpStatus.SC_OK);
+
+    gravitinoClient =
+        GravitinoClient.builder("http://127.0.0.1:"; + 
mockServer.getLocalPort())
+            .withMetalake(metalakeName)
+            .withVersionCheckDisabled()
+            .build();
+  }
+
+  @AfterAll
+  public static void tearDown() {
+    TestBase.tearDown();
+    gravitinoClient.close();
+  }
+
+  @Test
+  public void testAssociatedObjects() throws JsonProcessingException {
+    Tag tag = new GenericTag(tagDTO, gravitinoClient.restClient(), 
metalakeName);
+    String path = "/api/metalakes/" + metalakeName + "/tags/" + tagDTO.name() 
+ "/objects";
+
+    MetadataObjectDTO[] objects =
+        new MetadataObjectDTO[] {
+          MetadataObjectDTO.builder()
+              .withParent(null)
+              .withName("catalog1")
+              .withType(MetadataObject.Type.CATALOG)
+              .build(),
+          MetadataObjectDTO.builder()
+              .withParent("catalog1")
+              .withName("schema1")
+              .withType(MetadataObject.Type.SCHEMA)
+              .build(),
+          MetadataObjectDTO.builder()
+              .withParent("catalog1.schema1")
+              .withName("table1")
+              .withType(MetadataObject.Type.TABLE)
+              .build()
+        };
+
+    MetadataObjectListResponse resp = new MetadataObjectListResponse(objects);
+    buildMockResource(Method.GET, path, null, resp, HttpStatus.SC_OK);
+
+    MetadataObject[] actualObjects = tag.associatedObjects().objects();
+    Assertions.assertEquals(objects.length, actualObjects.length);
+    for (int i = 0; i < objects.length; i++) {
+      MetadataObjectDTO object = objects[i];
+      MetadataObject actualObject = actualObjects[i];
+      Assertions.assertEquals(object.parent(), actualObject.parent());
+      Assertions.assertEquals(object.name(), actualObject.name());
+      Assertions.assertEquals(object.type(), actualObject.type());
+    }
+
+    // Test return empty array
+    buildMockResource(
+        Method.GET,
+        path,
+        null,
+        new MetadataObjectListResponse(new MetadataObjectDTO[0]),
+        HttpStatus.SC_OK);
+    MetadataObject[] actualObjects1 = tag.associatedObjects().objects();
+    Assertions.assertEquals(0, actualObjects1.length);
+
+    // Test throw NoSuchMetalakeException
+    ErrorResponse errorResponse =
+        ErrorResponse.notFound(NoSuchMetalakeException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.GET, path, null, errorResponse, 
HttpStatus.SC_NOT_FOUND);
+
+    Throwable ex =
+        Assertions.assertThrows(
+            NoSuchMetalakeException.class, () -> 
tag.associatedObjects().objects());
+    Assertions.assertEquals("mock error", ex.getMessage());
+
+    // Test throw internal error
+    ErrorResponse errorResponse1 = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.GET, path, null, errorResponse1, 
HttpStatus.SC_INTERNAL_SERVER_ERROR);
+
+    Throwable ex1 =
+        Assertions.assertThrows(RuntimeException.class, () -> 
tag.associatedObjects().objects());
+    Assertions.assertEquals("mock error", ex1.getMessage());
+  }
+}
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoClient.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoAdminClient.java
similarity index 99%
rename from 
clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoClient.java
rename to 
clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoAdminClient.java
index 4c5cef167..66960791e 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoClient.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoAdminClient.java
@@ -54,7 +54,7 @@ import org.mockserver.matchers.Times;
 import org.mockserver.model.HttpRequest;
 import org.mockserver.model.HttpResponse;
 
-public class TestGravitinoClient extends TestBase {
+public class TestGravitinoAdminClient extends TestBase {
 
   @Test
   public void testListMetalakes() throws JsonProcessingException {
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 a3562cad6..48ade28d1 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
@@ -35,18 +35,30 @@ import 
org.apache.gravitino.dto.requests.CatalogCreateRequest;
 import org.apache.gravitino.dto.requests.CatalogUpdateRequest;
 import org.apache.gravitino.dto.requests.CatalogUpdatesRequest;
 import org.apache.gravitino.dto.requests.MetalakeCreateRequest;
+import org.apache.gravitino.dto.requests.TagCreateRequest;
+import org.apache.gravitino.dto.requests.TagUpdateRequest;
+import org.apache.gravitino.dto.requests.TagUpdatesRequest;
 import org.apache.gravitino.dto.responses.CatalogListResponse;
 import org.apache.gravitino.dto.responses.CatalogResponse;
 import org.apache.gravitino.dto.responses.DropResponse;
 import org.apache.gravitino.dto.responses.EntityListResponse;
 import org.apache.gravitino.dto.responses.ErrorResponse;
 import org.apache.gravitino.dto.responses.MetalakeResponse;
+import org.apache.gravitino.dto.responses.NameListResponse;
+import org.apache.gravitino.dto.responses.TagListResponse;
+import org.apache.gravitino.dto.responses.TagResponse;
+import org.apache.gravitino.dto.tag.TagDTO;
 import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchCatalogException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.RESTException;
+import org.apache.gravitino.exceptions.TagAlreadyExistsException;
+import org.apache.gravitino.tag.Tag;
+import org.apache.gravitino.tag.TagChange;
 import org.apache.hc.core5.http.HttpStatus;
 import org.apache.hc.core5.http.Method;
+import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -81,6 +93,12 @@ public class TestGravitinoMetalake extends TestBase {
             .build();
   }
 
+  @AfterAll
+  public static void tearDown() {
+    TestBase.tearDown();
+    gravitinoClient.close();
+  }
+
   @Test
   public void testListCatalogs() throws JsonProcessingException {
     String path = "/api/metalakes/" + metalakeName + "/catalogs";
@@ -390,6 +408,299 @@ public class TestGravitinoMetalake extends TestBase {
     Assertions.assertFalse(dropped1, "catalog should be non-existent");
   }
 
+  @Test
+  public void testListTags() throws JsonProcessingException {
+    String path = "/api/metalakes/" + metalakeName + "/tags";
+
+    String[] tagNames = new String[] {"tag1", "tag2"};
+    NameListResponse resp = new NameListResponse(tagNames);
+    buildMockResource(Method.GET, path, null, resp, HttpStatus.SC_OK);
+
+    String[] tags = gravitinoClient.listTags();
+    Assertions.assertEquals(2, tags.length);
+    Assertions.assertArrayEquals(tagNames, tags);
+
+    // Test return empty tag list
+    NameListResponse resp1 = new NameListResponse(new String[] {});
+    buildMockResource(Method.GET, path, null, resp1, HttpStatus.SC_OK);
+    String[] tags1 = gravitinoClient.listTags();
+    Assertions.assertEquals(0, tags1.length);
+
+    // Test throw MetalakeNotFoundException
+    ErrorResponse errorResponse =
+        ErrorResponse.notFound(NoSuchMetalakeException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.GET, path, null, errorResponse, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex =
+        Assertions.assertThrows(NoSuchMetalakeException.class, 
gravitinoClient::listTags);
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.GET, path, null, errorResp, 
HttpStatus.SC_INTERNAL_SERVER_ERROR);
+    Throwable ex1 = Assertions.assertThrows(RuntimeException.class, 
gravitinoClient::listTags);
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+  }
+
+  @Test
+  public void testListTagInfos() throws JsonProcessingException {
+    String path = "/api/metalakes/" + metalakeName + "/tags";
+    Map<String, String> params = Collections.singletonMap("details", "true");
+
+    TagDTO tag1 =
+        TagDTO.builder()
+            .withName("tag1")
+            .withComment("comment1")
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    TagDTO tag2 =
+        TagDTO.builder()
+            .withName("tag2")
+            .withComment("comment2")
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+
+    TagDTO[] tags = new TagDTO[] {tag1, tag2};
+    TagListResponse resp = new TagListResponse(tags);
+    buildMockResource(Method.GET, path, params, null, resp, HttpStatus.SC_OK);
+
+    Tag[] tagInfos = gravitinoClient.listTagsInfo();
+    Assertions.assertEquals(2, tagInfos.length);
+    Assertions.assertEquals("tag1", tagInfos[0].name());
+    Assertions.assertEquals("comment1", tagInfos[0].comment());
+    Assertions.assertEquals("tag2", tagInfos[1].name());
+    Assertions.assertEquals("comment2", tagInfos[1].comment());
+
+    // Test empty tag list
+    TagListResponse resp1 = new TagListResponse(new TagDTO[] {});
+    buildMockResource(Method.GET, path, params, null, resp1, HttpStatus.SC_OK);
+    Tag[] tagInfos1 = gravitinoClient.listTagsInfo();
+    Assertions.assertEquals(0, tagInfos1.length);
+
+    // Test throw NoSuchMetalakeException
+    ErrorResponse errorResponse =
+        ErrorResponse.notFound(NoSuchMetalakeException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.GET, path, params, null, errorResponse, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex =
+        Assertions.assertThrows(NoSuchMetalakeException.class, 
gravitinoClient::listTagsInfo);
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp = ErrorResponse.internalError("mock error");
+    buildMockResource(
+        Method.GET, path, params, null, errorResp, 
HttpStatus.SC_INTERNAL_SERVER_ERROR);
+    Throwable ex1 = Assertions.assertThrows(RuntimeException.class, 
gravitinoClient::listTagsInfo);
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+  }
+
+  @Test
+  public void testGetTag() throws JsonProcessingException {
+    String tagName = "tag1";
+    String path = "/api/metalakes/" + metalakeName + "/tags/" + tagName;
+    TagDTO tag1 =
+        TagDTO.builder()
+            .withName(tagName)
+            .withComment("comment1")
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+
+    TagResponse resp = new TagResponse(tag1);
+    buildMockResource(Method.GET, path, null, resp, HttpStatus.SC_OK);
+
+    Tag tag = gravitinoClient.getTag(tagName);
+    Assertions.assertEquals(tagName, tag.name());
+    Assertions.assertEquals("comment1", tag.comment());
+
+    // Test throw NoSuchMetalakeException
+    ErrorResponse errorResponse =
+        ErrorResponse.notFound(NoSuchMetalakeException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.GET, path, null, errorResponse, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex =
+        Assertions.assertThrows(
+            NoSuchMetalakeException.class, () -> 
gravitinoClient.getTag(tagName));
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test throw NoSuchTagException
+    ErrorResponse errorResponse1 =
+        ErrorResponse.notFound(NoSuchTagException.class.getSimpleName(), "mock 
error");
+    buildMockResource(Method.GET, path, null, errorResponse1, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex1 =
+        Assertions.assertThrows(NoSuchTagException.class, () -> 
gravitinoClient.getTag(tagName));
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.GET, path, null, errorResp, 
HttpStatus.SC_INTERNAL_SERVER_ERROR);
+    Throwable ex2 =
+        Assertions.assertThrows(RuntimeException.class, () -> 
gravitinoClient.getTag(tagName));
+    Assertions.assertTrue(ex2.getMessage().contains("mock error"));
+  }
+
+  @Test
+  public void testCreateTag() throws JsonProcessingException {
+    String tagName = "tag1";
+    String path = "/api/metalakes/" + metalakeName + "/tags";
+
+    TagDTO tag1 =
+        TagDTO.builder()
+            .withName(tagName)
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+    TagResponse resp = new TagResponse(tag1);
+
+    TagCreateRequest req = new TagCreateRequest(tagName, null, null);
+    buildMockResource(Method.POST, path, req, resp, HttpStatus.SC_OK);
+
+    Tag tag = gravitinoClient.createTag(tagName, null, null);
+    Assertions.assertEquals(tagName, tag.name());
+    Assertions.assertNull(tag.comment());
+    Assertions.assertNull(tag.properties());
+
+    // Test with null name
+    Throwable ex =
+        Assertions.assertThrows(
+            IllegalArgumentException.class, () -> 
gravitinoClient.createTag(null, null, null));
+    Assertions.assertTrue(ex.getMessage().contains("tag name must not be null 
or empty"));
+
+    // Test throw NoSuchMetalakeException
+    ErrorResponse errorResponse =
+        ErrorResponse.notFound(NoSuchMetalakeException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.POST, path, null, errorResponse, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex1 =
+        Assertions.assertThrows(
+            NoSuchMetalakeException.class, () -> 
gravitinoClient.createTag(tagName, null, null));
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+
+    // Test throw TagAlreadyExistsException
+    ErrorResponse errorResponse1 =
+        
ErrorResponse.alreadyExists(TagAlreadyExistsException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.POST, path, null, errorResponse1, 
HttpStatus.SC_CONFLICT);
+    Throwable ex2 =
+        Assertions.assertThrows(
+            TagAlreadyExistsException.class, () -> 
gravitinoClient.createTag(tagName, null, null));
+    Assertions.assertTrue(ex2.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.POST, path, null, errorResp, 
HttpStatus.SC_INTERNAL_SERVER_ERROR);
+    Throwable ex3 =
+        Assertions.assertThrows(
+            RuntimeException.class, () -> gravitinoClient.createTag(tagName, 
null, null));
+    Assertions.assertTrue(ex3.getMessage().contains("mock error"));
+  }
+
+  @Test
+  public void testAlterTag() throws JsonProcessingException {
+    String tagName = "tag1";
+    String path = "/api/metalakes/" + metalakeName + "/tags/" + tagName;
+
+    TagDTO tag2 =
+        TagDTO.builder()
+            .withName("tag2")
+            .withComment("comment2")
+            .withAudit(
+                
AuditDTO.builder().withCreator("creator").withCreateTime(Instant.now()).build())
+            .build();
+
+    TagResponse resp = new TagResponse(tag2);
+
+    List<TagUpdateRequest> reqs =
+        Arrays.asList(
+            new TagUpdateRequest.RenameTagRequest("tag2"),
+            new TagUpdateRequest.UpdateTagCommentRequest("comment2"));
+    TagUpdatesRequest request = new TagUpdatesRequest(reqs);
+    buildMockResource(Method.PUT, path, request, resp, HttpStatus.SC_OK);
+
+    Tag tag =
+        gravitinoClient.alterTag(
+            tagName, TagChange.rename("tag2"), 
TagChange.updateComment("comment2"));
+    Assertions.assertEquals("tag2", tag.name());
+    Assertions.assertEquals("comment2", tag.comment());
+    Assertions.assertNull(tag.properties());
+
+    // Test throw NoSuchMetalakeException
+    ErrorResponse errorResponse =
+        ErrorResponse.notFound(NoSuchMetalakeException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.PUT, path, request, errorResponse, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex =
+        Assertions.assertThrows(
+            NoSuchMetalakeException.class,
+            () ->
+                gravitinoClient.alterTag(
+                    tagName, TagChange.rename("tag2"), 
TagChange.updateComment("comment2")));
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test throw NoSuchTagException
+    ErrorResponse errorResponse1 =
+        ErrorResponse.notFound(NoSuchTagException.class.getSimpleName(), "mock 
error");
+    buildMockResource(Method.PUT, path, request, errorResponse1, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex1 =
+        Assertions.assertThrows(
+            NoSuchTagException.class,
+            () ->
+                gravitinoClient.alterTag(
+                    tagName, TagChange.rename("tag2"), 
TagChange.updateComment("comment2")));
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+
+    // Test throw IllegalArgumentException
+    ErrorResponse errorResponse2 = ErrorResponse.illegalArguments("mock 
error");
+    buildMockResource(Method.PUT, path, request, errorResponse2, 
HttpStatus.SC_BAD_REQUEST);
+    Throwable ex2 =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                gravitinoClient.alterTag(
+                    tagName, TagChange.rename("tag2"), 
TagChange.updateComment("comment2")));
+    Assertions.assertTrue(ex2.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.PUT, path, request, errorResp, 
HttpStatus.SC_INTERNAL_SERVER_ERROR);
+    Throwable ex3 =
+        Assertions.assertThrows(
+            RuntimeException.class,
+            () ->
+                gravitinoClient.alterTag(
+                    tagName, TagChange.rename("tag2"), 
TagChange.updateComment("comment2")));
+    Assertions.assertTrue(ex3.getMessage().contains("mock error"));
+  }
+
+  @Test
+  public void testDeleteTag() throws JsonProcessingException {
+    String tagName = "tag1";
+    String path = "/api/metalakes/" + metalakeName + "/tags/" + tagName;
+
+    DropResponse resp = new DropResponse(true);
+    buildMockResource(Method.DELETE, path, null, resp, HttpStatus.SC_OK);
+    boolean dropped = gravitinoClient.deleteTag(tagName);
+    Assertions.assertTrue(dropped);
+
+    // Test return false
+    DropResponse resp1 = new DropResponse(false);
+    buildMockResource(Method.DELETE, path, null, resp1, HttpStatus.SC_OK);
+    boolean dropped1 = gravitinoClient.deleteTag(tagName);
+    Assertions.assertFalse(dropped1);
+
+    // Test throw NoSuchMetalakeException
+    ErrorResponse errorResponse =
+        ErrorResponse.notFound(NoSuchMetalakeException.class.getSimpleName(), 
"mock error");
+    buildMockResource(Method.DELETE, path, null, errorResponse, 
HttpStatus.SC_NOT_FOUND);
+    Throwable ex =
+        Assertions.assertThrows(
+            NoSuchMetalakeException.class, () -> 
gravitinoClient.deleteTag(tagName));
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test internal error
+    ErrorResponse errorResp = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.DELETE, path, null, errorResp, 
HttpStatus.SC_INTERNAL_SERVER_ERROR);
+    Throwable ex1 =
+        Assertions.assertThrows(RuntimeException.class, () -> 
gravitinoClient.deleteTag(tagName));
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+  }
+
   static GravitinoMetalake createMetalake(GravitinoAdminClient client, String 
metalakeName)
       throws JsonProcessingException {
     MetalakeDTO mockMetalake =
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
new file mode 100644
index 000000000..095d78549
--- /dev/null
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportTags.java
@@ -0,0 +1,503 @@
+/*
+ * 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.client;
+
+import static org.apache.hc.core5.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.hc.core5.http.HttpStatus.SC_NOT_FOUND;
+import static org.apache.hc.core5.http.HttpStatus.SC_OK;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import java.util.Collections;
+import java.util.Locale;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.Schema;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.SchemaDTO;
+import org.apache.gravitino.dto.file.FilesetDTO;
+import org.apache.gravitino.dto.messaging.TopicDTO;
+import org.apache.gravitino.dto.rel.ColumnDTO;
+import org.apache.gravitino.dto.rel.TableDTO;
+import org.apache.gravitino.dto.requests.TagsAssociateRequest;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.dto.responses.NameListResponse;
+import org.apache.gravitino.dto.responses.TagListResponse;
+import org.apache.gravitino.dto.responses.TagResponse;
+import org.apache.gravitino.dto.tag.TagDTO;
+import org.apache.gravitino.exceptions.NoSuchTagException;
+import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.file.Fileset;
+import org.apache.gravitino.messaging.Topic;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.tag.SupportsTags;
+import org.apache.gravitino.tag.Tag;
+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;
+
+public class TestSupportTags extends TestBase {
+
+  private static final String METALAKE_NAME = "metalake";
+
+  private static Catalog relationalCatalog;
+
+  private static Catalog filesetCatalog;
+
+  private static Catalog messagingCatalog;
+
+  private static Schema genericSchema;
+
+  private static Table relationalTable;
+
+  private static Fileset genericFileset;
+
+  private static Topic genericTopic;
+
+  @BeforeAll
+  public static void setUp() throws Exception {
+    TestBase.setUp();
+    TestGravitinoMetalake.createMetalake(client, METALAKE_NAME);
+
+    relationalCatalog =
+        new RelationalCatalog(
+            Namespace.of(METALAKE_NAME),
+            "catalog1",
+            Catalog.Type.RELATIONAL,
+            "test",
+            "comment",
+            Collections.emptyMap(),
+            AuditDTO.builder().build(),
+            client.restClient());
+
+    filesetCatalog =
+        new FilesetCatalog(
+            Namespace.of(METALAKE_NAME),
+            "catalog2",
+            Catalog.Type.FILESET,
+            "test",
+            "comment",
+            Collections.emptyMap(),
+            AuditDTO.builder().build(),
+            client.restClient());
+
+    messagingCatalog =
+        new MessagingCatalog(
+            Namespace.of(METALAKE_NAME),
+            "catalog3",
+            Catalog.Type.MESSAGING,
+            "test",
+            "comment",
+            Collections.emptyMap(),
+            AuditDTO.builder().build(),
+            client.restClient());
+
+    genericSchema =
+        new GenericSchema(
+            SchemaDTO.builder()
+                .withName("schema1")
+                .withComment("comment1")
+                .withProperties(Collections.emptyMap())
+                .withAudit(AuditDTO.builder().withCreator("test").build())
+                .build(),
+            client.restClient(),
+            METALAKE_NAME,
+            "catalog1");
+
+    relationalTable =
+        RelationalTable.from(
+            Namespace.of(METALAKE_NAME, "catalog1", "schema1"),
+            TableDTO.builder()
+                .withName("table1")
+                .withComment("comment1")
+                .withColumns(
+                    new ColumnDTO[] {
+                      ColumnDTO.builder()
+                          .withName("col1")
+                          .withDataType(Types.IntegerType.get())
+                          .build()
+                    })
+                .withProperties(Collections.emptyMap())
+                .withAudit(AuditDTO.builder().withCreator("test").build())
+                .build(),
+            client.restClient());
+
+    genericFileset =
+        new GenericFileset(
+            FilesetDTO.builder()
+                .name("fileset1")
+                .comment("comment1")
+                .type(Fileset.Type.EXTERNAL)
+                .storageLocation("s3://bucket/path")
+                .properties(Collections.emptyMap())
+                .audit(AuditDTO.builder().withCreator("test").build())
+                .build(),
+            client.restClient(),
+            Namespace.of(METALAKE_NAME, "catalog1", "schema1"));
+
+    genericTopic =
+        new GenericTopic(
+            TopicDTO.builder()
+                .withName("topic1")
+                .withComment("comment1")
+                .withProperties(Collections.emptyMap())
+                .withAudit(AuditDTO.builder().withCreator("test").build())
+                .build(),
+            client.restClient(),
+            Namespace.of(METALAKE_NAME, "catalog1", "schema1"));
+  }
+
+  @Test
+  public void testListTagsForCatalog() throws JsonProcessingException {
+    testListTags(
+        relationalCatalog.supportsTags(),
+        MetadataObjects.of(null, relationalCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testListTags(
+        filesetCatalog.supportsTags(),
+        MetadataObjects.of(null, filesetCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testListTags(
+        messagingCatalog.supportsTags(),
+        MetadataObjects.of(null, messagingCatalog.name(), 
MetadataObject.Type.CATALOG));
+  }
+
+  @Test
+  public void testListTagsForSchema() throws JsonProcessingException {
+    testListTags(
+        genericSchema.supportsTags(),
+        MetadataObjects.of("catalog1", genericSchema.name(), 
MetadataObject.Type.SCHEMA));
+  }
+
+  @Test
+  public void testListTagsForTable() throws JsonProcessingException {
+    testListTags(
+        relationalTable.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", relationalTable.name(), 
MetadataObject.Type.TABLE));
+  }
+
+  @Test
+  public void testListTagsForFileset() throws JsonProcessingException {
+    testListTags(
+        genericFileset.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericFileset.name(), 
MetadataObject.Type.FILESET));
+  }
+
+  @Test
+  public void testListTagsForTopic() throws JsonProcessingException {
+    testListTags(
+        genericTopic.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericTopic.name(), 
MetadataObject.Type.TOPIC));
+  }
+
+  @Test
+  public void testListTagsInfoForCatalog() throws JsonProcessingException {
+    testListTagsInfo(
+        relationalCatalog.supportsTags(),
+        MetadataObjects.of(null, relationalCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testListTagsInfo(
+        filesetCatalog.supportsTags(),
+        MetadataObjects.of(null, filesetCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testListTagsInfo(
+        messagingCatalog.supportsTags(),
+        MetadataObjects.of(null, messagingCatalog.name(), 
MetadataObject.Type.CATALOG));
+  }
+
+  @Test
+  public void testListTagsInfoForSchema() throws JsonProcessingException {
+    testListTagsInfo(
+        genericSchema.supportsTags(),
+        MetadataObjects.of("catalog1", genericSchema.name(), 
MetadataObject.Type.SCHEMA));
+  }
+
+  @Test
+  public void testListTagsInfoForTable() throws JsonProcessingException {
+    testListTagsInfo(
+        relationalTable.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", relationalTable.name(), 
MetadataObject.Type.TABLE));
+  }
+
+  @Test
+  public void testListTagsInfoForFileset() throws JsonProcessingException {
+    testListTagsInfo(
+        genericFileset.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericFileset.name(), 
MetadataObject.Type.FILESET));
+  }
+
+  @Test
+  public void testListTagsInfoForTopic() throws JsonProcessingException {
+    testListTagsInfo(
+        genericTopic.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericTopic.name(), 
MetadataObject.Type.TOPIC));
+  }
+
+  @Test
+  public void testGetTagForCatalog() throws JsonProcessingException {
+    testGetTag(
+        relationalCatalog.supportsTags(),
+        MetadataObjects.of(null, relationalCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testGetTag(
+        filesetCatalog.supportsTags(),
+        MetadataObjects.of(null, filesetCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testGetTag(
+        messagingCatalog.supportsTags(),
+        MetadataObjects.of(null, messagingCatalog.name(), 
MetadataObject.Type.CATALOG));
+  }
+
+  @Test
+  public void testGetTagForSchema() throws JsonProcessingException {
+    testGetTag(
+        genericSchema.supportsTags(),
+        MetadataObjects.of("catalog1", genericSchema.name(), 
MetadataObject.Type.SCHEMA));
+  }
+
+  @Test
+  public void testGetTagForTable() throws JsonProcessingException {
+    testGetTag(
+        relationalTable.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", relationalTable.name(), 
MetadataObject.Type.TABLE));
+  }
+
+  @Test
+  public void testGetTagForFileset() throws JsonProcessingException {
+    testGetTag(
+        genericFileset.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericFileset.name(), 
MetadataObject.Type.FILESET));
+  }
+
+  @Test
+  public void testGetTagForTopic() throws JsonProcessingException {
+    testGetTag(
+        genericTopic.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericTopic.name(), 
MetadataObject.Type.TOPIC));
+  }
+
+  @Test
+  public void testAssociateTagsForCatalog() throws JsonProcessingException {
+    testAssociateTags(
+        relationalCatalog.supportsTags(),
+        MetadataObjects.of(null, relationalCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testAssociateTags(
+        filesetCatalog.supportsTags(),
+        MetadataObjects.of(null, filesetCatalog.name(), 
MetadataObject.Type.CATALOG));
+
+    testAssociateTags(
+        messagingCatalog.supportsTags(),
+        MetadataObjects.of(null, messagingCatalog.name(), 
MetadataObject.Type.CATALOG));
+  }
+
+  @Test
+  public void testAssociateTagsForSchema() throws JsonProcessingException {
+    testAssociateTags(
+        genericSchema.supportsTags(),
+        MetadataObjects.of("catalog1", genericSchema.name(), 
MetadataObject.Type.SCHEMA));
+  }
+
+  @Test
+  public void testAssociateTagsForTable() throws JsonProcessingException {
+    testAssociateTags(
+        relationalTable.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", relationalTable.name(), 
MetadataObject.Type.TABLE));
+  }
+
+  @Test
+  public void testAssociateTagsForFileset() throws JsonProcessingException {
+    testAssociateTags(
+        genericFileset.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericFileset.name(), 
MetadataObject.Type.FILESET));
+  }
+
+  @Test
+  public void testAssociateTagsForTopic() throws JsonProcessingException {
+    testAssociateTags(
+        genericTopic.supportsTags(),
+        MetadataObjects.of("catalog1.schema1", genericTopic.name(), 
MetadataObject.Type.TOPIC));
+  }
+
+  private void testListTags(SupportsTags supportsTags, MetadataObject 
metadataObject)
+      throws JsonProcessingException {
+    String path =
+        "/api/metalakes/"
+            + METALAKE_NAME
+            + "/tags/"
+            + metadataObject.type().name().toLowerCase(Locale.ROOT)
+            + "/"
+            + metadataObject.fullName();
+
+    String[] tags = new String[] {"tag1", "tag2"};
+    NameListResponse resp = new NameListResponse(tags);
+    buildMockResource(Method.GET, path, null, resp, SC_OK);
+
+    String[] actualTags = supportsTags.listTags();
+    Assertions.assertArrayEquals(tags, actualTags);
+
+    // Return empty list
+    NameListResponse resp1 = new NameListResponse(new String[0]);
+    buildMockResource(Method.GET, path, null, resp1, SC_OK);
+
+    String[] actualTags1 = supportsTags.listTags();
+    Assertions.assertArrayEquals(new String[0], actualTags1);
+
+    // Test throw NotFoundException
+    ErrorResponse errorResp =
+        ErrorResponse.notFound(NotFoundException.class.getSimpleName(), "mock 
error");
+    buildMockResource(Method.GET, path, null, errorResp, SC_NOT_FOUND);
+
+    Throwable ex = Assertions.assertThrows(NotFoundException.class, 
supportsTags::listTags);
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp1 = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.GET, path, null, errorResp1, 
SC_INTERNAL_SERVER_ERROR);
+
+    Throwable ex1 = Assertions.assertThrows(RuntimeException.class, 
supportsTags::listTags);
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+  }
+
+  private void testListTagsInfo(SupportsTags supportsTags, MetadataObject 
metadataObject)
+      throws JsonProcessingException {
+    String path =
+        "/api/metalakes/"
+            + METALAKE_NAME
+            + "/tags/"
+            + metadataObject.type().name().toLowerCase(Locale.ROOT)
+            + "/"
+            + metadataObject.fullName();
+
+    TagDTO tag1 =
+        TagDTO.builder()
+            .withName("tag1")
+            .withAudit(AuditDTO.builder().withCreator("test").build())
+            .build();
+    TagDTO tag2 =
+        TagDTO.builder()
+            .withName("tag2")
+            .withAudit(AuditDTO.builder().withCreator("test").build())
+            .build();
+    TagDTO[] tags = new TagDTO[] {tag1, tag2};
+    TagListResponse resp = new TagListResponse(tags);
+    buildMockResource(Method.GET, path, null, resp, SC_OK);
+
+    Tag[] actualTags = supportsTags.listTagsInfo();
+    Assertions.assertEquals(2, actualTags.length);
+    Assertions.assertEquals("tag1", actualTags[0].name());
+    Assertions.assertEquals("tag2", actualTags[1].name());
+
+    // Return empty list
+    TagListResponse resp1 = new TagListResponse(new TagDTO[0]);
+    buildMockResource(Method.GET, path, null, resp1, SC_OK);
+
+    Tag[] actualTags1 = supportsTags.listTagsInfo();
+    Assertions.assertArrayEquals(new Tag[0], actualTags1);
+
+    // Test throw NotFoundException
+    ErrorResponse errorResp =
+        ErrorResponse.notFound(NotFoundException.class.getSimpleName(), "mock 
error");
+    buildMockResource(Method.GET, path, null, errorResp, SC_NOT_FOUND);
+
+    Throwable ex = Assertions.assertThrows(NotFoundException.class, 
supportsTags::listTags);
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp1 = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.GET, path, null, errorResp1, 
SC_INTERNAL_SERVER_ERROR);
+
+    Throwable ex1 = Assertions.assertThrows(RuntimeException.class, 
supportsTags::listTags);
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+  }
+
+  private void testGetTag(SupportsTags supportsTags, MetadataObject 
metadataObject)
+      throws JsonProcessingException {
+    String path =
+        "/api/metalakes/"
+            + METALAKE_NAME
+            + "/tags/"
+            + metadataObject.type().name().toLowerCase(Locale.ROOT)
+            + "/"
+            + metadataObject.fullName()
+            + "/tag1";
+
+    TagDTO tag1 =
+        TagDTO.builder()
+            .withName("tag1")
+            .withAudit(AuditDTO.builder().withCreator("test").build())
+            .build();
+
+    TagResponse resp = new TagResponse(tag1);
+    buildMockResource(Method.GET, path, null, resp, SC_OK);
+
+    Tag actualTag = supportsTags.getTag("tag1");
+    Assertions.assertEquals("tag1", actualTag.name());
+
+    // Test throw NoSuchTagException
+    ErrorResponse errorResp =
+        ErrorResponse.notFound(NoSuchTagException.class.getSimpleName(), "mock 
error");
+    buildMockResource(Method.GET, path, null, errorResp, SC_NOT_FOUND);
+
+    Throwable ex =
+        Assertions.assertThrows(NoSuchTagException.class, () -> 
supportsTags.getTag("tag1"));
+    Assertions.assertTrue(ex.getMessage().contains("mock error"));
+
+    // Test throw internal error
+    ErrorResponse errorResp1 = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.GET, path, null, errorResp1, 
SC_INTERNAL_SERVER_ERROR);
+
+    Throwable ex1 =
+        Assertions.assertThrows(RuntimeException.class, () -> 
supportsTags.getTag("tag1"));
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+  }
+
+  private void testAssociateTags(SupportsTags supportsTags, MetadataObject 
metadataObject)
+      throws JsonProcessingException {
+    String path =
+        "/api/metalakes/"
+            + METALAKE_NAME
+            + "/tags/"
+            + metadataObject.type().name().toLowerCase(Locale.ROOT)
+            + "/"
+            + metadataObject.fullName();
+
+    String[] tagsToAdd = new String[] {"tag1", "tag2"};
+    String[] tagsToRemove = new String[] {"tag3", "tag4"};
+    TagsAssociateRequest request = new TagsAssociateRequest(tagsToAdd, 
tagsToRemove);
+
+    NameListResponse resp = new NameListResponse(tagsToAdd);
+    buildMockResource(Method.POST, path, request, resp, SC_OK);
+
+    String[] actualTags = supportsTags.associateTags(tagsToAdd, tagsToRemove);
+    Assertions.assertArrayEquals(tagsToAdd, actualTags);
+
+    // Test throw internal error
+    ErrorResponse errorResp1 = ErrorResponse.internalError("mock error");
+    buildMockResource(Method.POST, path, request, errorResp1, 
SC_INTERNAL_SERVER_ERROR);
+
+    Throwable ex1 =
+        Assertions.assertThrows(
+            RuntimeException.class, () -> 
supportsTags.associateTags(tagsToAdd, tagsToRemove));
+    Assertions.assertTrue(ex1.getMessage().contains("mock error"));
+  }
+}
diff --git a/common/src/main/java/org/apache/gravitino/dto/SchemaDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/SchemaDTO.java
index 5ba9a58de..90ff2e16d 100644
--- a/common/src/main/java/org/apache/gravitino/dto/SchemaDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/SchemaDTO.java
@@ -21,9 +21,11 @@ package org.apache.gravitino.dto;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.base.Preconditions;
 import java.util.Map;
+import lombok.EqualsAndHashCode;
 import org.apache.gravitino.Schema;
 
 /** Represents a Schema DTO (Data Transfer Object). */
+@EqualsAndHashCode
 public class SchemaDTO implements Schema {
 
   @JsonProperty("name")
diff --git a/common/src/main/java/org/apache/gravitino/dto/file/FilesetDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/file/FilesetDTO.java
index 3231404d6..7630b4c8d 100644
--- a/common/src/main/java/org/apache/gravitino/dto/file/FilesetDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/file/FilesetDTO.java
@@ -25,6 +25,7 @@ import javax.annotation.Nullable;
 import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
+import lombok.EqualsAndHashCode;
 import lombok.NoArgsConstructor;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.dto.AuditDTO;
@@ -33,6 +34,7 @@ import org.apache.gravitino.file.Fileset;
 /** Represents a Fileset DTO (Data Transfer Object). */
 @NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
 @AllArgsConstructor(access = AccessLevel.PRIVATE)
+@EqualsAndHashCode
 public class FilesetDTO implements Fileset {
 
   @JsonProperty("name")
diff --git a/common/src/main/java/org/apache/gravitino/dto/tag/TagDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/tag/TagDTO.java
index a7f25480b..83c1573f7 100644
--- a/common/src/main/java/org/apache/gravitino/dto/tag/TagDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/tag/TagDTO.java
@@ -19,14 +19,13 @@
 package org.apache.gravitino.dto.tag;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.base.Objects;
 import java.util.Map;
 import java.util.Optional;
-import lombok.EqualsAndHashCode;
 import org.apache.gravitino.dto.AuditDTO;
 import org.apache.gravitino.tag.Tag;
 
 /** Represents a Tag Data Transfer Object (DTO). */
-@EqualsAndHashCode
 public class TagDTO implements Tag {
 
   @JsonProperty("name")
@@ -71,6 +70,27 @@ public class TagDTO implements Tag {
     return inherited;
   }
 
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof TagDTO)) {
+      return false;
+    }
+
+    TagDTO tagDTO = (TagDTO) o;
+    return Objects.equal(name, tagDTO.name)
+        && Objects.equal(comment, tagDTO.comment)
+        && Objects.equal(properties, tagDTO.properties)
+        && Objects.equal(audit, tagDTO.audit);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hashCode(name, comment, properties, audit);
+  }
+
   /** @return a new builder for constructing a Tag DTO. */
   public static Builder builder() {
     return new Builder();

Reply via email to