From 20426fe20a5da8653dce6de8c8e549c6110d2c97 Mon Sep 17 00:00:00 2001 From: fbasios Date: Tue, 13 Jun 2023 11:13:52 +0300 Subject: [PATCH] Add User List Endpoint This commit adds a new endpoint /users to the existing API, which returns a list of users in the system. The endpoint is implemented to provide a convenient way for retrieving the available users in a paginated manner. --- .gitignore | 2 + api/pom.xml | 4 + .../cat/api/endpoints/UsersEndpoint.java | 73 ++++++ .../cat/api/templates/OidcClientTemplate.java | 19 ++ .../resources/META-INF/resources/index.html | 208 ++++++++++++++++++ .../org/grnet/cat/api/UsersEndpointTest.java | 36 ++- data-transfer-object/pom.xml | 12 + .../grnet/cat/dtos/pagination/PageLink.java | 26 +++ .../cat/dtos/pagination/PageResource.java | 151 +++++++++++++ .../src/main/resources/application.properties | 2 +- handler/pom.xml | 4 + .../exception/ValidationExceptionHandler.java | 31 +++ .../org/grnet/cat/mappers/UserMapper.java | 4 + .../cat/repositories/UserRepository.java | 13 ++ .../org/grnet/cat/services/UserService.java | 25 ++- 15 files changed, 598 insertions(+), 12 deletions(-) create mode 100644 api/src/main/resources/META-INF/resources/index.html create mode 100644 data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageLink.java create mode 100644 data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageResource.java create mode 100644 handler/src/main/java/org/grnet/cat/handlers/exception/ValidationExceptionHandler.java diff --git a/.gitignore b/.gitignore index 8c7863e7..0dec93a6 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ nb-configuration.xml # Plugin directory /.quarkus/cli/plugins/ + +api/cat-data/ \ No newline at end of file diff --git a/api/pom.xml b/api/pom.xml index 1258e786..0fc92b46 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -16,6 +16,10 @@ io.quarkus quarkus-resteasy-reactive-jackson + + io.quarkus + quarkus-hibernate-validator + io.quarkus quarkus-oidc diff --git a/api/src/main/java/org/grnet/cat/api/endpoints/UsersEndpoint.java b/api/src/main/java/org/grnet/cat/api/endpoints/UsersEndpoint.java index 8c4aee27..db79a6b5 100644 --- a/api/src/main/java/org/grnet/cat/api/endpoints/UsersEndpoint.java +++ b/api/src/main/java/org/grnet/cat/api/endpoints/UsersEndpoint.java @@ -2,18 +2,25 @@ import io.quarkus.security.Authenticated; import jakarta.inject.Inject; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeIn; import org.eclipse.microprofile.openapi.annotations.enums.SecuritySchemeType; import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; import org.eclipse.microprofile.openapi.annotations.security.SecurityScheme; @@ -22,9 +29,14 @@ import org.grnet.cat.api.utils.Utility; import org.grnet.cat.dtos.InformativeResponse; import org.grnet.cat.dtos.UserProfileDto; +import org.grnet.cat.dtos.pagination.PageResource; import org.grnet.cat.services.IdentifiedService; import org.grnet.cat.services.UserService; +import java.util.List; + +import static org.eclipse.microprofile.openapi.annotations.enums.ParameterIn.QUERY; + @Path("/v1/users") @Authenticated @SecurityScheme(securitySchemeName = "Authentication", @@ -133,4 +145,65 @@ public Response profile() { return Response.ok().entity(userProfile).build(); } + + @Tag(name = "User") + @Operation( + summary = "Retrieve a list of available users.", + description = "This endpoint returns a list of users registered in the service. Each user object includes basic information such as their type and unique id. " + + " By default, the first page of 10 Users will be returned. You can tune the default values by using the query parameters page and size.") + @SecurityScheme + @APIResponse( + responseCode = "200", + description = "List of Users.", + content = @Content(schema = @Schema( + type = SchemaType.OBJECT, + implementation = PageableUserProfile.class))) + @APIResponse( + responseCode = "401", + description = "User has not been authenticated.", + content = @Content(schema = @Schema( + type = SchemaType.OBJECT, + implementation = InformativeResponse.class))) + @APIResponse( + responseCode = "403", + description = "User has not been registered on CAT service.", + content = @Content(schema = @Schema( + type = SchemaType.OBJECT, + implementation = InformativeResponse.class))) + @APIResponse( + responseCode = "500", + description = "Internal Server Error.", + content = @Content(schema = @Schema( + type = SchemaType.OBJECT, + implementation = InformativeResponse.class))) + @SecurityRequirement(name = "Authentication") + @GET + @Produces(MediaType.APPLICATION_JSON) + @Registration + public Response usersByPage(@Parameter(name = "page", in = QUERY, + description = "Indicates the page number. Page number must be >= 1.") @DefaultValue("1") @Min(value = 1, message = "Page number must be >= 1.") @QueryParam("page") int page, + @Parameter(name = "size", in = QUERY, + description = "The page size.") @DefaultValue("10") @Min(value = 1, message = "Page size must be between 1 and 100.") + @Max(value = 100, message = "Page size must be between 1 and 100.") @QueryParam("size") int size, + @Context UriInfo uriInfo) { + + var userProfile = userService.getUsersByPage(page-1, size, uriInfo); + + return Response.ok().entity(userProfile).build(); + } + + public static class PageableUserProfile extends PageResource { + + private List content; + + @Override + public List getContent() { + return content; + } + + @Override + public void setContent(List content) { + this.content = content; + } + } } diff --git a/api/src/main/java/org/grnet/cat/api/templates/OidcClientTemplate.java b/api/src/main/java/org/grnet/cat/api/templates/OidcClientTemplate.java index a29a6b01..44dc449e 100644 --- a/api/src/main/java/org/grnet/cat/api/templates/OidcClientTemplate.java +++ b/api/src/main/java/org/grnet/cat/api/templates/OidcClientTemplate.java @@ -7,6 +7,13 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.grnet.cat.dtos.InformativeResponse; /** * This endpoint is responsible for rendering the client.html. @@ -32,6 +39,18 @@ public class OidcClientTemplate { @ConfigProperty(name = "keycloak.server.javascript.adapter") String keycloakServerJavascriptAdapter; + @Tag(name = "Authentication") + @Operation( + hidden = true, + summary = "Redirects a client to the AAI login page.", + description = "The user is presented with the AAI login page, which is hosted by the authentication server. " + + "This page contains options for selecting the user's identity provider (IdP) or organization. After selecting the identity provider, the user is redirected to the selected IdP's login page to enter their credentials." + + " The user enters their username and password on the IdP's login page. The identity provider verifies the user's credentials and determines whether they are valid. If the authentication is successful, the IdP generates an access token.") + @APIResponse( + responseCode = "200", + description = "Returns an html page containing the obtained access token." + ) + @GET @Produces(MediaType.TEXT_HTML) public String keycloakClient() { diff --git a/api/src/main/resources/META-INF/resources/index.html b/api/src/main/resources/META-INF/resources/index.html new file mode 100644 index 00000000..94b32d4b --- /dev/null +++ b/api/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,208 @@ + + + + + pid-meta-resolver - 1.0.0-SNAPSHOT + + + +
+
+
+ + +
+
+
+ +
+
+
+

This is the Compliance Assessment Toolkit API

+

via the FAIRCORE4EOSC project.

+ Visit API Documentation + Visit devel UI + Obtain an Access Token + +
+
+ +
+
+
+ + diff --git a/api/src/test/java/org/grnet/cat/api/UsersEndpointTest.java b/api/src/test/java/org/grnet/cat/api/UsersEndpointTest.java index ca1963cc..49ea78ed 100644 --- a/api/src/test/java/org/grnet/cat/api/UsersEndpointTest.java +++ b/api/src/test/java/org/grnet/cat/api/UsersEndpointTest.java @@ -3,10 +3,12 @@ import io.quarkus.test.common.http.TestHTTPEndpoint; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.keycloak.client.KeycloakTestClient; +import io.restassured.http.ContentType; import org.eclipse.microprofile.config.inject.ConfigProperty; import org.grnet.cat.api.endpoints.UsersEndpoint; import org.grnet.cat.dtos.InformativeResponse; import org.grnet.cat.dtos.UserProfileDto; +import org.grnet.cat.dtos.pagination.PageResource; import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; @@ -16,9 +18,6 @@ @TestHTTPEndpoint(UsersEndpoint.class) public class UsersEndpointTest { - @ConfigProperty(name = "oidc.user.unique.id") - public static String key; - KeycloakTestClient keycloakClient = new KeycloakTestClient(); @Test @@ -97,6 +96,37 @@ public void nonRegisterUserRequestsTheirUserProfile() { assertEquals("User has not been registered on CAT service. User registration is a prerequisite for accessing this API resource.", informativeResponse.message); } + @Test + public void fetchAllUsers() { + + var response = given() + .auth() + .oauth2(getAccessToken("alice")) + .contentType(ContentType.JSON) + .get() + .thenReturn(); + + assertEquals(200, response.statusCode()); + assertEquals(1, response.body().as(PageResource.class).getNumberOfPage()); + } + + @Test + public void fetchAllUsersBadRequest() { + + var informativeResponse = given() + .auth() + .oauth2(getAccessToken("alice")) + .contentType(ContentType.JSON) + .queryParam("page", 0) + .get() + .then() + .statusCode(400) + .extract() + .as(InformativeResponse.class); + + assertEquals("Page number must be >= 1.", informativeResponse.message); + } + protected String getAccessToken(String userName) { return keycloakClient.getAccessToken(userName); } diff --git a/data-transfer-object/pom.xml b/data-transfer-object/pom.xml index 005face5..f403a8f4 100644 --- a/data-transfer-object/pom.xml +++ b/data-transfer-object/pom.xml @@ -17,6 +17,18 @@ io.quarkus quarkus-smallrye-openapi
+ + io.quarkus + quarkus-hibernate-orm-panache + + + org.projectlombok + lombok + + + jakarta.ws.rs + jakarta.ws.rs-api + diff --git a/data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageLink.java b/data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageLink.java new file mode 100644 index 00000000..f74574da --- /dev/null +++ b/data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageLink.java @@ -0,0 +1,26 @@ +package org.grnet.cat.dtos.pagination; + +import lombok.Builder; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Builder +@Schema(name="PageLink", description="An object represents the links of paginated entities.") +public class PageLink { + + @Schema( + type = SchemaType.STRING, + implementation = String.class, + description = "Uri to paginated entities.", + example = "http://localhost:8080/accounting-system/metric-definitions/61eeab7bb3b68f5c3f8c4c24/metrics?page=1&size=10" + ) + public String href; + + @Schema( + type = SchemaType.STRING, + implementation = String.class, + description = "Descriptor for how the target resource relates to the current resource.", + example = "first" + ) + public String rel; +} diff --git a/data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageResource.java b/data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageResource.java new file mode 100644 index 00000000..237e5bd1 --- /dev/null +++ b/data-transfer-object/src/main/java/org/grnet/cat/dtos/pagination/PageResource.java @@ -0,0 +1,151 @@ +package org.grnet.cat.dtos.pagination; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.hibernate.orm.panache.PanacheQuery; +import jakarta.ws.rs.core.UriInfo; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.ArrayList; +import java.util.List; + +@Schema(name="PageResource", description="An object represents the paginated entities.") +public class PageResource { + + @Schema( + type = SchemaType.NUMBER, + implementation = Integer.class, + description = "Page size.", + example = "10" + ) + @JsonProperty("size_of_page") + private int sizeOfPage; + + @Schema( + type = SchemaType.NUMBER, + implementation = Integer.class, + description = "Page number.", + example = "1" + ) + @JsonProperty("number_of_page") + private int numberOfPage; + + @Schema( + type = SchemaType.NUMBER, + implementation = Long.class, + description = "Total elements.", + example = "15" + ) + @JsonProperty("total_elements") + private long totalElements; + + @Schema( + type = SchemaType.NUMBER, + implementation = Integer.class, + description = "Total pages.", + example = "2" + ) + @JsonProperty("total_pages") + private int totalPages; + + @Schema( + type = SchemaType.ARRAY, + implementation = Object.class, + description = "Paginated entities." + ) + @JsonProperty("content") + private List content; + + @Schema( + type = SchemaType.ARRAY, + implementation = PageLink.class, + description = "Link to paginated entities." + ) + @JsonProperty("links") + private List links; + + public PageResource() { + } + + public PageResource(PanacheQuery panacheQuery, List content, UriInfo uriInfo){ + + links = new ArrayList<>(); + this.content = content; + this.sizeOfPage = panacheQuery.list().size(); + this.numberOfPage = panacheQuery.page().index+1; + this.totalElements = panacheQuery.count(); + this.totalPages = panacheQuery.pageCount(); + + if(totalPages !=1 && numberOfPage <= totalPages){ + links.add(buildPageLink(uriInfo, 1, sizeOfPage, "first")); + links.add(buildPageLink(uriInfo, totalPages, sizeOfPage, "last")); + links.add(buildPageLink(uriInfo, numberOfPage, sizeOfPage, "self")); + + + if(panacheQuery.hasPreviousPage() && panacheQuery.list().size()!=0) { + links.add(buildPageLink(uriInfo, numberOfPage -1, sizeOfPage, "prev")); + } + + if(panacheQuery.hasNextPage()) { + links.add(buildPageLink(uriInfo, numberOfPage +1, sizeOfPage, "next")); + } + } + } + + private PageLink buildPageLink(UriInfo uriInfo, int page, int size, String rel) { + + return PageLink + .builder() + .href(uriInfo.getRequestUriBuilder().replaceQueryParam("page", page).replaceQueryParam("size", size).build().toString()) + .rel(rel) + .build(); + } + + public int getSizeOfPage() { + return sizeOfPage; + } + + public void setSizeOfPage(int sizeOfPage) { + this.sizeOfPage = sizeOfPage; + } + + public int getNumberOfPage() { + return numberOfPage; + } + + public void setNumberOfPage(int numberOfPage) { + this.numberOfPage = numberOfPage; + } + + public long getTotalElements() { + return totalElements; + } + + public void setTotalElements(long totalElements) { + this.totalElements = totalElements; + } + + public int getTotalPages() { + return totalPages; + } + + public void setTotalPages(int totalPages) { + this.totalPages = totalPages; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } + + public List getLinks() { + return links; + } + + public void setLinks(List links) { + this.links = links; + } +} diff --git a/entity/src/main/resources/application.properties b/entity/src/main/resources/application.properties index cb97ffb3..2e0a4d54 100644 --- a/entity/src/main/resources/application.properties +++ b/entity/src/main/resources/application.properties @@ -13,7 +13,7 @@ quarkus.datasource.db-kind=mysql %dev.quarkus.datasource.devservices.password=cat %dev.quarkus.hibernate-orm.log.sql=true -%dev.quarkus.datasource.devservices.volumes."/var/cat/data"=/var/lib/mysql +%dev.quarkus.datasource.devservices.volumes."cat-data"=/var/lib/mysql # flyway quarkus.flyway.migrate-at-start=true diff --git a/handler/pom.xml b/handler/pom.xml index 91de830f..95836cf6 100644 --- a/handler/pom.xml +++ b/handler/pom.xml @@ -17,6 +17,10 @@ org.grnet cat-exceptions + + io.quarkus + quarkus-hibernate-validator + org.grnet cat-data-transfer-objects diff --git a/handler/src/main/java/org/grnet/cat/handlers/exception/ValidationExceptionHandler.java b/handler/src/main/java/org/grnet/cat/handlers/exception/ValidationExceptionHandler.java new file mode 100644 index 00000000..3244d8c0 --- /dev/null +++ b/handler/src/main/java/org/grnet/cat/handlers/exception/ValidationExceptionHandler.java @@ -0,0 +1,31 @@ +package org.grnet.cat.handlers.exception; + +import io.quarkus.hibernate.validator.runtime.jaxrs.ResteasyReactiveViolationException; +import jakarta.validation.ValidationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.grnet.cat.dtos.InformativeResponse; + +@Provider +public class ValidationExceptionHandler implements ExceptionMapper { + + @Override + public Response toResponse(ValidationException e) { + + + InformativeResponse response = new InformativeResponse(); + + if(e instanceof ResteasyReactiveViolationException){ + + response.message = ((ResteasyReactiveViolationException) e).getConstraintViolations().stream().findFirst().get().getMessageTemplate(); + response.code = Response.Status.BAD_REQUEST.getStatusCode(); + return Response.status(Response.Status.BAD_REQUEST).entity(response).build(); + } else { + + response.message = e.getMessage(); + response.code = Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(response).build(); + } + } +} diff --git a/mapper/src/main/java/org/grnet/cat/mappers/UserMapper.java b/mapper/src/main/java/org/grnet/cat/mappers/UserMapper.java index 73080b1e..3d43a2e3 100644 --- a/mapper/src/main/java/org/grnet/cat/mappers/UserMapper.java +++ b/mapper/src/main/java/org/grnet/cat/mappers/UserMapper.java @@ -5,6 +5,8 @@ import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; +import java.util.List; + /** * The UserMapper is responsible for mapping User entities to DTOs. */ @@ -14,4 +16,6 @@ public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper( UserMapper.class ); UserProfileDto userProfileToDto(UserProfile userProfile); + + List usersProfileToDto(List users); } diff --git a/repository/src/main/java/org/grnet/cat/repositories/UserRepository.java b/repository/src/main/java/org/grnet/cat/repositories/UserRepository.java index 15318f5d..7e4771dd 100644 --- a/repository/src/main/java/org/grnet/cat/repositories/UserRepository.java +++ b/repository/src/main/java/org/grnet/cat/repositories/UserRepository.java @@ -1,5 +1,6 @@ package org.grnet.cat.repositories; +import io.quarkus.hibernate.orm.panache.PanacheQuery; import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import jakarta.enterprise.context.ApplicationScoped; @@ -23,4 +24,16 @@ public UserProfile fetchUserProfile(String id){ return find("select user.id, user.type, user.registeredOn from User user where user.id = ?1", id).project(UserProfile.class).firstResult(); } + + /** + * Retrieves a page of users from the database. + * + * @param page The index of the page to retrieve (starting from 0). + * @param size The maximum number of users to include in a page. + * @return A list of UserProfile objects representing the users in the requested page. + */ + public PanacheQuery fetchUsersByPage(int page, int size){ + + return find("select user.id, user.type, user.registeredOn from User user").project(UserProfile.class).page(page, size); + } } diff --git a/service/src/main/java/org/grnet/cat/services/UserService.java b/service/src/main/java/org/grnet/cat/services/UserService.java index 853a07fc..ba40d3ca 100644 --- a/service/src/main/java/org/grnet/cat/services/UserService.java +++ b/service/src/main/java/org/grnet/cat/services/UserService.java @@ -2,18 +2,12 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.transaction.Transactional; +import jakarta.ws.rs.core.UriInfo; import org.grnet.cat.dtos.UserProfileDto; -import org.grnet.cat.entities.Identified; -import org.grnet.cat.entities.User; -import org.grnet.cat.exceptions.ConflictException; +import org.grnet.cat.dtos.pagination.PageResource; import org.grnet.cat.mappers.UserMapper; -import org.grnet.cat.repositories.IdentifiedRepository; import org.grnet.cat.repositories.UserRepository; -import java.sql.Timestamp; -import java.time.Instant; - /** * The UserService provides operations for managing User entities. */ @@ -39,4 +33,19 @@ public UserProfileDto getUserProfile(String id){ return UserMapper.INSTANCE.userProfileToDto(userProfile); } + + /** + * Retrieves a page of users from the database. + * + * @param page The index of the page to retrieve (starting from 0). + * @param size The maximum number of users to include in a page. + * @param uriInfo The Uri Info. + * @return A list of UserProfileDto objects representing the users in the requested page. + */ + public PageResource getUsersByPage(int page, int size, UriInfo uriInfo){ + + var users = userRepository.fetchUsersByPage(page, size); + + return new PageResource<>(users, UserMapper.INSTANCE.usersProfileToDto(users.list()), uriInfo); + } } \ No newline at end of file