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

XiaoHongbo-Hope pushed a commit to branch release-2.1
in repository https://gitbox.apache.org/repos/asf/paimon.git

commit 2b29dfa1e7c9718d34c4fd48493adde366b43660
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 15 17:07:57 2026 +0800

    [rest] Add generic entity labels to REST Catalog (#9838)
---
 docs/docs/concepts/rest/management-api.md          | 104 +++++-
 docs/docs/concepts/rest/rest-api.md                |   4 +-
 docs/docs/program-api/rest-api.mdx                 |  25 ++
 docs/scripts/validate-rest-openapi.js              |  43 +++
 docs/static/rest-management-open-api.yaml          | 225 +++++++++++-
 .../java/org/apache/paimon/management/Label.java   |  58 +++
 .../apache/paimon/management/LabelManagement.java  |  68 ++++
 .../main/java/org/apache/paimon/rest/RESTApi.java  |  84 +++++
 .../apache/paimon/rest/RESTLabelManagement.java    |  78 ++++
 .../java/org/apache/paimon/rest/ResourcePaths.java |  27 ++
 .../paimon/rest/requests/UpsertLabelRequest.java   |  52 +++
 .../paimon/rest/responses/ErrorResponse.java       |   2 +
 .../paimon/rest/responses/GetLabelResponse.java    |  83 +++++
 .../paimon/rest/responses/ListLabelsResponse.java  |  75 ++++
 .../org/apache/paimon/rest/RESTApiLabelTest.java   | 392 +++++++++++++++++++++
 .../requests/RequestJacksonCompatibilityTest.java  |   7 +-
 .../java/org/apache/paimon/rest/RESTCatalog.java   |   6 +
 .../rest/RESTCatalogLabelManagementTest.java       | 236 +++++++++++++
 18 files changed, 1557 insertions(+), 12 deletions(-)

diff --git a/docs/docs/concepts/rest/management-api.md 
b/docs/docs/concepts/rest/management-api.md
index 43e9b3c16d..08726c2f62 100644
--- a/docs/docs/concepts/rest/management-api.md
+++ b/docs/docs/concepts/rest/management-api.md
@@ -21,18 +21,23 @@ limitations under the License.
 
 # REST Management API
 
-The REST Management API is an experimental OpenAPI 3.1 control-plane extension 
for object
-privileges, row filters, and column masks in a Paimon REST Catalog. Its 
current contract version is
-`1.0` and may evolve incompatibly while the design is being validated.
+The REST Management API is an experimental OpenAPI 3.1 control-plane extension 
for entity labels,
+object privileges, row filters, and column masks in a Paimon REST Catalog. Its 
current contract
+version is `1.0` and may evolve incompatibly while the design is being 
validated.
 
-`RESTCatalog` exposes `permissionManagement()` and `policyManagement()` 
directly. These methods are
-intentionally not part of the generic `Catalog` interface. Other catalog 
implementations do not
-expose this management contract.
+`RESTCatalog` exposes `labelManagement()`, `permissionManagement()`, and 
`policyManagement()`
+directly. These methods are intentionally not part of the generic `Catalog` 
interface. Other
+catalog implementations do not expose this management contract.
+
+`LabelManagement` provides generic label operations backed by the catalog's 
existing `RESTApi`
+client, authentication, and prefix configuration. The REST server must 
implement entity resolution,
+authorization, and atomic label storage.
 
 ## Find the Right Operation
 
 | Task | Guide |
 | --- | --- |
+| Set, inspect, or remove entity labels | [Entity labels](#entity-labels) |
 | Understand resources and allowed accesses | [Permission 
model](#permission-model) |
 | Grant, inspect, or revoke access | [Grant](#grant-permissions), 
[List](#list-permissions), [Revoke](#revoke-permissions) |
 | Restrict visible columns | [Column permissions](#column-permissions) |
@@ -46,6 +51,11 @@ All management endpoints use the opaque `prefix` returned by 
the REST Catalog co
 is not a catalog name in a payload and is independent of local engine catalog 
aliases.
 
 ```
+GET    /v1/{prefix}/labels/{entityType}/{entityName}
+GET    /v1/{prefix}/labels/{entityType}/{entityName}/{key}
+POST   /v1/{prefix}/labels/{entityType}/{entityName}/{key}
+DELETE /v1/{prefix}/labels/{entityType}/{entityName}/{key}
+
 GET  /v1/{prefix}/permissions
 POST /v1/{prefix}/permissions/grant
 POST /v1/{prefix}/permissions/revoke
@@ -63,6 +73,88 @@ path inheritance.
 The complete wire contract is available in
 [`rest-management-open-api.yaml`](/rest-management-open-api.yaml).
 
+## Entity labels
+
+A label is a string key/value bound directly to one existing entity. The same 
endpoints serve
+tables, columns, databases, and other server-supported entity types. Labels 
are separate from
+Paimon's table snapshot `tags`; a label does not itself grant access or 
enforce a policy.
+
+`entityType` is an extensible, server-defined string, for example `TABLE` or 
`COLUMN`.
+`entityName` is the server's canonical name within the configured catalog 
prefix. For example, a
+server may identify a table as `sales.orders` and a column as 
`sales.orders.order_id`. The server
+must define unambiguous names, including how to quote or escape identifier 
components. The client
+passes names unchanged and never splits them on dots. Path parameters use REST 
Catalog URL
+encoding as individual UTF-8 segments; JSON responses contain the original 
unencoded strings.
+
+### Java catalog access
+
+Obtain `LabelManagement` from a configured `RESTCatalog`. Read methods return 
`Label` objects with
+the entity type, canonical entity name, key, and value:
+
+```java
+import org.apache.paimon.management.LabelManagement;
+
+LabelManagement labels = restCatalog.labelManagement();
+labels.upsertLabel("TABLE", "sales.orders", "domain", "sales");
+String domain = labels.getLabel("TABLE", "sales.orders", "domain").getValue();
+labels.listLabels("TABLE", "sales.orders"); // Follows all pages.
+labels.listLabelsPaged("TABLE", "sales.orders", 100, null); // Reads one page.
+labels.deleteLabel("TABLE", "sales.orders", "domain");
+```
+
+The standalone `RESTApi` client also exposes these operations; see
+[Java REST API](../../program-api/rest-api#entity-labels).
+
+### Set a label
+
+`POST /v1/{prefix}/labels/{entityType}/{entityName}/{key}` atomically creates 
or replaces
+the binding identified by the path. For example, set the `domain` label on 
`sales.orders`:
+
+```http
+POST /v1/{prefix}/labels/TABLE/sales.orders/domain
+Content-Type: application/json
+```
+
+```json
+{
+  "value": "sales"
+}
+```
+
+The request body contains only the value; it does not repeat the identity from 
the path.
+The entity must already exist. Repeating the same request leaves the same 
label value, and
+updating one key leaves other keys unchanged. Identity fields must be 
non-blank; keys are case
+sensitive. The value must be a string; an empty string is allowed and null is 
rejected.
+The successful response is HTTP 200 with no body. There is no separate 
create-only or PATCH
+operation.
+
+### Read and delete labels
+
+`GET .../labels/{entityType}/{entityName}/{key}` returns the complete binding 
with
+`entityType`, `entityName`, `key`, and `value`. A missing entity or key 
returns 404.
+For a missing key, the error identifies
+`resourceType=LABEL` and `resourceName={key}`.
+
+`GET .../labels/{entityType}/{entityName}` lists direct bindings:
+
+```json
+{
+  "labels": [
+    {"entityType": "TABLE", "entityName": "sales.orders", "key": "domain", 
"value": "sales"}
+  ],
+  "nextPageToken": "opaque-continuation-token"
+}
+```
+
+Use `maxResults` (1–1000, omitted for the server default) and the returned 
opaque `pageToken`
+to request subsequent pages. The last page omits `nextPageToken`. As with 
other Paimon catalog
+listings, an empty page terminates pagination and must not carry a 
continuation token. An existing
+entity with no labels returns `{"labels":[]}`; a missing entity returns 404.
+
+`DELETE .../labels/{entityType}/{entityName}/{key}` removes only that binding. 
It returns HTTP 200
+with no body even if the key is already absent, provided the entity exists. A 
missing entity
+still returns 404.
+
 ## Privileges and policies are independent
 
 A permission grants one access on one resource to one principal. A data policy 
restricts rows or
diff --git a/docs/docs/concepts/rest/rest-api.md 
b/docs/docs/concepts/rest/rest-api.md
index 02fe552915..7be2bb8caf 100644
--- a/docs/docs/concepts/rest/rest-api.md
+++ b/docs/docs/concepts/rest/rest-api.md
@@ -27,8 +27,8 @@ The [REST Catalog OpenAPI 
specification](/rest-catalog-open-api.yaml) defines th
 wire contract for catalog servers and clients. Open the YAML specification to 
inspect request and
 response schemas, generate SDK models, or validate an implementation.
 
-For client configuration, start with the [REST Catalog overview](./). For 
privilege grants, row
-filters, and column masks, use the separate [REST Management 
API](./management-api).
+For client configuration, start with the [REST Catalog overview](./). For 
entity labels, privilege
+grants, row filters, and column masks, use the separate [REST Management 
API](./management-api).
 
 ## Connect to a Catalog
 
diff --git a/docs/docs/program-api/rest-api.mdx 
b/docs/docs/program-api/rest-api.mdx
index af6d79786f..0a1d9e3686 100644
--- a/docs/docs/program-api/rest-api.mdx
+++ b/docs/docs/program-api/rest-api.mdx
@@ -106,6 +106,31 @@ public class RESTApiExample {
 }
 ```
 
+## Entity labels
+
+The experimental label methods use a single endpoint family for all 
server-supported entity types.
+Names are canonical, server-defined strings within the configured prefix. For 
a server that uses
+`sales.orders` to identify a table:
+
+```java
+api.upsertLabel("TABLE", "sales.orders", "domain", "sales");
+String domain = api.getLabel("TABLE", "sales.orders", "domain").getValue();
+api.listLabels("TABLE", "sales.orders"); // Follows all pages.
+api.listLabelsPaged("TABLE", "sales.orders", 100, null); // Reads one page.
+api.deleteLabel("TABLE", "sales.orders", "domain");
+```
+
+When using `RESTCatalog`, call `restCatalog.labelManagement()` to obtain the 
same operations
+through `LabelManagement`, with `Label` objects as read results. This reuses 
the catalog's
+authentication and prefix configuration. See
+[Java catalog access](../concepts/rest/management-api#java-catalog-access).
+
+Upsert creates or replaces one binding using POST on the same single-key path 
used by GET and
+DELETE. Its request body contains only `value`. It requires the target entity 
to exist.
+Deleting an absent binding succeeds; deleting from a missing entity fails. 
Servers must implement
+these endpoints; the client does not fall back to table options or snapshot 
tags. See
+[the label wire contract](../concepts/rest/management-api#entity-labels).
+
 ## Authentication and table access
 
 See [bearer authentication](../concepts/rest/bear) or [DLF 
authentication](../concepts/rest/dlf)
diff --git a/docs/scripts/validate-rest-openapi.js 
b/docs/scripts/validate-rest-openapi.js
index 3dd73928aa..0d3c40155c 100644
--- a/docs/scripts/validate-rest-openapi.js
+++ b/docs/scripts/validate-rest-openapi.js
@@ -352,6 +352,10 @@ function validateCatalogOpenApi() {
 function validateManagementOpenApi() {
   const contract = validateCommon('rest-management-open-api.yaml');
   const operationIds = [
+    'upsertLabel',
+    'listLabels',
+    'getLabel',
+    'deleteLabel',
     'listPermissions',
     'grantPermission',
     'revokePermission',
@@ -360,6 +364,8 @@ function validateManagementOpenApi() {
     'dropTablePolicy',
   ];
   const resourcePaths = [
+    '/v1/{prefix}/labels/{entityType}/{entityName}',
+    '/v1/{prefix}/labels/{entityType}/{entityName}/{key}',
     '/v1/{prefix}/permissions',
     '/v1/{prefix}/permissions/grant',
     '/v1/{prefix}/permissions/revoke',
@@ -384,6 +390,43 @@ function validateManagementOpenApi() {
     ),
   );
   operationIds.forEach(contract.requireOperation);
+  ['upsertLabel', 'listLabels', 'getLabel', 
'deleteLabel'].forEach((operationId) =>
+    contract.requireResponses(operationId, ['200', '400', '401', '403', '404', 
'429', '500', '503']),
+  );
+  contract.requireRequiredProperties('UpsertLabelRequest', ['value']);
+  const labelFields = contract.requireProperties('UpsertLabelRequest', 
['value']);
+  contract.checkSpec(
+    Object.keys(labelFields).length === 1 &&
+      labelFields.value.type === 'string' &&
+      !labelFields.value.minLength,
+    'Label writes must contain only a string value, permitting empty strings 
but rejecting null',
+  );
+  contract.checkSpec(
+    contract.schema('LabelEntityType').type === 'string' && 
!contract.schema('LabelEntityType').enum,
+    'Label entity types must remain extensible strings',
+  );
+  const labelFieldNames = ['entityType', 'entityName', 'key', 'value'];
+  contract.requireRequiredProperties('GetLabelResponse', labelFieldNames);
+  contract.requireProperties('GetLabelResponse', labelFieldNames);
+  contract.requireRequiredProperties('ListLabelsResponse', ['labels']);
+  const labelList = contract.requireProperties('ListLabelsResponse', 
['labels', 'nextPageToken']);
+  contract.checkSpec(
+    labelList.labels.items.$ref === '#/components/schemas/GetLabelResponse',
+    'Label listings must return complete bindings',
+  );
+  const labelRoot = contract.spec.paths['/v1/{prefix}/labels'];
+  const labelEntity = 
contract.spec.paths['/v1/{prefix}/labels/{entityType}/{entityName}'];
+  const labelKey = 
contract.spec.paths['/v1/{prefix}/labels/{entityType}/{entityName}/{key}'];
+  contract.checkSpec(
+    !labelRoot &&
+      labelEntity.get.operationId === 'listLabels' &&
+      labelKey.get.operationId === 'getLabel' &&
+      labelKey.post.operationId === 'upsertLabel' &&
+      labelKey.delete.operationId === 'deleteLabel' &&
+      !labelKey.patch &&
+      !labelKey.put,
+    'Labels must use POST/GET/DELETE on the single-key path with no 
collection-level write endpoint',
+  );
   ['listPermissions', 'grantPermission', 
'revokePermission'].forEach((operationId) =>
     contract.requireResponses(operationId, [
       '200',
diff --git a/docs/static/rest-management-open-api.yaml 
b/docs/static/rest-management-open-api.yaml
index 8ad9ed16c9..79f1aa0754 100644
--- a/docs/static/rest-management-open-api.yaml
+++ b/docs/static/rest-management-open-api.yaml
@@ -19,8 +19,8 @@ info:
   title: Paimon REST Management API
   version: "1.0"
   description: |
-    Experimental control-plane extension for object and column permissions, 
table row filters, and
-    table column masks in one Paimon REST Catalog prefix. A data policy 
restricts an
+    Experimental control-plane extension for entity labels, object and column 
permissions, table
+    row filters, and table column masks in one Paimon REST Catalog prefix. A 
data policy restricts an
     already-authorized read;
     it never grants SELECT by itself. Each policy is attached to one 
principal: a principal has at
     most one row filter per table and at most one mask per table column. 
Policy creation is accepted
@@ -36,6 +36,139 @@ info:
 servers:
   - url: http://localhost:8080
 paths:
+  /v1/{prefix}/labels/{entityType}/{entityName}:
+    parameters:
+      - $ref: '#/components/parameters/Prefix'
+      - $ref: '#/components/parameters/LabelEntityType'
+      - $ref: '#/components/parameters/LabelEntityName'
+    get:
+      tags: [label]
+      summary: List labels attached directly to one entity
+      operationId: listLabels
+      description: |
+        Returns direct bindings for the exact entity in stable pagination 
order. Labels are
+        distinct from table snapshot tags. An existing entity with no labels 
returns an empty
+        list; a missing entity returns 404. Following catalog pagination, an 
empty page
+        terminates listing and must not carry a continuation token. Omit 
nextPageToken on
+        the last page.
+      parameters:
+        - $ref: '#/components/parameters/PageToken'
+        - $ref: '#/components/parameters/MaxResults'
+      responses:
+        '200':
+          description: One page of label bindings.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ListLabelsResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '429':
+          $ref: '#/components/responses/TooManyRequests'
+        '500':
+          $ref: '#/components/responses/ServerError'
+        '503':
+          $ref: '#/components/responses/ServiceUnavailable'
+  /v1/{prefix}/labels/{entityType}/{entityName}/{key}:
+    parameters:
+      - $ref: '#/components/parameters/Prefix'
+      - $ref: '#/components/parameters/LabelEntityType'
+      - $ref: '#/components/parameters/LabelEntityName'
+      - $ref: '#/components/parameters/LabelKey'
+    post:
+      tags: [label]
+      summary: Create or replace one label binding
+      operationId: upsertLabel
+      description: |
+        Atomically creates a binding or replaces the value identified by
+        (prefix, entityType, entityName, key) in the path. The body contains 
only value.
+        The entity must already exist. Other keys are unchanged. Repeating the 
same request
+        leaves the same label value. This operation does not create the target 
entity or a
+        separate label definition.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/UpsertLabelRequest'
+            example:
+              value: sales
+      responses:
+        '200':
+          description: Label binding created or replaced; no response body.
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '429':
+          $ref: '#/components/responses/TooManyRequests'
+        '500':
+          $ref: '#/components/responses/ServerError'
+        '503':
+          $ref: '#/components/responses/ServiceUnavailable'
+    get:
+      tags: [label]
+      summary: Get one label binding
+      operationId: getLabel
+      description: |
+        Returns one direct binding. A missing entity or key returns 404. For a 
missing key,
+        ErrorResponse.resourceType is LABEL and resourceName is the key; for a 
missing entity
+        they identify the entity instead.
+      responses:
+        '200':
+          description: The requested label binding.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GetLabelResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '429':
+          $ref: '#/components/responses/TooManyRequests'
+        '500':
+          $ref: '#/components/responses/ServerError'
+        '503':
+          $ref: '#/components/responses/ServiceUnavailable'
+    delete:
+      tags: [label]
+      summary: Idempotently delete one label binding
+      operationId: deleteLabel
+      description: |
+        Deletes only the binding identified by the path. An already absent key 
succeeds
+        when the entity exists. A 404 identifies a missing entity, not an 
absent label.
+      responses:
+        '200':
+          description: Label binding is absent; no response body.
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '429':
+          $ref: '#/components/responses/TooManyRequests'
+        '500':
+          $ref: '#/components/responses/ServerError'
+        '503':
+          $ref: '#/components/responses/ServiceUnavailable'
   /v1/{prefix}/permissions:
     parameters:
       - $ref: '#/components/parameters/Prefix'
@@ -283,6 +416,24 @@ components:
       schema:
         type: string
         minLength: 1
+    LabelEntityType:
+      name: entityType
+      in: path
+      required: true
+      schema:
+        $ref: '#/components/schemas/LabelEntityType'
+    LabelEntityName:
+      name: entityName
+      in: path
+      required: true
+      schema:
+        $ref: '#/components/schemas/LabelEntityName'
+    LabelKey:
+      name: key
+      in: path
+      required: true
+      schema:
+        $ref: '#/components/schemas/LabelKey'
     ResourceTypeQuery:
       name: resourceType
       in: query
@@ -378,7 +529,7 @@ components:
           schema:
             $ref: '#/components/schemas/ErrorResponse'
     NotFound:
-      description: Referenced resource, principal, or policy does not exist.
+      description: Referenced resource, principal, policy, or label does not 
exist.
       content:
         application/json:
           schema:
@@ -421,6 +572,74 @@ components:
         type: string
 
   schemas:
+    LabelEntityType:
+      type: string
+      minLength: 1
+      pattern: '\S'
+      description: |
+        Non-blank, server-defined entity type, such as DATABASE, TABLE, 
COLUMN, VIEW, or FUNCTION.
+        This is an extensible string, not a closed enum. Clients preserve it 
without normalization;
+        servers reject unsupported entity types with 400.
+      examples: [TABLE, COLUMN]
+    LabelEntityName:
+      type: string
+      minLength: 1
+      pattern: '\S'
+      description: |
+        Non-blank canonical entity name within the configured prefix. Its 
syntax and name
+        resolution are defined by the server for each entityType. For example, 
a server may use
+        sales.orders for a table and sales.orders.order_id for a column. 
Clients treat the name
+        as opaque: do not split it on dots or append a local engine catalog 
alias. Servers must
+        define unambiguous names, including quoting or escaping where 
necessary.
+        In a path, encode the complete name as one UTF-8 segment using REST 
Catalog URL
+        encoding (spaces as +, literal + as %2B, slash as %2F); decode exactly 
once. Encode
+        dot-only segments . and .. as %2E and %2E%2E. JSON responses carry the 
original unencoded
+        name. All operations resolve the entity through the path.
+      examples: [sales.orders]
+    LabelKey:
+      type: string
+      minLength: 1
+      pattern: '\S'
+      description: |
+        Non-blank, case-sensitive label key. Encode it as a single path 
segment using the same
+        encoding as entityName. Preserve the original string in JSON responses.
+      examples: [domain]
+    UpsertLabelRequest:
+      type: object
+      required: [value]
+      properties:
+        value:
+          type: string
+          description: Complete replacement value. An empty string is valid; 
null is not.
+      description: |
+        Value for the label binding identified by the request path. The body 
does not repeat
+        entityType, entityName, or key. Entity existence checks, 
authorization, and atomic
+        upsert are server responsibilities. Labels do not themselves grant 
access or enforce
+        a data policy.
+    GetLabelResponse:
+      type: object
+      required: [entityType, entityName, key, value]
+      properties:
+        entityType:
+          $ref: '#/components/schemas/LabelEntityType'
+        entityName:
+          $ref: '#/components/schemas/LabelEntityName'
+        key:
+          $ref: '#/components/schemas/LabelKey'
+        value:
+          type: string
+      description: One direct binding, including its canonical entity 
identity, key, and value.
+    ListLabelsResponse:
+      type: object
+      required: [labels]
+      properties:
+        labels:
+          type: array
+          items:
+            $ref: '#/components/schemas/GetLabelResponse'
+        nextPageToken:
+          type: string
+          description: Opaque continuation token; omitted on the last page.
     ResourceType:
       type: string
       enum: [CATALOG, CATALOG_ALL, DATABASE, DATABASE_ALL, TABLE, COLUMN, 
FUNCTION, VIEW]
diff --git a/paimon-api/src/main/java/org/apache/paimon/management/Label.java 
b/paimon-api/src/main/java/org/apache/paimon/management/Label.java
new file mode 100644
index 0000000000..9135431791
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/management/Label.java
@@ -0,0 +1,58 @@
+/*
+ * 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.paimon.management;
+
+import org.apache.paimon.annotation.Experimental;
+
+/** A string label bound directly to one entity in a catalog. */
+@Experimental
+public class Label {
+
+    private final String entityType;
+    private final String entityName;
+    private final String key;
+    private final String value;
+
+    public Label(String entityType, String entityName, String key, String 
value) {
+        this.entityType = entityType;
+        this.entityName = entityName;
+        this.key = key;
+        this.value = value;
+    }
+
+    /** Returns the extensible, server-defined entity type. */
+    public String getEntityType() {
+        return entityType;
+    }
+
+    /** Returns the server's canonical entity name within the catalog. */
+    public String getEntityName() {
+        return entityName;
+    }
+
+    /** Returns the case-sensitive label key. */
+    public String getKey() {
+        return key;
+    }
+
+    /** Returns the label value, which may be an empty string. */
+    public String getValue() {
+        return value;
+    }
+}
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/management/LabelManagement.java 
b/paimon-api/src/main/java/org/apache/paimon/management/LabelManagement.java
new file mode 100644
index 0000000000..0e7be5c6ca
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/management/LabelManagement.java
@@ -0,0 +1,68 @@
+/*
+ * 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.paimon.management;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.annotation.Experimental;
+
+import javax.annotation.Nullable;
+
+import java.util.List;
+
+/**
+ * Control-plane contract for managing labels attached directly to catalog 
entities.
+ *
+ * <p>Entity types are extensible, server-defined strings. Entity names are 
canonical names within
+ * the configured catalog; clients pass them unchanged without splitting or 
resolving them.
+ */
+@Experimental
+public interface LabelManagement {
+
+    /**
+     * Atomically creates or replaces one label on an existing entity, leaving 
other keys unchanged.
+     *
+     * @param value label value; an empty string is allowed, null is not
+     */
+    void upsertLabel(String entityType, String entityName, String key, String 
value);
+
+    /** Gets one direct binding. A missing entity or label is an error. */
+    Label getLabel(String entityType, String entityName, String key);
+
+    /** Lists all direct bindings, following catalog pagination. */
+    default List<Label> listLabels(String entityType, String entityName) {
+        return PagedList.listAllFromPagedApi(
+                pageToken -> listLabelsPaged(entityType, entityName, null, 
pageToken));
+    }
+
+    /**
+     * Lists one page of direct bindings. A missing entity is an error.
+     *
+     * @param maxResults maximum page size, from 1 to 1000; null uses the 
server default
+     * @param pageToken opaque continuation token from the preceding response; 
null for the first
+     *     page
+     */
+    PagedList<Label> listLabelsPaged(
+            String entityType,
+            String entityName,
+            @Nullable Integer maxResults,
+            @Nullable String pageToken);
+
+    /** Removes one binding. An absent key succeeds; a missing entity is an 
error. */
+    void deleteLabel(String entityType, String entityName, String key);
+}
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
index 6dbe7b872e..79c9fea3bf 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
@@ -68,6 +68,7 @@ import org.apache.paimon.rest.requests.ResetConsumerRequest;
 import org.apache.paimon.rest.requests.RevokePermissionRequest;
 import org.apache.paimon.rest.requests.RollbackSchemaRequest;
 import org.apache.paimon.rest.requests.RollbackTableRequest;
+import org.apache.paimon.rest.requests.UpsertLabelRequest;
 import org.apache.paimon.rest.responses.AlterDatabaseResponse;
 import org.apache.paimon.rest.responses.AuthTableQueryResponse;
 import org.apache.paimon.rest.responses.CommitTableResponse;
@@ -77,6 +78,7 @@ import 
org.apache.paimon.rest.responses.DropPartitionsResponse;
 import org.apache.paimon.rest.responses.ErrorResponse;
 import org.apache.paimon.rest.responses.GetDatabaseResponse;
 import org.apache.paimon.rest.responses.GetFunctionResponse;
+import org.apache.paimon.rest.responses.GetLabelResponse;
 import org.apache.paimon.rest.responses.GetSchemaResponse;
 import org.apache.paimon.rest.responses.GetTableResponse;
 import org.apache.paimon.rest.responses.GetTableSnapshotResponse;
@@ -90,6 +92,7 @@ import org.apache.paimon.rest.responses.ListDatabasesResponse;
 import org.apache.paimon.rest.responses.ListFunctionDetailsResponse;
 import org.apache.paimon.rest.responses.ListFunctionsGloballyResponse;
 import org.apache.paimon.rest.responses.ListFunctionsResponse;
+import org.apache.paimon.rest.responses.ListLabelsResponse;
 import org.apache.paimon.rest.responses.ListPartitionsResponse;
 import org.apache.paimon.rest.responses.ListPermissionsResponse;
 import org.apache.paimon.rest.responses.ListPoliciesResponse;
@@ -878,6 +881,87 @@ public class RESTApi {
                 restAuthFunction);
     }
 
+    /**
+     * Creates or replaces one label on an existing entity in the configured 
catalog prefix.
+     *
+     * <p>The server atomically upserts the binding identified by entity type, 
canonical entity
+     * name, and key. Only its value is replaced; other labels are unchanged. 
Repeating the same
+     * request leaves the same label value. Entity types and name resolution 
are server-defined;
+     * this client does not split, normalize, or resolve entity names. The 
path carries the binding
+     * identity; the request body contains only its value.
+     *
+     * @param value label value; an empty string is allowed, null is not
+     * @throws NoSuchResourceException if the entity does not exist
+     * @throws ForbiddenException if the caller cannot label the entity
+     */
+    @Experimental
+    public void upsertLabel(String entityType, String entityName, String key, 
String value) {
+        client.post(
+                resourcePaths.label(entityType, entityName, key),
+                new UpsertLabelRequest(value),
+                restAuthFunction);
+    }
+
+    /**
+     * Gets one label attached directly to an entity.
+     *
+     * @throws NoSuchResourceException if the entity or label does not exist
+     * @throws ForbiddenException if the caller cannot read labels on the 
entity
+     */
+    @Experimental
+    public GetLabelResponse getLabel(String entityType, String entityName, 
String key) {
+        return client.get(
+                resourcePaths.label(entityType, entityName, key),
+                GetLabelResponse.class,
+                restAuthFunction);
+    }
+
+    /** Lists all labels attached directly to an entity, following catalog 
pagination. */
+    @Experimental
+    public List<GetLabelResponse> listLabels(String entityType, String 
entityName) {
+        String path = resourcePaths.labels(entityType, entityName);
+        return listDataFromPageApi(
+                queryParams ->
+                        client.get(path, queryParams, 
ListLabelsResponse.class, restAuthFunction));
+    }
+
+    /**
+     * Lists one page of labels attached directly to an entity.
+     *
+     * @param maxResults maximum page size, from 1 to 1000; null uses the 
server default
+     * @param pageToken opaque continuation token from the preceding response
+     * @throws NoSuchResourceException if the entity does not exist
+     * @throws ForbiddenException if the caller cannot read labels on the 
entity
+     */
+    @Experimental
+    public PagedList<GetLabelResponse> listLabelsPaged(
+            String entityType,
+            String entityName,
+            @Nullable Integer maxResults,
+            @Nullable String pageToken) {
+        checkArgument(
+                maxResults == null || (maxResults >= 1 && maxResults <= 1000),
+                "maxResults must be between 1 and 1000");
+        ListLabelsResponse response =
+                client.get(
+                        resourcePaths.labels(entityType, entityName),
+                        buildPagedQueryParams(maxResults, pageToken),
+                        ListLabelsResponse.class,
+                        restAuthFunction);
+        return new PagedList<>(response.getLabels(), 
response.getNextPageToken());
+    }
+
+    /**
+     * Deletes a label binding. Deleting an already absent binding succeeds.
+     *
+     * @throws NoSuchResourceException if the entity does not exist
+     * @throws ForbiddenException if the caller cannot label the entity
+     */
+    @Experimental
+    public void deleteLabel(String entityType, String entityName, String key) {
+        client.delete(resourcePaths.label(entityType, entityName, key), 
restAuthFunction);
+    }
+
     /** Lists permissions on an exact resource in the configured REST catalog. 
*/
     @Experimental
     public ListPermissionsResponse listPermissions(ListPermissionsRequest 
request) {
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/RESTLabelManagement.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTLabelManagement.java
new file mode 100644
index 0000000000..55fd22e359
--- /dev/null
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTLabelManagement.java
@@ -0,0 +1,78 @@
+/*
+ * 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.paimon.rest;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.management.Label;
+import org.apache.paimon.management.LabelManagement;
+import org.apache.paimon.rest.responses.GetLabelResponse;
+
+import javax.annotation.Nullable;
+
+import java.util.stream.Collectors;
+
+/** REST implementation of label management, bound to the configured REST 
catalog prefix. */
+@Experimental
+public class RESTLabelManagement implements LabelManagement {
+
+    private final RESTApi api;
+
+    public RESTLabelManagement(RESTApi api) {
+        this.api = api;
+    }
+
+    @Override
+    public void upsertLabel(String entityType, String entityName, String key, 
String value) {
+        api.upsertLabel(entityType, entityName, key, value);
+    }
+
+    @Override
+    public Label getLabel(String entityType, String entityName, String key) {
+        return toLabel(api.getLabel(entityType, entityName, key));
+    }
+
+    @Override
+    public PagedList<Label> listLabelsPaged(
+            String entityType,
+            String entityName,
+            @Nullable Integer maxResults,
+            @Nullable String pageToken) {
+        PagedList<GetLabelResponse> response =
+                api.listLabelsPaged(entityType, entityName, maxResults, 
pageToken);
+        return new PagedList<>(
+                response.getElements().stream()
+                        .map(RESTLabelManagement::toLabel)
+                        .collect(Collectors.toList()),
+                response.getNextPageToken());
+    }
+
+    @Override
+    public void deleteLabel(String entityType, String entityName, String key) {
+        api.deleteLabel(entityType, entityName, key);
+    }
+
+    private static Label toLabel(GetLabelResponse response) {
+        return new Label(
+                response.getEntityType(),
+                response.getEntityName(),
+                response.getKey(),
+                response.getValue());
+    }
+}
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
index d51a3119e1..92cd466a22 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
@@ -25,6 +25,7 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.shade.guava30.com.google.common.base.Joiner;
 
 import static org.apache.paimon.rest.RESTUtil.encodeString;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** Resource paths for REST catalog. */
 public class ResourcePaths {
@@ -47,6 +48,7 @@ public class ResourcePaths {
     protected static final String FUNCTION_DETAILS = "function-details";
     protected static final String PERMISSIONS = "permissions";
     protected static final String POLICIES = "policies";
+    protected static final String LABELS = "labels";
     protected static final String ID = "id";
 
     private static final Joiner SLASH = Joiner.on("/").skipNulls();
@@ -65,6 +67,31 @@ public class ResourcePaths {
         this.prefix = encodeString(prefix);
     }
 
+    /** Labels attached to one entity, whose canonical name is encoded as a 
single segment. */
+    @Experimental
+    public String labels(String entityType, String entityName) {
+        checkArgument(
+                entityType != null && !entityType.trim().isEmpty(), 
"entityType must not be blank");
+        checkArgument(
+                entityName != null && !entityName.trim().isEmpty(), 
"entityName must not be blank");
+        return SLASH.join(
+                V1, prefix, LABELS, encodeLabelSegment(entityType), 
encodeLabelSegment(entityName));
+    }
+
+    @Experimental
+    public String label(String entityType, String entityName, String key) {
+        checkArgument(key != null && !key.trim().isEmpty(), "key must not be 
blank");
+        return SLASH.join(labels(entityType, entityName), 
encodeLabelSegment(key));
+    }
+
+    private static String encodeLabelSegment(String value) {
+        // Form encoding leaves dot segments unchanged, but they must be 
treated as names here.
+        if (".".equals(value) || "..".equals(value)) {
+            return value.replace(".", "%2E");
+        }
+        return encodeString(value);
+    }
+
     @Experimental
     public String permissions() {
         return SLASH.join(V1, prefix, PERMISSIONS);
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertLabelRequest.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertLabelRequest.java
new file mode 100644
index 0000000000..16f6908184
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertLabelRequest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.paimon.rest.requests;
+
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.rest.RESTRequest;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.beans.ConstructorProperties;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Value to set on the label binding identified by the request path. */
+@Experimental
+public class UpsertLabelRequest implements RESTRequest {
+
+    private static final String FIELD_VALUE = "value";
+
+    @JsonProperty(FIELD_VALUE)
+    private final String value;
+
+    @JsonCreator
+    @ConstructorProperties({FIELD_VALUE})
+    public UpsertLabelRequest(@JsonProperty(FIELD_VALUE) String value) {
+        checkArgument(value != null, "value must not be null");
+        this.value = value;
+    }
+
+    @JsonGetter(FIELD_VALUE)
+    public String getValue() {
+        return value;
+    }
+}
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
index 899b0896af..712a8cc110 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
@@ -47,6 +47,8 @@ public class ErrorResponse implements RESTResponse {
 
     public static final String RESOURCE_TYPE_TAG = "TAG";
 
+    public static final String RESOURCE_TYPE_LABEL = "LABEL";
+
     public static final String RESOURCE_TYPE_VIEW = "VIEW";
 
     public static final String RESOURCE_TYPE_DIALECT = "DIALECT";
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetLabelResponse.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetLabelResponse.java
new file mode 100644
index 0000000000..717b155908
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetLabelResponse.java
@@ -0,0 +1,83 @@
+/*
+ * 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.paimon.rest.responses;
+
+import org.apache.paimon.annotation.Experimental;
+import org.apache.paimon.rest.RESTResponse;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.beans.ConstructorProperties;
+
+/** A label bound directly to one entity in the configured REST catalog 
prefix. */
+@Experimental
+public class GetLabelResponse implements RESTResponse {
+
+    private static final String FIELD_ENTITY_TYPE = "entityType";
+    private static final String FIELD_ENTITY_NAME = "entityName";
+    private static final String FIELD_KEY = "key";
+    private static final String FIELD_VALUE = "value";
+
+    @JsonProperty(FIELD_ENTITY_TYPE)
+    private final String entityType;
+
+    @JsonProperty(FIELD_ENTITY_NAME)
+    private final String entityName;
+
+    @JsonProperty(FIELD_KEY)
+    private final String key;
+
+    @JsonProperty(FIELD_VALUE)
+    private final String value;
+
+    @JsonCreator
+    @ConstructorProperties({FIELD_ENTITY_TYPE, FIELD_ENTITY_NAME, FIELD_KEY, 
FIELD_VALUE})
+    public GetLabelResponse(
+            @JsonProperty(FIELD_ENTITY_TYPE) String entityType,
+            @JsonProperty(FIELD_ENTITY_NAME) String entityName,
+            @JsonProperty(FIELD_KEY) String key,
+            @JsonProperty(FIELD_VALUE) String value) {
+        this.entityType = entityType;
+        this.entityName = entityName;
+        this.key = key;
+        this.value = value;
+    }
+
+    @JsonGetter(FIELD_ENTITY_TYPE)
+    public String getEntityType() {
+        return entityType;
+    }
+
+    @JsonGetter(FIELD_ENTITY_NAME)
+    public String getEntityName() {
+        return entityName;
+    }
+
+    @JsonGetter(FIELD_KEY)
+    public String getKey() {
+        return key;
+    }
+
+    @JsonGetter(FIELD_VALUE)
+    public String getValue() {
+        return value;
+    }
+}
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListLabelsResponse.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListLabelsResponse.java
new file mode 100644
index 0000000000..43e008d749
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListLabelsResponse.java
@@ -0,0 +1,75 @@
+/*
+ * 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.paimon.rest.responses;
+
+import org.apache.paimon.annotation.Experimental;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.annotation.Nullable;
+
+import java.beans.ConstructorProperties;
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+
+/** One page of labels attached directly to an entity. */
+@Experimental
+public class ListLabelsResponse implements PagedResponse<GetLabelResponse> {
+
+    private static final String FIELD_LABELS = "labels";
+    private static final String FIELD_NEXT_PAGE_TOKEN = "nextPageToken";
+
+    @JsonProperty(FIELD_LABELS)
+    private final List<GetLabelResponse> labels;
+
+    @Nullable
+    @JsonProperty(FIELD_NEXT_PAGE_TOKEN)
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private final String nextPageToken;
+
+    @JsonCreator
+    @ConstructorProperties({FIELD_LABELS, FIELD_NEXT_PAGE_TOKEN})
+    public ListLabelsResponse(
+            @JsonProperty(FIELD_LABELS) List<GetLabelResponse> labels,
+            @Nullable @JsonProperty(FIELD_NEXT_PAGE_TOKEN) String 
nextPageToken) {
+        this.labels = labels == null ? emptyList() : labels;
+        this.nextPageToken = nextPageToken;
+    }
+
+    @JsonGetter(FIELD_LABELS)
+    public List<GetLabelResponse> getLabels() {
+        return labels;
+    }
+
+    @Override
+    @Nullable
+    @JsonGetter(FIELD_NEXT_PAGE_TOKEN)
+    public String getNextPageToken() {
+        return nextPageToken;
+    }
+
+    @Override
+    public List<GetLabelResponse> data() {
+        return labels;
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiLabelTest.java 
b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiLabelTest.java
new file mode 100644
index 0000000000..370a12cf33
--- /dev/null
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiLabelTest.java
@@ -0,0 +1,392 @@
+/*
+ * 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.paimon.rest;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.rest.exceptions.ForbiddenException;
+import org.apache.paimon.rest.exceptions.NoSuchResourceException;
+import org.apache.paimon.rest.exceptions.NotImplementedException;
+import org.apache.paimon.rest.requests.UpsertLabelRequest;
+import org.apache.paimon.rest.responses.GetLabelResponse;
+import org.apache.paimon.rest.responses.ListLabelsResponse;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
+
+import static org.apache.paimon.rest.RESTCatalogInternalOptions.PREFIX;
+import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN;
+import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN_PROVIDER;
+import static org.apache.paimon.rest.RESTCatalogOptions.URI;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** HTTP contract tests for generic label operations. */
+class RESTApiLabelTest {
+
+    private static final String BASE_PATH = "/v1/catalog%2Fid/labels";
+    private static final String LABEL_JSON =
+            "{\"entityType\":\"TABLE\",\"entityName\":\"sales.orders\","
+                    + "\"key\":\"domain\",\"value\":\"sales\"}";
+
+    private final Queue<Reply> replies = new ConcurrentLinkedQueue<>();
+    private final List<Request> requests = new CopyOnWriteArrayList<>();
+    private HttpServer server;
+    private RESTApi api;
+
+    @BeforeEach
+    void setUp() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext(
+                "/v1/",
+                exchange -> {
+                    requests.add(new Request(exchange));
+                    Reply reply = replies.poll();
+                    if (reply == null) {
+                        reply = new Reply(500, 
"{\"code\":500,\"message\":\"unexpected request\"}");
+                    }
+                    byte[] data = reply.body.getBytes(StandardCharsets.UTF_8);
+                    exchange.getResponseHeaders().set("Content-Type", 
"application/json");
+                    exchange.sendResponseHeaders(reply.code, data.length);
+                    try (OutputStream output = exchange.getResponseBody()) {
+                        output.write(data);
+                    } finally {
+                        exchange.close();
+                    }
+                });
+        server.start();
+
+        Options options = new Options();
+        options.set(URI, "http://127.0.0.1:"; + server.getAddress().getPort());
+        options.set(PREFIX, "catalog/id");
+        options.set(TOKEN_PROVIDER, "bear");
+        options.set(TOKEN, "test-token");
+        api = new RESTApi(options, false);
+    }
+
+    @AfterEach
+    void tearDown() {
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    void testUpsertUsesOnePostWithoutExistenceCheck() throws Exception {
+        for (String value : Arrays.asList("sales", "finance", "finance")) {
+            enqueue(200, "");
+            api.upsertLabel("TABLE", "sales.orders", "domain", value);
+        }
+
+        assertThat(requests).hasSize(3);
+        for (int i = 0; i < requests.size(); i++) {
+            Request request = requests.get(i);
+            assertThat(request.method).isEqualTo("POST");
+            assertThat(request.path).isEqualTo(BASE_PATH + 
"/TABLE/sales.orders/domain");
+            assertThat(request.query).isNull();
+            assertThat(request.authorization).isEqualTo("Bearer test-token");
+            assertThat(RESTApi.fromJson(request.body, Map.class))
+                    .isEqualTo(Collections.singletonMap("value", i == 0 ? 
"sales" : "finance"));
+        }
+    }
+
+    @Test
+    void testColumnAndProviderDefinedTypesUseGenericPaths() throws Exception {
+        for (String type : Arrays.asList("COLUMN", "MODEL_VERSION")) {
+            enqueue(200, "");
+            api.upsertLabel(type, "sales.orders.id", "classification", "");
+        }
+        assertThat(requests).hasSize(2);
+        for (int i = 0; i < requests.size(); i++) {
+            Request request = requests.get(i);
+            assertThat(request.method).isEqualTo("POST");
+            assertThat(request.path)
+                    .isEqualTo(
+                            BASE_PATH
+                                    + "/"
+                                    + (i == 0 ? "COLUMN" : "MODEL_VERSION")
+                                    + "/sales.orders.id/classification");
+            assertThat(RESTApi.fromJson(request.body, Map.class))
+                    .isEqualTo(Collections.singletonMap("value", ""));
+        }
+    }
+
+    @Test
+    void testEntityNameAndKeyRemainSingleEncodedSegments() throws Exception {
+        String name = "sales db.orders/华+%?#";
+        String key = "a/b+% ?#";
+        enqueue(200, RESTApi.toJson(labelFields("COLUMN", name, key, 
"confidential")));
+        GetLabelResponse label = api.getLabel("COLUMN", name, key);
+
+        assertThat(requests.get(0).method).isEqualTo("GET");
+        assertThat(requests.get(0).path)
+                .isEqualTo(
+                        BASE_PATH
+                                + 
"/COLUMN/sales+db.orders%2F%E5%8D%8E%2B%25%3F%23/a%2Fb%2B%25+%3F%23");
+        assertThat(label.getEntityType()).isEqualTo("COLUMN");
+        assertThat(label.getEntityName()).isEqualTo(name);
+        assertThat(label.getKey()).isEqualTo(key);
+        assertThat(label.getValue()).isEqualTo("confidential");
+
+        enqueue(200, "");
+        api.upsertLabel("COLUMN", name, key, "sensitive");
+        assertThat(requests.get(1).method).isEqualTo("POST");
+        assertThat(requests.get(1).path).isEqualTo(requests.get(0).path);
+        assertThat(RESTApi.fromJson(requests.get(1).body, Map.class))
+                .isEqualTo(Collections.singletonMap("value", "sensitive"));
+
+        enqueue(200, "");
+        api.deleteLabel("COLUMN", name, key);
+        assertThat(requests.get(2).method).isEqualTo("DELETE");
+        assertThat(requests.get(2).path).isEqualTo(requests.get(0).path);
+    }
+
+    @Test
+    void testDotOnlyNamesRemainDataInThePath() {
+        enqueue(200, LABEL_JSON);
+        api.getLabel("TABLE", ".", "..");
+        assertThat(requests.get(0).path).isEqualTo(BASE_PATH + 
"/TABLE/%2E/%2E%2E");
+        enqueue(200, "");
+        api.upsertLabel("TABLE", ".", "..", "value");
+        assertThat(requests.get(1).method).isEqualTo("POST");
+        assertThat(requests.get(1).path).isEqualTo(requests.get(0).path);
+    }
+
+    @Test
+    void testListFollowsOpaqueContinuationTokens() throws Exception {
+        enqueue(200, "{\"labels\":[" + LABEL_JSON + 
"],\"nextPageToken\":\"next +/%?&\"}");
+        enqueue(200, "{\"labels\":[" + LABEL_JSON.replace("domain", "owner") + 
"]}");
+
+        List<GetLabelResponse> labels = api.listLabels("TABLE", 
"sales.orders");
+
+        
assertThat(labels).extracting(GetLabelResponse::getKey).containsExactly("domain",
 "owner");
+        assertThat(requests).hasSize(2);
+        assertThat(requests.get(0).method).isEqualTo("GET");
+        assertThat(requests.get(0).path).isEqualTo(BASE_PATH + 
"/TABLE/sales.orders");
+        assertThat(requests.get(0).query).isNull();
+        assertThat(requests.get(1).path).isEqualTo(requests.get(0).path);
+        
assertThat(queryParameters(requests.get(1).query)).containsEntry("pageToken", 
"next +/%?&");
+    }
+
+    @Test
+    void testPagedListCarriesLimitsAndReturnsEmptyTerminalPage() throws 
Exception {
+        enqueue(200, "{\"labels\":[" + LABEL_JSON + 
"],\"nextPageToken\":\"next\"}");
+        PagedList<GetLabelResponse> first =
+                api.listLabelsPaged("TABLE", "sales.orders", 1000, "start 
+/%");
+        assertThat(first.getElements())
+                .extracting(GetLabelResponse::getValue)
+                .containsExactly("sales");
+        assertThat(first.getNextPageToken()).isEqualTo("next");
+        assertThat(queryParameters(requests.get(0).query))
+                .containsEntry("maxResults", "1000")
+                .containsEntry("pageToken", "start +/%");
+
+        enqueue(200, "{\"labels\":[]}");
+        PagedList<GetLabelResponse> last =
+                api.listLabelsPaged("COLUMN", "sales.orders.id", 1, "next");
+        assertThat(last.getElements()).isEmpty();
+        assertThat(last.getNextPageToken()).isNull();
+        assertThat(requests.get(1).path).isEqualTo(BASE_PATH + 
"/COLUMN/sales.orders.id");
+
+        enqueue(200, "{\"labels\":[]}");
+        assertThat(api.listLabels("TABLE", "sales.empty")).isEmpty();
+    }
+
+    @Test
+    void testRepeatedDeleteDoesNotRequireARead() {
+        enqueue(200, "");
+        enqueue(200, "");
+        api.deleteLabel("TABLE", "sales.orders", "domain");
+        api.deleteLabel("TABLE", "sales.orders", "domain");
+
+        assertThat(requests).hasSize(2);
+        for (Request request : requests) {
+            assertThat(request.method).isEqualTo("DELETE");
+            assertThat(request.path).isEqualTo(BASE_PATH + 
"/TABLE/sales.orders/domain");
+            assertThat(request.body).isEmpty();
+        }
+    }
+
+    @Test
+    void testMissingEntityIsPropagatedByEveryOperation() {
+        List<Consumer<RESTApi>> operations =
+                Arrays.asList(
+                        client -> client.upsertLabel("TABLE", "sales.missing", 
"domain", "sales"),
+                        client -> client.getLabel("TABLE", "sales.missing", 
"domain"),
+                        client -> client.listLabels("TABLE", "sales.missing"),
+                        client -> client.listLabelsPaged("TABLE", 
"sales.missing", null, null),
+                        client -> client.deleteLabel("TABLE", "sales.missing", 
"domain"));
+        for (Consumer<RESTApi> operation : operations) {
+            enqueue(
+                    404,
+                    "{\"code\":404,\"message\":\"entity 
missing\",\"resourceType\":\"TABLE\",\"resourceName\":\"sales.missing\"}");
+            assertThatThrownBy(() -> operation.accept(api))
+                    .isInstanceOf(NoSuchResourceException.class)
+                    .hasMessageContaining("entity missing");
+        }
+        assertThat(requests).hasSize(operations.size());
+    }
+
+    @Test
+    void testMissingLabelForbiddenAndUnsupportedHaveNoFallback() {
+        enqueue(
+                404,
+                "{\"code\":404,\"message\":\"label 
missing\",\"resourceType\":\"LABEL\",\"resourceName\":\"domain\"}");
+        assertThatThrownBy(() -> api.getLabel("TABLE", "sales.orders", 
"domain"))
+                .isInstanceOf(NoSuchResourceException.class)
+                .hasMessageContaining("label missing");
+
+        enqueue(403, "{\"code\":403,\"message\":\"label access denied\"}");
+        assertThatThrownBy(() -> api.upsertLabel("TABLE", "sales.orders", 
"domain", "sales"))
+                .isInstanceOf(ForbiddenException.class)
+                .hasMessageContaining("label access denied");
+
+        enqueue(501, "{\"code\":501,\"message\":\"labels unsupported\"}");
+        assertThatThrownBy(() -> api.listLabels("TABLE", "sales.orders"))
+                .isInstanceOf(NotImplementedException.class)
+                .hasMessageContaining("labels unsupported");
+        assertThat(requests).hasSize(3);
+    }
+
+    @Test
+    void testInvalidInputsFailBeforeHttp() {
+        for (String invalid : Arrays.asList(null, "", "  ")) {
+            assertThatThrownBy(() -> api.upsertLabel(invalid, "sales.orders", 
"domain", "sales"))
+                    .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.upsertLabel("TABLE", invalid, 
"domain", "sales"))
+                    .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.upsertLabel("TABLE", "sales.orders", 
invalid, "sales"))
+                    .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.getLabel(invalid, "sales.orders", 
"domain"))
+                    .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.listLabels("TABLE", invalid))
+                    .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.deleteLabel("TABLE", "sales.orders", 
invalid))
+                    .isInstanceOf(IllegalArgumentException.class);
+        }
+        assertThatThrownBy(() -> api.upsertLabel("TABLE", "sales.orders", 
"domain", null))
+                .isInstanceOf(IllegalArgumentException.class);
+        for (int invalid : new int[] {-1, 0, 1001}) {
+            assertThatThrownBy(() -> api.listLabelsPaged("TABLE", 
"sales.orders", invalid, null))
+                    .isInstanceOf(IllegalArgumentException.class);
+        }
+        assertThat(requests).isEmpty();
+    }
+
+    @Test
+    void testWireModelsRequireWriteFieldsAndTolerateFutureResponseFields() 
throws Exception {
+        UpsertLabelRequest request =
+                RESTApi.fromJson("{\"value\":\"sales\"}", 
UpsertLabelRequest.class);
+        assertThat(RESTApi.fromJson(RESTApi.toJson(request), Map.class))
+                .isEqualTo(Collections.singletonMap("value", "sales"));
+        for (String json : Arrays.asList("{}", "{\"value\":null}")) {
+            assertThatThrownBy(() -> RESTApi.fromJson(json, 
UpsertLabelRequest.class))
+                    .hasRootCauseInstanceOf(IllegalArgumentException.class);
+        }
+
+        String futureLabel =
+                LABEL_JSON.substring(0, LABEL_JSON.length() - 1) + 
",\"source\":\"system\"}";
+        ListLabelsResponse response =
+                RESTApi.fromJson(
+                        "{\"labels\":["
+                                + futureLabel
+                                + 
"],\"nextPageToken\":\"next\",\"future\":true}",
+                        ListLabelsResponse.class);
+        assertThat(response.getLabels()).hasSize(1);
+        assertThat(response.getLabels().get(0).getKey()).isEqualTo("domain");
+        assertThat(response.getNextPageToken()).isEqualTo("next");
+        assertThat(RESTApi.fromJson("{\"labels\":[]}", 
ListLabelsResponse.class).getNextPageToken())
+                .isNull();
+        assertThat(RESTApi.fromJson("{}", 
ListLabelsResponse.class).getLabels()).isEmpty();
+    }
+
+    private void enqueue(int code, String body) {
+        replies.add(new Reply(code, body));
+    }
+
+    private static Map<String, String> labelFields(
+            String type, String name, String key, String value) {
+        Map<String, String> fields = new HashMap<>();
+        fields.put("entityType", type);
+        fields.put("entityName", name);
+        fields.put("key", key);
+        fields.put("value", value);
+        return fields;
+    }
+
+    private static Map<String, String> queryParameters(String query) throws 
Exception {
+        Map<String, String> values = new HashMap<>();
+        for (String parameter : query.split("&")) {
+            String[] pair = parameter.split("=", 2);
+            values.put(URLDecoder.decode(pair[0], "UTF-8"), 
URLDecoder.decode(pair[1], "UTF-8"));
+        }
+        return values;
+    }
+
+    private static class Reply {
+        private final int code;
+        private final String body;
+
+        private Reply(int code, String body) {
+            this.code = code;
+            this.body = body;
+        }
+    }
+
+    private static class Request {
+        private final String method;
+        private final String path;
+        private final String query;
+        private final String body;
+        private final String authorization;
+
+        private Request(HttpExchange exchange) throws IOException {
+            method = exchange.getRequestMethod();
+            path = exchange.getRequestURI().getRawPath();
+            query = exchange.getRequestURI().getRawQuery();
+            authorization = 
exchange.getRequestHeaders().getFirst("Authorization");
+            ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+            byte[] data = new byte[1024];
+            int read;
+            while ((read = exchange.getRequestBody().read(data)) != -1) {
+                buffer.write(data, 0, read);
+            }
+            body = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
+        }
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
index 9e645db3b7..5674e90b17 100644
--- 
a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java
@@ -157,7 +157,12 @@ public class RequestJacksonCompatibilityTest {
                             RollbackSchemaRequest.class,
                             "{\"schemaId\":44}",
                             request -> 
assertThat(request.getSchemaId()).isEqualTo(44L),
-                            "schemaId"));
+                            "schemaId"),
+                    requestCase(
+                            UpsertLabelRequest.class,
+                            "{\"value\":\"identifier\"}",
+                            request -> 
assertThat(request.getValue()).isEqualTo("identifier"),
+                            "value"));
 
     private static final Set<Class<? extends RESTRequest>> COMPLEX_REQUESTS =
             Stream.<Class<? extends RESTRequest>>of(
diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java 
b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
index 71cdb10123..e18eae4d03 100644
--- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
@@ -40,6 +40,7 @@ import org.apache.paimon.fs.cache.CachingFileIO;
 import org.apache.paimon.fs.cache.LocalCacheManager;
 import org.apache.paimon.function.Function;
 import org.apache.paimon.function.FunctionChange;
+import org.apache.paimon.management.LabelManagement;
 import org.apache.paimon.management.PermissionManagement;
 import org.apache.paimon.management.PolicyManagement;
 import org.apache.paimon.options.Options;
@@ -153,6 +154,11 @@ public class RESTCatalog implements Catalog {
         return new RESTPolicyManagement(api);
     }
 
+    @Experimental
+    public LabelManagement labelManagement() {
+        return new RESTLabelManagement(api);
+    }
+
     @Override
     public List<String> listDatabases() {
         return api.listDatabases();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogLabelManagementTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogLabelManagementTest.java
new file mode 100644
index 0000000000..41ec08439e
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogLabelManagementTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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.paimon.rest;
+
+import org.apache.paimon.PagedList;
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.management.Label;
+import org.apache.paimon.management.LabelManagement;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.rest.exceptions.ForbiddenException;
+import org.apache.paimon.rest.exceptions.NoSuchResourceException;
+import org.apache.paimon.rest.exceptions.NotImplementedException;
+
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import static org.apache.paimon.options.CatalogOptions.WAREHOUSE;
+import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN;
+import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN_PROVIDER;
+import static org.apache.paimon.rest.RESTCatalogOptions.URI;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+
+/** Exercises label management through a configured REST catalog and its HTTP 
client. */
+class RESTCatalogLabelManagementTest {
+
+    private static final String BASE_PATH = "/v1/catalog%2Fid/labels";
+    private static final String LABEL_JSON =
+            "{\"entityType\":\"TABLE\",\"entityName\":\"sales.orders\","
+                    + "\"key\":\"domain\",\"value\":\"sales\"}";
+
+    private MockWebServer server;
+    private RESTCatalog catalog;
+    private LabelManagement labels;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        server = new MockWebServer();
+        server.start();
+        enqueue(
+                200,
+                "{\"defaults\":{},\"overrides\":{\"prefix\":\"catalog/id\","
+                        + "\"header.X-Catalog-Context\":\"configured\"}}");
+
+        Options options = new Options();
+        options.set(URI, server.url("/").toString());
+        options.set(WAREHOUSE, "warehouse-id");
+        options.set(TOKEN_PROVIDER, "bear");
+        options.set(TOKEN, "test-token");
+        catalog = new RESTCatalog(CatalogContext.create(options));
+        labels = catalog.labelManagement();
+
+        RecordedRequest config = server.takeRequest(10, TimeUnit.SECONDS);
+        assertThat(config).isNotNull();
+        
assertThat(config.getRequestUrl().encodedPath()).isEqualTo("/v1/config");
+        
assertThat(config.getRequestUrl().queryParameter("warehouse")).isEqualTo("warehouse-id");
+        assertThat(server.getRequestCount()).isEqualTo(1);
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        if (catalog != null) {
+            catalog.close();
+        }
+        if (server != null) {
+            server.shutdown();
+        }
+    }
+
+    @Test
+    void testUpsertReadAndDeleteUseCatalogConfiguration() throws Exception {
+        String entityName = "sales db.model/v1";
+        String key = "domain/华";
+        String path = BASE_PATH + 
"/MODEL_VERSION/sales+db.model%2Fv1/domain%2F%E5%8D%8E";
+
+        for (String value : Arrays.asList("sales", "")) {
+            enqueue(200, "");
+            labels.upsertLabel("MODEL_VERSION", entityName, key, value);
+            RecordedRequest request = takeRequest("POST", path);
+            assertThat(RESTApi.fromJson(request.getBody().readUtf8(), 
Map.class))
+                    .isEqualTo(Collections.singletonMap("value", value));
+        }
+
+        enqueue(
+                200,
+                "{\"entityType\":\"MODEL_VERSION\",\"entityName\":\"sales 
db.model/v1\","
+                        + "\"key\":\"domain/华\",\"value\":\"\"}");
+        Label label = labels.getLabel("MODEL_VERSION", entityName, key);
+        takeRequest("GET", path);
+        assertThat(label.getEntityType()).isEqualTo("MODEL_VERSION");
+        assertThat(label.getEntityName()).isEqualTo(entityName);
+        assertThat(label.getKey()).isEqualTo(key);
+        assertThat(label.getValue()).isEmpty();
+
+        for (int i = 0; i < 2; i++) {
+            enqueue(200, "");
+            labels.deleteLabel("MODEL_VERSION", entityName, key);
+            assertThat(takeRequest("DELETE", path).getBodySize()).isZero();
+        }
+        assertThat(server.getRequestCount()).isEqualTo(6);
+    }
+
+    @Test
+    void testListPagePreservesBindingsAndContinuationToken() throws Exception {
+        enqueue(200, "{\"labels\":[" + LABEL_JSON + 
"],\"nextPageToken\":\"next +/%?&\"}");
+
+        PagedList<Label> page = labels.listLabelsPaged("TABLE", 
"sales.orders", 1000, "start +/%");
+        assertThat(page.getElements())
+                .extracting(
+                        Label::getEntityType, Label::getEntityName, 
Label::getKey, Label::getValue)
+                .containsExactly(tuple("TABLE", "sales.orders", "domain", 
"sales"));
+        assertThat(page.getNextPageToken()).isEqualTo("next +/%?&");
+        RecordedRequest request = takeRequest("GET", BASE_PATH + 
"/TABLE/sales.orders");
+        
assertThat(request.getRequestUrl().queryParameter("maxResults")).isEqualTo("1000");
+        
assertThat(request.getRequestUrl().queryParameter("pageToken")).isEqualTo("start
 +/%");
+    }
+
+    @Test
+    void testListAllFollowsOpaqueTokens() throws Exception {
+        enqueue(200, "{\"labels\":[" + LABEL_JSON + 
"],\"nextPageToken\":\"next +/%?&\"}");
+        enqueue(200, "{\"labels\":[" + LABEL_JSON.replace("domain", "owner") + 
"]}");
+
+        List<Label> all = labels.listLabels("TABLE", "sales.orders");
+        assertThat(all).extracting(Label::getKey).containsExactly("domain", 
"owner");
+        assertThat(takeRequest("GET", BASE_PATH + 
"/TABLE/sales.orders").getRequestUrl().query())
+                .isNull();
+        RecordedRequest second = takeRequest("GET", BASE_PATH + 
"/TABLE/sales.orders");
+        
assertThat(second.getRequestUrl().queryParameter("pageToken")).isEqualTo("next 
+/%?&");
+        
assertThat(second.getRequestUrl().queryParameter("maxResults")).isNull();
+        assertThat(server.getRequestCount()).isEqualTo(3);
+    }
+
+    @Test
+    void testEmptyEntityLabelsTerminatePagination() throws Exception {
+        enqueue(200, "{\"labels\":[]}");
+        PagedList<Label> page = labels.listLabelsPaged("COLUMN", 
"sales.orders.id", 1, "last");
+        assertThat(page.getElements()).isEmpty();
+        assertThat(page.getNextPageToken()).isNull();
+        takeRequest("GET", BASE_PATH + "/COLUMN/sales.orders.id");
+
+        enqueue(200, "{\"labels\":[]}");
+        assertThat(labels.listLabels("TABLE", "sales.empty")).isEmpty();
+        takeRequest("GET", BASE_PATH + "/TABLE/sales.empty");
+        assertThat(server.getRequestCount()).isEqualTo(3);
+    }
+
+    @Test
+    void testMissingEntityAndAccessErrorsPropagate() {
+        List<Consumer<LabelManagement>> operations =
+                Arrays.asList(
+                        management -> management.upsertLabel("TABLE", 
"missing", "domain", "sales"),
+                        management -> management.getLabel("TABLE", "missing", 
"domain"),
+                        management -> management.listLabels("TABLE", 
"missing"),
+                        management -> management.listLabelsPaged("TABLE", 
"missing", null, null),
+                        management -> management.deleteLabel("TABLE", 
"missing", "domain"));
+        for (Consumer<LabelManagement> operation : operations) {
+            enqueue(
+                    404,
+                    "{\"code\":404,\"message\":\"entity 
missing\",\"resourceType\":\"TABLE\"}");
+            assertThatThrownBy(() -> operation.accept(labels))
+                    .isInstanceOf(NoSuchResourceException.class)
+                    .hasMessageContaining("entity missing");
+        }
+
+        enqueue(403, "{\"code\":403,\"message\":\"label access denied\"}");
+        assertThatThrownBy(() -> labels.upsertLabel("TABLE", "sales.orders", 
"domain", "sales"))
+                .isInstanceOf(ForbiddenException.class);
+        enqueue(501, "{\"code\":501,\"message\":\"labels unsupported\"}");
+        assertThatThrownBy(() -> labels.listLabels("TABLE", "sales.orders"))
+                .isInstanceOf(NotImplementedException.class);
+        assertThat(server.getRequestCount()).isEqualTo(operations.size() + 3);
+    }
+
+    @Test
+    void testInvalidInputsFailBeforeHttp() {
+        assertThatThrownBy(() -> labels.upsertLabel("TABLE", "sales.orders", 
"domain", null))
+                .isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> labels.getLabel("", "sales.orders", "domain"))
+                .isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> labels.listLabels("TABLE", " "))
+                .isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> labels.deleteLabel("TABLE", "sales.orders", 
null))
+                .isInstanceOf(IllegalArgumentException.class);
+        for (int limit : new int[] {0, 1001}) {
+            assertThatThrownBy(() -> labels.listLabelsPaged("TABLE", 
"sales.orders", limit, null))
+                    .isInstanceOf(IllegalArgumentException.class);
+        }
+        assertThat(server.getRequestCount()).isEqualTo(1);
+    }
+
+    private void enqueue(int status, String body) {
+        server.enqueue(
+                new MockResponse()
+                        .setResponseCode(status)
+                        .setHeader("Content-Type", "application/json")
+                        .setBody(body));
+    }
+
+    private RecordedRequest takeRequest(String method, String path) throws 
Exception {
+        RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS);
+        assertThat(request).isNotNull();
+        assertThat(request.getMethod()).isEqualTo(method);
+        assertThat(request.getRequestUrl().encodedPath()).isEqualTo(path);
+        assertThat(request.getHeader("Authorization")).isEqualTo("Bearer 
test-token");
+        
assertThat(request.getHeader("X-Catalog-Context")).isEqualTo("configured");
+        return request;
+    }
+}

Reply via email to