diff --git a/immersion_tracker_api/BUILD.bazel b/immersion_tracker_api/BUILD.bazel index 04079fd..939973c 100644 --- a/immersion_tracker_api/BUILD.bazel +++ b/immersion_tracker_api/BUILD.bazel @@ -68,6 +68,29 @@ java_binary( ], ) +java_binary( + name = "get-shows-handler", + srcs = glob(["src/main/java/com/jordansimsmith/immersiontracker/GetShowsHandler.java"]), + create_executable = False, + resources = glob([ + "src/main/resources/logback.xml", + ]), + deps = [ + ":lib", + "//lib/time:lib", + "@maven//:ch_qos_logback_logback_classic", + "@maven//:ch_qos_logback_logback_core", + "@maven//:com_amazonaws_aws_lambda_java_core", + "@maven//:com_amazonaws_aws_lambda_java_events", + "@maven//:com_fasterxml_jackson_core_jackson_annotations", + "@maven//:com_fasterxml_jackson_core_jackson_databind", + "@maven//:com_google_code_findbugs_jsr305", + "@maven//:com_google_guava_guava", + "@maven//:software_amazon_awssdk_dynamodb", + "@maven//:software_amazon_awssdk_dynamodb_enhanced", + ], +) + java_test_suite( name = "integration-tests", size = "medium", @@ -85,6 +108,7 @@ java_test_suite( ":auth-handler", ":dagger", ":get-progress-handler", + ":get-shows-handler", ":lib", "//lib/dynamodb:lib", "//lib/json:lib", diff --git a/immersion_tracker_api/infra/main.tf b/immersion_tracker_api/infra/main.tf index 360f867..b0fbd4f 100644 --- a/immersion_tracker_api/infra/main.tf +++ b/immersion_tracker_api/infra/main.tf @@ -202,10 +202,35 @@ resource "aws_lambda_function" "get_progress" { tags = local.tags } +data "external" "get_shows_handler_location" { + program = ["bash", "${path.module}/resolve_location.sh"] + + query = { + target = "//immersion_tracker_api:get-shows-handler_deploy.jar" + } +} + +data "local_file" "get_shows_handler_file" { + filename = data.external.get_shows_handler_location.result.location +} + +resource "aws_lambda_function" "get_shows" { + filename = data.local_file.get_shows_handler_file.filename + function_name = "${local.application_id}_get_shows" + role = aws_iam_role.lambda_role.arn + source_code_hash = data.local_file.get_shows_handler_file.content_base64sha256 + handler = "com.jordansimsmith.immersiontracker.GetShowsHandler" + runtime = "java17" + memory_size = 512 + timeout = 10 + tags = local.tags +} + resource "aws_lambda_permission" "api_gateway" { for_each = toset([ aws_lambda_function.auth.function_name, - aws_lambda_function.get_progress.function_name + aws_lambda_function.get_progress.function_name, + aws_lambda_function.get_shows.function_name, ]) statement_id = "AllowAPIGatewayInvoke" @@ -266,6 +291,29 @@ resource "aws_api_gateway_integration" "get_progress" { uri = aws_lambda_function.get_progress.invoke_arn } +resource "aws_api_gateway_resource" "get_shows" { + rest_api_id = aws_api_gateway_rest_api.immersion_tracker.id + parent_id = aws_api_gateway_rest_api.immersion_tracker.root_resource_id + path_part = "shows" +} + +resource "aws_api_gateway_method" "get_shows" { + rest_api_id = aws_api_gateway_rest_api.immersion_tracker.id + resource_id = aws_api_gateway_resource.get_shows.id + http_method = "GET" + authorization = "CUSTOM" + authorizer_id = aws_api_gateway_authorizer.immersion_tracker.id +} + +resource "aws_api_gateway_integration" "get_shows" { + rest_api_id = aws_api_gateway_rest_api.immersion_tracker.id + resource_id = aws_api_gateway_resource.get_shows.id + http_method = aws_api_gateway_method.get_shows.http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.get_shows.invoke_arn +} + resource "aws_api_gateway_deployment" "immersion_tracker" { rest_api_id = aws_api_gateway_rest_api.immersion_tracker.id @@ -276,6 +324,9 @@ resource "aws_api_gateway_deployment" "immersion_tracker" { aws_api_gateway_resource.get_progress, aws_api_gateway_method.get_progress, aws_api_gateway_integration.get_progress, + aws_api_gateway_resource.get_shows, + aws_api_gateway_method.get_shows, + aws_api_gateway_integration.get_shows, ])) } diff --git a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java index 56da09b..df9f378 100644 --- a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java +++ b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java @@ -33,14 +33,14 @@ public class GetProgressHandler private final DynamoDbTable immersionTrackerTable; @VisibleForTesting - record ProgressResponse( + record GetProgressResponse( @JsonProperty("total_episodes_watched") int totalEpisodesWatched, @JsonProperty("total_hours_watched") int totalHoursWatched, @JsonProperty("episodes_watched_today") int episodesWatchedToday, - @JsonProperty("shows") List shows) {} + @JsonProperty("shows") List shows) {} @VisibleForTesting - record ShowProgress( + record Show( @Nullable @JsonProperty("name") String name, @JsonProperty("episodes_watched") int episodesWatched) {} @@ -106,18 +106,15 @@ private APIGatewayV2HTTPResponse doHandleRequest(APIGatewayV2HTTPEvent event, Co var unknownShows = showEpisodes.stream().filter(e -> e.show() == null).toList(); var unknownShowsProgress = !unknownShows.isEmpty() - ? Stream.of(new ShowProgress(null, unknownShows.size())) - : Stream.empty(); + ? Stream.of(new Show(null, unknownShows.size())) + : Stream.empty(); var knownShows = showEpisodes.stream() .filter(e -> e.show() != null) .collect(Collectors.groupingBy(e -> Objects.requireNonNull(e.show().getTvdbId()))); var knownShowsProgress = knownShows.values().stream() - .map( - e -> - new ShowProgress( - Objects.requireNonNull(e.get(0).show()).getTvdbName(), e.size())); + .map(e -> new Show(Objects.requireNonNull(e.get(0).show()).getTvdbName(), e.size())); var progresses = Stream.concat(unknownShowsProgress, knownShowsProgress) @@ -125,7 +122,7 @@ private APIGatewayV2HTTPResponse doHandleRequest(APIGatewayV2HTTPEvent event, Co .toList(); var res = - new ProgressResponse( + new GetProgressResponse( totalEpisodesWatched, totalHoursWatched, episodesWatchedToday, progresses); return APIGatewayV2HTTPResponse.builder() diff --git a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetShowsHandler.java b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetShowsHandler.java new file mode 100644 index 0000000..0865d2e --- /dev/null +++ b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetShowsHandler.java @@ -0,0 +1,83 @@ +package com.jordansimsmith.immersiontracker; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPResponse; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; + +public class GetShowsHandler + implements RequestHandler { + + private final ObjectMapper objectMapper; + private final DynamoDbTable immersionTrackerTable; + + @VisibleForTesting + record GetShowsResponse(@JsonProperty("shows") List shows) {} + + @VisibleForTesting + record Show( + @JsonProperty("folder_name") String folderName, + @Nullable @JsonProperty("tvdb_id") Integer tvdbId, + @Nullable @JsonProperty("tvdb_name") String tvdbName, + @Nullable @JsonProperty("tvdb_image") String tvdbImage) {} + + public GetShowsHandler() { + this(ImmersionTrackerFactory.create()); + } + + @VisibleForTesting + GetShowsHandler(ImmersionTrackerFactory factory) { + this.objectMapper = factory.objectMapper(); + this.immersionTrackerTable = factory.immersionTrackerTable(); + } + + @Override + public APIGatewayV2HTTPResponse handleRequest(APIGatewayV2HTTPEvent event, Context context) { + try { + return doHandleRequest(event, context); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private APIGatewayV2HTTPResponse doHandleRequest(APIGatewayV2HTTPEvent event, Context context) + throws Exception { + var user = event.getQueryStringParameters().get("user"); + Preconditions.checkNotNull(user); + + var query = + immersionTrackerTable.query( + QueryEnhancedRequest.builder() + .queryConditional( + QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(ImmersionTrackerItem.formatPk(user)) + .sortValue(ImmersionTrackerItem.SHOW_PREFIX) + .build())) + .build()); + var items = query.items().stream().toList(); + var shows = + items.stream() + .map(i -> new Show(i.getFolderName(), i.getTvdbId(), i.getTvdbName(), i.getTvdbImage())) + .toList(); + + var res = new GetShowsResponse(shows); + + return APIGatewayV2HTTPResponse.builder() + .withStatusCode(200) + .withHeaders(Map.of("Content-Type", "application/json; charset=utf-8")) + .withBody(objectMapper.writeValueAsString(res)) + .build(); + } +} diff --git a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerItem.java b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerItem.java index f2f1aef..9387fce 100644 --- a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerItem.java +++ b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerItem.java @@ -13,6 +13,16 @@ public class ImmersionTrackerItem { public static final String EPISODE_PREFIX = "EPISODE" + DELIMITER; public static final String SHOW_PREFIX = "SHOW" + DELIMITER; + public static final String PK = "pk"; + public static final String SK = "sk"; + public static final String USER = "user"; + public static final String FOLDER_NAME = "folder_name"; + public static final String FILE_NAME = "file_name"; + public static final String TIMESTAMP = "timestamp"; + public static final String TVDB_ID = "tvdb_id"; + public static final String TVDB_NAME = "tvdb_name"; + public static final String TVDB_IMAGE = "tvdb_image"; + private String pk; private String sk; private String user; @@ -24,7 +34,7 @@ public class ImmersionTrackerItem { private String tvdbImage; @DynamoDbPartitionKey - @DynamoDbAttribute("pk") + @DynamoDbAttribute(PK) public String getPk() { return pk; } @@ -34,7 +44,7 @@ public void setPk(String pk) { } @DynamoDbSortKey - @DynamoDbAttribute("sk") + @DynamoDbAttribute(SK) public String getSk() { return sk; } @@ -43,7 +53,7 @@ public void setSk(String sk) { this.sk = sk; } - @DynamoDbAttribute("user") + @DynamoDbAttribute(USER) public String getUser() { return user; } @@ -52,7 +62,7 @@ public void setUser(String user) { this.user = user; } - @DynamoDbAttribute("folder_name") + @DynamoDbAttribute(FOLDER_NAME) public String getFolderName() { return folderName; } @@ -61,7 +71,7 @@ public void setFolderName(String folderName) { this.folderName = folderName; } - @DynamoDbAttribute("file_name") + @DynamoDbAttribute(FILE_NAME) public String getFileName() { return fileName; } @@ -70,7 +80,7 @@ public void setFileName(String fileName) { this.fileName = fileName; } - @DynamoDbAttribute("timestamp") + @DynamoDbAttribute(TIMESTAMP) public Integer getTimestamp() { return timestamp; } @@ -79,7 +89,7 @@ public void setTimestamp(Integer timestamp) { this.timestamp = timestamp; } - @DynamoDbAttribute("tvdb_id") + @DynamoDbAttribute(TVDB_ID) public Integer getTvdbId() { return tvdbId; } @@ -88,7 +98,7 @@ public void setTvdbId(Integer tvdbId) { this.tvdbId = tvdbId; } - @DynamoDbAttribute("tvdb_name") + @DynamoDbAttribute(TVDB_NAME) public String getTvdbName() { return tvdbName; } @@ -97,7 +107,7 @@ public void setTvdbName(String tvdbName) { this.tvdbName = tvdbName; } - @DynamoDbAttribute("tvdb_image") + @DynamoDbAttribute(TVDB_IMAGE) public String getTvdbImage() { return tvdbImage; } diff --git a/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java index 864150f..ef2bd0d 100644 --- a/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java +++ b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.jordansimsmith.testcontainers.DynamoDbContainer; import com.jordansimsmith.time.FakeClock; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.testcontainers.junit.jupiter.Container; @@ -47,20 +48,18 @@ void setUp() { @Test void handleRequestShouldCalculateProgress() throws Exception { // arrange + var user = "alice"; var now = (int) fakeClock.now().atZone(GetProgressHandler.ZONE_ID).toInstant().getEpochSecond(); - var episode1 = - ImmersionTrackerItem.createEpisode("jordansimsmith", "show1", "episode1", now - 100); - var episode2 = - ImmersionTrackerItem.createEpisode("jordansimsmith", "show2", "episode2", now + 100); - var episode3 = - ImmersionTrackerItem.createEpisode("jordansimsmith", "show3", "episode1", now - 100); - var show1 = ImmersionTrackerItem.createShow("jordansimsmith", "show1"); + var episode1 = ImmersionTrackerItem.createEpisode(user, "show1", "episode1", now - 100); + var episode2 = ImmersionTrackerItem.createEpisode(user, "show2", "episode2", now + 100); + var episode3 = ImmersionTrackerItem.createEpisode(user, "show3", "episode1", now - 100); + var show1 = ImmersionTrackerItem.createShow(user, "show1"); show1.setTvdbId(1); show1.setTvdbName("my show"); - var show2 = ImmersionTrackerItem.createShow("jordansimsmith", "show2"); + var show2 = ImmersionTrackerItem.createShow(user, "show2"); show2.setTvdbId(1); show2.setTvdbName("my show"); - var show3 = ImmersionTrackerItem.createShow("jordansimsmith", "show3"); + var show3 = ImmersionTrackerItem.createShow(user, "show3"); show3.setTvdbId(2); show3.setTvdbName("my other show"); @@ -72,13 +71,16 @@ void handleRequestShouldCalculateProgress() throws Exception { immersionTrackerTable.putItem(show3); // act - var res = getProgressHandler.handleRequest(APIGatewayV2HTTPEvent.builder().build(), null); + var req = + APIGatewayV2HTTPEvent.builder().withQueryStringParameters(Map.of("user", user)).build(); + var res = getProgressHandler.handleRequest(req, null); // assert assertThat(res.getStatusCode()).isEqualTo(200); assertThat(res.getHeaders()).containsEntry("Content-Type", "application/json; charset=utf-8"); - var progress = objectMapper.readValue(res.getBody(), GetProgressHandler.ProgressResponse.class); + var progress = + objectMapper.readValue(res.getBody(), GetProgressHandler.GetProgressResponse.class); assertThat(progress).isNotNull(); assertThat(progress.totalEpisodesWatched()).isEqualTo(3); assertThat(progress.totalHoursWatched()).isEqualTo(1); @@ -95,14 +97,12 @@ void handleRequestShouldCalculateProgress() throws Exception { @Test void handleRequestShouldReturnUnknownShow() throws Exception { // arrange + var user = "alice"; var now = (int) fakeClock.now().atZone(GetProgressHandler.ZONE_ID).toInstant().getEpochSecond(); - var episode1 = - ImmersionTrackerItem.createEpisode("jordansimsmith", "show1", "episode1", now - 100); - var episode2 = - ImmersionTrackerItem.createEpisode("jordansimsmith", "show1", "episode2", now + 100); - var episode3 = - ImmersionTrackerItem.createEpisode("jordansimsmith", "show3", "episode1", now - 100); - var show1 = ImmersionTrackerItem.createShow("jordansimsmith", "show1"); + var episode1 = ImmersionTrackerItem.createEpisode(user, "show1", "episode1", now - 100); + var episode2 = ImmersionTrackerItem.createEpisode(user, "show1", "episode2", now + 100); + var episode3 = ImmersionTrackerItem.createEpisode(user, "show3", "episode1", now - 100); + var show1 = ImmersionTrackerItem.createShow(user, "show1"); show1.setTvdbId(1); show1.setTvdbName("my show"); @@ -112,13 +112,16 @@ void handleRequestShouldReturnUnknownShow() throws Exception { immersionTrackerTable.putItem(show1); // act - var res = getProgressHandler.handleRequest(APIGatewayV2HTTPEvent.builder().build(), null); + var req = + APIGatewayV2HTTPEvent.builder().withQueryStringParameters(Map.of("user", user)).build(); + var res = getProgressHandler.handleRequest(req, null); // assert assertThat(res.getStatusCode()).isEqualTo(200); assertThat(res.getHeaders()).containsEntry("Content-Type", "application/json; charset=utf-8"); - var progress = objectMapper.readValue(res.getBody(), GetProgressHandler.ProgressResponse.class); + var progress = + objectMapper.readValue(res.getBody(), GetProgressHandler.GetProgressResponse.class); assertThat(progress).isNotNull(); var shows = progress.shows(); assertThat(shows).hasSize(2); diff --git a/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetShowsHandlerIntegrationTest.java b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetShowsHandlerIntegrationTest.java new file mode 100644 index 0000000..a80585b --- /dev/null +++ b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetShowsHandlerIntegrationTest.java @@ -0,0 +1,78 @@ +package com.jordansimsmith.immersiontracker; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jordansimsmith.testcontainers.DynamoDbContainer; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.services.dynamodb.waiters.DynamoDbWaiter; + +@Testcontainers +public class GetShowsHandlerIntegrationTest { + private ObjectMapper objectMapper; + private DynamoDbTable immersionTrackerTable; + + private GetShowsHandler getShowsHandler; + + @Container DynamoDbContainer dynamoDbContainer = new DynamoDbContainer(); + + @BeforeEach + void setUp() { + var factory = ImmersionTrackerTestFactory.create(dynamoDbContainer.getEndpoint()); + + objectMapper = factory.objectMapper(); + + var dynamoDbClient = factory.dynamoDbClient(); + immersionTrackerTable = factory.immersionTrackerTable(); + immersionTrackerTable.createTable(); + try (var waiter = DynamoDbWaiter.builder().client(dynamoDbClient).build()) { + var res = + waiter + .waitUntilTableExists(b -> b.tableName(immersionTrackerTable.tableName()).build()) + .matched(); + res.response().orElseThrow(); + } + + getShowsHandler = new GetShowsHandler(factory); + } + + @Test + void handleRequestShouldGetShows() throws Exception { + // arrange + var user = "alice"; + var episode1 = ImmersionTrackerItem.createEpisode(user, "show1", "episode1", 0); + var show1 = ImmersionTrackerItem.createShow(user, "show1"); + show1.setTvdbId(123); + show1.setTvdbName("my show"); + show1.setTvdbImage("my image"); + var show2 = ImmersionTrackerItem.createShow(user, "show2"); + + immersionTrackerTable.putItem(episode1); + immersionTrackerTable.putItem(show1); + immersionTrackerTable.putItem(show2); + + // act + var req = + APIGatewayV2HTTPEvent.builder().withQueryStringParameters(Map.of("user", user)).build(); + var res = getShowsHandler.handleRequest(req, null); + + // assert + assertThat(res.getStatusCode()).isEqualTo(200); + assertThat(res.getHeaders()).containsEntry("Content-Type", "application/json; charset=utf-8"); + + var shows = objectMapper.readValue(res.getBody(), GetShowsHandler.GetShowsResponse.class); + assertThat(shows).isNotNull(); + assertThat(shows.shows()).hasSize(2); + assertThat(shows.shows().get(0).folderName()).isEqualTo("show1"); + assertThat(shows.shows().get(0).tvdbId()).isEqualTo(123); + assertThat(shows.shows().get(0).tvdbName()).isEqualTo("my show"); + assertThat(shows.shows().get(0).tvdbImage()).isEqualTo("my image"); + assertThat(shows.shows().get(1).folderName()).isEqualTo("show2"); + } +}