From 0e4a3b26f98b35cd84e496a7edf5a24ea2c3cdd3 Mon Sep 17 00:00:00 2001 From: Suresh Srinivas Date: Wed, 25 Aug 2021 16:47:06 -0700 Subject: [PATCH] Add Chart and Dashboard Service entities --- .../catalog/jdbi3/ChartRepository.java | 2 - .../catalog/jdbi3/DashboardRepository.java | 219 ++++++++++++++++-- .../catalog/jdbi3/TableRepository.java | 2 +- .../resources/charts/ChartResource.java | 23 +- .../dashboards/DashboardResource.java | 180 ++++++++++++-- .../json/schema/api/data/createDashboard.json | 8 + 6 files changed, 386 insertions(+), 48 deletions(-) diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/ChartRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/ChartRepository.java index 2491615177a..079b20d8f48 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/ChartRepository.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/ChartRepository.java @@ -78,8 +78,6 @@ public abstract class ChartRepository { @CreateSqlObject abstract TagRepository.TagDAO tagDAO(); - @CreateSqlObject - abstract UsageDAO usageDAO(); @Transaction public List listAfter(Fields fields, String serviceName, int limitParam, String after) throws IOException, diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java index 0e10c57a125..576e9bd6f01 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/DashboardRepository.java @@ -16,36 +16,59 @@ package org.openmetadata.catalog.jdbi3; +import org.openmetadata.catalog.entity.data.Chart; +import org.openmetadata.catalog.entity.data.Database; +import org.openmetadata.catalog.entity.data.Table; +import org.openmetadata.catalog.exception.CatalogExceptionMessage; +import org.openmetadata.catalog.exception.EntityNotFoundException; import org.openmetadata.catalog.jdbi3.TeamRepository.TeamDAO; import org.openmetadata.catalog.jdbi3.UserRepository.UserDAO; +import org.openmetadata.catalog.resources.charts.ChartResource; import org.openmetadata.catalog.resources.dashboards.DashboardResource; import org.openmetadata.catalog.Entity; import org.openmetadata.catalog.entity.data.Dashboard; import org.openmetadata.catalog.jdbi3.UsageRepository.UsageDAO; +import org.openmetadata.catalog.jdbi3.ChartRepository.ChartDAO; +import org.openmetadata.catalog.jdbi3.DashboardServiceRepository.DashboardServiceDAO; import org.openmetadata.catalog.type.EntityReference; +import org.openmetadata.catalog.type.TagLabel; import org.openmetadata.catalog.util.EntityUtil; import org.openmetadata.catalog.util.EntityUtil.Fields; import org.openmetadata.catalog.util.JsonUtils; import org.openmetadata.catalog.util.RestUtil.PutResponse; +import org.openmetadata.common.utils.CipherText; import org.skife.jdbi.v2.sqlobject.Bind; import org.skife.jdbi.v2.sqlobject.CreateSqlObject; import org.skife.jdbi.v2.sqlobject.SqlQuery; import org.skife.jdbi.v2.sqlobject.SqlUpdate; import org.skife.jdbi.v2.sqlobject.Transaction; +import javax.json.JsonPatch; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import java.io.IOException; +import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.List; +import static org.openmetadata.catalog.exception.CatalogExceptionMessage.entityNotFound; + public abstract class DashboardRepository { - private static final Fields METRICS_UPDATE_FIELDS = new Fields(DashboardResource.FIELD_LIST, - "owner,service"); + private static final Fields DASHBOARD_UPDATE_FIELDS = new Fields(DashboardResource.FIELD_LIST, + "owner,service,tags,charts"); + private static final Fields DASHBOARD_PATCH_FIELDS = new Fields(DashboardResource.FIELD_LIST, + "owner,service,tags,charts"); + @CreateSqlObject abstract DashboardDAO dashboardDAO(); + @CreateSqlObject + abstract ChartDAO chartDAO(); + + @CreateSqlObject + abstract DashboardServiceDAO dashboardServiceDAO(); + @CreateSqlObject abstract EntityRelationshipDAO relationshipDAO(); @@ -58,6 +81,35 @@ public abstract class DashboardRepository { @CreateSqlObject abstract UsageDAO usageDAO(); + @CreateSqlObject + abstract TagRepository.TagDAO tagDAO(); + + + @Transaction + public List listAfter(Fields fields, String serviceName, int limitParam, String after) throws IOException, + GeneralSecurityException { + // forward scrolling, either because after != null or first page is being asked + List jsons = dashboardDAO().listAfter(serviceName, limitParam, after == null ? "" : + CipherText.instance().decrypt(after)); + + List dashboards = new ArrayList<>(); + for (String json : jsons) { + dashboards.add(setFields(JsonUtils.readValue(json, Dashboard.class), fields)); + } + return dashboards; + } + + @Transaction + public List listBefore(Fields fields, String serviceName, int limitParam, String before) + throws IOException, GeneralSecurityException { + // Reverse scrolling + List jsons = dashboardDAO().listBefore(serviceName, limitParam, CipherText.instance().decrypt(before)); + List dashboards = new ArrayList<>(); + for (String json : jsons) { + dashboards.add(setFields(JsonUtils.readValue(json, Dashboard.class), fields)); + } + return dashboards; + } @Transaction public Dashboard create(Dashboard dashboard, EntityReference service, EntityReference owner) throws IOException { @@ -81,8 +133,8 @@ public abstract class DashboardRepository { dashboardDAO().update(storedDashboard.getId().toString(), JsonUtils.pojoToJson(storedDashboard)); // Update owner relationship - setFields(storedDashboard, METRICS_UPDATE_FIELDS); // First get the ownership information - updateOwner(storedDashboard, storedDashboard.getOwner(), newOwner); + setFields(storedDashboard, DASHBOARD_UPDATE_FIELDS); // First get the ownership information + updateRelationships(storedDashboard, updatedDashboard); // Service can't be changed in update since service name is part of FQN and // change to a different service will result in a different FQN and creation of a new database under the new service @@ -91,27 +143,66 @@ public abstract class DashboardRepository { return new PutResponse<>(Response.Status.OK, storedDashboard); } - public Dashboard get(String id, Fields fields) throws IOException { - return setFields(EntityUtil.validate(id, dashboardDAO().findById(id), Dashboard.class), fields); + @Transaction + public Dashboard patch(String id, JsonPatch patch) throws IOException { + Dashboard original = setFields(validateDashboard(id), DASHBOARD_PATCH_FIELDS); + Dashboard updated = JsonUtils.applyPatch(original, patch, Dashboard.class); + patch(original, updated); + return updated; } - public List list(Fields fields) throws IOException { - List jsonList = dashboardDAO().list(); - List dashboardList = new ArrayList<>(); - for (String json : jsonList) { - dashboardList.add(setFields(JsonUtils.readValue(json, Dashboard.class), fields)); + @Transaction + public Status addFollower(String dashboardId, String userId) throws IOException { + EntityUtil.validate(dashboardId, dashboardDAO().findById(dashboardId), Dashboard.class); + return EntityUtil.addFollower(relationshipDAO(), userDAO(), dashboardId, Entity.DASHBOARD, userId, Entity.USER) ? + Status.CREATED : Status.OK; + } + + @Transaction + public void deleteFollower(String dashboardId, String userId) { + EntityUtil.validateUser(userDAO(), userId); + EntityUtil.removeFollower(relationshipDAO(), dashboardId, userId); + } + + @Transaction + public void delete(String id) { + if (relationshipDAO().findToCount(id, Relationship.CONTAINS.ordinal(), Entity.DASHBOARD) > 0) { + throw new IllegalArgumentException("Dashboard is not empty"); } - return dashboardList; + if (dashboardDAO().delete(id) <= 0) { + throw EntityNotFoundException.byMessage(entityNotFound(Entity.DASHBOARD, id)); + } + relationshipDAO().deleteAll(id); + } + + public static List toEntityReference(List charts) { + List refList = new ArrayList<>(); + for (Chart chart: charts) { + refList.add(EntityUtil.getEntityReference(chart)); + } + return refList; + } + + public Dashboard get(String id, Fields fields) throws IOException { + return setFields(EntityUtil.validate(id, dashboardDAO().findById(id), Dashboard.class), fields); } private Dashboard setFields(Dashboard dashboard, Fields fields) throws IOException { dashboard.setOwner(fields.contains("owner") ? getOwner(dashboard) : null); dashboard.setService(fields.contains("service") ? getService(dashboard) : null); + dashboard.setFollowers(fields.contains("followers") ? getFollowers(dashboard) : null); + dashboard.setCharts(fields.contains("charts") ? toEntityReference(getCharts(dashboard)) : null); + dashboard.setTags(fields.contains("tags") ? getTags(dashboard.getFullyQualifiedName()) : null); dashboard.setUsageSummary(fields.contains("usageSummary") ? EntityUtil.getLatestUsage(usageDAO(), dashboard.getId()) : null); return dashboard; } + private List getTags(String fqn) { + return tagDAO().getTags(fqn); + } + + private Dashboard createInternal(Dashboard dashboard, EntityReference service, EntityReference owner) throws IOException { String fqn = service.getName() + "." + dashboard.getName(); @@ -121,7 +212,7 @@ public abstract class DashboardRepository { dashboardDAO().insert(JsonUtils.pojoToJson(dashboard)); setService(dashboard, service); - setOwner(dashboard, owner); + addRelationships(dashboard); return dashboard; } @@ -143,6 +234,32 @@ public abstract class DashboardRepository { } } + private void patch(Dashboard original, Dashboard updated) throws IOException { + String dashboardId = original.getId().toString(); + if (!original.getId().equals(updated.getId())) { + throw new IllegalArgumentException(CatalogExceptionMessage.readOnlyAttribute(Entity.DASHBOARD, "id")); + } + if (!original.getName().equals(updated.getName())) { + throw new IllegalArgumentException(CatalogExceptionMessage.readOnlyAttribute(Entity.DASHBOARD, "name")); + } + if (updated.getService() == null || !original.getService().getId().equals(updated.getService().getId())) { + throw new IllegalArgumentException(CatalogExceptionMessage.readOnlyAttribute(Entity.DASHBOARD, + "service")); + } + // Validate new owner + EntityReference newOwner = EntityUtil.populateOwner(userDAO(), teamDAO(), updated.getOwner()); + + EntityReference newService = updated.getService(); + + updated.setHref(null); + updated.setOwner(null); + updated.setService(null); + dashboardDAO().update(dashboardId, JsonUtils.pojoToJson(updated)); + updateOwner(updated, original.getOwner(), newOwner); + updated.setService(newService); + applyTags(updated); + } + private EntityReference getOwner(Dashboard dashboard) throws IOException { return dashboard == null ? null : EntityUtil.populateOwner(dashboard.getId(), relationshipDAO(), userDAO(), teamDAO()); @@ -158,6 +275,57 @@ public abstract class DashboardRepository { dashboard.setOwner(newOwner); } + private void applyTags(Dashboard dashboard) throws IOException { + // Add dashboard level tags by adding tag to dashboard relationship + EntityUtil.applyTags(tagDAO(), dashboard.getTags(), dashboard.getFullyQualifiedName()); + dashboard.setTags(getTags(dashboard.getFullyQualifiedName())); // Update tag to handle additional derived tags + } + + private List getFollowers(Dashboard dashboard) throws IOException { + return dashboard == null ? null : EntityUtil.getFollowers(dashboard.getId(), relationshipDAO(), userDAO()); + } + + private List getCharts(Dashboard dashboard) throws IOException { + if (dashboard == null) { + return null; + } + String dashboardId = dashboard.getId().toString(); + List chartIds = relationshipDAO().findTo(dashboardId, Relationship.CONTAINS.ordinal(), Entity.CHART); + List charts = new ArrayList<>(); + for (String chartId : chartIds) { + String json = chartDAO().findById(chartId); + Chart chart = JsonUtils.readValue(json, Chart.class); + charts.add(chart); + } + return charts; + } + + private void addRelationships(Dashboard dashboard) throws IOException { + // Add relationship from dashboard to chart + String dashboardId = dashboard.getId().toString(); + for (EntityReference chart: dashboard.getCharts()) { + relationshipDAO().insert(dashboardId, chart.getId().toString(), Entity.DASHBOARD, Entity.CHART, + Relationship.CONTAINS.ordinal()); + } + // Add owner relationship + EntityUtil.setOwner(relationshipDAO(), dashboard.getId(), Entity.DASHBOARD, dashboard.getOwner()); + + // Add tag to dashboard relationship + applyTags(dashboard); + } + + private void updateRelationships(Dashboard origDashboard, Dashboard updatedDashboard) throws IOException { + // Add owner relationship + origDashboard.setOwner(getOwner(origDashboard)); + EntityUtil.updateOwner(relationshipDAO(), origDashboard.getOwner(), updatedDashboard.getOwner(), + origDashboard.getId(), Entity.TABLE); + applyTags(updatedDashboard); + } + + private Dashboard validateDashboard(String id) throws IOException { + return EntityUtil.validate(id, dashboardDAO().findById(id), Dashboard.class); + } + public interface DashboardDAO { @SqlUpdate("INSERT INTO dashboard_entity(json) VALUES (:json)") void insert(@Bind("json") String json); @@ -171,7 +339,28 @@ public abstract class DashboardRepository { @SqlQuery("SELECT json FROM dashboard_entity WHERE fullyQualifiedName = :name") String findByFQN(@Bind("name") String name); - @SqlQuery("SELECT json FROM dashboard_entity") - List list(); + @SqlQuery( + "SELECT json FROM (" + + "SELECT fullyQualifiedName, json FROM dashboard_entity WHERE " + + "(fullyQualifiedName LIKE CONCAT(:fqnPrefix, '.%') OR :fqnPrefix IS NULL) AND " +// Filter by + // service name + "fullyQualifiedName < :before " + // Pagination by dashboard fullyQualifiedName + "ORDER BY fullyQualifiedName DESC " + // Pagination ordering by chart fullyQualifiedName + "LIMIT :limit" + + ") last_rows_subquery ORDER BY fullyQualifiedName") + List listBefore(@Bind("fqnPrefix") String fqnPrefix, @Bind("limit") int limit, + @Bind("before") String before); + + @SqlQuery("SELECT json FROM chart_entity WHERE " + + "(fullyQualifiedName LIKE CONCAT(:fqnPrefix, '.%') OR :fqnPrefix IS NULL) AND " + + "fullyQualifiedName > :after " + + "ORDER BY fullyQualifiedName " + + "LIMIT :limit") + List listAfter(@Bind("fqnPrefix") String fqnPrefix, @Bind("limit") int limit, + @Bind("after") String after); + + + @SqlUpdate("DELETE FROM dashboard_entity WHERE id = :id") + int delete(@Bind("id") String id); } } diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/TableRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/TableRepository.java index 9c58ac0330b..6edbec61615 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/TableRepository.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/TableRepository.java @@ -80,7 +80,7 @@ public abstract class TableRepository { "owner,columns,database,tags,tableConstraints"); // Table fields that can be updated in a PUT request private static final Fields TABLE_UPDATE_FIELDS = new Fields(TableResource.FIELD_LIST, - "owner,columns,database,tags, tableConstraints"); + "owner,columns,database,tags,tableConstraints"); @CreateSqlObject abstract DatabaseDAO databaseDAO(); diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/charts/ChartResource.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/charts/ChartResource.java index da7c16aeaa1..98c01a33038 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/charts/ChartResource.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/charts/ChartResource.java @@ -27,12 +27,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.openmetadata.catalog.api.data.CreateChart; -import org.openmetadata.catalog.api.data.CreateTopic; import org.openmetadata.catalog.entity.data.Chart; import org.openmetadata.catalog.entity.data.Dashboard; -import org.openmetadata.catalog.entity.data.Topic; import org.openmetadata.catalog.jdbi3.ChartRepository; -import org.openmetadata.catalog.jdbi3.TopicRepository; import org.openmetadata.catalog.resources.Collection; import org.openmetadata.catalog.security.CatalogAuthorizer; import org.openmetadata.catalog.security.SecurityUtil; @@ -80,7 +77,7 @@ import java.util.UUID; @Api(value = "Chart data asset collection", tags = "Chart data asset collection") @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) -@Collection(name = "topics", repositoryClass = "org.openmetadata.catalog.jdbi3.ChartRepository") +@Collection(name = "charts", repositoryClass = "org.openmetadata.catalog.jdbi3.ChartRepository") public class ChartResource { private static final Logger LOG = LoggerFactory.getLogger(ChartResource.class); private static final String CHART_COLLECTION_PATH = "v1/charts/"; @@ -146,15 +143,15 @@ public class ChartResource { @Parameter(description = "Filter charts by service name", schema = @Schema(type = "string", example = "superset")) @QueryParam("service") String serviceParam, - @Parameter(description = "Limit the number topics returned. (1 to 1000000, default = 10)") + @Parameter(description = "Limit the number charts returned. (1 to 1000000, default = 10)") @DefaultValue("10") @Min(1) @Max(1000000) @QueryParam("limit") int limitParam, - @Parameter(description = "Returns list of topics before this cursor", + @Parameter(description = "Returns list of charts before this cursor", schema = @Schema(type = "string")) @QueryParam("before") String before, - @Parameter(description = "Returns list of topics after this cursor", + @Parameter(description = "Returns list of charts after this cursor", schema = @Schema(type = "string")) @QueryParam("after") String after ) throws IOException, GeneralSecurityException { @@ -191,10 +188,10 @@ public class ChartResource { @Operation(summary = "Get a Chart", tags = "charts", description = "Get a chart by `id`.", responses = { - @ApiResponse(responseCode = "200", description = "The topic", + @ApiResponse(responseCode = "200", description = "The chart", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Dashboard.class))), - @ApiResponse(responseCode = "404", description = "Topic for instance {id} is not found") + @ApiResponse(responseCode = "404", description = "Chart for instance {id} is not found") }) public Chart get(@Context UriInfo uriInfo, @PathParam("id") String id, @Context SecurityContext securityContext, @@ -213,7 +210,7 @@ public class ChartResource { @ApiResponse(responseCode = "200", description = "The chart", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Chart.class))), - @ApiResponse(responseCode = "404", description = "Topic for instance {id} is not found") + @ApiResponse(responseCode = "404", description = "Chart for instance {id} is not found") }) public Response getByName(@Context UriInfo uriInfo, @PathParam("fqn") String fqn, @Context SecurityContext securityContext, @@ -230,7 +227,7 @@ public class ChartResource { @Operation(summary = "Create a chart", tags = "charts", description = "Create a chart under an existing `service`.", responses = { - @ApiResponse(responseCode = "200", description = "The topic", + @ApiResponse(responseCode = "200", description = "The chart", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Chart.class))), @ApiResponse(responseCode = "400", description = "Bad request") @@ -302,7 +299,7 @@ public class ChartResource { description = "Add a user identified by `userId` as followed of this chart", responses = { @ApiResponse(responseCode = "200", description = "OK"), - @ApiResponse(responseCode = "404", description = "Topic for instance {id} is not found") + @ApiResponse(responseCode = "404", description = "Chart for instance {id} is not found") }) public Response addFollower(@Context UriInfo uriInfo, @Context SecurityContext securityContext, @@ -320,7 +317,7 @@ public class ChartResource { @DELETE @Path("/{id}/followers/{userId}") @Operation(summary = "Remove a follower", tags = "charts", - description = "Remove the user identified `userId` as a follower of the topic.") + description = "Remove the user identified `userId` as a follower of the chart.") public Chart deleteFollower(@Context UriInfo uriInfo, @Context SecurityContext securityContext, @Parameter(description = "Id of the chart", diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/dashboards/DashboardResource.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/dashboards/DashboardResource.java index de9ee26d2e0..953206ecac3 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/dashboards/DashboardResource.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/resources/dashboards/DashboardResource.java @@ -17,10 +17,16 @@ package org.openmetadata.catalog.resources.dashboards; import com.google.inject.Inject; +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import org.openmetadata.catalog.api.data.CreateDashboard; +import org.openmetadata.catalog.entity.data.Chart; import org.openmetadata.catalog.entity.data.Dashboard; import org.openmetadata.catalog.jdbi3.DashboardRepository; import org.openmetadata.catalog.resources.Collection; import org.openmetadata.catalog.security.SecurityUtil; +import org.openmetadata.catalog.util.EntityUtil; import org.openmetadata.catalog.util.EntityUtil.Fields; import org.openmetadata.catalog.util.RestUtil; import org.openmetadata.catalog.util.RestUtil.PutResponse; @@ -33,9 +39,15 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.openmetadata.catalog.security.CatalogAuthorizer; +import javax.json.JsonPatch; import javax.validation.Valid; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; +import javax.ws.rs.PATCH; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; @@ -49,6 +61,9 @@ import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.text.ParseException; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -87,29 +102,78 @@ public class DashboardResource { } static class DashboardList extends ResultList { - DashboardList(List data) { - super(data); + @SuppressWarnings("unused") + DashboardList() { + // Empty constructor needed for deserialization + } + + DashboardList(List data, int limitParam, String beforeCursor, + String afterCursor) throws GeneralSecurityException, UnsupportedEncodingException { + super(data, limitParam, beforeCursor, afterCursor); } } - static final String FIELDS ="owner,service,usageSummary"; + static final String FIELDS = "owner,service,followers,tags,usageSummary"; public static final List FIELD_LIST = Arrays.asList(FIELDS.replaceAll(" ", "") .split(",")); + @GET - @Operation(summary = "List dashboards", tags = "dashboards", - description = "Get a list of dashboards. Use `fields` parameter to get only necessary fields.", + @Valid + @Operation(summary = "List Dashboards", tags = "dashboards", + description = "Get a list of dashboards, optionally filtered by `service` it belongs to. Use `fields` " + + "parameter to get only necessary fields. Use cursor-based pagination to limit the number " + + "entries in the list using `limit` and `before` or `after` query params.", responses = { - @ApiResponse(responseCode = "200", description = "List of dashboards", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = DashboardList.class))) + @ApiResponse(responseCode = "200", description = "List of dashboardss", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = DashboardList.class))) }) public DashboardList list(@Context UriInfo uriInfo, - @Context SecurityContext securityContext, - @Parameter(description = "Fields requested in the returned resource", - schema = @Schema(type = "string", example = FIELDS)) - @QueryParam("fields") String fieldsParam) throws IOException { + @Context SecurityContext securityContext, + @Parameter(description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") String fieldsParam, + @Parameter(description = "Filter dashboards by service name", + schema = @Schema(type = "string", example = "superset")) + @QueryParam("service") String serviceParam, + @Parameter(description = "Limit the number dashboards returned. (1 to 1000000, default = 10)") + @DefaultValue("10") + @Min(1) + @Max(1000000) + @QueryParam("limit") int limitParam, + @Parameter(description = "Returns list of dashboards before this cursor", + schema = @Schema(type = "string")) + @QueryParam("before") String before, + @Parameter(description = "Returns list of dashboards after this cursor", + schema = @Schema(type = "string")) + @QueryParam("after") String after + ) throws IOException, GeneralSecurityException { + RestUtil.validateCursors(before, after); Fields fields = new Fields(FIELD_LIST, fieldsParam); - return new DashboardList(addHref(uriInfo, dao.list(fields))); + + List dashboards; + String beforeCursor = null, afterCursor = null; + + // For calculating cursors, ask for one extra entry beyond limit. If the extra entry exists, then in forward + // scrolling afterCursor is not null. Similarly, if the extra entry exists, then in reverse scrolling, + // beforeCursor is not null. Remove the extra entry before returning results. + if (before != null) { // Reverse paging + dashboards = dao.listBefore(fields, serviceParam, limitParam + 1, before); // Ask for one extra entry + if (dashboards.size() > limitParam) { + dashboards.remove(0); + beforeCursor = dashboards.get(0).getFullyQualifiedName(); + } + afterCursor = dashboards.get(dashboards.size() - 1).getFullyQualifiedName(); + } else { // Forward paging or first page + dashboards = dao.listAfter(fields, serviceParam, limitParam + 1, after); + beforeCursor = after == null ? null : dashboards.get(0).getFullyQualifiedName(); + if (dashboards.size() > limitParam) { + dashboards.remove(limitParam); + afterCursor = dashboards.get(limitParam - 1).getFullyQualifiedName(); + } + } + addHref(uriInfo, dashboards); + return new DashboardList(dashboards, limitParam, beforeCursor, afterCursor); } @GET @@ -142,13 +206,40 @@ public class DashboardResource { @ApiResponse(responseCode = "400", description = "Bad request") }) public Response create(@Context UriInfo uriInfo, @Context SecurityContext securityContext, - @Valid Dashboard dashboard) throws IOException { + @Valid CreateDashboard create) throws IOException { SecurityUtil.checkAdminOrBotRole(authorizer, securityContext); - dashboard.setId(UUID.randomUUID()); + Dashboard dashboard = new Dashboard().withId(UUID.randomUUID()).withName(create.getName()) + .withService(create.getService()).withCharts(create.getCharts()) + .withDashboardUrl(create.getDashboardUrl()).withTags(create.getTags()); addHref(uriInfo, dao.create(dashboard, dashboard.getService(), dashboard.getOwner())); return Response.created(dashboard.getHref()).entity(dashboard).build(); } + @PATCH + @Path("/{id}") + @Operation(summary = "Update a Dashboard", tags = "dashboards", + description = "Update an existing dashboard using JsonPatch.", + externalDocs = @ExternalDocumentation(description = "JsonPatch RFC", + url = "https://tools.ietf.org/html/rfc6902")) + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + public Dashboard updateDescription(@Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @PathParam("id") String id, + @RequestBody(description = "JsonPatch with array of operations", + content = @Content(mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = {@ExampleObject("[" + + "{op:remove, path:/a}," + + "{op:add, path: /b, value: val}" + + "]")})) + JsonPatch patch) throws IOException { + Fields fields = new Fields(FIELD_LIST, FIELDS); + Dashboard dashboard = dao.get(id, fields); + SecurityUtil.checkAdminRoleOrPermissions(authorizer, securityContext, + EntityUtil.getEntityReference(dashboard)); + dashboard = dao.patch(id, patch); + return addHref(uriInfo, dashboard); + } + @PUT @Operation(summary = "Create or update a dashboard", tags = "dashboards", description = "Create a new dashboard, if it does not exist or update an existing dashboard.", @@ -159,10 +250,65 @@ public class DashboardResource { @ApiResponse(responseCode = "400", description = "Bad request") }) public Response createOrUpdate(@Context UriInfo uriInfo, @Context SecurityContext securityContext, - @Valid Dashboard dashboard) throws IOException { - dashboard.setId(UUID.randomUUID()); + @Valid Dashboard create) throws IOException { + Dashboard dashboard = new Dashboard().withId(UUID.randomUUID()).withName(create.getName()) + .withService(create.getService()).withCharts(create.getCharts()) + .withDashboardUrl(create.getDashboardUrl()).withTags(create.getTags()); + addHref(uriInfo, dao.create(dashboard, dashboard.getService(), dashboard.getOwner())); PutResponse response = dao.createOrUpdate(dashboard, dashboard.getService(), dashboard.getOwner()); addHref(uriInfo, response.getEntity()); return Response.status(response.getStatus()).entity(response.getEntity()).build(); } + + @PUT + @Path("/{id}/followers") + @Operation(summary = "Add a follower", tags = "dashboards", + description = "Add a user identified by `userId` as follower of this dashboard", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Dashboard for instance {id} is not found") + }) + public Response addFollower(@Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the dashboard", schema = @Schema(type = "string")) + @PathParam("id") String id, + @Parameter(description = "Id of the user to be added as follower", + schema = @Schema(type = "string")) + String userId) throws IOException, ParseException { + Fields fields = new Fields(FIELD_LIST, "followers"); + Response.Status status = dao.addFollower(id, userId); + Dashboard dashboard = dao.get(id, fields); + return Response.status(status).entity(dashboard).build(); + } + + @DELETE + @Path("/{id}/followers/{userId}") + @Operation(summary = "Remove a follower", tags = "dashboards", + description = "Remove the user identified `userId` as a follower of the dashboard.") + public Dashboard deleteFollower(@Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the dashboard", + schema = @Schema(type = "string")) + @PathParam("id") String id, + @Parameter(description = "Id of the user being removed as follower", + schema = @Schema(type = "string")) + @PathParam("userId") String userId) throws IOException, ParseException { + Fields fields = new Fields(FIELD_LIST, "followers"); + dao.deleteFollower(id, userId); + Dashboard dashboard = dao.get(id, fields); + return addHref(uriInfo, dashboard); + } + + @DELETE + @Path("/{id}") + @Operation(summary = "Delete a Dashboard", tags = "dashboards", + description = "Delete a dashboard by `id`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Dashboard for instance {id} is not found") + }) + public Response delete(@Context UriInfo uriInfo, @PathParam("id") String id) { + dao.delete(id); + return Response.ok().build(); + } } diff --git a/catalog-rest-service/src/main/resources/json/schema/api/data/createDashboard.json b/catalog-rest-service/src/main/resources/json/schema/api/data/createDashboard.json index c57e5f9996d..88350b55355 100644 --- a/catalog-rest-service/src/main/resources/json/schema/api/data/createDashboard.json +++ b/catalog-rest-service/src/main/resources/json/schema/api/data/createDashboard.json @@ -28,6 +28,14 @@ }, "default": null }, + "tags": { + "description": "Tags for this chart", + "type": "array", + "items": { + "$ref": "../../type/tagLabel.json" + }, + "default": null + }, "owner": { "description": "Owner of this database", "$ref": "../../type/entityReference.json"