From e250f691a6bbcfaa6748cd775eff81ad394dc12d Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 27 May 2024 15:16:11 -0700 Subject: [PATCH] Introduce GCS Retrofit endpoint. This pulls in the GCS endpoint from #5398 as part of a broader effort of splitting it up. --- .../oppia/android/scripts/gae/gcs/BUILD.bazel | 23 ++++ .../android/scripts/gae/gcs/GcsEndpointApi.kt | 21 ++++ .../android/scripts/gae/gcs/GcsService.kt | 105 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 scripts/src/java/org/oppia/android/scripts/gae/gcs/BUILD.bazel create mode 100644 scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsEndpointApi.kt create mode 100644 scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsService.kt diff --git a/scripts/src/java/org/oppia/android/scripts/gae/gcs/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/gae/gcs/BUILD.bazel new file mode 100644 index 00000000000..f186fc1e6fb --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/gcs/BUILD.bazel @@ -0,0 +1,23 @@ +""" +Library for providing the endpoint functionality to inspect and download assets from Google Cloud +Storage. +""" + +load("@io_bazel_rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library") + +kt_jvm_library( + name = "api", + testonly = True, + srcs = [ + "GcsEndpointApi.kt", + "GcsService.kt", + ], + visibility = [ + "//scripts/src/java/org/oppia/android/scripts/gae:__subpackages__", + ], + deps = [ + "//third_party:com_squareup_retrofit2_converter-moshi", + "//third_party:com_squareup_retrofit2_retrofit", + "//third_party:org_jetbrains_kotlinx_kotlinx-coroutines-core", + ], +) diff --git a/scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsEndpointApi.kt b/scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsEndpointApi.kt new file mode 100644 index 00000000000..bcef1f33faf --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsEndpointApi.kt @@ -0,0 +1,21 @@ +package org.oppia.android.scripts.gae.gcs + +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Path +import retrofit2.http.Streaming + +interface GcsEndpointApi { + @GET("{gcs_bucket}/{entity_type}/{entity_id}/assets/{image_type}/{image_filename}") + @Headers("Content-Type:application/octet-stream") + @Streaming + fun fetchImageData( + @Path("gcs_bucket") gcsBucket: String, + @Path("entity_type") entityType: String, + @Path("entity_id") entityId: String, + @Path("image_type") imageType: String, + @Path("image_filename") imageFilename: String + ): Call +} diff --git a/scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsService.kt b/scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsService.kt new file mode 100644 index 00000000000..b8df150dd0c --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/gae/gcs/GcsService.kt @@ -0,0 +1,105 @@ +package org.oppia.android.scripts.gae.gcs + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import okhttp3.Request +import retrofit2.Call +import retrofit2.Response +import retrofit2.Retrofit + +class GcsService(private val baseUrl: String, private val gcsBucket: String) { + private val retrofit by lazy { Retrofit.Builder().baseUrl(baseUrl).build() } + private val apiService by lazy { retrofit.create(GcsEndpointApi::class.java) } + + fun fetchImageContentLengthAsync( + imageContainerType: ImageContainerType, + imageType: ImageType, + entityId: String, + imageFilename: String + ): Deferred { + return apiService.fetchImageData( + gcsBucket, + imageContainerType.httpRepresentation, + entityId, + imageType.httpRepresentation, + imageFilename + ).resolveAsync( + transform = { request, response -> + checkNotNull(response.body()) { + "Failed to receive body for request: $request." + }.use { it.contentLength() } + }, + default = { _, _, -> null } +// default = { request, response -> +// error("Failed to call: $request. Encountered failure:\n$response") +// } + ) + } + + fun fetchImageContentDataAsync( + imageContainerType: ImageContainerType, + imageType: ImageType, + entityId: String, + imageFilename: String + ): Deferred { + return apiService.fetchImageData( + gcsBucket, + imageContainerType.httpRepresentation, + entityId, + imageType.httpRepresentation, + imageFilename + ).resolveAsync( + transform = { request, response -> + checkNotNull(response.body()) { "Failed to receive body for request: $request." }.use { + it.byteStream().readBytes() + } + }, + default = { _, _ -> null } +// default = { request, response -> +// error("Failed to call: $request. Encountered failure:\n$response") +// } + ) + } + + fun computeImageUrl( + imageContainerType: ImageContainerType, + imageType: ImageType, + entityId: String, + imageFilename: String + ): String { + val containerTypeHttpRep = imageContainerType.httpRepresentation + val imgTypeHttpRep = imageType.httpRepresentation + return "${baseUrl.removeSuffix("/")}/$gcsBucket/$containerTypeHttpRep/$entityId/assets" + + "/$imgTypeHttpRep/$imageFilename" + } + + enum class ImageContainerType(val httpRepresentation: String) { + EXPLORATION(httpRepresentation = "exploration"), + SKILL(httpRepresentation = "skill"), + TOPIC(httpRepresentation = "topic"), + STORY(httpRepresentation = "story") + } + + enum class ImageType(val httpRepresentation: String) { + HTML_IMAGE(httpRepresentation = "image"), + THUMBNAIL(httpRepresentation = "thumbnail") + } + + private companion object { + private fun Call.resolveAsync( + transform: (Request, Response) -> O, + default: (Request, Response) -> O + ): Deferred { + // Use the I/O dispatcher for blocking HTTP operations (since it's designed to handle blocking + // operations that might otherwise stall a coroutine dispatcher). + return CoroutineScope(Dispatchers.IO).async { + val result = execute() + return@async if (result.isSuccessful) { + transform(request(), result) + } else default(request(), result) + } + } + } +}