From 32e1abc6ccf981da9e29406e53a6b8bba4125c32 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 13 Oct 2026 18:12:15 -0700 Subject: [PATCH 1/4] Serve the protocol schema list of granted shares, on Armeria. --- .../asset/SharedDataObjectStore.java | 18 +++++ .../asset/schema/ProtocolSchemas.java | 34 ++++++++ .../asset/schema/SchemaResponse.java | 10 +++ .../asset/share/ProtocolShares.java | 9 ++- .../protocol/OpenSharingProtocolService.java | 16 +++- .../protocol/OpenSharingRecipientApi.java | 4 +- .../io/opensharing/runtime/OpenSharing.java | 17 ++-- .../src/main/resources/local-catalog.yml | 10 +++ .../protocol/ProtocolApiSupport.java | 15 ++++ .../protocol/ProtocolSchemasTest.java | 80 +++++++++++++++++++ 10 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/SchemaResponse.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java index 28d58997..3342f142 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java @@ -87,6 +87,24 @@ public List list(ShareEntity share) { .getResultList()); } + /** + * {@code SELECT DISTINCT shared_as_schema FROM os_shared_data_objects WHERE share_id = ? ORDER BY + * shared_as_schema LIMIT ? OFFSET ?}: the schemas recipients see in the share. + */ + public List listSchemas(ShareEntity share, int offset, int limit) { + return tx.inTransaction( + true, + em -> + em.createQuery( + "select distinct o.sharedAsSchema from SharedDataObjectEntity o" + + " where o.share.id = :shareId order by o.sharedAsSchema", + String.class) + .setParameter("shareId", share.getId()) + .setFirstResult(offset) + .setMaxResults(limit) + .getResultList()); + } + /** Removes the object shared under the alias. Fails unless it exists with the given type. */ public void removeByAlias( EntityManager em, diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java new file mode 100644 index 00000000..870358ea --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java @@ -0,0 +1,34 @@ +package io.opensharing.asset.schema; + +import io.opensharing.asset.SharedDataObjectStore; +import io.opensharing.asset.share.ProtocolShares; +import io.opensharing.http.ListResponse; +import io.opensharing.http.Pagination; +import io.opensharing.recipient.RecipientPrincipal; +import io.opensharing.share.ShareEntity; + +/** + * The schemas a recipient sees through the sharing protocol: the distinct schema aliases of the + * objects in a share it holds SELECT on. + */ +public class ProtocolSchemas { + + private final ProtocolShares shares; + private final SharedDataObjectStore objects; + + public ProtocolSchemas(ProtocolShares shares, SharedDataObjectStore objects) { + this.shares = shares; + this.objects = objects; + } + + /** Lists one page of the share's schemas by name. */ + public ListResponse list( + RecipientPrincipal recipient, String share, Integer maxResults, String pageToken) { + ShareEntity granted = shares.requireGranted(recipient, share); + return Pagination.page( + maxResults, + pageToken, + (offset, limit) -> objects.listSchemas(granted, offset, limit), + name -> new SchemaResponse(name, granted.getName())); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/SchemaResponse.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/SchemaResponse.java new file mode 100644 index 00000000..1c496776 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/SchemaResponse.java @@ -0,0 +1,10 @@ +package io.opensharing.asset.schema; + +/** + * Protocol schema listed under a share: + * + *
{@code
+ * {"name": "", "share": ""}
+ * }
+ */ +public record SchemaResponse(String name, String share) {} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java index 27847e5d..dccd1d13 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java @@ -4,6 +4,7 @@ import io.opensharing.http.ListResponse; import io.opensharing.http.Pagination; import io.opensharing.recipient.RecipientPrincipal; +import io.opensharing.share.ShareEntity; import io.opensharing.share.SharePermissionStore; import io.opensharing.share.ShareResponse; import io.opensharing.share.ShareStore; @@ -34,10 +35,16 @@ public ListResponse list( /** Gets one of the recipient's shares by name in any case. */ public GetShareResponse get(RecipientPrincipal recipient, String share) { + return new GetShareResponse(ShareResponse.from(requireGranted(recipient, share))); + } + + /** + * Looks up a share by name in any case. Fails with not-found unless the recipient holds SELECT. + */ + public ShareEntity requireGranted(RecipientPrincipal recipient, String share) { return shares .find(share) .filter(candidate -> permissions.hasSelect(candidate, recipient.recipientId())) - .map(granted -> new GetShareResponse(ShareResponse.from(granted))) .orElseThrow(() -> ApiException.notFound("share '" + share + "' does not exist")); } } diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java index 208e52d7..37c9be71 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java @@ -6,6 +6,8 @@ import com.linecorp.armeria.server.annotation.Get; import com.linecorp.armeria.server.annotation.Param; import com.linecorp.armeria.server.annotation.ProducesJson; +import io.opensharing.asset.schema.ProtocolSchemas; +import io.opensharing.asset.schema.SchemaResponse; import io.opensharing.asset.share.GetShareResponse; import io.opensharing.asset.share.ProtocolShares; import io.opensharing.http.ListResponse; @@ -20,9 +22,11 @@ public final class OpenSharingProtocolService { private final ProtocolShares shares; + private final ProtocolSchemas schemas; - public OpenSharingProtocolService(ProtocolShares shares) { + public OpenSharingProtocolService(ProtocolShares shares, ProtocolSchemas schemas) { this.shares = shares; + this.schemas = schemas; } /** {@code GET /shares}: lists one page of the shares the recipient holds SELECT on. */ @@ -39,4 +43,14 @@ public ListResponse listShares( public GetShareResponse getShare(ServiceRequestContext ctx, @Param String share) { return shares.get(RecipientTokenDecorator.recipient(ctx), share); } + + /** {@code GET /shares/{share}/schemas}: lists one page of the schemas in a granted share. */ + @Get("/shares/{share}/schemas") + public ListResponse listSchemas( + ServiceRequestContext ctx, + @Param String share, + @Param @Nullable Integer maxResults, + @Param @Nullable String pageToken) { + return schemas.list(RecipientTokenDecorator.recipient(ctx), share, maxResults, pageToken); + } } diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java index 4c0bf1de..28244db7 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java @@ -39,7 +39,9 @@ public static void mount( .exceptionHandlers(errors) .decorator(new RecipientTokenDecorator(openSharing.recipients())) .decorator(errors) - .build(new OpenSharingProtocolService(openSharing.protocolShares())); + .build( + new OpenSharingProtocolService( + openSharing.protocolShares(), openSharing.protocolSchemas())); builder .annotatedService() .pathPrefix(activationBasePath) diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java index d576f1d5..9cf8a5a1 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java @@ -3,6 +3,7 @@ import io.opensharing.Transactions; import io.opensharing.asset.SharedDataObjectEntity; import io.opensharing.asset.SharedDataObjectStore; +import io.opensharing.asset.schema.ProtocolSchemas; import io.opensharing.asset.share.ProtocolShares; import io.opensharing.catalog.CatalogConnector; import io.opensharing.recipient.RecipientEntity; @@ -27,22 +28,19 @@ public final class OpenSharing { private final ShareService shares; private final RecipientService recipients; private final ProtocolShares protocolShares; + private final ProtocolSchemas protocolSchemas; private OpenSharing(CatalogConnector catalog, Transactions tx) { this.catalog = catalog; ShareStore shareStore = new ShareStore(tx); + SharedDataObjectStore objectStore = new SharedDataObjectStore(tx); SharePermissionStore permissionStore = new SharePermissionStore(tx); RecipientStore recipientStore = new RecipientStore(tx); this.shares = - new ShareService( - catalog, - tx, - shareStore, - new SharedDataObjectStore(tx), - permissionStore, - recipientStore); + new ShareService(catalog, tx, shareStore, objectStore, permissionStore, recipientStore); this.recipients = new RecipientService(catalog, recipientStore); this.protocolShares = new ProtocolShares(shareStore, permissionStore); + this.protocolSchemas = new ProtocolSchemas(protocolShares, objectStore); } public static Builder builder() { @@ -76,6 +74,11 @@ public ProtocolShares protocolShares() { return protocolShares; } + /** The schemas recipients see in their shares through the sharing protocol. */ + public ProtocolSchemas protocolSchemas() { + return protocolSchemas; + } + public static final class Builder { private CatalogConnector catalog; diff --git a/server/opensharing-server/src/main/resources/local-catalog.yml b/server/opensharing-server/src/main/resources/local-catalog.yml index 4c01c06f..4708decc 100644 --- a/server/opensharing-server/src/main/resources/local-catalog.yml +++ b/server/opensharing-server/src/main/resources/local-catalog.yml @@ -46,3 +46,13 @@ assets: subtype: MANAGED storageLocation: s3://test/main.sales.orders/ format: delta + + - identifier: main.hr + type: SCHEMA + + - identifier: main.ops.people + type: TABLE + attributes: + subtype: MANAGED + storageLocation: s3://test/main.ops.people/ + format: delta diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java index 048b8383..0d102a7c 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java @@ -33,12 +33,27 @@ static void stopServer() { server.close(); } + protected static void createShare(String name) { + String body = "{\"name\":\"%s\"}".formatted(name); + assertEquals(201, server.post(PROVIDER + "/shares", ALICE, body).status()); + } + /** Creates a share owned by Alice through the provider API. */ protected static void createShare(String name, String displayName) { String body = "{\"name\":\"%s\",\"displayName\":\"%s\"}".formatted(name, displayName); assertEquals(201, server.post(PROVIDER + "/shares", ALICE, body).status()); } + /** Adds the catalog asset {@code name} of {@code type} to the share as {@code sharedAs}. */ + protected static void addObject(String share, String type, String name, String sharedAs) { + String body = + "{\"updates\":[{\"action\":\"ADD\",\"dataObject\":" + + "{\"name\":\"%s\",\"type\":\"%s\",\"sharedAs\":\"%s\"}}]}"; + Response added = + server.patch(PROVIDER + "/shares/" + share, ALICE, body.formatted(name, type, sharedAs)); + assertEquals(200, added.status()); + } + /** Creates a TOKEN recipient, redeems its activation URL, and returns the bearer token. */ protected static String createAndActivateRecipient(String name) { Response created = diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java new file mode 100644 index 00000000..5e4524ca --- /dev/null +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java @@ -0,0 +1,80 @@ +package io.opensharing.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linecorp.armeria.common.MediaType; +import io.opensharing.TestServer.Response; +import io.opensharing.http.ErrorCodes; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ProtocolSchemasTest extends ProtocolApiSupport { + + @Test + void listsDistinctSchemasInAGrantedShare() { + createShare("schema-share"); + addObject("schema-share", "TABLE", "main.sales.table1", "alpha.t1"); + addObject("schema-share", "TABLE", "main.sales.orders", "alpha.t2"); + addObject("schema-share", "SCHEMA", "main.hr", "beta"); + addObject("schema-share", "TABLE", "main.ops.people", "gamma.t3"); + createShare("hidden-share"); + addObject("hidden-share", "TABLE", "main.sales.table1", "hidden.t"); + String bearer = createAndActivateRecipient("schema-partner"); + grant("schema-share", "schema-partner"); + + // Distinct shared-as schemas, ordered by name; the share lookup is case-insensitive. + Response list = server.get(PROTOCOL + "/shares/SCHEMA-SHARE/schemas", bearer); + assertEquals(200, list.status()); + MediaType contentType = list.headers().contentType(); + assertTrue(contentType.is(MediaType.JSON), contentType.toString()); + assertEquals(StandardCharsets.UTF_8, contentType.charset()); + JsonNode items = list.body().path("items"); + assertEquals(3, items.size()); + assertEquals("alpha", items.path(0).path("name").asText()); + assertEquals("schema-share", items.path(0).path("share").asText()); + assertEquals("beta", items.path(1).path("name").asText()); + assertEquals("gamma", items.path(2).path("name").asText()); + assertTrue(list.body().path("nextPageToken").isMissingNode()); + + // Ungranted and missing shares are both reported as missing. + Response hidden = server.get(PROTOCOL + "/shares/hidden-share/schemas", bearer); + assertEquals(404, hidden.status()); + assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, hidden.errorCode()); + Response missing = server.get(PROTOCOL + "/shares/missing/schemas", bearer); + assertEquals(404, missing.status()); + assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode()); + } + + @Test + void paginatesSchemasAndAllowsAnEmptyGrantedShare() { + createShare("paged-schemas"); + addObject("paged-schemas", "TABLE", "main.sales.table1", "alpha.t"); + addObject("paged-schemas", "TABLE", "main.sales.orders", "beta.t"); + createShare("empty-share"); + String bearer = createAndActivateRecipient("schema-paged"); + grant("paged-schemas", "schema-paged"); + grant("empty-share", "schema-paged"); + + String schemas = PROTOCOL + "/shares/paged-schemas/schemas"; + Response first = server.get(page(schemas, 1, null), bearer); + assertEquals(200, first.status()); + assertEquals(1, first.body().path("items").size()); + assertEquals("alpha", first.body().path("items").path(0).path("name").asText()); + String pageToken = first.body().path("nextPageToken").asText(); + assertFalse(pageToken.isEmpty()); + Response second = server.get(page(schemas, 1, pageToken), bearer); + assertEquals(200, second.status()); + assertEquals(1, second.body().path("items").size()); + assertEquals("beta", second.body().path("items").path(0).path("name").asText()); + assertTrue(second.body().path("nextPageToken").isMissingNode()); + + // A granted share with no objects lists no schemas. + Response empty = server.get(PROTOCOL + "/shares/empty-share/schemas", bearer); + assertEquals(200, empty.status()); + assertEquals(0, empty.body().path("items").size()); + assertTrue(empty.body().path("nextPageToken").isMissingNode()); + } +} From 80298777c3884079ee2642962fb41b9cea85c4e7 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 19 Oct 2026 15:01:55 -0700 Subject: [PATCH 2/4] Keep the protocol's maxResults=0 for schema lists. --- .../java/io/opensharing/asset/schema/ProtocolSchemas.java | 2 +- .../java/io/opensharing/protocol/ProtocolSchemasTest.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java index 870358ea..c6df0065 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/schema/ProtocolSchemas.java @@ -25,7 +25,7 @@ public ProtocolSchemas(ProtocolShares shares, SharedDataObjectStore objects) { public ListResponse list( RecipientPrincipal recipient, String share, Integer maxResults, String pageToken) { ShareEntity granted = shares.requireGranted(recipient, share); - return Pagination.page( + return Pagination.protocolPage( maxResults, pageToken, (offset, limit) -> objects.listSchemas(granted, offset, limit), diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java index 5e4524ca..f2f263d7 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolSchemasTest.java @@ -71,6 +71,11 @@ void paginatesSchemasAndAllowsAnEmptyGrantedShare() { assertEquals("beta", second.body().path("items").path(0).path("name").asText()); assertTrue(second.body().path("nextPageToken").isMissingNode()); + // A page size of 0 returns no schemas, but a token to resume from. + Response zero = server.get(page(schemas, 0, null), bearer); + assertEquals(0, zero.body().path("items").size()); + assertFalse(zero.body().path("nextPageToken").isMissingNode()); + // A granted share with no objects lists no schemas. Response empty = server.get(PROTOCOL + "/shares/empty-share/schemas", bearer); assertEquals(200, empty.status()); From 3ae5787cb2e97aa30616e14e72fc5e8d8dad8ae6 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 19 Oct 2026 17:02:28 -0700 Subject: [PATCH 3/4] Describe store queries in prose rather than SQL. --- .../java/io/opensharing/asset/SharedDataObjectStore.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java index 3342f142..434b1d46 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/SharedDataObjectStore.java @@ -87,10 +87,7 @@ public List list(ShareEntity share) { .getResultList()); } - /** - * {@code SELECT DISTINCT shared_as_schema FROM os_shared_data_objects WHERE share_id = ? ORDER BY - * shared_as_schema LIMIT ? OFFSET ?}: the schemas recipients see in the share. - */ + /** One page of the schemas recipients see in {@code share}, ordered by name. */ public List listSchemas(ShareEntity share, int offset, int limit) { return tx.inTransaction( true, From 92588270dc0c8fa5569b636c8f8afc4c0d12ccb9 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Wed, 21 Oct 2026 14:00:26 -0700 Subject: [PATCH 4/4] Comment the protocol schema test helper. --- .../test/java/io/opensharing/protocol/ProtocolApiSupport.java | 1 + 1 file changed, 1 insertion(+) diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java index 0d102a7c..bc82b254 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java @@ -33,6 +33,7 @@ static void stopServer() { server.close(); } + /** Creates a share owned by Alice, with no display name, through the provider API. */ protected static void createShare(String name) { String body = "{\"name\":\"%s\"}".formatted(name); assertEquals(201, server.post(PROVIDER + "/shares", ALICE, body).status());