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