Set base url through Interceptor

This commit is contained in:
Kirill Kamakin
2022-12-11 20:22:36 +01:00
parent 85b863227d
commit f6c0e862fc
27 changed files with 265 additions and 234 deletions

View File

@@ -8,6 +8,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.datasource.impl.AuthInterceptor
import gq.kirmanak.mealient.datasource.impl.BaseUrlInterceptor
import gq.kirmanak.mealient.datasource.impl.CacheBuilderImpl
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl
@@ -20,7 +21,6 @@ import gq.kirmanak.mealient.datasource.v1.MealieDataSourceV1Impl
import gq.kirmanak.mealient.datasource.v1.MealieServiceV1
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Converter
@@ -55,8 +55,11 @@ interface DataSourceModule {
@Provides
@Singleton
fun provideRetrofit(retrofitBuilder: RetrofitBuilder): Retrofit =
retrofitBuilder.buildRetrofit("https://beta.mealie.io/")
fun provideRetrofit(retrofitBuilder: RetrofitBuilder): Retrofit {
// Fake base URL which will be replaced later by BaseUrlInterceptor
// Solution was suggested here https://github.com/square/retrofit/issues/2161#issuecomment-274204152
return retrofitBuilder.buildRetrofit("http://localhost/")
}
@Provides
@Singleton
@@ -92,5 +95,10 @@ interface DataSourceModule {
@Binds
@Singleton
@IntoSet
fun bindAuthInterceptor(authInterceptor: AuthInterceptor): Interceptor
fun bindAuthInterceptor(authInterceptor: AuthInterceptor): LocalInterceptor
@Binds
@Singleton
@IntoSet
fun bindBaseUrlInterceptor(baseUrlInterceptor: BaseUrlInterceptor): LocalInterceptor
}

View File

@@ -0,0 +1,12 @@
package gq.kirmanak.mealient.datasource
import okhttp3.Interceptor
import okhttp3.OkHttpClient
/**
* Marker interface which is different from [Interceptor] only in how it is handled.
* [Interceptor]s are added as network interceptors to OkHttpClient whereas [LocalInterceptor]s
* are added via [OkHttpClient.Builder.addInterceptor] function. They will observe the
* full call lifecycle, whereas network interceptors will see only the network part.
*/
interface LocalInterceptor : Interceptor

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.datasource
interface ServerUrlProvider {
suspend fun getUrl(): String?
}

View File

@@ -2,6 +2,7 @@ package gq.kirmanak.mealient.datasource.impl
import androidx.annotation.VisibleForTesting
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.datasource.LocalInterceptor
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
@@ -14,13 +15,13 @@ import javax.inject.Singleton
class AuthInterceptor @Inject constructor(
private val logger: Logger,
private val authenticationProviderProvider: Provider<AuthenticationProvider>,
) : Interceptor {
) : LocalInterceptor {
private val authenticationProvider: AuthenticationProvider
get() = authenticationProviderProvider.get()
override fun intercept(chain: Interceptor.Chain): Response {
logger.v { "intercept() was called" }
logger.v { "intercept() was called with: request = ${chain.request()}" }
val header = getAuthHeader()
val request = chain.request().let {
if (header == null) it else it.newBuilder().header(HEADER_NAME, header).build()

View File

@@ -0,0 +1,41 @@
package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.LocalInterceptor
import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class BaseUrlInterceptor @Inject constructor(
private val serverUrlProviderProvider: Provider<ServerUrlProvider>,
private val logger: Logger,
) : LocalInterceptor {
private val serverUrlProvider: ServerUrlProvider
get() = serverUrlProviderProvider.get()
override fun intercept(chain: Interceptor.Chain): Response {
logger.v { "intercept() was called with: request = ${chain.request()}" }
val oldRequest = chain.request()
val baseUrl = getBaseUrl()
val correctUrl = oldRequest.url
.newBuilder()
.host(baseUrl.host)
.scheme(baseUrl.scheme)
.build()
val newRequest = oldRequest.newBuilder().url(correctUrl).build()
logger.d { "Replaced ${oldRequest.url} with ${newRequest.url}" }
return chain.proceed(newRequest)
}
private fun getBaseUrl() = runBlocking {
serverUrlProvider.getUrl()?.toHttpUrlOrNull() ?: throw IOException("Base URL is unknown")
}
}

View File

@@ -1,7 +1,9 @@
package gq.kirmanak.mealient.datasource.impl
import gq.kirmanak.mealient.datasource.CacheBuilder
import gq.kirmanak.mealient.datasource.LocalInterceptor
import gq.kirmanak.mealient.datasource.OkHttpBuilder
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import javax.inject.Inject
@@ -12,10 +14,16 @@ class OkHttpBuilderImpl @Inject constructor(
private val cacheBuilder: CacheBuilder,
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
private val interceptors: Set<@JvmSuppressWildcards Interceptor>,
private val localInterceptors: Set<@JvmSuppressWildcards LocalInterceptor>,
private val logger: Logger,
) : OkHttpBuilder {
override fun buildOkHttp(): OkHttpClient = OkHttpClient.Builder()
.apply { interceptors.forEach(::addNetworkInterceptor) }
.cache(cacheBuilder.buildCache())
.build()
override fun buildOkHttp(): OkHttpClient {
logger.v { "buildOkHttp() was called with cacheBuilder = $cacheBuilder, interceptors = $interceptors, localInterceptors = $localInterceptors" }
return OkHttpClient.Builder().apply {
localInterceptors.forEach(::addInterceptor)
interceptors.forEach(::addNetworkInterceptor)
cache(cacheBuilder.buildCache())
}.build()
}
}

View File

@@ -10,7 +10,6 @@ import gq.kirmanak.mealient.datasource.v0.models.VersionResponseV0
interface MealieDataSourceV0 {
suspend fun addRecipe(
baseUrl: String,
recipe: AddRecipeRequestV0,
): String
@@ -18,33 +17,27 @@ interface MealieDataSourceV0 {
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String
suspend fun getVersionInfo(
baseUrl: String,
): VersionResponseV0
suspend fun requestRecipes(
baseUrl: String,
start: Int,
limit: Int,
): List<GetRecipeSummaryResponseV0>
suspend fun requestRecipeInfo(
baseUrl: String,
slug: String,
): GetRecipeResponseV0
suspend fun parseRecipeFromURL(
baseUrl: String,
request: ParseRecipeURLRequestV0,
): String
suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV0,
): String
}

View File

@@ -26,34 +26,30 @@ class MealieDataSourceV0Impl @Inject constructor(
) : MealieDataSourceV0 {
override suspend fun addRecipe(
baseUrl: String,
recipe: AddRecipeRequestV0,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.addRecipe("$baseUrl/api/recipes/create", recipe) },
block = { service.addRecipe(recipe) },
logMethod = { "addRecipe" },
logParameters = { "baseUrl = $baseUrl, recipe = $recipe" }
logParameters = { "recipe = $recipe" }
)
override suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String = networkRequestWrapper.makeCall(
block = { service.getToken("$baseUrl/api/auth/token", username, password) },
block = { service.getToken(username, password) },
logMethod = { "authenticate" },
logParameters = { "baseUrl = $baseUrl, username = $username, password = $password" }
logParameters = { "username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it
val errorDetailV0 = errorBody.decode<ErrorDetailV0>(json)
throw if (errorDetailV0.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
override suspend fun getVersionInfo(
baseUrl: String
): VersionResponseV0 = networkRequestWrapper.makeCall(
block = { service.getVersion("$baseUrl/api/debug/version") },
override suspend fun getVersionInfo(): VersionResponseV0 = networkRequestWrapper.makeCall(
block = { service.getVersion() },
logMethod = { "getVersionInfo" },
logParameters = { "baseUrl = $baseUrl" },
logParameters = { "" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
@@ -63,39 +59,35 @@ class MealieDataSourceV0Impl @Inject constructor(
}
override suspend fun requestRecipes(
baseUrl: String,
start: Int,
limit: Int,
): List<GetRecipeSummaryResponseV0> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary("$baseUrl/api/recipes/summary", start, limit) },
block = { service.getRecipeSummary(start, limit) },
logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, start = $start, limit = $limit" }
logParameters = { "start = $start, limit = $limit" }
)
override suspend fun requestRecipeInfo(
baseUrl: String,
slug: String,
): GetRecipeResponseV0 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe("$baseUrl/api/recipes/$slug") },
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, slug = $slug" }
logParameters = { "slug = $slug" }
)
override suspend fun parseRecipeFromURL(
baseUrl: String,
request: ParseRecipeURLRequestV0
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", request) },
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
logParameters = { "baseUrl = $baseUrl, request = $request" },
logParameters = { "request = $request" },
)
override suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV0,
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken("$baseUrl/api/users/api-tokens", request) },
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "baseUrl = $baseUrl, request = $request" }
logParameters = { "request = $request" }
)
}

View File

@@ -6,45 +6,38 @@ import retrofit2.http.*
interface MealieServiceV0 {
@FormUrlEncoded
@POST
@POST("/api/auth/token")
suspend fun getToken(
@Url url: String,
@Field("username") username: String,
@Field("password") password: String,
): GetTokenResponseV0
@POST
@POST("/api/recipes/create")
suspend fun addRecipe(
@Url url: String,
@Body addRecipeRequestV0: AddRecipeRequestV0,
): String
@GET
suspend fun getVersion(
@Url url: String,
): VersionResponseV0
@GET("/api/debug/version")
suspend fun getVersion(): VersionResponseV0
@GET
@GET("/api/recipes/summary")
suspend fun getRecipeSummary(
@Url url: String,
@Query("start") start: Int,
@Query("limit") limit: Int,
): List<GetRecipeSummaryResponseV0>
@GET
@GET("/api/recipes/{slug}")
suspend fun getRecipe(
@Url url: String,
@Path("slug") slug: String,
): GetRecipeResponseV0
@POST
@POST("/api/recipes/create-url")
suspend fun createRecipeFromURL(
@Url url: String,
@Body request: ParseRecipeURLRequestV0,
): String
@POST
@POST("/api/users/api-tokens")
suspend fun createApiToken(
@Url url: String,
@Body request: CreateApiTokenRequestV0,
): String
}

View File

@@ -12,12 +12,10 @@ import gq.kirmanak.mealient.datasource.v1.models.VersionResponseV1
interface MealieDataSourceV1 {
suspend fun createRecipe(
baseUrl: String,
recipe: CreateRecipeRequestV1,
): String
suspend fun updateRecipe(
baseUrl: String,
slug: String,
recipe: UpdateRecipeRequestV1,
): GetRecipeResponseV1
@@ -26,33 +24,27 @@ interface MealieDataSourceV1 {
* Tries to acquire authentication token using the provided credentials
*/
suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String
suspend fun getVersionInfo(
baseUrl: String,
): VersionResponseV1
suspend fun requestRecipes(
baseUrl: String,
page: Int,
perPage: Int,
): List<GetRecipeSummaryResponseV1>
suspend fun requestRecipeInfo(
baseUrl: String,
slug: String,
): GetRecipeResponseV1
suspend fun parseRecipeFromURL(
baseUrl: String,
request: ParseRecipeURLRequestV1,
): String
suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV1,
): CreateApiTokenResponseV1
}

View File

@@ -28,44 +28,39 @@ class MealieDataSourceV1Impl @Inject constructor(
) : MealieDataSourceV1 {
override suspend fun createRecipe(
baseUrl: String,
recipe: CreateRecipeRequestV1
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipe("$baseUrl/api/recipes", recipe) },
block = { service.createRecipe(recipe) },
logMethod = { "createRecipe" },
logParameters = { "baseUrl = $baseUrl, recipe = $recipe" }
logParameters = { "recipe = $recipe" }
)
override suspend fun updateRecipe(
baseUrl: String,
slug: String,
recipe: UpdateRecipeRequestV1
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.updateRecipe("$baseUrl/api/recipes/$slug", recipe) },
block = { service.updateRecipe(recipe, slug) },
logMethod = { "updateRecipe" },
logParameters = { "baseUrl = $baseUrl, slug = $slug, recipe = $recipe" }
logParameters = { "slug = $slug, recipe = $recipe" }
)
override suspend fun authenticate(
baseUrl: String,
username: String,
password: String,
): String = networkRequestWrapper.makeCall(
block = { service.getToken("$baseUrl/api/auth/token", username, password) },
block = { service.getToken(username, password) },
logMethod = { "authenticate" },
logParameters = { "baseUrl = $baseUrl, username = $username, password = $password" }
logParameters = { "username = $username, password = $password" }
).map { it.accessToken }.getOrElse {
val errorBody = (it as? HttpException)?.response()?.errorBody() ?: throw it
val errorDetailV0 = errorBody.decode<ErrorDetailV1>(json)
throw if (errorDetailV0.detail == "Unauthorized") NetworkError.Unauthorized(it) else it
}
override suspend fun getVersionInfo(
baseUrl: String,
): VersionResponseV1 = networkRequestWrapper.makeCall(
block = { service.getVersion("$baseUrl/api/app/about") },
override suspend fun getVersionInfo(): VersionResponseV1 = networkRequestWrapper.makeCall(
block = { service.getVersion() },
logMethod = { "getVersionInfo" },
logParameters = { "baseUrl = $baseUrl" },
logParameters = { "" },
).getOrElse {
throw when (it) {
is HttpException, is SerializationException -> NetworkError.NotMealie(it)
@@ -75,40 +70,36 @@ class MealieDataSourceV1Impl @Inject constructor(
}
override suspend fun requestRecipes(
baseUrl: String,
page: Int,
perPage: Int
): List<GetRecipeSummaryResponseV1> = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipeSummary("$baseUrl/api/recipes", page, perPage) },
block = { service.getRecipeSummary(page, perPage) },
logMethod = { "requestRecipes" },
logParameters = { "baseUrl = $baseUrl, page = $page, perPage = $perPage" }
logParameters = { "page = $page, perPage = $perPage" }
).items
override suspend fun requestRecipeInfo(
baseUrl: String,
slug: String
): GetRecipeResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.getRecipe("$baseUrl/api/recipes/$slug") },
block = { service.getRecipe(slug) },
logMethod = { "requestRecipeInfo" },
logParameters = { "baseUrl = $baseUrl, slug = $slug" }
logParameters = { "slug = $slug" }
)
override suspend fun parseRecipeFromURL(
baseUrl: String,
request: ParseRecipeURLRequestV1
): String = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createRecipeFromURL("$baseUrl/api/recipes/create-url", request) },
block = { service.createRecipeFromURL(request) },
logMethod = { "parseRecipeFromURL" },
logParameters = { "baseUrl = $baseUrl, request = $request" }
logParameters = { "request = $request" }
)
override suspend fun createApiToken(
baseUrl: String,
request: CreateApiTokenRequestV1
): CreateApiTokenResponseV1 = networkRequestWrapper.makeCallAndHandleUnauthorized(
block = { service.createApiToken("$baseUrl/api/users/api-tokens", request) },
block = { service.createApiToken(request) },
logMethod = { "createApiToken" },
logParameters = { "baseUrl = $baseUrl, request = $request" }
logParameters = { "request = $request" }
)
}

View File

@@ -6,51 +6,44 @@ import retrofit2.http.*
interface MealieServiceV1 {
@FormUrlEncoded
@POST
@POST("/api/auth/token")
suspend fun getToken(
@Url url: String,
@Field("username") username: String,
@Field("password") password: String,
): GetTokenResponseV1
@POST
@POST("/api/recipes")
suspend fun createRecipe(
@Url url: String,
@Body addRecipeRequest: CreateRecipeRequestV1,
): String
@PATCH
@PATCH("/api/recipes/{slug}")
suspend fun updateRecipe(
@Url url: String,
@Body addRecipeRequest: UpdateRecipeRequestV1,
@Path("slug") slug: String,
): GetRecipeResponseV1
@GET
suspend fun getVersion(
@Url url: String,
): VersionResponseV1
@GET("/api/app/about")
suspend fun getVersion(): VersionResponseV1
@GET
@GET("/api/recipes")
suspend fun getRecipeSummary(
@Url url: String,
@Query("page") page: Int,
@Query("perPage") perPage: Int,
): GetRecipesResponseV1
@GET
@GET("/api/recipes/{slug}")
suspend fun getRecipe(
@Url url: String,
@Path("slug") slug: String,
): GetRecipeResponseV1
@POST
@POST("/api/recipes/create-url")
suspend fun createRecipeFromURL(
@Url url: String,
@Body request: ParseRecipeURLRequestV1,
): String
@POST
@POST("/api/users/api-tokens")
suspend fun createApiToken(
@Url url: String,
@Body request: CreateApiTokenRequestV1,
): CreateApiTokenResponseV1
}

View File

@@ -38,34 +38,34 @@ class MealieDataSourceV0ImplTest : BaseUnitTest() {
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws HttpException then NotMealie`() = runTest {
val error = HttpException(Response.error<VersionResponseV0>(404, "".toJsonResponseBody()))
coEvery { service.getVersion(any()) } throws error
subject.getVersionInfo(TEST_BASE_URL)
coEvery { service.getVersion() } throws error
subject.getVersionInfo()
}
@Test(expected = NetworkError.NotMealie::class)
fun `when getVersionInfo and getVersion throws SerializationException then NotMealie`() =
runTest {
coEvery { service.getVersion(any()) } throws SerializationException()
subject.getVersionInfo(TEST_BASE_URL)
coEvery { service.getVersion() } throws SerializationException()
subject.getVersionInfo()
}
@Test(expected = NetworkError.NoServerConnection::class)
fun `when getVersionInfo and getVersion throws IOException then NoServerConnection`() =
runTest {
coEvery { service.getVersion(any()) } throws ConnectException()
subject.getVersionInfo(TEST_BASE_URL)
coEvery { service.getVersion() } throws ConnectException()
subject.getVersionInfo()
}
@Test
fun `when getVersionInfo and getVersion returns result then result`() = runTest {
val versionResponse = VersionResponseV0("v0.5.6")
coEvery { service.getVersion(any()) } returns versionResponse
assertThat(subject.getVersionInfo(TEST_BASE_URL)).isSameInstanceAs(versionResponse)
coEvery { service.getVersion() } returns versionResponse
assertThat(subject.getVersionInfo()).isSameInstanceAs(versionResponse)
}
@Test
fun `when authentication is successful then token is correct`() = runTest {
coEvery { service.getToken(any(), any(), any()) } returns GetTokenResponseV0(TEST_TOKEN)
coEvery { service.getToken(any(), any()) } returns GetTokenResponseV0(TEST_TOKEN)
assertThat(callAuthenticate()).isEqualTo(TEST_TOKEN)
}
@@ -73,7 +73,7 @@ class MealieDataSourceV0ImplTest : BaseUnitTest() {
fun `when authenticate receives 401 and Unauthorized then throws Unauthorized`() = runTest {
val body = "{\"detail\":\"Unauthorized\"}".toJsonResponseBody()
coEvery {
service.getToken(any(), any(), any())
service.getToken(any(), any())
} throws HttpException(Response.error<GetTokenResponseV0>(401, body))
callAuthenticate()
}
@@ -82,7 +82,7 @@ class MealieDataSourceV0ImplTest : BaseUnitTest() {
fun `when authenticate receives 401 but not Unauthorized then throws NotMealie`() = runTest {
val body = "{\"detail\":\"Something\"}".toJsonResponseBody()
coEvery {
service.getToken(any(), any(), any())
service.getToken(any(), any())
} throws HttpException(Response.error<GetTokenResponseV0>(401, body))
callAuthenticate()
}
@@ -91,22 +91,21 @@ class MealieDataSourceV0ImplTest : BaseUnitTest() {
fun `when authenticate receives 404 and empty body then throws NotMealie`() = runTest {
val body = "".toJsonResponseBody()
coEvery {
service.getToken(any(), any(), any())
service.getToken(any(), any())
} throws HttpException(Response.error<GetTokenResponseV0>(401, body))
callAuthenticate()
}
@Test(expected = IOException::class)
fun `when authenticate and getToken throws then throws NoServerConnection`() = runTest {
coEvery { service.getToken(any(), any(), any()) } throws IOException("Server not found")
coEvery { service.getToken(any(), any()) } throws IOException("Server not found")
callAuthenticate()
}
private suspend fun callAuthenticate(): String =
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, TEST_BASE_URL)
subject.authenticate(TEST_PASSWORD, TEST_BASE_URL)
companion object {
const val TEST_USERNAME = "TEST_USERNAME"
const val TEST_PASSWORD = "TEST_PASSWORD"
const val TEST_BASE_URL = "https://example.com/"
const val TEST_TOKEN = "TEST_TOKEN"