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 13d6e46bee [rest] Add generic entity labels to REST Catalog (#9838)
13d6e46bee is described below
commit 13d6e46beea321aed027f7efb523a8180a86c423
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;
+ }
+}