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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new a95b080a95 [rest] Move label entity names and keys out of the URL path 
(#10571)
a95b080a95 is described below

commit a95b080a950447c36264280a672020a8d8ce9126
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Thu Oct 15 11:27:46 2026 +0800

    [rest] Move label entity names and keys out of the URL path (#10571)
---
 docs/docs/concepts/rest/management-api.md          |  42 +++---
 docs/docs/program-api/rest-api.mdx                 |   9 +-
 docs/scripts/validate-rest-openapi.js              |  67 ++++++---
 docs/static/rest-management-open-api.yaml          | 160 +++++++++++++-------
 .../main/java/org/apache/paimon/rest/RESTApi.java  |  51 +++++--
 .../java/org/apache/paimon/rest/ResourcePaths.java |  27 ++--
 .../apache/paimon/rest/auth/DLFOpenApiActions.java |  20 +++
 .../paimon/rest/requests/DeleteLabelRequest.java   |  74 ++++++++++
 .../paimon/rest/requests/GetLabelRequest.java      |  74 ++++++++++
 ...{UpsertLabelRequest.java => LabelRequests.java} |  30 +---
 .../paimon/rest/requests/UpsertLabelRequest.java   |  41 +++++-
 .../org/apache/paimon/rest/RESTApiLabelTest.java   | 163 +++++++++++++++------
 .../paimon/rest/auth/DLFOpenApiActionsTest.java    |  12 +-
 .../requests/RequestJacksonCompatibilityTest.java  |  37 ++++-
 .../rest/RESTCatalogLabelManagementTest.java       |  47 ++++--
 15 files changed, 643 insertions(+), 211 deletions(-)

diff --git a/docs/docs/concepts/rest/management-api.md 
b/docs/docs/concepts/rest/management-api.md
index bd05c10941..7ce0aae3ae 100644
--- a/docs/docs/concepts/rest/management-api.md
+++ b/docs/docs/concepts/rest/management-api.md
@@ -55,10 +55,10 @@ 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}/labels?entityType=...&entityName=...
+POST /v1/{prefix}/labels/get
+POST /v1/{prefix}/labels/upsert
+POST /v1/{prefix}/labels/delete
 
 GET  /v1/{prefix}/permissions
 POST /v1/{prefix}/permissions/grant
@@ -87,8 +87,14 @@ Paimon's table snapshot `tags`; a label does not itself 
grant access or enforce
 `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.
+passes names unchanged and never splits them on dots.
+
+Entity names and label keys never appear in the URL path. Canonical names 
routinely contain
+characters such as backticks, dots, percent signs, slashes, or non-ASCII text, 
and API gateways in
+front of a REST server may reject or rewrite such characters in a path before 
the request is
+signed or routed. Listing therefore names the entity in query parameters, and 
the operations on a
+single binding carry the entity type, entity name, and key in the JSON body. 
Query parameters use
+standard URL query encoding; JSON carries the original strings.
 
 ### Java catalog access
 
@@ -111,21 +117,23 @@ The standalone `RESTApi` client also exposes these 
operations; see
 
 ### 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`:
+`POST /v1/{prefix}/labels/upsert` atomically creates or replaces the binding 
identified by the
+body. For example, set the `domain` label on `sales.orders`:
 
 ```http
-POST /v1/{prefix}/labels/TABLE/sales.orders/domain
+POST /v1/{prefix}/labels/upsert
 Content-Type: application/json
 ```
 
 ```json
 {
+  "entityType": "TABLE",
+  "entityName": "sales.orders",
+  "key": "domain",
   "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.
@@ -134,12 +142,12 @@ 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
+`POST .../labels/get` with `{"entityType", "entityName", "key"}` returns the 
complete binding with
+`entityType`, `entityName`, `key`, and `value`. It does not change state and 
is safe to retry.
+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:
+`GET .../labels?entityType={entityType}&entityName={entityName}` lists direct 
bindings:
 
 ```json
 {
@@ -155,9 +163,9 @@ to request subsequent pages. The last page omits 
`nextPageToken`. As with other
 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.
+`POST .../labels/delete` with `{"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
 
diff --git a/docs/docs/program-api/rest-api.mdx 
b/docs/docs/program-api/rest-api.mdx
index ed19d4e380..db3aad897e 100644
--- a/docs/docs/program-api/rest-api.mdx
+++ b/docs/docs/program-api/rest-api.mdx
@@ -125,10 +125,11 @@ through `LabelManagement`, with `Label` objects as read 
results. This reuses the
 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
+Listing names the entity in the query string; get, upsert, and delete each 
send one POST whose
+JSON body names the entity type, entity name, and key. No entity name or key 
is ever placed in the
+URL path. Upsert 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
diff --git a/docs/scripts/validate-rest-openapi.js 
b/docs/scripts/validate-rest-openapi.js
index e51eb2295d..3f90fcba17 100644
--- a/docs/scripts/validate-rest-openapi.js
+++ b/docs/scripts/validate-rest-openapi.js
@@ -431,8 +431,10 @@ function validateManagementOpenApi() {
     'dropTablePolicy',
   ];
   const resourcePaths = [
-    '/v1/{prefix}/labels/{entityType}/{entityName}',
-    '/v1/{prefix}/labels/{entityType}/{entityName}/{key}',
+    '/v1/{prefix}/labels',
+    '/v1/{prefix}/labels/get',
+    '/v1/{prefix}/labels/upsert',
+    '/v1/{prefix}/labels/delete',
     '/v1/{prefix}/permissions',
     '/v1/{prefix}/permissions/grant',
     '/v1/{prefix}/permissions/revoke',
@@ -460,13 +462,21 @@ function validateManagementOpenApi() {
   ['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']);
+  const labelIdentity = ['entityType', 'entityName', 'key'];
+  ['GetLabelRequest', 'DeleteLabelRequest'].forEach((schemaName) => {
+    contract.requireRequiredProperties(schemaName, labelIdentity);
+    contract.checkSpec(
+      Object.keys(contract.requireProperties(schemaName, 
labelIdentity)).length === 3,
+      `${schemaName} must contain only the label binding identity`,
+    );
+  });
+  contract.requireRequiredProperties('UpsertLabelRequest', [...labelIdentity, 
'value']);
+  const labelFields = contract.requireProperties('UpsertLabelRequest', 
[...labelIdentity, 'value']);
   contract.checkSpec(
-    Object.keys(labelFields).length === 1 &&
+    Object.keys(labelFields).length === 4 &&
       labelFields.value.type === 'string' &&
       !labelFields.value.minLength,
-    'Label writes must contain only a string value, permitting empty strings 
but rejecting null',
+    'Label writes must carry the binding identity and a string value, 
permitting empty strings but rejecting null',
   );
   contract.checkSpec(
     contract.schema('LabelEntityType').type === 'string' && 
!contract.schema('LabelEntityType').enum,
@@ -481,18 +491,41 @@ function validateManagementOpenApi() {
     labelList.labels.items.$ref === '#/components/schemas/GetLabelResponse',
     'Label listings must return complete bindings',
   );
+  const labelPaths = Object.keys(contract.spec.paths).filter(
+    (resourcePath) =>
+      resourcePath === '/v1/{prefix}/labels' || 
resourcePath.startsWith('/v1/{prefix}/labels/'),
+  );
+  contract.checkSpec(
+    labelPaths.length === 4 &&
+      labelPaths.every((resourcePath) => 
!resourcePath.slice('/v1/{prefix}'.length).includes('{')),
+    'Label paths must never carry entity names or keys, which gateways may 
reject or rewrite',
+  );
+  const labelMethods = (resourcePath) =>
+    Object.keys(contract.spec.paths[resourcePath]).filter((method) => 
HTTP_METHODS.has(method));
   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',
+  contract.checkSpec(
+    labelMethods('/v1/{prefix}/labels').join() === 'get' &&
+      labelRoot.get.operationId === 'listLabels' &&
+      [
+        ['get', 'getLabel'],
+        ['upsert', 'upsertLabel'],
+        ['delete', 'deleteLabel'],
+      ].every(([action, operationId]) => {
+        const resourcePath = `/v1/{prefix}/labels/${action}`;
+        return (
+          labelMethods(resourcePath).join() === 'post' &&
+          contract.spec.paths[resourcePath].post.operationId === operationId
+        );
+      }),
+    'Labels must be listed with GET and read, written, or deleted one binding 
at a time with POST',
+  );
+  const listQuery = labelRoot.get.parameters
+    .map((parameter) => parameter.$ref)
+    .map((ref) => contract.spec.components.parameters[ref.split('/').pop()])
+    .filter((parameter) => parameter.in === 'query' && parameter.required);
+  contract.checkSpec(
+    ['entityType', 'entityName'].every((name) => listQuery.some((parameter) => 
parameter.name === name)),
+    'Label listings must name the entity with required query parameters',
   );
   ['listPermissions', 'grantPermission', 
'revokePermission'].forEach((operationId) =>
     contract.requireResponses(operationId, [
diff --git a/docs/static/rest-management-open-api.yaml 
b/docs/static/rest-management-open-api.yaml
index 79f1aa0754..b474809c76 100644
--- a/docs/static/rest-management-open-api.yaml
+++ b/docs/static/rest-management-open-api.yaml
@@ -36,22 +36,22 @@ info:
 servers:
   - url: http://localhost:8080
 paths:
-  /v1/{prefix}/labels/{entityType}/{entityName}:
+  /v1/{prefix}/labels:
     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.
+        Returns direct bindings for the exact entity named by the entityType 
and entityName query
+        parameters, 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/LabelEntityTypeQuery'
+        - $ref: '#/components/parameters/LabelEntityNameQuery'
         - $ref: '#/components/parameters/PageToken'
         - $ref: '#/components/parameters/MaxResults'
       responses:
@@ -75,33 +75,35 @@ paths:
           $ref: '#/components/responses/ServerError'
         '503':
           $ref: '#/components/responses/ServiceUnavailable'
-  /v1/{prefix}/labels/{entityType}/{entityName}/{key}:
+  /v1/{prefix}/labels/get:
     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
+      summary: Get one label binding
+      operationId: getLabel
       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.
+        Returns the direct binding identified by (prefix, entityType, 
entityName, key) in the
+        body. This operation is read-only and safe to retry. 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.
       requestBody:
         required: true
         content:
           application/json:
             schema:
-              $ref: '#/components/schemas/UpsertLabelRequest'
+              $ref: '#/components/schemas/GetLabelRequest'
             example:
-              value: sales
+              entityType: TABLE
+              entityName: sales.orders
+              key: domain
       responses:
         '200':
-          description: Label binding created or replaced; no response body.
+          description: The requested label binding.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GetLabelResponse'
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -116,21 +118,32 @@ paths:
           $ref: '#/components/responses/ServerError'
         '503':
           $ref: '#/components/responses/ServiceUnavailable'
-    get:
+  /v1/{prefix}/labels/upsert:
+    parameters:
+      - $ref: '#/components/parameters/Prefix'
+    post:
       tags: [label]
-      summary: Get one label binding
-      operationId: getLabel
+      summary: Create or replace one label binding
+      operationId: upsertLabel
       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.
+        Atomically creates a binding or replaces the value identified by
+        (prefix, entityType, entityName, key) in the body. 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:
+              entityType: TABLE
+              entityName: sales.orders
+              key: domain
+              value: sales
       responses:
         '200':
-          description: The requested label binding.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/GetLabelResponse'
+          description: Label binding created or replaced; no response body.
         '400':
           $ref: '#/components/responses/BadRequest'
         '401':
@@ -145,13 +158,26 @@ paths:
           $ref: '#/components/responses/ServerError'
         '503':
           $ref: '#/components/responses/ServiceUnavailable'
-    delete:
+  /v1/{prefix}/labels/delete:
+    parameters:
+      - $ref: '#/components/parameters/Prefix'
+    post:
       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.
+        Deletes only the binding identified by the body. An already absent key 
succeeds when the
+        entity exists. A 404 identifies a missing entity, not an absent label.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/DeleteLabelRequest'
+            example:
+              entityType: TABLE
+              entityName: sales.orders
+              key: domain
       responses:
         '200':
           description: Label binding is absent; no response body.
@@ -416,24 +442,18 @@ components:
       schema:
         type: string
         minLength: 1
-    LabelEntityType:
+    LabelEntityTypeQuery:
       name: entityType
-      in: path
+      in: query
       required: true
       schema:
         $ref: '#/components/schemas/LabelEntityType'
-    LabelEntityName:
+    LabelEntityNameQuery:
       name: entityName
-      in: path
+      in: query
       required: true
       schema:
         $ref: '#/components/schemas/LabelEntityName'
-    LabelKey:
-      name: key
-      in: path
-      required: true
-      schema:
-        $ref: '#/components/schemas/LabelKey'
     ResourceTypeQuery:
       name: resourceType
       in: query
@@ -591,31 +611,57 @@ components:
         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.
+        The name travels as a query parameter or a JSON string, never as a URL 
path segment, so
+        that gateways which reject or rewrite characters such as backticks, 
percent signs, or
+        slashes in paths pass every name unchanged.
       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.
+        Non-blank, case-sensitive label key. Like entityName it travels only 
in JSON, never in a
+        URL path. Preserve the original string in JSON responses.
       examples: [domain]
+    GetLabelRequest:
+      type: object
+      required: [entityType, entityName, key]
+      properties:
+        entityType:
+          $ref: '#/components/schemas/LabelEntityType'
+        entityName:
+          $ref: '#/components/schemas/LabelEntityName'
+        key:
+          $ref: '#/components/schemas/LabelKey'
+      description: Identity of the label binding to read.
     UpsertLabelRequest:
       type: object
-      required: [value]
+      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: 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.
+        Identity and value of the label binding to create or replace. Entity 
existence checks,
+        authorization, and atomic upsert are server responsibilities. Labels 
do not themselves
+        grant access or enforce a data policy.
+    DeleteLabelRequest:
+      type: object
+      required: [entityType, entityName, key]
+      properties:
+        entityType:
+          $ref: '#/components/schemas/LabelEntityType'
+        entityName:
+          $ref: '#/components/schemas/LabelEntityName'
+        key:
+          $ref: '#/components/schemas/LabelKey'
+      description: Identity of the label binding to delete.
     GetLabelResponse:
       type: object
       required: [entityType, entityName, key, value]
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 f97425a16b..b50957d3d9 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
@@ -54,9 +54,11 @@ import 
org.apache.paimon.rest.requests.CreatePartitionsRequest;
 import org.apache.paimon.rest.requests.CreateTableRequest;
 import org.apache.paimon.rest.requests.CreateTagRequest;
 import org.apache.paimon.rest.requests.CreateViewRequest;
+import org.apache.paimon.rest.requests.DeleteLabelRequest;
 import org.apache.paimon.rest.requests.DropPartitionsRequest;
 import org.apache.paimon.rest.requests.DropPolicyRequest;
 import org.apache.paimon.rest.requests.ForwardBranchRequest;
+import org.apache.paimon.rest.requests.GetLabelRequest;
 import org.apache.paimon.rest.requests.GrantPermissionRequest;
 import org.apache.paimon.rest.requests.ListPartitionsByFilterRequest;
 import org.apache.paimon.rest.requests.ListPartitionsByNamesRequest;
@@ -197,6 +199,8 @@ public class RESTApi {
     public static final String FUNCTION_NAME_PATTERN = "functionNamePattern";
     public static final String PARTITION_NAME_PATTERN = "partitionNamePattern";
     public static final String TAG_NAME_PREFIX = "tagNamePrefix";
+    public static final String ENTITY_TYPE = "entityType";
+    public static final String ENTITY_NAME = "entityName";
 
     public static final long TOKEN_EXPIRATION_SAFE_TIME_MILLIS = 3_600_000L;
 
@@ -924,8 +928,7 @@ public class RESTApi {
      * <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.
+     * this client does not split, normalize, or resolve entity names.
      *
      * @param value label value; an empty string is allowed, null is not
      * @throws NoSuchResourceException if the entity does not exist
@@ -934,8 +937,8 @@ public class RESTApi {
     @Experimental
     public void upsertLabel(String entityType, String entityName, String key, 
String value) {
         client.post(
-                resourcePaths.label(entityType, entityName, key),
-                new UpsertLabelRequest(value),
+                resourcePaths.upsertLabel(),
+                new UpsertLabelRequest(entityType, entityName, key, value),
                 restAuthFunction);
     }
 
@@ -947,8 +950,9 @@ public class RESTApi {
      */
     @Experimental
     public GetLabelResponse getLabel(String entityType, String entityName, 
String key) {
-        return client.get(
-                resourcePaths.label(entityType, entityName, key),
+        return client.post(
+                resourcePaths.getLabel(),
+                new GetLabelRequest(entityType, entityName, key),
                 GetLabelResponse.class,
                 restAuthFunction);
     }
@@ -956,10 +960,16 @@ public class RESTApi {
     /** 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);
+        Map<String, String> entity = labelEntityQueryParams(entityType, 
entityName);
         return listDataFromPageApi(
-                queryParams ->
-                        client.get(path, queryParams, 
ListLabelsResponse.class, restAuthFunction));
+                queryParams -> {
+                    queryParams.putAll(entity);
+                    return client.get(
+                            resourcePaths.labels(),
+                            queryParams,
+                            ListLabelsResponse.class,
+                            restAuthFunction);
+                });
     }
 
     /**
@@ -979,10 +989,12 @@ public class RESTApi {
         checkArgument(
                 maxResults == null || (maxResults >= 1 && maxResults <= 1000),
                 "maxResults must be between 1 and 1000");
+        Map<String, String> queryParams = labelEntityQueryParams(entityType, 
entityName);
+        queryParams.putAll(buildPagedQueryParams(maxResults, pageToken));
         ListLabelsResponse response =
                 client.get(
-                        resourcePaths.labels(entityType, entityName),
-                        buildPagedQueryParams(maxResults, pageToken),
+                        resourcePaths.labels(),
+                        queryParams,
                         ListLabelsResponse.class,
                         restAuthFunction);
         return new PagedList<>(response.getLabels(), 
response.getNextPageToken());
@@ -996,7 +1008,10 @@ public class RESTApi {
      */
     @Experimental
     public void deleteLabel(String entityType, String entityName, String key) {
-        client.delete(resourcePaths.label(entityType, entityName, key), 
restAuthFunction);
+        client.post(
+                resourcePaths.deleteLabel(),
+                new DeleteLabelRequest(entityType, entityName, key),
+                restAuthFunction);
     }
 
     /**
@@ -2056,6 +2071,18 @@ public class RESTApi {
         return restAuthFunction;
     }
 
+    private static Map<String, String> labelEntityQueryParams(
+            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");
+        Map<String, String> queryParams = Maps.newHashMap();
+        queryParams.put(ENTITY_TYPE, entityType);
+        queryParams.put(ENTITY_NAME, entityName);
+        return queryParams;
+    }
+
     private static void putQueryParameter(
             Map<String, String> queryParams, String name, @Nullable String 
value) {
         if (StringUtils.isNotEmpty(value)) {
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 90ca5e9012..be7152d72d 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
@@ -68,21 +68,26 @@ public class ResourcePaths {
         this.prefix = encodeString(prefix);
     }
 
-    /** Labels attached to one entity, whose canonical name is encoded as a 
single segment. */
+    /** Label listing; the entity is named in the query, never in the path. */
     @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, encodePathSegment(entityType), 
encodePathSegment(entityName));
+    public String labels() {
+        return SLASH.join(V1, prefix, LABELS);
+    }
+
+    /** Single-binding label actions name the entity and key in the request 
body. */
+    @Experimental
+    public String getLabel() {
+        return SLASH.join(labels(), "get");
+    }
+
+    @Experimental
+    public String upsertLabel() {
+        return SLASH.join(labels(), "upsert");
     }
 
     @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), 
encodePathSegment(key));
+    public String deleteLabel() {
+        return SLASH.join(labels(), "delete");
     }
 
     private static String encodePathSegment(String value) {
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
index 4d72b9c991..cb5d581593 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiActions.java
@@ -107,6 +107,22 @@ final class DLFOpenApiActions {
         {"POST", "/v1/{prefix}/databases/{database}/views/{view}", 
"AlterView"},
         {"DELETE", "/v1/{prefix}/databases/{database}/views/{view}", 
"DropView"},
         {"POST", "/v1/{prefix}/views/rename", "RenameView"},
+        {"GET", "/v1/{prefix}/databases/{database}/semantic-views", 
"ListSemanticViews"},
+        {
+            "GET",
+            "/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+            "GetSemanticView"
+        },
+        {
+            "POST",
+            "/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+            "UpsertSemanticView"
+        },
+        {
+            "DELETE",
+            "/v1/{prefix}/databases/{database}/semantic-views/{semanticView}",
+            "DeleteSemanticView"
+        },
         {"GET", "/v1/{prefix}/databases/{database}/functions", 
"ListFunctions"},
         {"POST", "/v1/{prefix}/databases/{database}/functions", 
"CreateFunction"},
         {"GET", "/v1/{prefix}/databases/{database}/functions/{function}", 
"GetFunction"},
@@ -115,6 +131,10 @@ final class DLFOpenApiActions {
         {"GET", "/v1/{prefix}/permissions", "ListPermissionAssignments"},
         {"POST", "/v1/{prefix}/permissions/grant", 
"GrantPermissionAssignment"},
         {"POST", "/v1/{prefix}/permissions/revoke", 
"RevokePermissionAssignment"},
+        {"GET", "/v1/{prefix}/labels", "ListLabels"},
+        {"POST", "/v1/{prefix}/labels/get", "GetLabel"},
+        {"POST", "/v1/{prefix}/labels/upsert", "UpsertLabel"},
+        {"POST", "/v1/{prefix}/labels/delete", "DeleteLabel"},
     };
 
     private static final Map<String, List<Operation>> BY_METHOD = index();
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/DeleteLabelRequest.java
 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/DeleteLabelRequest.java
new file mode 100644
index 0000000000..10aa177b3b
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/DeleteLabelRequest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.rest.requests.LabelRequests.requireNonBlank;
+
+/** Label binding to remove, identified entirely by the request body. */
+@Experimental
+public class DeleteLabelRequest implements RESTRequest {
+
+    private static final String FIELD_ENTITY_TYPE = "entityType";
+    private static final String FIELD_ENTITY_NAME = "entityName";
+    private static final String FIELD_KEY = "key";
+
+    @JsonProperty(FIELD_ENTITY_TYPE)
+    private final String entityType;
+
+    @JsonProperty(FIELD_ENTITY_NAME)
+    private final String entityName;
+
+    @JsonProperty(FIELD_KEY)
+    private final String key;
+
+    @JsonCreator
+    @ConstructorProperties({FIELD_ENTITY_TYPE, FIELD_ENTITY_NAME, FIELD_KEY})
+    public DeleteLabelRequest(
+            @JsonProperty(FIELD_ENTITY_TYPE) String entityType,
+            @JsonProperty(FIELD_ENTITY_NAME) String entityName,
+            @JsonProperty(FIELD_KEY) String key) {
+        this.entityType = requireNonBlank(entityType, FIELD_ENTITY_TYPE);
+        this.entityName = requireNonBlank(entityName, FIELD_ENTITY_NAME);
+        this.key = requireNonBlank(key, FIELD_KEY);
+    }
+
+    @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;
+    }
+}
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/GetLabelRequest.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/GetLabelRequest.java
new file mode 100644
index 0000000000..5ea7058044
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/GetLabelRequest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.rest.requests.LabelRequests.requireNonBlank;
+
+/** Label binding to read, identified entirely by the request body. */
+@Experimental
+public class GetLabelRequest implements RESTRequest {
+
+    private static final String FIELD_ENTITY_TYPE = "entityType";
+    private static final String FIELD_ENTITY_NAME = "entityName";
+    private static final String FIELD_KEY = "key";
+
+    @JsonProperty(FIELD_ENTITY_TYPE)
+    private final String entityType;
+
+    @JsonProperty(FIELD_ENTITY_NAME)
+    private final String entityName;
+
+    @JsonProperty(FIELD_KEY)
+    private final String key;
+
+    @JsonCreator
+    @ConstructorProperties({FIELD_ENTITY_TYPE, FIELD_ENTITY_NAME, FIELD_KEY})
+    public GetLabelRequest(
+            @JsonProperty(FIELD_ENTITY_TYPE) String entityType,
+            @JsonProperty(FIELD_ENTITY_NAME) String entityName,
+            @JsonProperty(FIELD_KEY) String key) {
+        this.entityType = requireNonBlank(entityType, FIELD_ENTITY_TYPE);
+        this.entityName = requireNonBlank(entityName, FIELD_ENTITY_NAME);
+        this.key = requireNonBlank(key, FIELD_KEY);
+    }
+
+    @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;
+    }
+}
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/LabelRequests.java
similarity index 50%
copy from 
paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertLabelRequest.java
copy to 
paimon-api/src/main/java/org/apache/paimon/rest/requests/LabelRequests.java
index 16f6908184..7e08b2e673 100644
--- 
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertLabelRequest.java
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/LabelRequests.java
@@ -18,35 +18,15 @@
 
 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 {
+/** Shared checks for the label binding identity carried in request bodies. */
+final class LabelRequests {
 
-    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;
-    }
+    private LabelRequests() {}
 
-    @JsonGetter(FIELD_VALUE)
-    public String getValue() {
+    static String requireNonBlank(String value, String field) {
+        checkArgument(value != null && !value.trim().isEmpty(), "%s must not 
be blank", field);
         return value;
     }
 }
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
index 16f6908184..2750e601a2 100644
--- 
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
@@ -27,24 +27,59 @@ import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonPro
 
 import java.beans.ConstructorProperties;
 
+import static org.apache.paimon.rest.requests.LabelRequests.requireNonBlank;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 
-/** Value to set on the label binding identified by the request path. */
+/** Label binding to create or replace, identified entirely by the request 
body. */
 @Experimental
 public class UpsertLabelRequest implements RESTRequest {
 
+    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_VALUE})
-    public UpsertLabelRequest(@JsonProperty(FIELD_VALUE) String value) {
+    @ConstructorProperties({FIELD_ENTITY_TYPE, FIELD_ENTITY_NAME, FIELD_KEY, 
FIELD_VALUE})
+    public UpsertLabelRequest(
+            @JsonProperty(FIELD_ENTITY_TYPE) String entityType,
+            @JsonProperty(FIELD_ENTITY_NAME) String entityName,
+            @JsonProperty(FIELD_KEY) String key,
+            @JsonProperty(FIELD_VALUE) String value) {
+        this.entityType = requireNonBlank(entityType, FIELD_ENTITY_TYPE);
+        this.entityName = requireNonBlank(entityName, FIELD_ENTITY_NAME);
+        this.key = requireNonBlank(key, FIELD_KEY);
         checkArgument(value != null, "value must not be null");
         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/test/java/org/apache/paimon/rest/RESTApiLabelTest.java 
b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiLabelTest.java
index 370a12cf33..a1838f5949 100644
--- a/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiLabelTest.java
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiLabelTest.java
@@ -23,6 +23,8 @@ 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.DeleteLabelRequest;
+import org.apache.paimon.rest.requests.GetLabelRequest;
 import org.apache.paimon.rest.requests.UpsertLabelRequest;
 import org.apache.paimon.rest.responses.GetLabelResponse;
 import org.apache.paimon.rest.responses.ListLabelsResponse;
@@ -40,7 +42,6 @@ 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;
@@ -55,6 +56,7 @@ 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.entry;
 
 /** HTTP contract tests for generic label operations. */
 class RESTApiLabelTest {
@@ -117,16 +119,21 @@ class RESTApiLabelTest {
         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.path).isEqualTo(BASE_PATH + "/upsert");
             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"));
+                    .isEqualTo(
+                            labelFields(
+                                    "TABLE",
+                                    "sales.orders",
+                                    "domain",
+                                    i == 0 ? "sales" : "finance"));
         }
     }
 
     @Test
-    void testColumnAndProviderDefinedTypesUseGenericPaths() throws Exception {
+    void testColumnAndProviderDefinedTypesUseTheSameRoutes() throws Exception {
         for (String type : Arrays.asList("COLUMN", "MODEL_VERSION")) {
             enqueue(200, "");
             api.upsertLabel(type, "sales.orders.id", "classification", "");
@@ -135,56 +142,65 @@ class RESTApiLabelTest {
         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(request.path).isEqualTo(BASE_PATH + "/upsert");
             assertThat(RESTApi.fromJson(request.body, Map.class))
-                    .isEqualTo(Collections.singletonMap("value", ""));
+                    .isEqualTo(
+                            labelFields(
+                                    i == 0 ? "COLUMN" : "MODEL_VERSION",
+                                    "sales.orders.id",
+                                    "classification",
+                                    ""));
         }
     }
 
     @Test
-    void testEntityNameAndKeyRemainSingleEncodedSegments() throws Exception {
-        String name = "sales db.orders/华+%?#";
-        String key = "a/b+% ?#";
+    void testEntityNamesAndKeysNeverEnterThePath() throws Exception {
+        String name = "`sales db`.`orders/华+%?#;\\`.`a.b`";
+        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(requests.get(0).method).isEqualTo("POST");
+        assertThat(requests.get(0).path).isEqualTo(BASE_PATH + "/get");
+        assertThat(requests.get(0).query).isNull();
+        assertThat(RESTApi.fromJson(requests.get(0).body, Map.class))
+                .isEqualTo(identityFields("COLUMN", name, key));
         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(requests.get(1).path).isEqualTo(BASE_PATH + "/upsert");
         assertThat(RESTApi.fromJson(requests.get(1).body, Map.class))
-                .isEqualTo(Collections.singletonMap("value", "sensitive"));
+                .isEqualTo(labelFields("COLUMN", name, key, "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);
+        assertThat(requests.get(2).method).isEqualTo("POST");
+        assertThat(requests.get(2).path).isEqualTo(BASE_PATH + "/delete");
+        assertThat(RESTApi.fromJson(requests.get(2).body, Map.class))
+                .isEqualTo(identityFields("COLUMN", name, key));
+
+        enqueue(200, "{\"labels\":[]}");
+        api.listLabelsPaged("COLUMN", name, null, null);
+        assertThat(requests.get(3).method).isEqualTo("GET");
+        assertThat(requests.get(3).path).isEqualTo(BASE_PATH);
+        assertThat(queryParameters(requests.get(3).query))
+                .containsOnly(entry("entityType", "COLUMN"), 
entry("entityName", name));
     }
 
     @Test
-    void testDotOnlyNamesRemainDataInThePath() {
+    void testDotOnlyNamesRemainData() throws Exception {
         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);
+        assertThat(requests.get(0).path).isEqualTo(BASE_PATH + "/get");
+        assertThat(RESTApi.fromJson(requests.get(0).body, Map.class))
+                .isEqualTo(identityFields("TABLE", ".", ".."));
+        enqueue(200, "{\"labels\":[]}");
+        api.listLabels("TABLE", "..");
+        assertThat(requests.get(1).path).isEqualTo(BASE_PATH);
+        
assertThat(queryParameters(requests.get(1).query)).containsEntry("entityName", 
"..");
     }
 
     @Test
@@ -197,10 +213,15 @@ class RESTApiLabelTest {
         
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 +/%?&");
+        assertThat(requests.get(0).path).isEqualTo(BASE_PATH);
+        assertThat(queryParameters(requests.get(0).query))
+                .containsOnly(entry("entityType", "TABLE"), 
entry("entityName", "sales.orders"));
+        assertThat(requests.get(1).path).isEqualTo(BASE_PATH);
+        assertThat(queryParameters(requests.get(1).query))
+                .containsOnly(
+                        entry("entityType", "TABLE"),
+                        entry("entityName", "sales.orders"),
+                        entry("pageToken", "next +/%?&"));
     }
 
     @Test
@@ -213,22 +234,28 @@ class RESTApiLabelTest {
                 .containsExactly("sales");
         assertThat(first.getNextPageToken()).isEqualTo("next");
         assertThat(queryParameters(requests.get(0).query))
-                .containsEntry("maxResults", "1000")
-                .containsEntry("pageToken", "start +/%");
+                .containsOnly(
+                        entry("entityType", "TABLE"),
+                        entry("entityName", "sales.orders"),
+                        entry("maxResults", "1000"),
+                        entry("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");
+        assertThat(requests.get(1).path).isEqualTo(BASE_PATH);
+        assertThat(queryParameters(requests.get(1).query))
+                .containsEntry("entityType", "COLUMN")
+                .containsEntry("entityName", "sales.orders.id");
 
         enqueue(200, "{\"labels\":[]}");
         assertThat(api.listLabels("TABLE", "sales.empty")).isEmpty();
     }
 
     @Test
-    void testRepeatedDeleteDoesNotRequireARead() {
+    void testRepeatedDeleteDoesNotRequireARead() throws Exception {
         enqueue(200, "");
         enqueue(200, "");
         api.deleteLabel("TABLE", "sales.orders", "domain");
@@ -236,9 +263,11 @@ class RESTApiLabelTest {
 
         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();
+            assertThat(request.method).isEqualTo("POST");
+            assertThat(request.path).isEqualTo(BASE_PATH + "/delete");
+            assertThat(request.query).isNull();
+            assertThat(RESTApi.fromJson(request.body, Map.class))
+                    .isEqualTo(identityFields("TABLE", "sales.orders", 
"domain"));
         }
     }
 
@@ -294,10 +323,16 @@ class RESTApiLabelTest {
                     .isInstanceOf(IllegalArgumentException.class);
             assertThatThrownBy(() -> api.getLabel(invalid, "sales.orders", 
"domain"))
                     .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.getLabel("TABLE", "sales.orders", 
invalid))
+                    .isInstanceOf(IllegalArgumentException.class);
             assertThatThrownBy(() -> api.listLabels("TABLE", invalid))
                     .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.listLabelsPaged(invalid, 
"sales.orders", null, null))
+                    .isInstanceOf(IllegalArgumentException.class);
             assertThatThrownBy(() -> api.deleteLabel("TABLE", "sales.orders", 
invalid))
                     .isInstanceOf(IllegalArgumentException.class);
+            assertThatThrownBy(() -> api.deleteLabel("TABLE", invalid, 
"domain"))
+                    .isInstanceOf(IllegalArgumentException.class);
         }
         assertThatThrownBy(() -> api.upsertLabel("TABLE", "sales.orders", 
"domain", null))
                 .isInstanceOf(IllegalArgumentException.class);
@@ -309,15 +344,39 @@ class RESTApiLabelTest {
     }
 
     @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}")) {
+    void testWireModelsRequireIdentityAndTolerateFutureResponseFields() throws 
Exception {
+        String identity =
+                
"\"entityType\":\"TABLE\",\"entityName\":\"sales.orders\",\"key\":\"domain\"";
+        UpsertLabelRequest upsert =
+                RESTApi.fromJson(
+                        "{" + identity + ",\"value\":\"sales\"}", 
UpsertLabelRequest.class);
+        assertThat(RESTApi.fromJson(RESTApi.toJson(upsert), Map.class))
+                .isEqualTo(labelFields("TABLE", "sales.orders", "domain", 
"sales"));
+        for (String json :
+                Arrays.asList(
+                        "{" + identity + "}",
+                        "{" + identity + ",\"value\":null}",
+                        
"{\"entityType\":\"TABLE\",\"entityName\":\"sales.orders\",\"value\":\"x\"}",
+                        
"{\"entityType\":\"TABLE\",\"key\":\"domain\",\"value\":\"x\"}",
+                        
"{\"entityName\":\"sales.orders\",\"key\":\"domain\",\"value\":\"x\"}")) {
             assertThatThrownBy(() -> RESTApi.fromJson(json, 
UpsertLabelRequest.class))
                     .hasRootCauseInstanceOf(IllegalArgumentException.class);
         }
+        for (Class<? extends RESTRequest> type :
+                Arrays.<Class<? extends RESTRequest>>asList(
+                        GetLabelRequest.class, DeleteLabelRequest.class)) {
+            assertThat(
+                            RESTApi.fromJson(
+                                    RESTApi.toJson(RESTApi.fromJson("{" + 
identity + "}", type)),
+                                    Map.class))
+                    .isEqualTo(identityFields("TABLE", "sales.orders", 
"domain"));
+            assertThatThrownBy(
+                            () ->
+                                    RESTApi.fromJson(
+                                            
"{\"entityType\":\"TABLE\",\"entityName\":\" \",\"key\":\"domain\"}",
+                                            type))
+                    .hasRootCauseInstanceOf(IllegalArgumentException.class);
+        }
 
         String futureLabel =
                 LABEL_JSON.substring(0, LABEL_JSON.length() - 1) + 
",\"source\":\"system\"}";
@@ -349,6 +408,14 @@ class RESTApiLabelTest {
         return fields;
     }
 
+    private static Map<String, String> identityFields(String type, String 
name, String key) {
+        Map<String, String> fields = new HashMap<>();
+        fields.put("entityType", type);
+        fields.put("entityName", name);
+        fields.put("key", key);
+        return fields;
+    }
+
     private static Map<String, String> queryParameters(String query) throws 
Exception {
         Map<String, String> values = new HashMap<>();
         for (String parameter : query.split("&")) {
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
index d9a11067b3..4d21036e90 100644
--- 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiActionsTest.java
@@ -44,7 +44,7 @@ public class DLFOpenApiActionsTest {
             assertEquals(operation[2], DLFOpenApiActions.resolve(operation[0], 
path), path);
             actions.add(operation[2]);
         }
-        assertEquals(53, actions.size());
+        assertEquals(61, actions.size());
     }
 
     @Test
@@ -96,6 +96,10 @@ public class DLFOpenApiActionsTest {
         assertAction("AlterView", "POST", paths.view("db", "v"));
         assertAction("DropView", "DELETE", paths.view("db", "v"));
         assertAction("RenameView", "POST", paths.renameView());
+        assertAction("ListSemanticViews", "GET", paths.semanticViews("db"));
+        assertAction("GetSemanticView", "GET", paths.semanticView("db", "sv"));
+        assertAction("UpsertSemanticView", "POST", paths.semanticView("db", 
"sv"));
+        assertAction("DeleteSemanticView", "DELETE", paths.semanticView("db", 
"sv"));
         assertAction("ListFunctions", "GET", paths.functions("db"));
         assertAction("CreateFunction", "POST", paths.functions("db"));
         assertAction("GetFunction", "GET", paths.function("db", "f"));
@@ -104,6 +108,10 @@ public class DLFOpenApiActionsTest {
         assertAction("ListPermissionAssignments", "GET", paths.permissions());
         assertAction("GrantPermissionAssignment", "POST", 
paths.grantPermission());
         assertAction("RevokePermissionAssignment", "POST", 
paths.revokePermission());
+        assertAction("ListLabels", "GET", paths.labels());
+        assertAction("GetLabel", "POST", paths.getLabel());
+        assertAction("UpsertLabel", "POST", paths.upsertLabel());
+        assertAction("DeleteLabel", "POST", paths.deleteLabel());
     }
 
     /** Names match whole segments, so one spelled like a literal still 
resolves. */
@@ -113,6 +121,8 @@ public class DLFOpenApiActionsTest {
         assertAction("GetTable", "GET", paths.table("db", "token"));
         assertAction("DropBranch", "DELETE", paths.branch("db", "t", 
"forward"));
         assertAction("GetTag", "GET", paths.tag("db", "t", "tags"));
+        assertAction("GetSemanticView", "GET", paths.semanticView("db", 
"views"));
+        assertAction("GetView", "GET", paths.view("db", "semantic-views"));
         assertAction("GetTableToken", "GET", paths.tableToken("config", 
"t$snapshots"));
         assertAction("GetTable", "GET", paths.table("a/b", "c d"));
         assertAction("ListDatabases", "GET", new 
ResourcePaths("rename").databases());
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 64dae0cb5e..8962a200b8 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
@@ -167,10 +167,43 @@ public class RequestJacksonCompatibilityTest {
                             "{\"schemaId\":44}",
                             request -> 
assertThat(request.getSchemaId()).isEqualTo(44L),
                             "schemaId"),
+                    requestCase(
+                            GetLabelRequest.class,
+                            
"{\"entityType\":\"COLUMN\",\"entityName\":\"db.t.`a.b`\","
+                                    + "\"key\":\"pii\"}",
+                            request -> {
+                                
assertThat(request.getEntityType()).isEqualTo("COLUMN");
+                                
assertThat(request.getEntityName()).isEqualTo("db.t.`a.b`");
+                                assertThat(request.getKey()).isEqualTo("pii");
+                            },
+                            "entityType",
+                            "entityName",
+                            "key"),
+                    requestCase(
+                            DeleteLabelRequest.class,
+                            
"{\"entityType\":\"TABLE\",\"entityName\":\"db.t\","
+                                    + "\"key\":\"owner\"}",
+                            request -> {
+                                
assertThat(request.getEntityType()).isEqualTo("TABLE");
+                                
assertThat(request.getEntityName()).isEqualTo("db.t");
+                                
assertThat(request.getKey()).isEqualTo("owner");
+                            },
+                            "entityType",
+                            "entityName",
+                            "key"),
                     requestCase(
                             UpsertLabelRequest.class,
-                            "{\"value\":\"identifier\"}",
-                            request -> 
assertThat(request.getValue()).isEqualTo("identifier"),
+                            
"{\"entityType\":\"TABLE\",\"entityName\":\"db.t\","
+                                    + 
"\"key\":\"owner\",\"value\":\"identifier\"}",
+                            request -> {
+                                
assertThat(request.getEntityType()).isEqualTo("TABLE");
+                                
assertThat(request.getEntityName()).isEqualTo("db.t");
+                                
assertThat(request.getKey()).isEqualTo("owner");
+                                
assertThat(request.getValue()).isEqualTo("identifier");
+                            },
+                            "entityType",
+                            "entityName",
+                            "key",
                             "value"));
 
     private static final Set<Class<? extends RESTRequest>> COMPLEX_REQUESTS =
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
index 41ec08439e..7a762bb0a4 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogLabelManagementTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogLabelManagementTest.java
@@ -35,7 +35,6 @@ 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;
@@ -47,6 +46,7 @@ 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.entry;
 import static org.assertj.core.api.Assertions.tuple;
 
 /** Exercises label management through a configured REST catalog and its HTTP 
client. */
@@ -97,24 +97,32 @@ class RESTCatalogLabelManagementTest {
 
     @Test
     void testUpsertReadAndDeleteUseCatalogConfiguration() throws Exception {
-        String entityName = "sales db.model/v1";
+        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);
+            RecordedRequest request = takeRequest("POST", BASE_PATH + 
"/upsert");
             assertThat(RESTApi.fromJson(request.getBody().readUtf8(), 
Map.class))
-                    .isEqualTo(Collections.singletonMap("value", value));
+                    .containsOnly(
+                            entry("entityType", "MODEL_VERSION"),
+                            entry("entityName", entityName),
+                            entry("key", key),
+                            entry("value", value));
         }
 
         enqueue(
                 200,
-                "{\"entityType\":\"MODEL_VERSION\",\"entityName\":\"sales 
db.model/v1\","
+                "{\"entityType\":\"MODEL_VERSION\",\"entityName\":\"`sales 
db`.`model/v1`\","
                         + "\"key\":\"domain/华\",\"value\":\"\"}");
         Label label = labels.getLabel("MODEL_VERSION", entityName, key);
-        takeRequest("GET", path);
+        RecordedRequest get = takeRequest("POST", BASE_PATH + "/get");
+        assertThat(RESTApi.fromJson(get.getBody().readUtf8(), Map.class))
+                .containsOnly(
+                        entry("entityType", "MODEL_VERSION"),
+                        entry("entityName", entityName),
+                        entry("key", key));
         assertThat(label.getEntityType()).isEqualTo("MODEL_VERSION");
         assertThat(label.getEntityName()).isEqualTo(entityName);
         assertThat(label.getKey()).isEqualTo(key);
@@ -123,7 +131,12 @@ class RESTCatalogLabelManagementTest {
         for (int i = 0; i < 2; i++) {
             enqueue(200, "");
             labels.deleteLabel("MODEL_VERSION", entityName, key);
-            assertThat(takeRequest("DELETE", path).getBodySize()).isZero();
+            RecordedRequest delete = takeRequest("POST", BASE_PATH + 
"/delete");
+            assertThat(RESTApi.fromJson(delete.getBody().readUtf8(), 
Map.class))
+                    .containsOnly(
+                            entry("entityType", "MODEL_VERSION"),
+                            entry("entityName", entityName),
+                            entry("key", key));
         }
         assertThat(server.getRequestCount()).isEqualTo(6);
     }
@@ -138,7 +151,9 @@ class RESTCatalogLabelManagementTest {
                         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");
+        RecordedRequest request = takeRequest("GET", BASE_PATH);
+        
assertThat(request.getRequestUrl().queryParameter("entityType")).isEqualTo("TABLE");
+        
assertThat(request.getRequestUrl().queryParameter("entityName")).isEqualTo("sales.orders");
         
assertThat(request.getRequestUrl().queryParameter("maxResults")).isEqualTo("1000");
         
assertThat(request.getRequestUrl().queryParameter("pageToken")).isEqualTo("start
 +/%");
     }
@@ -150,9 +165,11 @@ class RESTCatalogLabelManagementTest {
 
         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");
+        RecordedRequest first = takeRequest("GET", BASE_PATH);
+        assertThat(first.getRequestUrl().queryParameterNames())
+                .containsExactlyInAnyOrder("entityType", "entityName");
+        RecordedRequest second = takeRequest("GET", BASE_PATH);
+        
assertThat(second.getRequestUrl().queryParameter("entityName")).isEqualTo("sales.orders");
         
assertThat(second.getRequestUrl().queryParameter("pageToken")).isEqualTo("next 
+/%?&");
         
assertThat(second.getRequestUrl().queryParameter("maxResults")).isNull();
         assertThat(server.getRequestCount()).isEqualTo(3);
@@ -164,11 +181,13 @@ class RESTCatalogLabelManagementTest {
         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");
+        assertThat(takeRequest("GET", 
BASE_PATH).getRequestUrl().queryParameter("entityName"))
+                .isEqualTo("sales.orders.id");
 
         enqueue(200, "{\"labels\":[]}");
         assertThat(labels.listLabels("TABLE", "sales.empty")).isEmpty();
-        takeRequest("GET", BASE_PATH + "/TABLE/sales.empty");
+        assertThat(takeRequest("GET", 
BASE_PATH).getRequestUrl().queryParameter("entityName"))
+                .isEqualTo("sales.empty");
         assertThat(server.getRequestCount()).isEqualTo(3);
     }
 

Reply via email to