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);
}