From 42833233856c01fad7fdb1313ec932a9547bc9bb Mon Sep 17 00:00:00 2001 From: Mourjo Sen Date: Wed, 28 Aug 2024 19:27:38 +0200 Subject: [PATCH] Movie ratings --- README.md | 4 +- .../java/soc/movies/entities/MovieEntity.java | 24 +++- .../soc/movies/entities/RatingEntity.java | 86 ++++++++++++ .../exceptions/InvalidRatingException.java | 17 +++ .../RatingAlreadyExistsException.java | 17 +++ src/main/java/soc/movies/web/Launcher.java | 1 + .../web/controller/MovieController.java | 124 ++++++++++++++++-- .../soc/movies/web/dto/MovieInfoResponse.java | 3 +- .../soc/movies/web/dto/RateMovieRequest.java | 5 + .../java/soc/movies/web/LauncherTest.java | 105 --------------- 10 files changed, 266 insertions(+), 120 deletions(-) create mode 100644 src/main/java/soc/movies/entities/RatingEntity.java create mode 100644 src/main/java/soc/movies/exceptions/InvalidRatingException.java create mode 100644 src/main/java/soc/movies/exceptions/RatingAlreadyExistsException.java create mode 100644 src/main/java/soc/movies/web/dto/RateMovieRequest.java delete mode 100644 src/test/java/soc/movies/web/LauncherTest.java diff --git a/README.md b/README.md index 6c572c8..3f026f1 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ - done - Get movie by slug - done - Get user by user-id - rate movie -- Search movie by random phrases (show name, avg rating, slug) -- Search by tag (show name, avg rating, slug) +- done - Search movie by random phrases (show name, avg rating, slug) + ## Start dependent services diff --git a/src/main/java/soc/movies/entities/MovieEntity.java b/src/main/java/soc/movies/entities/MovieEntity.java index 3155ec3..49f7eeb 100644 --- a/src/main/java/soc/movies/entities/MovieEntity.java +++ b/src/main/java/soc/movies/entities/MovieEntity.java @@ -83,13 +83,26 @@ public static Field languageField() { return DSL.field("lang", String.class); } + public static Field ratingField() { + return DSL.field("avg_rating", Double.class); + } + public static org.jooq.Table table() { return DSL.table("movies"); } public static SelectFieldOrAsterisk[] asterisk() { - return new SelectFieldOrAsterisk[]{idField(), createdAtField(), slugField(), - descriptionField(), releasedYearField(), languageField(), tagsField(), nameField()}; + return new SelectFieldOrAsterisk[]{ + idField(), + createdAtField(), + slugField(), + descriptionField(), + releasedYearField(), + languageField(), + tagsField(), + nameField(), + ratingField() + }; } public long getId() { @@ -156,4 +169,11 @@ public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + public void setRating(double rating) { + this.rating = rating; + } + + public double getRating() { + return rating; + } } diff --git a/src/main/java/soc/movies/entities/RatingEntity.java b/src/main/java/soc/movies/entities/RatingEntity.java new file mode 100644 index 0000000..29181a0 --- /dev/null +++ b/src/main/java/soc/movies/entities/RatingEntity.java @@ -0,0 +1,86 @@ +package soc.movies.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import org.jooq.Field; +import org.jooq.SelectFieldOrAsterisk; +import org.jooq.impl.DSL; + +@Entity +@Table(name = "ratings") +public class RatingEntity { + + @Column(name = "id") + long id; + + @Column(name = "user_id") + long userId; + + @Column(name = "movie_id") + long movieId; + + @Column(name = "rating") + double rating; + + @Column(name = "created_at") + OffsetDateTime createdAt; + + public RatingEntity() { + } + + public static Field idField() { + return DSL.field("id", Long.class); + } + + public static Field createdAtField() { + return DSL.field("created_at", OffsetDateTime.class); + } + + public static Field movieIdField() { + return DSL.field("movie_id", Long.class); + } + + public static Field userIdField() { + return DSL.field("user_id", Long.class); + } + + public static Field ratingField() { + return DSL.field("rating", Double.class); + } + + public static org.jooq.Table table() { + return DSL.table("ratings"); + } + + public static SelectFieldOrAsterisk[] asterisk() { + return new SelectFieldOrAsterisk[]{ + idField(), + movieIdField(), + userIdField(), + ratingField(), + createdAtField() + }; + } + + public long getId() { + return id; + } + + public long getUserId() { + return userId; + } + + public long getMovieId() { + return movieId; + } + + public double getRating() { + return rating; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/soc/movies/exceptions/InvalidRatingException.java b/src/main/java/soc/movies/exceptions/InvalidRatingException.java new file mode 100644 index 0000000..bf03ce4 --- /dev/null +++ b/src/main/java/soc/movies/exceptions/InvalidRatingException.java @@ -0,0 +1,17 @@ +package soc.movies.exceptions; + +import io.javalin.http.HttpStatus; +import soc.movies.web.dto.ErrorResponse; + +public class InvalidRatingException extends MovieException { + + @Override + public ErrorResponse buildResponse() { + return ErrorResponse.build("Rating must be between 0 and 10"); + } + + @Override + public HttpStatus getStatus() { + return HttpStatus.BAD_REQUEST; + } +} diff --git a/src/main/java/soc/movies/exceptions/RatingAlreadyExistsException.java b/src/main/java/soc/movies/exceptions/RatingAlreadyExistsException.java new file mode 100644 index 0000000..e1d53a1 --- /dev/null +++ b/src/main/java/soc/movies/exceptions/RatingAlreadyExistsException.java @@ -0,0 +1,17 @@ +package soc.movies.exceptions; + +import io.javalin.http.HttpStatus; +import soc.movies.web.dto.ErrorResponse; + +public class RatingAlreadyExistsException extends MovieException { + + @Override + public ErrorResponse buildResponse() { + return ErrorResponse.build("Movie rating from this user already exists"); + } + + @Override + public HttpStatus getStatus() { + return HttpStatus.CONFLICT; + } +} diff --git a/src/main/java/soc/movies/web/Launcher.java b/src/main/java/soc/movies/web/Launcher.java index 4939e5c..623b493 100644 --- a/src/main/java/soc/movies/web/Launcher.java +++ b/src/main/java/soc/movies/web/Launcher.java @@ -24,6 +24,7 @@ public static Javalin buildApp() { .get("/user/{username}", userController::retrieveUser) .post("/user", userController::createUser) .post("/movie", movieController::createMovie) + .post("/movie/{slug}/rate", movieController::rateMovie) .get("/movie/{slug}", movieController::retrieveMovie) .get("/search/movie", movieController::searchMovie) .get("/", ctx -> ctx.json(Map.of("message", "Hello world!"))) diff --git a/src/main/java/soc/movies/web/controller/MovieController.java b/src/main/java/soc/movies/web/controller/MovieController.java index 0d85bc8..fd7289b 100644 --- a/src/main/java/soc/movies/web/controller/MovieController.java +++ b/src/main/java/soc/movies/web/controller/MovieController.java @@ -16,6 +16,7 @@ import io.javalin.openapi.OpenApiParam; import io.javalin.openapi.OpenApiRequestBody; import io.javalin.openapi.OpenApiResponse; +import java.math.BigDecimal; import java.sql.Connection; import java.sql.DriverManager; import lombok.SneakyThrows; @@ -27,7 +28,12 @@ import soc.movies.common.Environment; import soc.movies.common.TextTransformer; import soc.movies.entities.MovieEntity; +import soc.movies.entities.RatingEntity; +import soc.movies.entities.UserEntity; +import soc.movies.exceptions.InvalidRatingException; import soc.movies.exceptions.MovieAlreadyExistsException; +import soc.movies.exceptions.MovieNotFoundException; +import soc.movies.exceptions.RatingAlreadyExistsException; import soc.movies.exceptions.UnauthenticatedRequest; import soc.movies.exceptions.UserAlreadyExistsException; import soc.movies.exceptions.UserNotFoundException; @@ -35,6 +41,7 @@ import soc.movies.web.dto.MovieCreationRequest; import soc.movies.web.dto.MovieInfoResponse; import soc.movies.web.dto.MovieSearchResponse; +import soc.movies.web.dto.RateMovieRequest; import soc.movies.web.dto.UserInfoResponse; public class MovieController { @@ -96,19 +103,11 @@ public void createMovie(Context ctx) { .fetchAnyInto(MovieEntity.class); if (movie == null) { - throw new UserAlreadyExistsException(); + throw new MovieAlreadyExistsException(); } - RestClient restClient = RestClient - .builder(HttpHost.create("http://localhost:9200")) - .build(); - ElasticsearchTransport transport = new RestClientTransport(restClient, - new JacksonJsonpMapper(JavalinJackson.defaultMapper())); - - ElasticsearchClient esClient = new ElasticsearchClient(transport); - - esClient.index(i -> i + esClient().index(i -> i .index(Environment.getEsIndex()) .id(String.valueOf(movie.getId())) .document(movie) @@ -164,6 +163,107 @@ public void retrieveMovie(Context ctx) { } } + @SneakyThrows + @OpenApi( + summary = "Rate movie", + operationId = "rateMovie", + path = "/movie/{slug}/rate", + methods = HttpMethod.POST, + requestBody = @OpenApiRequestBody(required = true, content = { + @OpenApiContent(from = RateMovieRequest.class)}), + pathParams = { + @OpenApiParam(name = "slug", type = String.class, description = "Movie identifier") + }, + headers = { + @OpenApiParam(name = AUTH_HEADER_NAME, required = true, description = "Authentication Token")}, + responses = { + @OpenApiResponse(status = "200", content = { + @OpenApiContent(from = UserInfoResponse.class)}), + @OpenApiResponse(status = "400", content = { + @OpenApiContent(from = ErrorResponse.class)}), + @OpenApiResponse(status = "401", content = { + @OpenApiContent(from = ErrorResponse.class)}) + } + ) + public void rateMovie(Context ctx) { + String slug = ctx.pathParam("slug"); + var request = ctx.bodyAsClass(RateMovieRequest.class); + + if (request.rating() > 10 || request.rating() < 0 ) { + throw new InvalidRatingException(); + } + + try (Connection conn = getConnection()) { + UserEntity user = DSL.using(conn, SQLDialect.POSTGRES) + .select(UserEntity.asterisk()) + .from(UserEntity.table()) + .where(UserEntity.usernameField().eq(request.username())) + .fetchAnyInto(UserEntity.class); + + MovieEntity movie = DSL.using(conn, SQLDialect.POSTGRES) + .select(MovieEntity.asterisk()) + .from(MovieEntity.table()) + .where(MovieEntity.slugField().eq(slug)) + .fetchAnyInto(MovieEntity.class); + + if (user == null) { + throw new UserNotFoundException(); + } + + if (movie == null) { + throw new MovieNotFoundException(); + } + + if (!Environment.getApiSecret().equals(ctx.header(AUTH_HEADER_NAME))) { + throw new UnauthenticatedRequest(); + } + + RatingEntity rating = DSL.using(conn, SQLDialect.POSTGRES) + .insertInto(RatingEntity.table()) + .columns( + RatingEntity.userIdField(), + RatingEntity.movieIdField(), + RatingEntity.ratingField() + ).values( + user.getId(), + movie.getId(), + request.rating() + ).returningResult( + RatingEntity.asterisk() + ) + .fetchAnyInto(RatingEntity.class); + + + BigDecimal avgRating = DSL.using(conn, SQLDialect.POSTGRES) + .select(DSL.avg(RatingEntity.ratingField()).as("avg_rating")) + .from(RatingEntity.table()) + .where(RatingEntity.movieIdField().eq(movie.getId())) + .fetchAny() + .value1(); + + DSL.using(conn, SQLDialect.POSTGRES) + .update(MovieEntity.table()) + .set(MovieEntity.ratingField(), avgRating.doubleValue()) + .execute(); + + movie.setRating(avgRating.doubleValue()); + + + + + esClient().index(i -> i + .index(Environment.getEsIndex()) + .id(String.valueOf(movie.getId())) + .document(movie) + ); + + ctx.json(MovieInfoResponse.build(movie)); + ctx.status(HttpStatus.OK); + } catch (IntegrityConstraintViolationException icve) { + throw new RatingAlreadyExistsException(); + } + } + @SneakyThrows @OpenApi( summary = "Search movie", @@ -201,6 +301,10 @@ public void searchMovie(Context ctx) { .map(Hit::source) .toList(); + if (!Environment.getApiSecret().equals(ctx.header(AUTH_HEADER_NAME))) { + throw new UnauthenticatedRequest(); + } + ctx.status(HttpStatus.OK); ctx.json(MovieSearchResponse.build(qWords, movies)); } diff --git a/src/main/java/soc/movies/web/dto/MovieInfoResponse.java b/src/main/java/soc/movies/web/dto/MovieInfoResponse.java index 3ea9a68..4854d22 100644 --- a/src/main/java/soc/movies/web/dto/MovieInfoResponse.java +++ b/src/main/java/soc/movies/web/dto/MovieInfoResponse.java @@ -5,7 +5,7 @@ import soc.movies.common.TextTransformer; import soc.movies.entities.MovieEntity; -public record MovieInfoResponse(long id, String slug, String name, String description, +public record MovieInfoResponse(long id, String slug, String name, String rating, String description, List tags, int releasedYear, String language, String createdAt) { @@ -14,6 +14,7 @@ public static MovieInfoResponse build(MovieEntity entity) { entity.getId(), entity.getSlug(), entity.getName(), + "%.2f".formatted(entity.getRating()), entity.getDescription(), Arrays.stream(entity.getTags().split(",")).toList(), entity.getReleasedYear(), diff --git a/src/main/java/soc/movies/web/dto/RateMovieRequest.java b/src/main/java/soc/movies/web/dto/RateMovieRequest.java new file mode 100644 index 0000000..c9097f4 --- /dev/null +++ b/src/main/java/soc/movies/web/dto/RateMovieRequest.java @@ -0,0 +1,5 @@ +package soc.movies.web.dto; + +public record RateMovieRequest(String username, double rating) { + +} diff --git a/src/test/java/soc/movies/web/LauncherTest.java b/src/test/java/soc/movies/web/LauncherTest.java deleted file mode 100644 index 0dc791c..0000000 --- a/src/test/java/soc/movies/web/LauncherTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package soc.movies.web; - -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import co.elastic.clients.elasticsearch.core.SearchResponse; -import co.elastic.clients.json.jackson.JacksonJsonpMapper; -import co.elastic.clients.transport.ElasticsearchTransport; -import co.elastic.clients.transport.rest_client.RestClientTransport; -import com.fasterxml.jackson.annotation.JsonProperty; -import io.javalin.Javalin; -import io.javalin.testtools.JavalinTest; -import java.io.IOException; -import java.util.List; -import org.apache.http.HttpHost; -import org.elasticsearch.client.RestClient; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import soc.movies.integrationtests.TypeConversion; - -class LauncherTest { - - final Javalin app = Launcher.buildApp(); - - @Test - void helloWorld() throws IOException { - - String serverUrl = "http://localhost:9200"; - - RestClient restClient = RestClient - .builder(HttpHost.create(serverUrl)) - .build(); - - ElasticsearchTransport transport = new RestClientTransport(restClient, - new JacksonJsonpMapper()); - - ElasticsearchClient esClient = new ElasticsearchClient(transport); - - esClient.index(i -> i - .index("movies") - .id("999111") - .document(new Movie( - "Movie 1", - "this is a movie", - List.of("test", "drama"), - 8.3, - "EN", - "1999")) - ); - - esClient.indices().refresh(); - - SearchResponse r = esClient.search(s -> s - .index("movies") - .query(q -> q - .match(t -> t - .field("description") - .query("movie") - ) - ), - Movie.class - ); - - Assertions.assertEquals("999111", r.hits().hits().getFirst().id()); - Assertions.assertEquals("1999", r.hits().hits().getFirst().source().releasedYear); - - SearchResponse r2 = esClient.search(s -> s - .index("movies") - .query(q -> q - .match(t -> t - .field("tags") - .query("drama") - ) - ), - Movie.class - ); - - Assertions.assertEquals("999111", r2.hits().hits().getFirst().id()); - - SearchResponse r3 = esClient.search(s -> s - .index("movies") - .query(q -> q - .match(t -> t - .field("tags") - .query("comedy") - ) - ), - Movie.class - ); - - Assertions.assertEquals(0, r3.hits().total().value()); - - JavalinTest.test(app, (server, client) -> { - var response = client.get("/"); - Assertions.assertEquals(200, response.code()); - var body = TypeConversion.toMap(response); - Assertions.assertEquals("Hello world!", body.get("message")); - }); - } - - public static record Movie(String name, String description, List tags, double rating, - String language, - @JsonProperty("released_year") String releasedYear) { - - } - -}