From 80327873d703b421f978d2a5e8d2f25725cb4996 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Sun, 12 Jan 2025 16:16:35 +0300 Subject: [PATCH 01/15] feat: add new SDK core module --- core/build.gradle | 89 +++ .../AbstractBearerAuthenticationManager.kt | 100 +++ .../BearerAuthenticationAsyncManager.kt | 97 +++ .../bearer/BearerAuthenticationManager.kt | 107 ++++ .../bearer/BearerTokenResponse.kt | 73 +++ .../bearer/BearerTokenStorage.kt | 103 ++++ .../common/AuthenticationManager.kt | 43 ++ .../core/authentication/common/Credentials.kt | 54 ++ .../sdk/core/common/Extensions.kt | 19 + .../sdk/core/common/MetadataLoader.kt | 81 +++ .../core/exception/ExpediaGroupException.kt | 28 + .../client/ExpediaGroupClientException.kt | 30 + .../ExpediaGroupConfigurationException.kt | 28 + .../ExpediaGroupResponseParsingException.kt | 31 + .../service/ExpediaGroupAuthException.kt | 54 ++ .../service/ExpediaGroupNetworkException.kt | 34 ++ .../service/ExpediaGroupServiceException.kt | 31 + .../sdk/core/http/CommonMediaTypes.kt | 103 ++++ .../com/expediagroup/sdk/core/http/Headers.kt | 181 ++++++ .../expediagroup/sdk/core/http/MediaType.kt | 153 +++++ .../com/expediagroup/sdk/core/http/Method.kt | 37 ++ .../expediagroup/sdk/core/http/Protocol.kt | 47 ++ .../com/expediagroup/sdk/core/http/Request.kt | 220 +++++++ .../expediagroup/sdk/core/http/RequestBody.kt | 171 ++++++ .../expediagroup/sdk/core/http/Response.kt | 255 ++++++++ .../sdk/core/http/ResponseBody.kt | 108 ++++ .../com/expediagroup/sdk/core/http/Status.kt | 112 ++++ .../expediagroup/sdk/core/logging/Constant.kt | 24 + .../sdk/core/logging/LoggableContentTypes.kt | 44 ++ .../sdk/core/logging/LoggerDecorator.kt | 72 +++ .../sdk/core/logging/RequestLogger.kt | 75 +++ .../sdk/core/logging/ResponseLogger.kt | 74 +++ .../sdk/core/pipeline/ExecutionPipeline.kt | 33 + .../pipeline/step/BearerAuthenticationStep.kt | 54 ++ .../core/pipeline/step/RequestHeadersStep.kt | 31 + .../core/pipeline/step/RequestLoggingStep.kt | 55 ++ .../core/pipeline/step/ResponseLoggingStep.kt | 28 + .../transport/AbstractAsyncRequestExecutor.kt | 96 +++ .../core/transport/AbstractRequestExecutor.kt | 98 +++ .../sdk/core/transport/AsyncTransport.kt | 81 +++ .../sdk/core/transport/Disposable.kt | 34 ++ .../sdk/core/transport/Transport.kt | 59 ++ .../sdk/core/http/HeadersJavaTest.java | 51 ++ .../sdk/core/http/MediaTypeJavaTest.java | 26 + .../sdk/core/http/ProtocolJavaTest.java | 14 + .../sdk/core/http/RequestBodyJavaTest.java | 91 +++ .../sdk/core/http/RequestJavaTest.java | 14 + .../sdk/core/http/ResponseBodyJavaTest.java | 45 ++ .../sdk/core/http/ResponseJavaTest.java | 24 + .../sdk/core/http/StatusJavaTest.java | 14 + ...AbstractBearerAuthenticationManagerTest.kt | 82 +++ .../BearerAuthenticationAsyncManagerTest.kt | 299 +++++++++ .../bearer/BearerAuthenticationManagerTest.kt | 294 +++++++++ .../bearer/BearerTokenResponseTest.kt | 195 ++++++ .../bearer/BearerTokenStorageTest.kt | 283 +++++++++ .../authentication/common/CredentialsTest.kt | 111 ++++ .../sdk/core/common/ExtensionsTest.kt | 33 + .../sdk/core/common/MetadataLoaderTest.kt | 108 ++++ .../sdk/core/http/CommonMediaTypesTest.kt | 188 ++++++ .../expediagroup/sdk/core/http/HeadersTest.kt | 449 ++++++++++++++ .../sdk/core/http/MediaTypeTest.kt | 568 ++++++++++++++++++ .../expediagroup/sdk/core/http/MethodTest.kt | 53 ++ .../sdk/core/http/ProtocolTest.kt | 43 ++ .../sdk/core/http/RequestBodyTest.kt | 138 +++++ .../expediagroup/sdk/core/http/RequestTest.kt | 327 ++++++++++ .../sdk/core/http/ResponseBodyTest.kt | 132 ++++ .../sdk/core/http/ResponseTest.kt | 409 +++++++++++++ .../expediagroup/sdk/core/http/StatusTest.kt | 62 ++ .../sdk/core/logging/RequestLoggerTest.kt | 228 +++++++ .../AbstractAsyncRequestExecutorTest.kt | 124 ++++ .../transport/AbstractRequestExecutorTest.kt | 123 ++++ core/src/test/resources/sdk.properties | 3 + settings.gradle | 1 + 73 files changed, 7779 insertions(+) create mode 100644 core/build.gradle create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt create mode 100644 core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java create mode 100644 core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt create mode 100644 core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt create mode 100644 core/src/test/resources/sdk.properties diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 00000000..01a636f6 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,89 @@ +import kotlinx.kover.gradle.plugin.dsl.AggregationType +import kotlinx.kover.gradle.plugin.dsl.CoverageUnit + +plugins { + id 'org.jetbrains.kotlin.jvm' version '2.1.0' + + /* Test Reporting */ + id 'org.jetbrains.kotlinx.kover' version "0.9.0" + + /* Linting */ + id "org.jlleitschuh.gradle.ktlint" version "12.1.2" +} + +kotlin { + jvmToolchain(8) +} + +dependencies { + /* Kotlin */ + implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.1.0' + + /* EG SDK Core */ + implementation 'com.squareup.okio:okio:3.10.2' + implementation 'com.ebay.ejmask:ejmask-api:1.3.0' + implementation 'com.ebay.ejmask:ejmask-extensions:1.3.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.2' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2' + implementation 'org.slf4j:slf4j-api:2.0.16' + + /* Testing */ + testImplementation platform('org.junit:junit-bom:5.11.4') + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testImplementation 'org.junit.jupiter:junit-jupiter-params' + testImplementation 'io.mockk:mockk:1.13.14' + testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0" +} + +ktlint { + debug = true + version = "1.5.0" + verbose = true + + additionalEditorconfig = [ + "max_line_length": "200", + "indent_style": "space", + "indent_size": "4", + "insert_final_newline": "true", + "end_of_line": "lf", + ] +} + +test { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed" + } + + classpath = classpath.filter { + // exclude original sdk.properties file during testing + !it.absolutePath.contains("build/resources/main/sdk.properties") + } +} + +tasks.named("check") { + finalizedBy("koverHtmlReport") +} + +kover { + reports { + total { + verify { + rule { + bound { + aggregationForGroup = AggregationType.COVERED_PERCENTAGE + coverageUnits = CoverageUnit.LINE + minValue = 92 + } + bound { + aggregationForGroup = AggregationType.COVERED_PERCENTAGE + coverageUnits = CoverageUnit.BRANCH + minValue = 86 + } + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt new file mode 100644 index 00000000..c4253d78 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.authentication.common.AuthenticationManager +import com.expediagroup.sdk.core.authentication.common.Credentials +import com.expediagroup.sdk.core.http.CommonMediaTypes +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.RequestBody + +/** + * Abstract class that contains common functionalities to store and renew bearer tokens. + * This class does not handle the bearer token fetching or parsing. Subclasses have to implement the + * [AuthenticationManager.authenticate] method where the token is fetched and parsed. + */ +abstract class AbstractBearerAuthenticationManager( + private val authUrl: String, + protected val credentials: Credentials, +) : AuthenticationManager { + @Volatile + private var bearerTokenStorage = BearerTokenStorage.empty + + /** + * Checks if the current bearer token is about to expire and needs renewal. + * + * @return `true` if the token is near expiration, `false` otherwise. + */ + fun isTokenAboutToExpire(): Boolean = + run { + bearerTokenStorage.isAboutToExpire() + } + + /** + * Clears the stored authentication token. + * + * This method resets the internal token storage, effectively invalidating the current session. + */ + override fun clearAuthentication() = + run { + bearerTokenStorage = BearerTokenStorage.empty + } + + /** + * Retrieves the stored token formatted as an `Authorization` header value. + * + * @return The token in the format `Bearer ` for use in HTTP headers. + */ + fun getAuthorizationHeaderValue(): String = + run { + bearerTokenStorage.getAuthorizationHeaderValue() + } + + /** + * Creates an HTTP request to fetch a new bearer token from the authentication server. + * + * @return A [Request] object configured with the necessary headers and parameters. + */ + fun buildAuthenticationRequest(): Request = + run { + Request + .Builder() + .url(authUrl) + .method(Method.POST) + .body( + RequestBody + .create(mapOf("grant_type" to "client_credentials")), + ).setHeader("Authorization", credentials.encodeBasic()) + .setHeader("Content-Type", CommonMediaTypes.APPLICATION_FORM_URLENCODED.toString()) + .build() + } + + /** + * Stores the retrieved token in internal storage for subsequent use. + * + * @param bearerTokenResponse The [BearerTokenResponse] containing the token and its expiration time. + */ + fun storeToken(bearerTokenResponse: BearerTokenResponse) = + run { + bearerTokenStorage = + BearerTokenStorage.create( + accessToken = bearerTokenResponse.accessToken, + expiresIn = bearerTokenResponse.expiresIn, + ) + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt new file mode 100644 index 00000000..d003bd87 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.authentication.common.Credentials +import com.expediagroup.sdk.core.exception.service.ExpediaGroupAuthException +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.logging.LoggerDecorator +import com.expediagroup.sdk.core.pipeline.ExecutionPipeline +import com.expediagroup.sdk.core.pipeline.step.RequestHeadersStep +import com.expediagroup.sdk.core.pipeline.step.RequestLoggingStep +import com.expediagroup.sdk.core.pipeline.step.ResponseLoggingStep +import com.expediagroup.sdk.core.transport.AbstractAsyncRequestExecutor +import com.expediagroup.sdk.core.transport.AsyncTransport +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture + +/** + * Manages bearer tokens fetching, parsing, storing, and renewal. + * This async implementation uses the injected [AsyncTransport] to make the authentication requests. + * Typically, this async implementation should be used with async SDK calls where the [AsyncTransport] is already + * configured and used to handle requests. + */ +class BearerAuthenticationAsyncManager( + authUrl: String, + credentials: Credentials, + private val asyncTransport: AsyncTransport, +) : AbstractBearerAuthenticationManager(authUrl, credentials) { + private val requestExecutor = + object : AbstractAsyncRequestExecutor(asyncTransport) { + override val executionPipeline = + ExecutionPipeline( + requestPipeline = + listOf( + RequestHeadersStep(), + RequestLoggingStep(logger), + ), + responsePipeline = + listOf( + ResponseLoggingStep(logger), + ), + ) + } + + /** + * Initiates authentication to obtain a new bearer token. + * + * This method sends a request to the authentication server, parses the response, and + * stores the token for future use. + */ + override fun authenticate() { + try { + clearAuthentication() + executeAuthenticationRequest(buildAuthenticationRequest()) + .thenApply { BearerTokenResponse.parse(it) } + .thenAccept { storeToken(it) } + .join() + } catch (e: Exception) { + throw ExpediaGroupAuthException(message = "Authentication Failed", cause = e) + } + } + + /** + * Executes the authentication request and validates the response. + */ + private fun executeAuthenticationRequest(request: Request): CompletableFuture = + requestExecutor + .execute(request) + .thenApply { + if (it.isSuccessful) { + it + } else { + throw ExpediaGroupAuthException("Received unsuccessful authentication response: [${it.status}]") + } + }.exceptionally { + throw it + } + + companion object { + private val logger = LoggerDecorator(LoggerFactory.getLogger(this::class.java.enclosingClass)) + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt new file mode 100644 index 00000000..b81d0731 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.authentication.common.Credentials +import com.expediagroup.sdk.core.exception.service.ExpediaGroupAuthException +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.logging.LoggerDecorator +import com.expediagroup.sdk.core.pipeline.ExecutionPipeline +import com.expediagroup.sdk.core.pipeline.step.RequestHeadersStep +import com.expediagroup.sdk.core.pipeline.step.RequestLoggingStep +import com.expediagroup.sdk.core.pipeline.step.ResponseLoggingStep +import com.expediagroup.sdk.core.transport.AbstractRequestExecutor +import com.expediagroup.sdk.core.transport.Transport +import org.slf4j.LoggerFactory + +/** + * Manages bearer token authentication for HTTP requests. + * + * The `BearerAuthenticationManager` handles the lifecycle of bearer tokens, including retrieval, storage, + * and validation. It interacts with an authentication server to fetch tokens using client credentials, + * ensures tokens are refreshed when necessary, and provides them in the required format for authorization headers. + * + * @param authUrl The URL of the authentication server's endpoint to obtain bearer tokens. + * @param credentials The [Credentials] containing the client key and secret used for authentication. + */ +class BearerAuthenticationManager( + authUrl: String, + credentials: Credentials, + private val transport: Transport, +) : AbstractBearerAuthenticationManager(authUrl, credentials) { + private val requestExecutor = + object : AbstractRequestExecutor(transport) { + override val executionPipeline: ExecutionPipeline = + ExecutionPipeline( + requestPipeline = + listOf( + RequestHeadersStep(), + RequestLoggingStep(logger), + ), + responsePipeline = + listOf( + ResponseLoggingStep(logger), + ), + ) + } + + /** + * Initiates authentication to obtain a new bearer token. + * + * This method sends a request to the authentication server, parses the response, and + * stores the token for future use. + * + * @throws ExpediaGroupAuthException If the authentication request fails. + */ + override fun authenticate() { + try { + clearAuthentication() + .let { + buildAuthenticationRequest() + }.let { + executeAuthenticationRequest(it) + }.let { + BearerTokenResponse.parse(it) + }.also { + storeToken(it) + } + } catch (e: Exception) { + throw ExpediaGroupAuthException(message = "Authentication Failed", cause = e) + } + } + + /** + * Executes the authentication request and validates the response. + * + * @param request The [Request] object to be executed. + * @return The [Response] received from the server. + * @throws ExpediaGroupAuthException If the server responds with an error. + */ + private fun executeAuthenticationRequest(request: Request): Response = + run { + requestExecutor.execute(request).apply { + if (!this.isSuccessful) { + throw throw ExpediaGroupAuthException("Received unsuccessful authentication response: [${this.status}]") + } + } + } + + companion object { + private val logger = LoggerDecorator(LoggerFactory.getLogger(this::class.java.enclosingClass)) + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt new file mode 100644 index 00000000..36771c90 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.common.getOrThrow +import com.expediagroup.sdk.core.exception.client.ExpediaGroupResponseParsingException +import com.expediagroup.sdk.core.http.Response +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule + +/** + * Represents the response from an authentication server containing a bearer token and its expiration details. + * + * The `TokenResponse` class is used to deserialize the response from an authentication server. It includes + * the bearer token and the duration (in seconds) until the token expires. + * + * @param accessToken The bearer token issued by the authentication server. + * @param expiresIn The time in seconds until the token expires, starting from when it was issued. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class BearerTokenResponse( + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("expires_in") val expiresIn: Long, +) { + companion object { + private val objectMapper = + ObjectMapper() + .registerKotlinModule() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + /** + * Parses the response from the authentication server to extract token details. + * + * @param response The [Response] from the authentication server. + * @return A [BearerTokenResponse] object containing the token and its metadata. + * @throws ExpediaGroupResponseParsingException If the response cannot be parsed. + */ + fun parse(response: Response): BearerTokenResponse { + val responseBody = + response.body.getOrThrow { + ExpediaGroupResponseParsingException("Authenticate response body is empty or cannot be parsed") + } + + val responseString = + responseBody.source().use { + it.readString(responseBody.mediaType()?.charset ?: Charsets.UTF_8) + } + + return try { + objectMapper.readValue(responseString, BearerTokenResponse::class.java) + } catch (e: Exception) { + throw ExpediaGroupResponseParsingException("Failed to parse authentication response", e) + } + } + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt new file mode 100644 index 00000000..acf31397 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.authentication.bearer + +import java.time.Clock +import java.time.Instant + +/** + * Stores and manages a bearer token and its expiration details. + * + * The `BearerTokenStorage` class is responsible for encapsulating the bearer token along with its + * expiration time. It provides utilities to check if the token is about to expire and to format + * the token as an `Authorization` header value. + * + * @param accessToken The bearer token. + * @param expiresIn The time in seconds until the token expires, relative to when it was issued. + * @param expirationBufferSeconds The number of seconds before the token's expiration time that it is considered "about to expire". + * @param clock The clock to use for time-based operations. Defaults to system clock. + */ +class BearerTokenStorage private constructor( + val accessToken: String, + val expiresIn: Long, + private val expirationBufferSeconds: Long, + private val clock: Clock, + private val expiryInstant: Instant, +) { + /** + * Checks if the bearer token is about to expire. + * + * A token is considered "about to expire" if the current time is within the configured buffer + * of the token's expiration time. + * + * @return `true` if the token is about to expire; `false` otherwise. + */ + fun isAboutToExpire(): Boolean = + run { + Instant.now(clock).isAfter(expiryInstant.minusSeconds(expirationBufferSeconds)) + } + + /** + * Formats the bearer token as an `Authorization` header value. + * + * @return The token in the format `Bearer `. + */ + fun getAuthorizationHeaderValue(): String = "Bearer $accessToken" + + companion object { + private const val DEFAULT_EXPIRATION_BUFFER_SECONDS = 60L + + /** + * Creates an empty bearer token storage instance. + * This instance will always report as expired. + */ + val empty: BearerTokenStorage = create("", -1) + + /** + * Creates a new bearer token storage instance with default settings. + * + * @param accessToken The bearer token + * @param expiresIn The time in seconds until the token expires + * @param expirationBufferSeconds Optional buffer time before expiration. Defaults to 60 seconds. + * @param clock Optional clock for time operations. Defaults to system clock. + * @return A new BearerTokenStorage instance + */ + fun create( + accessToken: String, + expiresIn: Long, + expirationBufferSeconds: Long = DEFAULT_EXPIRATION_BUFFER_SECONDS, + clock: Clock = Clock.systemUTC(), + ): BearerTokenStorage { + val expiryInstant = + if (expiresIn >= 0) { + Instant.now(clock).plusSeconds(expiresIn) + } else { + Instant.EPOCH + } + + return BearerTokenStorage( + accessToken = accessToken, + expiresIn = expiresIn, + expirationBufferSeconds = expirationBufferSeconds, + clock = clock, + expiryInstant = expiryInstant, + ) + } + } + + override fun toString(): String = "BearerTokenStorage(expiresIn=$expiresIn, expirationBufferSeconds=$expirationBufferSeconds, expiryInstant=$expiryInstant)" +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt new file mode 100644 index 00000000..dadde808 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.authentication.common + +import com.expediagroup.sdk.core.exception.service.ExpediaGroupAuthException + +/** + * Defines the contract for managing authentication within the SDK. + * + * An `AuthenticationManager` is responsible for handling the process of authenticating with an external + * service or API and maintaining the authentication state. Implementations should handle token lifecycle, + * including acquisition, storage, and renewal. + */ +interface AuthenticationManager { + /** + * Performs the authentication process, obtaining the necessary credentials or tokens. + * + * This method is responsible for executing the authentication logic, such as sending requests to an + * authentication server, handling the response, and storing the retrieved credentials or tokens for future use. + * + * @throws ExpediaGroupAuthException If authentication fails due to invalid credentials or server errors + */ + fun authenticate() + + /** + * Clears any stored authentication state. + */ + fun clearAuthentication() +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt new file mode 100644 index 00000000..5def8933 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.authentication.common + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets.ISO_8859_1 +import java.util.Base64 + +/** + * Represents a set of credentials consisting of a key and a secret. + * + * The `Credentials` class encapsulates authentication details required for accessing secure resources. + * It provides functionality to encode the credentials into a `Basic` authentication header value. + * + * @param key The client key or username for authentication. + * @param secret The client secret or password for authentication. + */ +data class Credentials( + private val key: String, + private val secret: String, +) { + /** + * Encodes the credentials into a `Basic` authentication header value. + * + * This method combines the `key` and `secret` into a single string in the format `key:secret`, + * encodes it using Base64, and prefixes it with `Basic`. The resulting string is suitable for use + * in the `Authorization` header of HTTP requests. + * + * @param charset The character set to use for encoding the credentials. Defaults to [ISO_8859_1]. + * @return The `Basic` authentication header value as a string. + */ + fun encodeBasic(charset: Charset = ISO_8859_1): String { + val keyAndSecret = "$key:$secret" + val bytes = keyAndSecret.toByteArray(charset) + val encoded = Base64.getEncoder().encodeToString(bytes) + return "Basic $encoded" + } + + override fun toString(): String = "Credentials(key=$key)" +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt new file mode 100644 index 00000000..f1bb41a8 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.common + +inline fun T?.getOrThrow(exceptionSupplier: () -> Throwable): T = this ?: throw exceptionSupplier() diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt new file mode 100644 index 00000000..e819b8d1 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.common + +import java.util.Locale +import java.util.Properties + +/** + * Utility object for loading SDK metadata from the generated `sdk.properties` file along with other + * system properties like JVM info and host OS info. + */ +internal object MetadataLoader { + private const val UNKNOWN = "unknown" + + @Volatile + private var cachedMetadata: Metadata? = null + + /** + * Loads the SDK metadata from sdk.properties file and caches the results for future calls. + */ + fun load(): Metadata = + cachedMetadata ?: synchronized(this) { + cachedMetadata ?: readPropertiesFile().also { cachedMetadata = it } + } + + /** + * Clears the cached metadata + */ + @Synchronized + fun clear() { + cachedMetadata = null + } + + private fun readPropertiesFile(): Metadata { + val props = Properties() + + Thread.currentThread().contextClassLoader?.getResourceAsStream("sdk.properties")?.use { + props.load(it) + } + + return Metadata( + artifactName = props.getProperty("artifactName", UNKNOWN), + groupId = props.getProperty("groupId", UNKNOWN), + version = props.getProperty("version", UNKNOWN), + jdkVersion = System.getProperty("java.version", UNKNOWN), + jdkVendor = System.getProperty("java.vendor", UNKNOWN), + osName = System.getProperty("os.name", UNKNOWN), + osVersion = System.getProperty("os.version", UNKNOWN), + arch = System.getProperty("os.arch", UNKNOWN), + locale = Locale.getDefault().toString(), + ) + } +} + +data class Metadata( + val artifactName: String, + val groupId: String, + val version: String, + val jdkVersion: String, + val jdkVendor: String, + val osName: String, + val osVersion: String, + val arch: String, + val locale: String, +) { + fun asUserAgentString(): String = "$artifactName/$version (Provider/$groupId; Java/$jdkVersion; Vendor/$jdkVendor; OS/$osName - $osVersion; Arch/$arch; Locale/$locale)" +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt new file mode 100644 index 00000000..88bae1c9 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.exception + +/** + * A base exception for all ExpediaGroup exceptions. + * + * @param message An optional error message. + * @param cause An optional cause of the error. + */ +open class ExpediaGroupException( + message: String? = null, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt new file mode 100644 index 00000000..73831091 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2022 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.exception.client + +import com.expediagroup.sdk.core.exception.ExpediaGroupException + +/** + * An exception that is thrown when a client error occurs. + * + * @param message An optional message. + * @param cause An optional cause. + */ +open class ExpediaGroupClientException( + message: String? = null, + cause: Throwable? = null, +) : ExpediaGroupException(message, cause) diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt new file mode 100644 index 00000000..565a2d8b --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.exception.client + +/** + * An exception that is thrown when a configuration error occurs. + * + * @param message An optional error message. + * @param cause An optional cause of the error. + */ +class ExpediaGroupConfigurationException( + message: String? = null, + cause: Throwable? = null, +) : ExpediaGroupClientException(message, cause) diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt new file mode 100644 index 00000000..c92f6359 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.exception.client + +/** + * Exception thrown when the SDK fails to parse a service response. + * + * This is a client-side exception that indicates the response was received + * but could not be properly deserialized into the expected format. + * + * @param message A description of the parsing failure + * @param cause The underlying parsing/mapping exception + */ +class ExpediaGroupResponseParsingException( + message: String? = null, + cause: Throwable? = null, +) : ExpediaGroupClientException(message, cause) diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt new file mode 100644 index 00000000..1cd883a8 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2022 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.expediagroup.sdk.core.exception.service + +import com.expediagroup.sdk.core.http.Status + +/** + * An exception that is thrown when an authentication error occurs. + * + * @param message The error message. + * @param cause The cause of the error. + * @param transactionId The transaction-id of the auth request. + */ + +class ExpediaGroupAuthException( + message: String? = null, + cause: Throwable? = null, + transactionId: String? = null, +) : ExpediaGroupServiceException(message, cause, transactionId) { + /** + * An exception that is thrown when an authentication error occurs. + * + * @param status The HTTP status of the error. + * @param message The error message. + */ + constructor( + status: Status, + message: String, + ) : this(message = "[${status.code}] $message") + + /** + * An exception that is thrown when an authentication error occurs. + * + * @param status The HTTP status of the error (as an integer). + * @param message The error message. + */ + constructor( + status: Int, + message: String, + ) : this(Status.fromCode(status), message) +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt new file mode 100644 index 00000000..9009afe3 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.exception.service + +/** + * Exception thrown when network-related errors occur during service operations. + * + * This exception wraps network-level failures that occur while communicating with + * Expedia Group services (e.g., connection timeouts, DNS failures, SSL/TLS errors). + * + * @param message A human-readable description of the network error + * @param cause The underlying exception that caused this network error + * @param transactionId Unique identifier for tracking this request across systems + */ + +class ExpediaGroupNetworkException( + message: String? = null, + cause: Throwable? = null, + transactionId: String? = null, +) : ExpediaGroupServiceException(message, cause, transactionId) diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt new file mode 100644 index 00000000..6cf331a8 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2022 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.exception.service + +import com.expediagroup.sdk.core.exception.ExpediaGroupException + +/** + * An exception that is thrown when a service error occurs. + * + * @param message An optional error message. + * @param cause An optional cause of the error. + */ +open class ExpediaGroupServiceException( + message: String? = null, + cause: Throwable? = null, + val transactionId: String? = null, +) : ExpediaGroupException(message, cause) diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt new file mode 100644 index 00000000..4a737f81 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +object CommonMediaTypes { + // Text Types + @JvmField + val TEXT_PLAIN = MediaType.of("text", "plain") + + @JvmField + val TEXT_HTML = MediaType.of("text", "html") + + @JvmField + val TEXT_CSS = MediaType.of("text", "css") + + @JvmField + val TEXT_JAVASCRIPT = MediaType.of("text", "javascript") + + @JvmField + val TEXT_CSV = MediaType.of("text", "csv") + + // Application Types + @JvmField + val APPLICATION_JSON = MediaType.of("application", "json") + + @JvmField + val APPLICATION_XML = MediaType.of("application", "xml") + + @JvmField + val APPLICATION_FORM_URLENCODED = MediaType.of("application", "x-www-form-urlencoded") + + @JvmField + val APPLICATION_OCTET_STREAM = MediaType.of("application", "octet-stream") + + @JvmField + val APPLICATION_PDF = MediaType.of("application", "pdf") + + @JvmField + val APPLICATION_VND_API_JSON = MediaType.of("application", "vnd.api+json") + + @JvmField + val APPLICATION_JSON_GRAPHQL = MediaType.of("application", "json+graphql") + + @JvmField + val APPLICATION_HAL_JSON = MediaType.of("application", "hal+json") + + @JvmField + val APPLICATION_PROBLEM_JSON = MediaType.of("application", "problem+json") + + @JvmField + val APPLICATION_ZIP = MediaType.of("application", "zip") + + // Image Types + @JvmField + val IMAGE_JPEG = MediaType.of("image", "jpeg") + + @JvmField + val IMAGE_PNG = MediaType.of("image", "png") + + @JvmField + val IMAGE_GIF = MediaType.of("image", "gif") + + @JvmField + val IMAGE_SVG_XML = MediaType.of("image", "svg+xml") + + // Audio/Video Types + @JvmField + val AUDIO_MPEG = MediaType.of("audio", "mpeg") + + @JvmField + val AUDIO_WAV = MediaType.of("audio", "wav") + + @JvmField + val VIDEO_MP4 = MediaType.of("video", "mp4") + + @JvmField + val VIDEO_MPEG = MediaType.of("video", "mpeg") + + // Multipart Types + @JvmField + val MULTIPART_FORM_DATA = MediaType.of("multipart", "form-data") + + @JvmField + val MULTIPART_BYTERANGES = MediaType.of("multipart", "byteranges") + + // Other Types + @JvmField + val APPLICATION_JAVASCRIPT = MediaType.of("application", "javascript") +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt new file mode 100644 index 00000000..858c776d --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +import java.util.Locale + +/** + * Represents a collection of HTTP headers. + */ +@ConsistentCopyVisibility +data class Headers private constructor( + private val headersMap: Map>, +) { + /** + * Returns the first header value for the given name, or null if none. + * + * @param name the header name (case-insensitive) + * @return the first header value, or null if not found + * @throws IllegalArgumentException if [name] is null + */ + fun get(name: String): String? = headersMap[sanitizeName(name)]?.firstOrNull() + + /** + * Returns all header values for the given name. + * + * @param name the header name (case-insensitive) + * @return an unmodifiable list of header values, or an empty list if none + * @throws IllegalArgumentException if [name] is null + */ + fun values(name: String): List = headersMap[sanitizeName(name)] ?: emptyList() + + /** + * Returns an unmodifiable set of all header names. + * + * @return an unmodifiable set of header names + */ + fun names(): Set = headersMap.keys + + /** + * Returns an unmodifiable list of all header entries. + * + * @return an unmodifiable list of header entries as [Map.Entry] + */ + fun entries(): Set>> = headersMap.entries + + /** + * Returns a new [Builder] initialized with the existing headers. + * + * @return a new [Builder] + */ + fun newBuilder(): Builder = Builder(this) + + override fun toString(): String = headersMap.toString() + + /** + * Builder for constructing [Headers] instances. + */ + class Builder { + private val headersMap: MutableMap> = LinkedHashMap() + + /** + * Creates a new builder + */ + constructor() + + /** + * Creates a new builder initialized with the headers from [headers]. + * + * @param headers the headers to initialize from + */ + constructor(headers: Headers) : this() { + headers.headersMap.forEach { (key, values) -> + headersMap[key] = values.toMutableList() + } + } + + /** + * Adds a header with the specified name and value. + * Multiple headers with the same name are allowed. + * + * @param name the header name + * @param value the header value + * @return this builder + * @throws IllegalArgumentException if [name] or [value] is invalid + */ + fun add( + name: String, + value: String, + ): Builder = apply { add(sanitizeName(name), listOf(value)) } + + /** + * Adds all header values for the specified name. + * + * @param name the header name + * @param values the list of header values + * @return this builder + * @throws IllegalArgumentException if [name] or any [values] are invalid + */ + fun add( + name: String, + values: List, + ): Builder = + apply { + headersMap.computeIfAbsent(sanitizeName(name)) { mutableListOf() }.addAll(values) + } + + /** + * Sets the header with the specified name to the single value provided. + * If headers with this name already exist, they are removed. + * + * @param name the header name + * @param value the header value + * @return this builder + * @throws IllegalArgumentException if [name] or [value] is invalid + */ + fun set( + name: String, + value: String, + ): Builder = apply { set(sanitizeName(name), listOf(value)) } + + /** + * Sets the header with the specified name to the values list provided. + * If headers with this name already exist, they are removed. + * + * @param name the header name + * @param values the header value + * @return this builder + * @throws IllegalArgumentException if [name] or [values] are invalid + */ + fun set( + name: String, + values: List, + ): Builder = + apply { + remove(sanitizeName(name)) + add(sanitizeName(name), values) + } + + /** + * Removes any header with the specified name. + * + * @param name the header name + * @return this builder + */ + fun remove(name: String): Builder = + apply { + headersMap.remove(sanitizeName(name)) + } + + /** + * Builds an immutable [Headers] instance. + * + * @return the built [Headers] + */ + fun build(): Headers = Headers(LinkedHashMap(headersMap)) + } + + companion object { + @JvmStatic + fun builder(headers: Headers): Builder = Builder(headers) + + @JvmStatic + fun builder(): Builder = Builder() + + private fun sanitizeName(value: String): String = value.lowercase(Locale.US).trim() + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt new file mode 100644 index 00000000..2b636eb5 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +import java.nio.charset.Charset +import java.util.Locale + +/** + * Represents a media type, as defined in the HTTP specification. + * + * @property type The primary type (e.g., "application", "text"). + * @property subtype The subtype (e.g., "json", "plain"). + * @property parameters The map of parameters associated with the media type (e.g., charset). + */ +@ConsistentCopyVisibility +data class MediaType private constructor( + val type: String, + val subtype: String, + val parameters: Map = emptyMap(), +) { + /** + * The full representation of a standard media type consisting of type/subtype + */ + val fullType: String + get() = "$type/$subtype" + + /** + * The charset parameter if present, null otherwise + * */ + val charset: Charset? + get() { + return parameters["charset"]?.let { + try { + Charset.forName(it) + } catch (_: Exception) { + null + } + } + } + + /** + * Checks if this media type includes the given media type. + * + * @param other The media type to compare against. + * @return `true` if this media type includes the given media type, `false` otherwise. + */ + fun includes(other: MediaType): Boolean { + val typeMatches = this.type == "*" || this.type.equals(other.type, ignoreCase = true) + val subtypeMatches = this.subtype == "*" || this.subtype.equals(other.subtype, ignoreCase = true) + + return typeMatches && subtypeMatches + } + + /** + * Returns the full representation of a standard media type consisting of type/subtype followed by all parameters, if any + * */ + override fun toString(): String { + val formattedParams = + parameters.entries.joinToString(separator = ";") { (key, value) -> + "$key=$value" + } + return if (formattedParams.isNotEmpty()) "$type/$subtype;$formattedParams" else "$type/$subtype" + } + + companion object { + /** + * Factory method for creating a MediaType. + */ + @JvmStatic + @JvmOverloads + fun of( + type: String, + subtype: String, + parameters: Map = emptyMap(), + ): MediaType { + require(type.isNotBlank()) { "Type must not be blank" } + require(subtype.isNotBlank()) { "Subtype must not be blank" } + + if (type == "*" && subtype != "*") { + throw IllegalArgumentException("Invalid media type format: type=$type, subtype=$subtype") + } + + return MediaType( + type = type.lowercase(Locale.getDefault()), + subtype = subtype.lowercase(Locale.getDefault()), + parameters = parameters.mapKeys { it.key.lowercase(Locale.getDefault()) }, + ) + } + + /** + * Parses a media type string into a [MediaType] object. + * + * @param mediaType The media type string to parse. + * @return The parsed [MediaType]. + * @throws IllegalArgumentException If the media type cannot be parsed. + */ + @JvmStatic + fun parse(mediaType: String): MediaType { + require(mediaType.isNotBlank()) { "Media type must not be blank" } + + // Split into MIME type and optional parameters + val parts = mediaType.split(";").map(String::trim) + val mimeString = parts.first() + val parametersList = parts.drop(1) + + // Parse type and subtype + val slashIndex = mimeString.indexOf("/") + if (slashIndex == -1 || slashIndex == 0 || slashIndex == mimeString.length - 1) { + throw IllegalArgumentException("Invalid media type format: $mediaType") + } + val type = mimeString.substring(0, slashIndex).trim().lowercase(Locale.getDefault()) + val subtype = mimeString.substring(slashIndex + 1).trim().lowercase(Locale.getDefault()) + + if (type == "*" && subtype != "*") { + throw IllegalArgumentException("Invalid media type format: $mediaType") + } + + val parametersMap = + parametersList + .filter(String::isNotBlank) + .associate { parameter -> + // Split the parameter into key-value parts + val parts = parameter.split("=").map(String::trim) + val isValid = parts.size == 2 && parts.none { it.isBlank() } + + if (!isValid) { + throw IllegalArgumentException("Invalid parameter format: $parameter") + } + + val key = parts[0].lowercase(Locale.getDefault()) + val value = parts[1].lowercase(Locale.getDefault()) + + key to value + } + + return MediaType(type, subtype, parametersMap) + } + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt new file mode 100644 index 00000000..1bc71bea --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +/** + * Enumeration of HTTP methods. + */ +enum class Method( + val method: String, +) { + GET("GET"), + POST("POST"), + PUT("PUT"), + DELETE("DELETE"), + PATCH("PATCH"), + HEAD("HEAD"), + OPTIONS("OPTIONS"), + TRACE("TRACE"), + CONNECT("CONNECT"), + ; + + override fun toString(): String = method +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt new file mode 100644 index 00000000..32483e5a --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +/** + * Enumeration of HTTP protocols. + */ +enum class Protocol( + private val protocolString: String, +) { + HTTP_1_0("http/1.0"), + HTTP_1_1("http/1.1"), + HTTP_2("http/2"), + H2_PRIOR_KNOWLEDGE("h2_prior_knowledge"), + QUIC("quic"), + ; + + override fun toString(): String = protocolString + + companion object { + private val lookup = Protocol.entries.associateBy { it.protocolString.uppercase() } + + /** + * Parses a protocol string to a [Protocol] enum. + */ + @JvmStatic + fun get(protocol: String): Protocol = + when (protocol.uppercase()) { + "HTTP/2", "HTTP/2.0" -> HTTP_2 + else -> lookup[protocol.uppercase()] ?: throw IllegalArgumentException("Unexpected protocol: $protocol") + } + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt new file mode 100644 index 00000000..ef0a867f --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +import java.net.MalformedURLException +import java.net.URL + +/** + * Represents an immutable HTTP request. + * + * Use [Request.builder()] to create an instance. + */ +@ConsistentCopyVisibility +data class Request private constructor( + val method: Method, + val url: URL, + val headers: Headers, + val body: RequestBody?, +) { + /** + * Returns a new [Builder] initialized with this request's data. + * + * @return A new builder. + */ + fun newBuilder(): Builder = Builder(this) + + /** + * Builder class for [Request]. + */ + class Builder { + private var method: Method? = null + private var url: URL? = null + private var headersBuilder: Headers.Builder = Headers.Builder() + private var body: RequestBody? = null + + /** + * Creates a new builder. + */ + constructor() + + /** + * Creates a builder initialized with the data from [request]. + * + * @param request The request to copy data from. + */ + constructor(request: Request) { + this.method = request.method + this.url = request.url + this.headersBuilder = request.headers.newBuilder() + this.body = request.body + } + + /** + * Sets the HTTP method. + * + * @param method HTTP method, e.g., GET, POST. + * @return This builder. + */ + fun method(method: Method) = + apply { + this.method = method + } + + /** + * Sets the request body. + * + * @param body The request body. + * @return This builder. + */ + fun body(body: RequestBody) = + apply { + this.body = body + } + + /** + * Sets the URL. + * + * @param url The URL as a string. + * @return This builder. + * @throws MalformedURLException If [url] is invalid. + */ + @Throws(MalformedURLException::class) + fun url(url: String) = + apply { + val parsedUrl = URL(url) + this.url = parsedUrl + } + + /** + * Sets the URL. + * + * @param url The URL as an [URL] object. + * @return This builder. + */ + fun url(url: URL) = + apply { + this.url = url + } + + /** + * Adds a header with the specified name and value. + * + * @param name The header name. + * @param value The header value. + * @return This builder. + * @throws IllegalArgumentException If [name] or [value] is invalid. + */ + fun addHeader( + name: String, + value: String, + ) = apply { + headersBuilder.add(name, value) + } + + /** + * Adds a header with the specified name and values. + * + * @param name The header name. + * @param values The header values list. + * @return This builder. + * @throws IllegalArgumentException If [name] or [values] are invalid. + */ + fun addHeader( + name: String, + values: List, + ) = apply { + headersBuilder.add(name, values) + } + + /** + * Sets a header with the specified name and value, replacing any existing values. + * + * @param name The header name. + * @param value The header value. + * @return This builder. + * @throws IllegalArgumentException If [name] or [value] is invalid. + */ + fun setHeader( + name: String, + value: String, + ) = apply { + headersBuilder.set(name, value) + } + + /** + * Sets a header with the specified name and values list, replacing any existing values. + * + * @param name The header name. + * @param values The header values list. + * @return This builder. + * @throws IllegalArgumentException If [name] or [values] are invalid. + */ + fun setHeader( + name: String, + values: List, + ) = apply { + headersBuilder.set(name, values) + } + + /** + * Sets a complete Headers instance, replacing all other headers + * + * @param headers The [Headers] instance + * @return This builder. + */ + fun headers(headers: Headers) = + apply { + this.headersBuilder = headers.newBuilder() + } + + /** + * Removes all headers with the specified name. + * + * @param name The header name. + * @return This builder. + * @throws IllegalArgumentException If [name] is null. + */ + fun removeHeader(name: String) = + apply { + headersBuilder.remove(name) + } + + /** + * Builds the [Request]. + * + * @return The built request. + * @throws IllegalStateException If the request is invalid. + */ + fun build(): Request { + val method = this.method ?: throw IllegalStateException("Method is required.") + val url = this.url ?: throw IllegalStateException("URL is required.") + + return Request( + method = method, + url = url, + headers = headersBuilder.build(), + body = body, + ) + } + } + + companion object { + @JvmStatic + fun builder(): Builder = Builder() + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt new file mode 100644 index 00000000..d4859f5c --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +import okio.BufferedSink +import okio.ByteString +import okio.Source +import okio.source +import java.io.IOException +import java.io.InputStream +import java.net.URLEncoder +import java.nio.charset.Charset + +/** + * Represents an HTTP request body. + */ +abstract class RequestBody { + /** + * Returns the media type of the request body. + */ + abstract fun mediaType(): MediaType? + + /** + * Returns the number of bytes that will be written to [writeTo], or -1 if unknown. + */ + open fun contentLength(): Long = -1 + + /** + * Writes the request body to the given [sink]. + * + * @param sink the sink to write to. + * @throws IOException if an I/O error occurs. + */ + @Throws(IOException::class) + abstract fun writeTo(sink: BufferedSink) + + companion object { + /** + * Creates a new request body that reads from the given [inputStream]. + * + * @param mediaType the media type, or null if unknown. + * @param contentLength the length of the content, or -1 if unknown. + * @param inputStream the input stream to read from. + * @return a new [RequestBody] instance. + */ + @JvmStatic + @JvmOverloads + fun create( + inputStream: InputStream, + mediaType: MediaType? = null, + contentLength: Long = -1, + ): RequestBody = + object : RequestBody() { + override fun mediaType(): MediaType? = mediaType + + override fun contentLength(): Long = contentLength + + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + inputStream.use { + sink.writeAll(it.source()) + } + } + } + + /** + * Creates a new request body that reads from the given [source]. + * + * @param mediaType the media type, or null if unknown. + * @param contentLength the length of the content, or -1 if unknown. + * @param source the source to read from. + * @return a new [RequestBody] instance. + */ + @JvmStatic + @JvmOverloads + fun create( + source: Source, + mediaType: MediaType? = null, + contentLength: Long = -1, + ): RequestBody = + object : RequestBody() { + override fun mediaType(): MediaType? = mediaType + + override fun contentLength(): Long = contentLength + + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + source.use { src -> + sink.writeAll(src) + } + } + } + + /** + * Creates a new request body that reads from the given [byteString]. + * + * RequestBody from [byteString] is reusable. + * + * @param byteString the byteString object to read from. + * @param mediaType the media type, or null if unknown. + * @param contentLength the length of the content, or -1 if unknown. + * @return a new [RequestBody] instance. + */ + @JvmStatic + @JvmOverloads + fun create( + byteString: ByteString, + mediaType: MediaType? = null, + contentLength: Long = -1, + ): RequestBody = + object : RequestBody() { + override fun mediaType(): MediaType? = mediaType + + override fun contentLength(): Long = contentLength + + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + sink.write(byteString) + } + } + + /** + * Creates a new request body for form data with content type "application/x-www-form-urlencoded". + * + * @param formData The form data as a map of parameter names and values. + * @param charset The character set to use; defaults to UTF-8. + * @return A new [RequestBody] instance. + * @throws IllegalArgumentException If [formData] is null. + */ + @JvmStatic + @JvmOverloads + fun create( + formData: Map, + charset: Charset = Charsets.UTF_8, + ): RequestBody { + val encodedForm = + formData + .map { (key, value) -> + "${encode(key, charset)}=${encode(value, charset)}" + }.joinToString("&") + + val contentBytes = encodedForm.toByteArray(charset) + + return create(contentBytes.inputStream(), CommonMediaTypes.APPLICATION_FORM_URLENCODED) + } + + private fun encode( + value: String, + charset: Charset, + ): String = + URLEncoder + .encode(value, charset.name()) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~") + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt new file mode 100644 index 00000000..a1833dc1 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +import java.io.Closeable +import java.io.IOException + +/** + * Represents an immutable HTTP response. + * + * Use [Builder] to create an instance. + */ +@ConsistentCopyVisibility +data class Response private constructor( + val request: Request, + val protocol: Protocol, + val status: Status, + val message: String?, + val headers: Headers, + val body: ResponseBody?, +) : Closeable { + /** + * Returns true if the response code is in the 200-299 range. + */ + val isSuccessful: Boolean + get() = status.code in 200..299 + + /** + * Returns a new [Builder] initialized with this response's data. + * + * @return A new builder. + */ + fun newBuilder(): Builder = Builder(this) + + /** + * Closes the response body and releases any resources. + * + * After calling this method, the response body cannot be read. + * + * @throws IOException If an I/O error occurs. + */ + @Throws(IOException::class) + override fun close() { + body?.close() + } + + /** + * Builder class for [Response]. + */ + class Builder { + private var request: Request? = null + private var protocol: Protocol? = null + private var status: Status? = null + private var message: String? = null + private var headersBuilder: Headers.Builder = Headers.Builder() + private var body: ResponseBody? = null + + /** + * Creates an empty builder. + */ + constructor() + + /** + * Creates a builder initialized with the data from [response]. + * + * @param response The response to copy data from. + */ + constructor(response: Response) { + this.request = response.request + this.protocol = response.protocol + this.status = response.status + this.message = response.message + this.headersBuilder = response.headers.newBuilder() + this.body = response.body + } + + /** + * Sets the request that initiated this response. + * + * @param request The originating request. + * @return This builder. + */ + fun request(request: Request) = + apply { + this.request = request + } + + /** + * Sets the protocol used for the response. + * + * @param protocol The protocol (e.g., HTTP/1.1). + * @return This builder. + */ + fun protocol(protocol: Protocol) = + apply { + this.protocol = protocol + } + + /** + * Sets the HTTP status code. + * + * @param status The HTTP status code. + * @return This builder. + * @throws IllegalArgumentException If [status] is negative. + */ + fun status(status: Status) = + apply { + this.status = status + } + + /** + * Sets the HTTP reason phrase. + * + * @param message The reason phrase. + * @return This builder. + */ + fun message(message: String) = + apply { + this.message = message + } + + /** + * Adds a header with the specified name and value. + * + * @param name The header name. + * @param value The header value. + * @return This builder. + * @throws IllegalArgumentException If [name] or [value] is invalid. + */ + fun addHeader( + name: String, + value: String, + ) = apply { + headersBuilder.add(name, value) + } + + /** + * Adds a header with the specified name and values list. + * + * @param name The header name. + * @param values The header value. + * @return This builder. + * @throws IllegalArgumentException If [name] or [values] are invalid. + */ + fun addHeader( + name: String, + values: List, + ) = apply { + headersBuilder.add(name, values) + } + + /** + * Sets a header with the specified name and value, replacing any existing values. + * + * @param name The header name. + * @param value The header value. + * @return This builder. + * @throws IllegalArgumentException If [name] or [value] is invalid. + */ + fun setHeader( + name: String, + value: String, + ) = apply { + headersBuilder.set(name, value) + } + + /** + * Sets a header with the specified name and values list, replacing any existing values. + * + * @param name The header name. + * @param values The header values list. + * @return This builder. + * @throws IllegalArgumentException If [name] or [values] are invalid. + */ + fun setHeader( + name: String, + values: List, + ) = apply { + headersBuilder.set(name, values) + } + + /** + * Removes all headers with the specified name. + * + * @param name The header name. + * @return This builder. + */ + fun removeHeader(name: String) = + apply { + headersBuilder.remove(name) + } + + /** + * Sets the response headers. + * + * @param headers The response headers. + * @return This builder. + */ + fun headers(headers: Headers) = + apply { + headersBuilder = headers.newBuilder() + } + + /** + * Sets the response body. + * + * @param body The response body, or null if none. + * @return This builder. + */ + fun body(body: ResponseBody?) = + apply { + this.body = body + } + + /** + * Builds the [Response]. + * + * @return The built response. + * @throws IllegalStateException If required fields are missing. + */ + fun build(): Response { + val request = this.request ?: throw IllegalStateException("request is required") + val protocol = this.protocol ?: throw IllegalStateException("protocol is required") + val code = this.status ?: throw IllegalStateException("status is required") + + return Response( + request = request, + protocol = protocol, + status = code, + message = message, + headers = headersBuilder.build(), + body = body, + ) + } + } + + companion object { + @JvmStatic + fun builder() = Builder() + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt new file mode 100644 index 00000000..e10c8071 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +import okio.BufferedSource +import okio.IOException +import okio.buffer +import okio.source +import java.io.Closeable +import java.io.InputStream + +/** + * Represents the body of an HTTP response. + */ +abstract class ResponseBody : Closeable { + /** + * Returns the media type of the response body, or null if unknown. + */ + abstract fun mediaType(): MediaType? + + /** + * Returns the content length, or -1 if unknown. + */ + abstract fun contentLength(): Long + + /** + * Returns a [BufferedSource] to read the response body. + * + * Note: The source can be read only once. Multiple calls will return the same source. + * + * @return The buffered source. + */ + abstract fun source(): BufferedSource + + /** + * Closes the response body and releases any resources. + * + * @throws IOException If an I/O error occurs. + */ + @Throws(IOException::class) + override fun close() { + source().close() + } + + companion object { + /** + * Creates a new response body from an [InputStream] and [mediaType]. + * + * @param inputStream The input stream to read from. + * @param contentLength The length of the content, or -1 if unknown. + * @param mediaType The media type, or null if unknown. + * @return A new [ResponseBody] instance. + */ + @JvmStatic + @JvmOverloads + fun create( + inputStream: InputStream, + mediaType: MediaType? = null, + contentLength: Long = -1L, + ): ResponseBody = + object : ResponseBody() { + private val source = inputStream.source().buffer() + + override fun mediaType(): MediaType? = mediaType + + override fun contentLength(): Long = contentLength + + override fun source(): BufferedSource = source + } + + /** + * Creates a new response body from a [BufferedSource] and [mediaType]. + * + * @param source The buffered source to read from. + * @param contentLength The length of the content, or -1 if unknown. + * @param mediaType The media type, or null if unknown. + * @return A new [ResponseBody] instance. + */ + @JvmStatic + @JvmOverloads + fun create( + source: BufferedSource, + mediaType: MediaType? = null, + contentLength: Long = -1L, + ): ResponseBody = + object : ResponseBody() { + override fun mediaType(): MediaType? = mediaType + + override fun contentLength(): Long = contentLength + + override fun source(): BufferedSource = source + } + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt new file mode 100644 index 00000000..132f0186 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.http + +/** + * Enumeration of HTTP status codes. + */ +enum class Status( + val code: Int, +) { + // Informational responses (100–199) + CONTINUE(100), + SWITCHING_PROTOCOLS(101), + PROCESSING(102), + EARLY_HINTS(103), + + // Successful responses (200–299) + OK(200), + CREATED(201), + ACCEPTED(202), + NON_AUTHORITATIVE_INFORMATION(203), + NO_CONTENT(204), + RESET_CONTENT(205), + PARTIAL_CONTENT(206), + MULTI_STATUS(207), + ALREADY_REPORTED(208), + IM_USED(226), + + // Redirection messages (300–399) + MULTIPLE_CHOICES(300), + MOVED_PERMANENTLY(301), + FOUND(302), + SEE_OTHER(303), + NOT_MODIFIED(304), + USE_PROXY(305), + TEMPORARY_REDIRECT(307), + PERMANENT_REDIRECT(308), + + // Client error responses (400–499) + BAD_REQUEST(400), + UNAUTHORIZED(401), + PAYMENT_REQUIRED(402), + FORBIDDEN(403), + NOT_FOUND(404), + METHOD_NOT_ALLOWED(405), + NOT_ACCEPTABLE(406), + PROXY_AUTHENTICATION_REQUIRED(407), + REQUEST_TIMEOUT(408), + CONFLICT(409), + GONE(410), + LENGTH_REQUIRED(411), + PRECONDITION_FAILED(412), + PAYLOAD_TOO_LARGE(413), + URI_TOO_LONG(414), + UNSUPPORTED_MEDIA_TYPE(415), + RANGE_NOT_SATISFIABLE(416), + EXPECTATION_FAILED(417), + IM_A_TEAPOT(418), + MISDIRECTED_REQUEST(421), + UNPROCESSABLE_ENTITY(422), + LOCKED(423), + FAILED_DEPENDENCY(424), + TOO_EARLY(425), + UPGRADE_REQUIRED(426), + PRECONDITION_REQUIRED(428), + TOO_MANY_REQUESTS(429), + REQUEST_HEADER_FIELDS_TOO_LARGE(431), + UNAVAILABLE_FOR_LEGAL_REASONS(451), + + // Server error responses (500–599) + INTERNAL_SERVER_ERROR(500), + NOT_IMPLEMENTED(501), + BAD_GATEWAY(502), + SERVICE_UNAVAILABLE(503), + GATEWAY_TIMEOUT(504), + HTTP_VERSION_NOT_SUPPORTED(505), + VARIANT_ALSO_NEGOTIATES(506), + INSUFFICIENT_STORAGE(507), + LOOP_DETECTED(508), + NOT_EXTENDED(510), + NETWORK_AUTHENTICATION_REQUIRED(511), + + // Non-standard status codes (e.g., Apache) + THIS_IS_FINE(218), // Non-standard code, used by some Apache modules + ; + + companion object { + @JvmStatic + @Throws(IllegalArgumentException::class) + fun fromCode(code: Int): Status { + entries.find { it.code == code }?.let { + return it + } + + throw IllegalArgumentException("Invalid status code: $code") + } + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt new file mode 100644 index 00000000..e980c563 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.logging + +internal object Constant { + const val NEWLINE = "\n" + const val OMITTED = "<-- omitted -->" + const val DEFAULT_MAX_BODY_SIZE = 8192L // 8KB + const val EXPEDIA_GROUP_SDK = "Expedia Group SDK" +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt new file mode 100644 index 00000000..9e9ab0ed --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.logging + +import com.expediagroup.sdk.core.http.MediaType + +/** + * A list of MIME types representing content types that are deemed loggable. + * This collection is used to determine whether the content of HTTP requests or responses + * can be logged based on their MIME types. + */ +internal val LOGGABLE_CONTENT_TYPES = + listOf( + MediaType.of("text", "plain"), + MediaType.of("text", "html"), + MediaType.of("text", "css"), + MediaType.of("text", "javascript"), + MediaType.of("text", "csv"), + MediaType.of("text", "*"), + MediaType.of("application", "json"), + MediaType.of("application", "xml"), + MediaType.of("application", "x-www-form-urlencoded"), + MediaType.of("application", "json+graphql"), + MediaType.of("application", "hal+json"), + ) + +internal fun isLoggable(mediaType: MediaType): Boolean = + LOGGABLE_CONTENT_TYPES.any { loggableType -> + loggableType.includes(mediaType) + } diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt new file mode 100644 index 00000000..5a702acd --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.logging + +import org.slf4j.Logger + +class LoggerDecorator( + private val logger: Logger, +) : Logger by logger { + override fun info(msg: String) = logger.info(decorate(msg)) + + override fun warn(msg: String) = logger.warn(decorate(msg)) + + override fun debug(msg: String) = logger.debug(decorate(msg)) + + override fun error(msg: String) = logger.error(decorate(msg)) + + override fun trace(msg: String) = logger.trace(decorate(msg)) + + fun info( + msg: String, + vararg tags: String, + ) = logger.info(decorate(msg, tags.toSet())) + + fun warn( + msg: String, + vararg tags: String, + ) = logger.warn(decorate(msg, tags.toSet())) + + fun debug( + msg: String, + vararg tags: String, + ) = logger.debug(decorate(msg, tags.toSet())) + + fun error( + msg: String, + vararg tags: String, + ) = logger.error(decorate(msg, tags.toSet())) + + fun trace( + msg: String, + vararg tags: String, + ) = logger.trace(decorate(msg, tags.toSet())) + + private fun decorate( + msg: String, + tags: Set? = null, + ): String = + buildString { + append("[${Constant.EXPEDIA_GROUP_SDK}] - ") + tags?.let { + append("[") + append(it.joinToString(", ")) + append("] - ") + } + append(msg.trim()) + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt new file mode 100644 index 00000000..2f1c00f1 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.logging + +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.RequestBody +import com.expediagroup.sdk.core.logging.Constant.DEFAULT_MAX_BODY_SIZE +import okio.Buffer +import java.io.IOException +import java.nio.charset.Charset + +internal object RequestLogger { + fun log( + logger: LoggerDecorator, + request: Request, + vararg tags: String, + maxBodyLogSize: Long? = null, + ) { + try { + var logString = + buildString { + append("URL=${request.url}, Method=${request.method}, Headers=[${request.headers}]") + } + + if (logger.isDebugEnabled) { + val requestBodyString = + request.body?.let { + it.readLoggableBody(maxBodyLogSize, it.mediaType()?.charset) + } + + logString += ", Body=$requestBodyString" + logger.debug(logString, "Outgoing", *tags) + } else { + logger.info(logString, "Outgoing", *tags) + } + } catch (e: Exception) { + logger.error("Failed to log request") + } + } + + @Throws(IOException::class) + private fun RequestBody.readLoggableBody( + maxBodyLogSize: Long?, + charset: Charset?, + ): String { + this.mediaType().also { + if (it === null) { + return "Request body of unknown media type cannot be logged" + } + + if (!isLoggable(it)) { + return "Request body of type ${it.fullType} cannot be logged" + } + } + + val buffer = Buffer().apply { use { writeTo(this) } } + val bytesToRead = minOf(maxBodyLogSize ?: DEFAULT_MAX_BODY_SIZE, buffer.size) + + return buffer.readString(bytesToRead, charset ?: Charsets.UTF_8) + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt new file mode 100644 index 00000000..565d34e7 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.logging + +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.http.ResponseBody +import com.expediagroup.sdk.core.logging.Constant.DEFAULT_MAX_BODY_SIZE +import okio.Buffer +import java.nio.charset.Charset + +internal object ResponseLogger { + fun log( + logger: LoggerDecorator, + response: Response, + vararg tags: String, + maxBodyLogSize: Long = DEFAULT_MAX_BODY_SIZE, + ) { + try { + var logString = + buildString { + append("[URL=${response.request.url}, Code=${response.status.code}, Headers=[${response.headers}]") + } + + if (logger.isDebugEnabled) { + val responseBodyString = + response.body?.let { + it.readLoggableBody(maxBodyLogSize, it.mediaType()?.charset) + } + + logString += ", Body=$responseBodyString]" + + logger.debug(logString, "Incoming", *tags) + } else { + logger.info(logString, "Incoming", *tags) + } + } catch (e: Exception) { + logger.warn("Failed to log response") + } + } + + private fun ResponseBody.readLoggableBody( + maxSize: Long, + charset: Charset?, + ): String { + this.mediaType().also { + if (it === null) { + return "Response body of unknown media type cannot be logged" + } + + if (!isLoggable(it)) { + return "Response body of type ${it.fullType} cannot be logged" + } + } + + val buffer = Buffer() + val bytesToRead = minOf(maxSize, this.contentLength()) + this.source().peek().read(buffer, bytesToRead) + return buffer.readString(charset ?: Charsets.UTF_8) + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt new file mode 100644 index 00000000..f14d3c79 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.pipeline + +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response + +fun interface RequestPipelineStep : (Request) -> Request + +fun interface ResponsePipelineStep : (Response) -> Response + +class ExecutionPipeline( + private val requestPipeline: List, + private val responsePipeline: List, +) { + fun startRequestPipeline(request: Request): Request = requestPipeline.fold(request) { req, step -> step(req) } + + fun startResponsePipeline(response: Response): Response = responsePipeline.fold(response) { res, step -> step(res) } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt new file mode 100644 index 00000000..00be84b2 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.authentication.bearer.AbstractBearerAuthenticationManager +import com.expediagroup.sdk.core.exception.service.ExpediaGroupAuthException +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.pipeline.RequestPipelineStep +import java.io.IOException + +class BearerAuthenticationStep( + private val authenticationManager: AbstractBearerAuthenticationManager, +) : RequestPipelineStep { + private val lock = Any() + + override fun invoke(request: Request): Request { + ensureValidAuthentication() + return addAuthorizationHeader(request) + } + + private fun ensureValidAuthentication() { + try { + if (authenticationManager.isTokenAboutToExpire()) { + synchronized(lock) { + if (authenticationManager.isTokenAboutToExpire()) { + authenticationManager.authenticate() + } + } + } + } catch (e: IOException) { + throw ExpediaGroupAuthException("Failed to authenticate", e) + } + } + + private fun addAuthorizationHeader(request: Request): Request = + request + .newBuilder() + .addHeader("Authorization", authenticationManager.getAuthorizationHeaderValue()) + .build() +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt new file mode 100644 index 00000000..7d17e93d --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.common.MetadataLoader +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.pipeline.RequestPipelineStep + +class RequestHeadersStep : RequestPipelineStep { + private val metadata = MetadataLoader.load() + + override fun invoke(request: Request): Request = + request + .newBuilder() + .setHeader("User-Agent", metadata.asUserAgentString()) + .build() +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt new file mode 100644 index 00000000..7a5c8a55 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.RequestBody +import com.expediagroup.sdk.core.logging.LoggerDecorator +import com.expediagroup.sdk.core.logging.RequestLogger +import com.expediagroup.sdk.core.pipeline.RequestPipelineStep +import okio.Buffer + +class RequestLoggingStep( + private val logger: LoggerDecorator, + private val maxRequestBodySize: Long? = null, +) : RequestPipelineStep { + override fun invoke(request: Request): Request { + var reusableRequest: Request = request + + request.body?.let { + reusableRequest = + reusableRequest + .newBuilder() + .body(it.snapshot()) + .build() + } + + RequestLogger.log(logger, reusableRequest, maxBodyLogSize = maxRequestBodySize) + + return reusableRequest + } + + private fun RequestBody.snapshot(): RequestBody { + val buffer = Buffer().apply { use { writeTo(this) } } + + return RequestBody.create( + byteString = buffer.snapshot(), + mediaType = this.mediaType(), + contentLength = this.contentLength(), + ) + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt new file mode 100644 index 00000000..bed6794f --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.logging.LoggerDecorator +import com.expediagroup.sdk.core.logging.ResponseLogger +import com.expediagroup.sdk.core.pipeline.ResponsePipelineStep + +class ResponseLoggingStep( + private val logger: LoggerDecorator, +) : ResponsePipelineStep { + override fun invoke(response: Response): Response = response.also { ResponseLogger.log(logger, it) } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt new file mode 100644 index 00000000..8389778c --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.transport + +import com.expediagroup.sdk.core.common.getOrThrow +import com.expediagroup.sdk.core.exception.client.ExpediaGroupConfigurationException +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.pipeline.ExecutionPipeline +import java.util.ServiceLoader +import java.util.concurrent.CompletableFuture + +/** + * Abstract base class for processing **asynchronous** HTTP requests within the SDK. + * + * This class serves as the main entry point for executing async HTTP requests through the SDK core. **Each product-SDK is + * expected to have its own implementation of this abstract class to enable async SDK request.** + * + * It wraps and enhances the request and response processing by: + * + * 1. Applying request/response pipeline steps + * 2. Enforcing SDK-specific policies and rules (e.g., authentication) + * 3. Providing common error handling and retry logic (if needed) + * 4. Managing request/response lifecycle and transformation + * + * Implementations should define the order and types of steps to be applied in the request and response pipelines. The + * execution logic is already handled. + * + * ### Execution Pipeline Integration: + * The [ExecutionPipeline] manages the request and response processing pipelines, allowing flexible and extensible + * handling of request and response transformations. The pipeline is composed of ordered steps that are applied + * sequentially. + * + * ### Usage Example: + * ``` + * class AsyncRequestExecutor : AbstractAsyncRequestExecutor() { + * override val executionPipeline = ExecutionPipeline( + * requestPipeline = listOf( + * AuthenticationStep(), + * RequestLoggingStep() + * ), + * responsePipeline = listOf( + * ResponseLoggingStep() + * ) + * ) + * } + * ``` + */ +abstract class AbstractAsyncRequestExecutor( + asyncTransport: AsyncTransport? = null, +) : Disposable { + protected val asyncTransport: AsyncTransport = asyncTransport ?: loadTransport() + + abstract val executionPipeline: ExecutionPipeline + + /** + * Executes a request through the request pipeline and processes the response through the response pipeline. + * + * The method applies all configured request pipeline steps to the incoming request, executes the request, + * and then applies all configured response pipeline steps to the resulting response. + * + * @param request The request to be processed through the pipelines. + * @return [CompletableFuture] attached with a callback for response pipeline execution + */ + fun execute(request: Request): CompletableFuture = + executionPipeline + .startRequestPipeline(request) + .let { + asyncTransport.execute(it).thenApply { response -> executionPipeline.startResponsePipeline(response) } + } + + override fun dispose() = asyncTransport.dispose() + + companion object { + private fun loadTransport(): AsyncTransport = + ServiceLoader.load(AsyncTransport::class.java).firstOrNull().getOrThrow { + ExpediaGroupConfigurationException( + "No AsyncTransport implementation found. Please include valid HTTP client dependency", + ) + } + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt new file mode 100644 index 00000000..c6684112 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.transport + +import com.expediagroup.sdk.core.common.getOrThrow +import com.expediagroup.sdk.core.exception.client.ExpediaGroupConfigurationException +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.pipeline.ExecutionPipeline +import java.util.ServiceLoader + +/** + * Abstract base class for processing HTTP requests within the SDK. + * + * This class serves as the main entry point for executing HTTP requests through the SDK core. **Each product-SDK is + * expected to have its own implementation of this abstract class.** + * + * It wraps and enhances the request and response processing by: + * + * 1. Applying request/response pipeline steps + * 2. Enforcing SDK-specific policies and rules (e.g., authentication) + * 3. Providing common error handling and retry logic (if needed) + * 4. Managing request/response lifecycle and transformation + * + * Implementations should define the order and types of steps to be applied in the request and response pipelines. The + * execution logic is already handled. + * + * ### Execution Pipeline Integration: + * The [ExecutionPipeline] manages the request and response processing pipelines, allowing flexible and extensible + * handling of request and response transformations. The pipeline is composed of ordered steps that are applied + * sequentially. + * + * ### Usage Example: + * ``` + * class RequestExecutor : AbstractRequestExecutor() { + * override val executionPipeline = ExecutionPipeline( + * requestPipeline = listOf( + * AuthenticationStep(), + * RequestLoggingStep() + * ), + * responsePipeline = listOf( + * ResponseLoggingStep() + * ) + * ) + * } + * ``` + */ +abstract class AbstractRequestExecutor( + transport: Transport? = null, +) : Disposable { + protected val transport: Transport = transport ?: loadTransport() + + abstract val executionPipeline: ExecutionPipeline + + /** + * Executes a request through the request pipeline and processes the response through the response pipeline. + * + * The method applies all configured request pipeline steps to the incoming request, executes the request, + * and then applies all configured response pipeline steps to the resulting response. + * + * @param request The request to be processed through the pipelines. + * @return The fully processed response after all pipeline steps are applied. + */ + fun execute(request: Request): Response = + executionPipeline + .startRequestPipeline(request) + .let { + executionPipeline.startResponsePipeline(transport.execute(it)) + } + + /** + * Closes the underlying [Transport]. + */ + override fun dispose() = transport.dispose() + + companion object { + private fun loadTransport(): Transport = + ServiceLoader.load(Transport::class.java).firstOrNull().getOrThrow { + ExpediaGroupConfigurationException( + "No Transport implementation found. Please include valid HTTP client dependency", + ) + } + } +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt new file mode 100644 index 00000000..74e273a5 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.transport + +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import java.util.concurrent.CompletableFuture + +/** + * Asynchronous transport layer interface that adapts different HTTP client libraries to work with the SDK. + * + * This interface serves as an abstraction layer between the SDK and the underlying HTTP client, + * allowing users to integrate their preferred HTTP client library while maintaining consistent + * behavior across the SDK. Implementers are responsible for: + * + * 1. Converting SDK request/response models to their HTTP client's models + * 2. Handling HTTP client-specific configuration and setup + * 3. Managing resources and connections appropriately + * + * Example implementation using OkHttp: + * ``` + * class OkHttpAsyncTransport( + * private val okHttpClient: OkHttpClient + * ) : AsyncTransport { + * + * override fun execute(request: Request): CompletableFuture { + * val future = CompletableFuture() + * + * request.toOkHttpRequest().let { + * okHttpClient.newCall(it).enqueue(object : Callback { + * override fun onFailure(call: Call, e: IOException) { + * future.completeExceptionally(e) + * } + * + * override fun onResponse(call: Call, response: okhttp3.Response) { + * future.complete(response.toSDKResponse(request)) + * } + * }) + * } + * + * return future + * } + * + * override fun dispose() { + * okHttpClient.dispatcher.executorService.shutdown() + * okHttpClient.connectionPool.evictAll() + * } + * } + * ``` + * + * @see [Request] + * @see [Response] + */ +interface AsyncTransport : Disposable { + /** + * Executes an HTTP request asynchronously. + * + * This method should: + * - Convert the SDK request to the HTTP client's request format + * - Execute the request using the underlying HTTP client + * - Return a [CompletableFuture] of SDK [Response] + * + * @param request The SDK request to execute + * @return [CompletableFuture] wrapping the SDK [Response] + */ + fun execute(request: Request): CompletableFuture +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt new file mode 100644 index 00000000..5980cd77 --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.transport + +/** + * Represents a resource or operation that can be explicitly disposed of to release its resources. + * + * Implementing classes should ensure that the `dispose()` method is called when the resource + * is no longer needed to prevent resource leaks or unnecessary resource consumption. + */ +interface Disposable { + /** + * Releases any resources or operations associated with this instance. + * + * Implementers should ensure that: + * - This method can be safely called multiple times. + * - After calling this method, the instance is no longer usable. + */ + fun dispose() +} diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt new file mode 100644 index 00000000..c6a4b4ad --- /dev/null +++ b/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 Expedia, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.expediagroup.sdk.core.transport + +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response + +/** + * A transport layer interface that adapts different HTTP client libraries to work with the SDK. + * + * This interface serves as an abstraction layer between the SDK and the underlying HTTP client, + * allowing users to integrate their preferred HTTP client library while maintaining consistent + * behavior across the SDK. Implementers are responsible for: + * + * 1. Converting SDK request/response models to their HTTP client's models + * 2. Handling HTTP client-specific configuration and setup + * 3. Managing resources and connections appropriately + * + * Example implementation using OkHttp: + * ``` + * class OkHttpTransport(private val client: OkHttpClient) : Transport { + * override fun execute(request: Request): Response { + * val okHttpRequest = request.toOkHttpRequest() + * return client.newCall(okHttpRequest).execute().toSdkResponse() + * } + * } + * ``` + * + * @see [Request] + * @see [Response] + */ +interface Transport : Disposable { + /** + * Executes an HTTP request synchronously. + * + * This method should: + * - Convert the SDK request to the HTTP client's request format + * - Execute the request using the underlying HTTP client + * - Convert the HTTP client's response to the SDK response format + * + * @param request The SDK request to execute + * @return The response from the server wrapped in the SDK response model + */ + fun execute(request: Request): Response +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java new file mode 100644 index 00000000..530e6345 --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java @@ -0,0 +1,51 @@ +package com.expediagroup.sdk.core.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class HeadersJavaTest { + + @Test + @DisplayName("should access default builder static method") + public void defaultStaticBuilderIsAccessibleFromJava() { + // Given + Headers headers = Headers + .builder() + .add("content-type", "application/json") + .build(); + + // When + String headerValue = headers.get("content-type"); + + // Expect + assertEquals("application/json", headerValue); + } + + @Test + @DisplayName("should access parameterized builder static method") + public void parameterizedStaticBuilderIsAccessibleFromJava() { + // Given + Headers originalHeaders = Headers + .builder() + .add("content-type", "application/json") + .build(); + + Headers derivedHeaders = Headers + .builder(originalHeaders) + .add("accept", "text/plain") + .build(); + + // When + String contentTypeHeaderValue = originalHeaders.get("content-type"); + String acceptHeaderValue = derivedHeaders.get("accept"); + + // Expect + assertEquals("application/json", contentTypeHeaderValue); + assertEquals("text/plain", acceptHeaderValue); + assertEquals("application/json", derivedHeaders.get("content-type")); + assertNull(originalHeaders.get("accept")); + } +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java new file mode 100644 index 00000000..b9ce22fd --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java @@ -0,0 +1,26 @@ +package com.expediagroup.sdk.core.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; + +public class MediaTypeJavaTest { + @Test + @DisplayName("should be able to access of() static method with type and subtype as params") + public void ofStaticMethodShouldBeCallableWithTypeAndSubtypeOnly() { + MediaType.of("application", "json"); + } + + @Test + @DisplayName("should be able to access of() static method with type, subtype, parameters map as params") + public void ofStaticMethodShouldBeCallableWithParametersMap() { + MediaType.of("application", "json", new HashMap<>()); + } + + @Test + @DisplayName("should be able to access of() static method with type, subtype, parameters map as params") + public void parseStaticMethodShouldBeAccessible() { + MediaType.parse("application/json; charset=utf-8"); + } +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java new file mode 100644 index 00000000..78b6b7ba --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java @@ -0,0 +1,14 @@ +package com.expediagroup.sdk.core.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ProtocolJavaTest { + @Test + @DisplayName("should access Protocol.get() as static method in Java") + public void shouldAccessGetAsStaticMethod() { + assertEquals(Protocol.HTTP_1_0, Protocol.get("HTTP/1.0")); + } +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java new file mode 100644 index 00000000..f9d26601 --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java @@ -0,0 +1,91 @@ +package com.expediagroup.sdk.core.http; + +import okio.Buffer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.HashMap; + +public class RequestBodyJavaTest { + + @Test + @DisplayName("should access RequestBody.create as static method with InputStream only") + public void shouldCreateRequestBodyFromInputStreamOnly() { + String content = "Hello World"; + InputStream inputStream = new ByteArrayInputStream(content.getBytes()); + RequestBody.create(inputStream); + } + + @Test + @DisplayName("should access RequestBody.create as static method with InputStream, media type, content length") + public void shouldCreateRequestBodyFromInputStreamAndMediaTypeAndContentLength() { + String content = "Hello World"; + InputStream inputStream = new ByteArrayInputStream(content.getBytes()); + RequestBody.create(inputStream, CommonMediaTypes.TEXT_PLAIN, content.getBytes().length); + } + + @Test + @DisplayName("should access RequestBody.create as static method with Source only") + public void shouldCreateRequestBodyFromSourceOnly() { + String content = "Hello World"; + + try (Buffer source = new Buffer().writeUtf8(content)) { + RequestBody.create(source); + } + } + + @Test + @DisplayName("should access RequestBody.create as static method with Source, media type, content length") + public void shouldCreateRequestBodyFromSourceAndMediaTypeAndContentLength() { + String content = "Hello World"; + try (Buffer source = new Buffer().writeUtf8(content)) { + RequestBody.create(source, CommonMediaTypes.TEXT_PLAIN, content.getBytes().length); + } + } + + @Test + @DisplayName("should access RequestBody.create as static method with ByteString only") + public void shouldCreateRequestBodyFromByteStringOnly() { + String content = "Hello World"; + + try (Buffer source = new Buffer().writeUtf8(content)) { + RequestBody.create(source.snapshot()); + } + } + + @Test + @DisplayName("should access RequestBody.create as static method with ByteString, media type, content length") + public void shouldCreateRequestBodyFromByteStringAndMediaTypeAndContentLength() { + String content = "Hello World"; + try (Buffer source = new Buffer().writeUtf8(content)) { + RequestBody.create(source.snapshot(), CommonMediaTypes.TEXT_PLAIN, content.getBytes().length); + } + } + + @Test + @DisplayName("should access RequestBody.create as static method with map data as form") + public void shouldCreateRequestBodyFromMapDataAsFormData() { + HashMap map = new HashMap() {{ + put("A", "1"); + put("B", "2"); + put("C", "3"); + }}; + + RequestBody.create(map); + } + + @Test + @DisplayName("should access RequestBody.create as static method with map data as form with charset") + public void shouldCreateRequestBodyFromMapDataAndCharsetAsFormData() { + HashMap map = new HashMap() {{ + put("A", "1"); + put("B", "2"); + put("C", "3"); + }}; + + RequestBody.create(map, Charset.defaultCharset()); + } +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java new file mode 100644 index 00000000..e7fb6486 --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java @@ -0,0 +1,14 @@ +package com.expediagroup.sdk.core.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; + +public class RequestJavaTest { + @Test + @DisplayName("should access default builder static method") + public void defaultStaticBuilderIsAccessibleFromJava() throws MalformedURLException { + Request.builder().method(Method.GET).url("https://www.example.com").build(); + } +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java new file mode 100644 index 00000000..a19513d2 --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java @@ -0,0 +1,45 @@ +package com.expediagroup.sdk.core.http; + +import okio.Buffer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +public class ResponseBodyJavaTest { + @Test + @DisplayName("should access ResponseBody.create as static method with InputStream only") + public void shouldCreateResponseBodyFromInputStreamOnly() { + String content = "Hello World"; + InputStream inputStream = new ByteArrayInputStream(content.getBytes()); + ResponseBody.create(inputStream); + } + + @Test + @DisplayName("should access ResponseBody.create as static method with InputStream, media type, content length") + public void shouldCreateResponseBodyFromInputStreamAndMediaTypeAndContentLength() { + String content = "Hello World"; + InputStream inputStream = new ByteArrayInputStream(content.getBytes()); + ResponseBody.create(inputStream, CommonMediaTypes.TEXT_PLAIN, content.getBytes().length); + } + + @Test + @DisplayName("should access ResponseBody.create as static method with Source only") + public void shouldCreateResponseBodyFromSourceOnly() { + String content = "Hello World"; + + try (Buffer source = new Buffer().writeUtf8(content)) { + ResponseBody.create(source); + } + } + + @Test + @DisplayName("should access ResponseBody.create as static method with Source, media type, content length") + public void shouldCreateResponseBodyFromSourceAndMediaTypeAndContentLength() { + String content = "Hello World"; + try (Buffer source = new Buffer().writeUtf8(content)) { + ResponseBody.create(source, CommonMediaTypes.TEXT_PLAIN, content.getBytes().length); + } + } +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java new file mode 100644 index 00000000..9449597e --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java @@ -0,0 +1,24 @@ +package com.expediagroup.sdk.core.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ResponseJavaTest { + @Test + @DisplayName("should access default builder static method") + public void defaultStaticBuilderIsAccessibleFromJava() throws IOException { + try (Response response = Response.builder() + .request(new Request.Builder().method(Method.GET).url("https://example.com").build()) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .status(Status.OK) + .build() + ) { + assertEquals("OK", response.getMessage()); + } + } +} diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java b/core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java new file mode 100644 index 00000000..f2ae011e --- /dev/null +++ b/core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java @@ -0,0 +1,14 @@ +package com.expediagroup.sdk.core.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class StatusJavaTest { + @Test + @DisplayName("should access Status.fromCode() as static method in Java") + public void shouldAccessGetAsStaticMethod() { + assertEquals(Status.OK, Status.fromCode(200)); + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt new file mode 100644 index 00000000..6b519cdb --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt @@ -0,0 +1,82 @@ +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.authentication.common.Credentials +import com.expediagroup.sdk.core.http.Method +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TestBearerAuthenticationManager( + authUrl: String, + credentials: Credentials, +) : AbstractBearerAuthenticationManager(authUrl, credentials) { + override fun authenticate() {} +} + +class AbstractBearerAuthenticationManagerTest { + private val authUrl = "https://example.com/auth" + private val credentials = mockk(relaxed = true) + private lateinit var authManager: AbstractBearerAuthenticationManager + + @BeforeEach + fun setUp() { + authManager = TestBearerAuthenticationManager(authUrl, credentials) + } + + @Test + fun `should store and retrieve token correctly`() { + // Given + val bearerTokenResponse = BearerTokenResponse("dummyToken", 3600) + authManager.storeToken(bearerTokenResponse) + + // When + val header = authManager.getAuthorizationHeaderValue() + + // Expect + assertEquals("Bearer dummyToken", header) + } + + @Test + fun `should detect token about to expire`() { + // Given + authManager = TestBearerAuthenticationManager(authUrl, credentials) + val bearerTokenResponse = BearerTokenResponse("dummyToken", 10) + authManager.storeToken(bearerTokenResponse) + + // When + val isAboutToExpire = authManager.isTokenAboutToExpire() + + // Expect + assertTrue(isAboutToExpire) + } + + @Test + fun `should clear authentication`() { + val bearerTokenResponse = BearerTokenResponse("dummyToken", 3600) + authManager.storeToken(bearerTokenResponse) + + // When + authManager.clearAuthentication() + + // Expect + assertEquals("Bearer ", authManager.getAuthorizationHeaderValue()) + } + + @Test + fun `should build authentication request with correct headers`() { + // Given + every { credentials.encodeBasic() } returns "Basic encodedCredentials" + + // When + val request = authManager.buildAuthenticationRequest() + + // Expect + assertEquals(authUrl, request.url.toString()) + assertEquals(Method.POST, request.method) + assertEquals("Basic encodedCredentials", request.headers.get("Authorization")) + assertEquals("application/x-www-form-urlencoded", request.headers.get("Content-Type")) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt new file mode 100644 index 00000000..28f002db --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt @@ -0,0 +1,299 @@ +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.authentication.common.Credentials +import com.expediagroup.sdk.core.exception.client.ExpediaGroupResponseParsingException +import com.expediagroup.sdk.core.exception.service.ExpediaGroupAuthException +import com.expediagroup.sdk.core.exception.service.ExpediaGroupNetworkException +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Protocol +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.http.ResponseBody +import com.expediagroup.sdk.core.http.Status +import com.expediagroup.sdk.core.transport.AsyncTransport +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertInstanceOf +import org.junit.jupiter.api.assertThrows +import java.util.concurrent.CompletableFuture + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BearerAuthenticationAsyncManagerTest { + private lateinit var asyncTransport: AsyncTransport + private lateinit var credentials: Credentials + private lateinit var asyncAuthenticationManager: BearerAuthenticationAsyncManager + private val authUrl = "https://auth.example.com/token" + + @BeforeAll + fun setup() { + asyncTransport = mockk() + credentials = Credentials("client_key", "client_secret") + asyncAuthenticationManager = BearerAuthenticationAsyncManager(authUrl, credentials, asyncTransport) + } + + @AfterEach + fun tearDown() { + clearMocks(asyncTransport) + asyncAuthenticationManager.clearAuthentication() + } + + @Test + fun `should authenticate and store access token on successful response`() { + // Given + val responseString = """{ "access_token": "first_token", "expires_in": 360 }""" + val response = + Response + .builder() + .body(ResponseBody.create(responseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { asyncTransport.execute(any()) } returns CompletableFuture.completedFuture(response) + + // When + asyncAuthenticationManager.authenticate() + + // Expect + assertEquals("Bearer first_token", asyncAuthenticationManager.getAuthorizationHeaderValue()) + verify(exactly = 1) { asyncTransport.execute(any()) } + } + + @Test + fun `should throw ExpediaGroupAuthException on unsuccessful response`() { + // Given + val request = + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build() + + val response = + Response + .builder() + .request(request) + .status(Status.FORBIDDEN) + .protocol(Protocol.HTTP_1_1) + .message(Status.FORBIDDEN.name) + .build() + + every { asyncTransport.execute(any()) } returns CompletableFuture.completedFuture(response) + + // When + val exception = + assertThrows { + asyncAuthenticationManager.authenticate() + } + + // Expect + assertEquals("Authentication Failed", exception.message) + verify(exactly = 1) { asyncTransport.execute(any()) } + } + + @Test + fun `should wrap unexpected exceptions with ExpediaGroupAuthException`() { + // Given + every { asyncTransport.execute(any()) } throws ExpediaGroupNetworkException("Network error") + + // When + val exception = + assertThrows { + asyncAuthenticationManager.authenticate() + } + + // Expect + assertEquals("Authentication Failed", exception.message) + assertInstanceOf(exception) + assertInstanceOf(exception.cause) + verify(exactly = 1) { asyncTransport.execute(any()) } + } + + @Test + fun `should wrap token parsing exception with ExpediaGroupAuthException`() { + // Given + val invalidAuthResponseBodyString = """{ "id": 1 }""" + val response = + Response + .builder() + .body(ResponseBody.create(invalidAuthResponseBodyString.toByteArray().inputStream())) + .status(Status.OK) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { asyncTransport.execute(any()) } returns CompletableFuture.completedFuture(response) + + // When + val exception = + assertThrows { + asyncAuthenticationManager.authenticate() + } + + // Expect + assertEquals("Authentication Failed", exception.message) + assertInstanceOf(exception) + assertInstanceOf(exception.cause?.cause) + verify(exactly = 1) { asyncTransport.execute(any()) } + } + + @Test + fun `should treat the stored token as a valid token when not expired`() { + // Given + val authResponseString = """{ "access_token": "accessToken", "expires_in": 360 }""" + val response = + Response + .builder() + .body(ResponseBody.create(authResponseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { asyncTransport.execute(any()) } returns CompletableFuture.completedFuture(response) + + // When + asyncAuthenticationManager.authenticate() + + // Expect + assertFalse(asyncAuthenticationManager.isTokenAboutToExpire()) + } + + @Test + fun `should treat the stored token as invalid token if about to expire`() { + // Given + val authResponseString = """{ "access_token": "accessToken", "expires_in": 1 }""" + val response = + Response + .builder() + .body(ResponseBody.create(authResponseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { asyncTransport.execute(any()) } returns CompletableFuture.completedFuture(response) + + // When + asyncAuthenticationManager.authenticate() + + // Expect + assertTrue(asyncAuthenticationManager.isTokenAboutToExpire()) + } + + @Test + fun `should handle token clearance`() { + // Given + val authResponseString = """{ "access_token": "accessToken", "expires_in": 360 }""" + val response = + Response + .builder() + .body(ResponseBody.create(authResponseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { asyncTransport.execute(any()) } returns CompletableFuture.completedFuture(response) + + asyncAuthenticationManager.authenticate() + assertEquals("Bearer accessToken", asyncAuthenticationManager.getAuthorizationHeaderValue()) + + // When + asyncAuthenticationManager.clearAuthentication() + + // Expect + assertEquals("Bearer ", asyncAuthenticationManager.getAuthorizationHeaderValue()) + } + + @Test + fun `should execute authentication request and update token each time authenticate is called`() { + // Given + val authResponseString1 = """{ "access_token": "accessToken1", "expires_in": 360 }""" + val response1 = + Response + .builder() + .body(ResponseBody.create(authResponseString1.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + val authResponseString2 = """{ "access_token": "accessToken2", "expires_in": 360 }""" + val response2 = + Response + .builder() + .body(ResponseBody.create(authResponseString2.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { asyncTransport.execute(any()) } returnsMany + listOf( + CompletableFuture.completedFuture(response1), + CompletableFuture.completedFuture(response2), + ) + + // When + asyncAuthenticationManager.authenticate() + val firstAuthHeader = asyncAuthenticationManager.getAuthorizationHeaderValue() + + asyncAuthenticationManager.authenticate() + val secondAuthHeader = asyncAuthenticationManager.getAuthorizationHeaderValue() + + // Expect + assertEquals("Bearer accessToken1", firstAuthHeader) + assertEquals("Bearer accessToken2", secondAuthHeader) + verify(exactly = 2) { asyncTransport.execute(any()) } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt new file mode 100644 index 00000000..0c27b14a --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt @@ -0,0 +1,294 @@ +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.authentication.common.Credentials +import com.expediagroup.sdk.core.exception.client.ExpediaGroupResponseParsingException +import com.expediagroup.sdk.core.exception.service.ExpediaGroupAuthException +import com.expediagroup.sdk.core.exception.service.ExpediaGroupNetworkException +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Protocol +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.http.ResponseBody +import com.expediagroup.sdk.core.http.Status +import com.expediagroup.sdk.core.transport.Transport +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertInstanceOf +import org.junit.jupiter.api.assertThrows + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BearerAuthenticationManagerTest { + private lateinit var transport: Transport + private lateinit var credentials: Credentials + private lateinit var authenticationManager: BearerAuthenticationManager + private val authUrl = "https://auth.example.com/token" + + @BeforeAll + fun setup() { + transport = mockk() + credentials = Credentials("client_key", "client_secret") + authenticationManager = BearerAuthenticationManager(authUrl, credentials, transport) + } + + @AfterEach + fun tearDown() { + clearMocks(transport) + authenticationManager.clearAuthentication() + } + + @Test + fun `should authenticate and store access token on successful response`() { + // Given + val responseString = """{ "access_token": "first_token", "expires_in": 360 }""" + val response = + Response + .builder() + .body(ResponseBody.create(responseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { transport.execute(any()) } returns response + + // When + authenticationManager.authenticate() + + // Expect + assertEquals("Bearer first_token", authenticationManager.getAuthorizationHeaderValue()) + verify(exactly = 1) { transport.execute(any()) } + } + + @Test + fun `should throw ExpediaGroupAuthException on unsuccessful response`() { + // Given + val request = + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build() + + val response = + Response + .builder() + .request(request) + .status(Status.FORBIDDEN) + .protocol(Protocol.HTTP_1_1) + .message(Status.FORBIDDEN.name) + .build() + + every { transport.execute(any()) } returns response + + // When + val exception = + assertThrows { + authenticationManager.authenticate() + } + + // Expect + assertEquals("Authentication Failed", exception.message) + verify(exactly = 1) { transport.execute(any()) } + } + + @Test + fun `should wrap unexpected exceptions with ExpediaGroupAuthException`() { + // Given + every { transport.execute(any()) } throws ExpediaGroupNetworkException("Network error") + + // When + val exception = + assertThrows { + authenticationManager.authenticate() + } + + // Expect + assertEquals("Authentication Failed", exception.message) + assertInstanceOf(exception) + assertInstanceOf(exception.cause) + verify(exactly = 1) { transport.execute(any()) } + } + + @Test + fun `should wrap token parsing exception with ExpediaGroupAuthException`() { + // Given + val invalidAuthResponseBodyString = """{ "id": 1 }""" + val response = + Response + .builder() + .body(ResponseBody.create(invalidAuthResponseBodyString.toByteArray().inputStream())) + .status(Status.OK) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { transport.execute(any()) } returns response + + // When + val exception = + assertThrows { + authenticationManager.authenticate() + } + + // Expect + assertEquals("Authentication Failed", exception.message) + assertInstanceOf(exception) + assertInstanceOf(exception.cause) + verify(exactly = 1) { transport.execute(any()) } + } + + @Test + fun `should treat the stored token as a valid token when not expired`() { + // Given + val authResponseString = """{ "access_token": "accessToken", "expires_in": 360 }""" + val response = + Response + .builder() + .body(ResponseBody.create(authResponseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { transport.execute(any()) } returns response + + // When + authenticationManager.authenticate() + + // Expect + assertFalse(authenticationManager.isTokenAboutToExpire()) + } + + @Test + fun `should treat the stored token as invalid token if about to expire`() { + // Given + val authResponseString = """{ "access_token": "accessToken", "expires_in": 1 }""" + val response = + Response + .builder() + .body(ResponseBody.create(authResponseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { transport.execute(any()) } returns response + + // When + authenticationManager.authenticate() + + // Expect + assertTrue(authenticationManager.isTokenAboutToExpire()) + } + + @Test + fun `should handle token clearance`() { + // Given + val authResponseString = """{ "access_token": "accessToken", "expires_in": 360 }""" + val response = + Response + .builder() + .body(ResponseBody.create(authResponseString.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { transport.execute(any()) } returns response + + authenticationManager.authenticate() + assertEquals("Bearer accessToken", authenticationManager.getAuthorizationHeaderValue()) + + // When + authenticationManager.clearAuthentication() + + // Expect + assertEquals("Bearer ", authenticationManager.getAuthorizationHeaderValue()) + } + + @Test + fun `should execute authentication request and update token each time authenticate is called`() { + // Given + val authResponseString1 = """{ "access_token": "accessToken1", "expires_in": 360 }""" + val response1 = + Response + .builder() + .body(ResponseBody.create(authResponseString1.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + val authResponseString2 = """{ "access_token": "accessToken2", "expires_in": 360 }""" + val response2 = + Response + .builder() + .body(ResponseBody.create(authResponseString2.toByteArray().inputStream())) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build() + + every { transport.execute(any()) } returnsMany listOf(response1, response2) + + // When + authenticationManager.authenticate() + val firstAuthHeader = authenticationManager.getAuthorizationHeaderValue() + + authenticationManager.authenticate() + val secondAuthHeader = authenticationManager.getAuthorizationHeaderValue() + + // Expect + assertEquals("Bearer accessToken1", firstAuthHeader) + assertEquals("Bearer accessToken2", secondAuthHeader) + verify(exactly = 2) { transport.execute(any()) } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt new file mode 100644 index 00000000..e7b26f2d --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt @@ -0,0 +1,195 @@ +package com.expediagroup.sdk.core.authentication.bearer + +import com.expediagroup.sdk.core.exception.client.ExpediaGroupResponseParsingException +import com.expediagroup.sdk.core.http.CommonMediaTypes +import com.expediagroup.sdk.core.http.MediaType +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Protocol +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.http.ResponseBody +import com.expediagroup.sdk.core.http.Status +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class BearerTokenResponseTest { + @Test + fun `should map to the expected api response`() { + val accessToken = "token" + val expiresIn = 3600L + + var available: Int? + val bearerTokenResponse = + BearerTokenResponse.parse( + Response + .builder() + .body( + ResponseBody.create( + """{ "access_token": "$accessToken", "expires_in": $expiresIn }""" + .toByteArray() + .inputStream() + .also { + available = it.available() + }, + CommonMediaTypes.APPLICATION_FORM_URLENCODED, + available!!.toLong(), + ), + ).status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build(), + ) + + assertEquals(bearerTokenResponse.accessToken, accessToken) + assertEquals(bearerTokenResponse.expiresIn, expiresIn) + } + + @Test + fun `should map to the expected api response with the response media type charset`() { + // Given + val accessToken = "token" + val responseString = """{ "access_token": "$accessToken", "expires_in": 3600 }""" + + val bearerTokenResponse = + BearerTokenResponse.parse( + Response + .builder() + .body( + ResponseBody.create( + responseString.toByteArray().inputStream(), + mediaType = MediaType.parse("application/json; charset=utf-8"), + ), + ).status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build(), + ) + + assertEquals(bearerTokenResponse.accessToken, accessToken) + assertEquals(bearerTokenResponse.expiresIn, 3600L) + } + + @Test + fun `should map to the expected api response without media type and default to utf8`() { + // Given + val accessToken = "token" + val responseString = """{ "access_token": "$accessToken", "expires_in": 3600 }""" + + val bearerTokenResponse = + BearerTokenResponse.parse( + Response + .builder() + .body(ResponseBody.create(responseString.toByteArray().inputStream(), mediaType = null)) + .status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build(), + ) + + assertEquals(bearerTokenResponse.accessToken, accessToken) + assertEquals(bearerTokenResponse.expiresIn, 3600L) + } + + @Test + fun `parse should throw ExpediaGroupResponseParsingException in case of unsuccessful response`() { + assertThrows { + BearerTokenResponse.parse( + Response + .builder() + .status(Status.INTERNAL_SERVER_ERROR) + .protocol(Protocol.HTTP_1_1) + .message(Status.INTERNAL_SERVER_ERROR.name) + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build(), + ) + } + } + + @Test + fun `parse should throw ExpediaGroupResponseParsingException if access_token or expiresIn are missing`() { + var available: Int? + assertThrows { + BearerTokenResponse.parse( + Response + .builder() + .body( + ResponseBody.create( + """{ "expires_in": 3600 }""" + .toByteArray() + .inputStream() + .also { available = it.available() }, + CommonMediaTypes.APPLICATION_FORM_URLENCODED, + available!!.toLong(), + ), + ).status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build(), + ) + } + } + + @Test + fun `parse should ignore extra fields as long as access_token and expires_in are present`() { + val accessToken = "token" + val expiresIn = 3600L + var available: Int? + val bearerTokenResponse = + BearerTokenResponse.parse( + Response + .builder() + .body( + ResponseBody.create( + """{ "access_token": "$accessToken", "expires_in": $expiresIn, "extra": "random" }""" + .toByteArray() + .inputStream() + .also { + available = it.available() + }, + CommonMediaTypes.APPLICATION_FORM_URLENCODED, + available!!.toLong(), + ), + ).status(Status.ACCEPTED) + .protocol(Protocol.HTTP_1_1) + .message("Accepted") + .request( + Request + .builder() + .url("http://localhost") + .method(Method.POST) + .build(), + ).build(), + ) + + assertEquals(bearerTokenResponse.accessToken, accessToken) + assertEquals(bearerTokenResponse.expiresIn, expiresIn) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt new file mode 100644 index 00000000..ffdd263f --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt @@ -0,0 +1,283 @@ +package com.expediagroup.sdk.core.authentication.bearer + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class BearerTokenStorageTest { + @Test + fun `toString should not expose sensitive data`() { + // Given + val mockClock = + mockk { + every { instant() } returns Instant.parse("2024-01-01T12:00:00Z") + every { zone } returns ZoneOffset.UTC + } + val tokenStorage = BearerTokenStorage.create("standard_token", 3600, clock = mockClock) + + // When + val stringToken = tokenStorage.toString() + + // Expect + val expected = "BearerTokenStorage(expiresIn=3600, expirationBufferSeconds=60, expiryInstant=${ + Instant.now(mockClock).plusSeconds(3600) + })" + assertEquals(expected, stringToken) + } + + @Nested + inner class TokenCreationTests { + @Test + fun `create token with standard parameters`() { + val mockClock = + mockk { + every { instant() } returns Instant.parse("2024-01-01T12:00:00Z") + every { zone } returns ZoneOffset.UTC + } + + val tokenStorage = + BearerTokenStorage.create( + accessToken = "standard_token", + expiresIn = 3600, + clock = mockClock, + ) + + assertAll( + "Token creation assertions", + { assertEquals("standard_token", tokenStorage.accessToken) }, + { assertFalse(tokenStorage.isAboutToExpire()) }, + { assertEquals("Bearer standard_token", tokenStorage.getAuthorizationHeaderValue()) }, + ) + } + + @ParameterizedTest + @ValueSource(longs = [0, 1, 59, 60, 61, 3600, 3600 * 24 * 365]) // Use a more reasonable large value + fun `create token with varied expiration times`(expiresIn: Long) { + val tokenStorage = + try { + BearerTokenStorage.create( + accessToken = "test_token", + expiresIn = expiresIn.coerceAtMost(3600 * 24 * 365), // Limit to max 1 year + ) + } catch (e: Exception) { + // If creation fails, the token should be considered expired + null + } + + if (expiresIn < 0) { + assertTrue( + tokenStorage == null || tokenStorage.isAboutToExpire(), + "Token with negative expiration should be null or expired", + ) + } else { + assertNotNull(tokenStorage, "Token storage should not be null for non-negative expiration") + } + } + } + + @Nested + inner class ExpirationTests { + @Test + fun `token with negative expiration time`() { + val tokenStorage = + BearerTokenStorage.create( + accessToken = "expired_token", + expiresIn = -1, + ) + + assertTrue(tokenStorage.isAboutToExpire(), "Negative expiration should always be considered expired") + } + + @Test + fun `empty token always expires`() { + val emptyTokenStorage = BearerTokenStorage.empty + + assertAll( + "Empty token assertions", + { assertTrue(emptyTokenStorage.isAboutToExpire()) }, + { assertEquals("Bearer ", emptyTokenStorage.getAuthorizationHeaderValue()) }, + { assertEquals("", emptyTokenStorage.accessToken) }, + ) + } + + @ParameterizedTest + @CsvSource( + "3, 1, false", // Not yet expired + "3, 2, true", // About to expire + ) + fun `token expiration with custom buffer`( + expiresIn: Long, + bufferSeconds: Long, + expectedExpired: Boolean, + ) { + val tokenStorage = + BearerTokenStorage.create( + accessToken = "buffer_test_token", + expiresIn = expiresIn, + expirationBufferSeconds = bufferSeconds, + clock = Clock.systemUTC(), + ) + + Thread.sleep(1000 * bufferSeconds) + + assertEquals(expectedExpired, tokenStorage.isAboutToExpire()) + } + } + + @Nested + inner class AuthorizationHeaderTests { + @Test + fun `authorization header with special characters`() { + val tokenWithSpecialChars = "token!@#$%^&*()_+" + val tokenStorage = + BearerTokenStorage.create( + accessToken = tokenWithSpecialChars, + expiresIn = 3600, + ) + + assertEquals("Bearer token!@#\$%^&*()_+", tokenStorage.getAuthorizationHeaderValue()) + } + + @Test + fun `authorization header with unicode characters`() { + val tokenWithUnicode = "token_🚀_special@chars_😊" + val tokenStorage = + BearerTokenStorage.create( + accessToken = tokenWithUnicode, + expiresIn = 3600, + ) + + assertEquals("Bearer token_🚀_special@chars_😊", tokenStorage.getAuthorizationHeaderValue()) + } + } + + @Nested + inner class ConcurrencyTests { + @Test + fun `concurrent token creation`() { + val executor = Executors.newFixedThreadPool(10) + val latch = CountDownLatch(100) + val tokens = ConcurrentLinkedQueue() + + repeat(100) { + executor.submit { + try { + val token = + BearerTokenStorage.create( + accessToken = "concurrent_token_$it", + expiresIn = 3600, + ) + tokens.add(token) + } finally { + latch.countDown() + } + } + } + + latch.await(5, TimeUnit.SECONDS) + executor.shutdown() + + assertEquals(100, tokens.size, "All tokens should be created successfully") + assertTrue(tokens.all { it.accessToken.startsWith("concurrent_token_") }) + } + } + + @Nested + inner class EdgeCaseTests { + @Test + fun `extreme expiration buffer scenarios`() { + // Very small buffer + val smallBufferToken = + BearerTokenStorage.create( + accessToken = "small_buffer_token", + expiresIn = 10, + expirationBufferSeconds = 1, + ) + + // Extremely large buffer + val largeBufferToken = + BearerTokenStorage.create( + accessToken = "large_buffer_token", + expiresIn = 3600, + expirationBufferSeconds = 3599, + ) + + assertAll( + "Extreme buffer assertions", + { assertNotNull(smallBufferToken) }, + { assertNotNull(largeBufferToken) }, + ) + } + + @Test + fun `clock precision edge cases`() { + val precisionTestClock = + mockk { + every { instant() } returns + Instant + .now() + .plusSeconds(30) + .plusNanos(999_999_999) + every { zone } returns ZoneOffset.UTC + } + + val tokenStorage = + BearerTokenStorage.create( + accessToken = "precision_test_token", + expiresIn = 30, + expirationBufferSeconds = 31, + clock = precisionTestClock, + ) + + assertTrue( + tokenStorage.isAboutToExpire(), + "Token should be considered about to expire near precision boundaries", + ) + } + } + + @Nested + inner class StabilityTests { + @Test + fun `verify empty token singleton`() { + val emptyToken1 = BearerTokenStorage.empty + val emptyToken2 = BearerTokenStorage.empty + + assertSame(emptyToken1, emptyToken2, "Empty token should be a singleton") + } + + @Test + fun `multiple token creations consistency`() { + val tokens = + (1..100).map { + BearerTokenStorage.create("token_$it", 3600) + } + + assertTrue( + tokens.all { !it.isAboutToExpire() }, + "None of the created tokens should be expired", + ) + assertTrue( + tokens.all { it.getAuthorizationHeaderValue().startsWith("Bearer token_") }, + "All tokens should have correct authorization header", + ) + } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt new file mode 100644 index 00000000..11b06997 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt @@ -0,0 +1,111 @@ +package com.expediagroup.sdk.core.authentication.common + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.Base64 + +class CredentialsTest { + @Test + fun `toString should not print secret`() { + val key = "username" + val secret = "password" + + val credentials = Credentials(key = key, secret = secret) + + assertFalse(credentials.toString().contains("secret")) + assertFalse(credentials.toString().contains(secret)) + } + + @Test + fun `toString should print key=$value`() { + val key = "username" + val secret = "password" + + val credentials = Credentials(key = key, secret = secret) + assertTrue(credentials.toString().contains("key=username")) + } + + @Test + fun `encodeBasic result should be prefixed with Basic`() { + val key = "username" + val secret = "password" + + val credentials = Credentials(key = key, secret = secret) + assertTrue(credentials.encodeBasic().startsWith("Basic")) + } + + @Test + fun `encodeBasic result should contain only one space`() { + val key = "username" + val secret = "password" + + val credentials = Credentials(key = key, secret = secret) + assertEquals(1, credentials.encodeBasic().count { it == ' ' }) + } + + @Test + fun `encodeBasic result should encode to base64`() { + val key = "username" + val secret = "password" + val iso8859DecodedCredentials = "$key:$secret" + val iso8859EncodedCredentials = "Basic ${Base64.getEncoder().encodeToString(iso8859DecodedCredentials.toByteArray())}" + + val encoded = Credentials(key = key, secret = secret).encodeBasic() + assertEquals(encoded, iso8859EncodedCredentials) + } + + @Test + fun `encodeBasic result should decode to key double-colon value`() { + val key = "username" + val secret = "password" + val iso8859DecodedCredentials = "$key:$secret" + val iso8859EncodedCredentials = "Basic ${Base64.getEncoder().encodeToString(iso8859DecodedCredentials.toByteArray())}" + + val encoded = Credentials(key = key, secret = secret).encodeBasic() + assertEquals(encoded, iso8859EncodedCredentials) + } + + @Test + fun `encodeBasic should handle empty key and secret`() { + val credentials = Credentials(key = "", secret = "") + val expected = "Basic ${Base64.getEncoder().encodeToString(":".toByteArray(Charsets.ISO_8859_1))}" + + assertEquals(expected, credentials.encodeBasic()) + } + + @Test + fun `encodeBasic should handle special characters in key and secret`() { + val specialKey = "user!@#" + val specialSecret = "pass$%^" + + val credentials = Credentials(key = specialKey, secret = specialSecret) + val expected = + "Basic ${Base64.getEncoder().encodeToString("$specialKey:$specialSecret".toByteArray(Charsets.ISO_8859_1))}" + + assertEquals(expected, credentials.encodeBasic()) + } + + @Test + fun `encodeBasic should handle non-ISO_8859_1 charset`() { + val key = "username" + val secret = "password" + + val credentials = Credentials(key = key, secret = secret) + val expected = "Basic ${Base64.getEncoder().encodeToString("$key:$secret".toByteArray(Charsets.UTF_8))}" + + assertEquals(expected, credentials.encodeBasic(Charsets.UTF_8)) + } + + @Test + fun `encodeBasic should use ISO_8859_1 as default charset`() { + val key = "username" + val secret = "password" + + val credentials = Credentials(key = key, secret = secret) + val expected = "Basic ${Base64.getEncoder().encodeToString("$key:$secret".toByteArray(Charsets.ISO_8859_1))}" + + assertEquals(expected, credentials.encodeBasic()) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt new file mode 100644 index 00000000..c4e235ca --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt @@ -0,0 +1,33 @@ +package com.expediagroup.sdk.core.common + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class ExtensionsTest { + @Test + fun `getOrThrow should return value when not null`() { + // Given + val value = "Hello, World!" + + // When + val result = value.getOrThrow { IllegalStateException("Value should not be null") } + + // Expect + assertEquals("Hello, World!", result) + } + + @Test + fun `getOrThrow should throw exception when value is null`() { + // Given + val value: String? = null + + // When & Expect + val exception = + assertThrows(IllegalStateException::class.java) { + value.getOrThrow { IllegalStateException("Value should not be null") } + } + + assertEquals("Value should not be null", exception.message) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt new file mode 100644 index 00000000..dad7d2f5 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt @@ -0,0 +1,108 @@ +package com.expediagroup.sdk.core.common + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import java.io.InputStream +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class MetadataLoaderTest { + @AfterEach + fun tearDown() { + MetadataLoader.clear() + } + + @Test + fun `should load metadata from sdk properties file`() { + val metadata = MetadataLoader.load() + + assertEquals("com.expediagroup", metadata.groupId) + assertEquals("expedia-group-test-sdk", metadata.artifactName) + assertEquals("1.0.0", metadata.version) + assertNotNull(metadata.jdkVersion) + assertNotNull(metadata.jdkVendor) + assertNotNull(metadata.osName) + assertNotNull(metadata.osVersion) + assertNotNull(metadata.arch) + assertNotNull(metadata.locale) + } + + @Test + fun `should load default metadata if no sdk properties file is found`() { + val originalClassLoader = Thread.currentThread().contextClassLoader + + try { + Thread.currentThread().contextClassLoader = + object : ClassLoader(originalClassLoader) { + override fun getResourceAsStream(name: String?): InputStream? = if (name == "sdk.properties") null else super.getResourceAsStream(name) + } + + val metadata = MetadataLoader.load() + + assertEquals("unknown", metadata.artifactName) + assertEquals("unknown", metadata.groupId) + assertEquals("unknown", metadata.version) + assertNotNull(metadata.jdkVersion) + assertNotNull(metadata.jdkVendor) + assertNotNull(metadata.osName) + assertNotNull(metadata.osVersion) + assertNotNull(metadata.arch) + assertNotNull(metadata.locale) + } finally { + Thread.currentThread().contextClassLoader = originalClassLoader + } + } + + @Test + fun `should load default metadata if failed to get the current thread class loader`() { + val originalClassLoader = Thread.currentThread().contextClassLoader + + try { + Thread.currentThread().contextClassLoader = null + + val metadata = MetadataLoader.load() + + assertEquals("unknown", metadata.artifactName) + assertEquals("unknown", metadata.groupId) + assertEquals("unknown", metadata.version) + assertNotNull(metadata.jdkVersion) + assertNotNull(metadata.jdkVendor) + assertNotNull(metadata.osName) + assertNotNull(metadata.osVersion) + assertNotNull(metadata.arch) + assertNotNull(metadata.locale) + } finally { + Thread.currentThread().contextClassLoader = originalClassLoader + } + } + + @Test + fun `should return the same metadata instance if called multiple times`() { + assertSame(MetadataLoader.load(), MetadataLoader.load()) + } + + @Test + fun `should return the same metadata instance if called multiple times by concurrent threads`() { + // Given + val threadsCount = 5 + val executor = Executors.newFixedThreadPool(threadsCount) + val tasks = List(threadsCount) { Callable { MetadataLoader.load() } } + + // When + val results = executor.invokeAll(tasks).map { it.get() } + executor.shutdown() + executor.awaitTermination(5, TimeUnit.SECONDS) + + val firstInstance = results.first() + + // Expect + results.forEach { instance -> + assertNotNull(instance) + assertSame(firstInstance, instance) + } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt new file mode 100644 index 00000000..9b2fae62 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt @@ -0,0 +1,188 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CommonMediaTypesTest { + @Test + fun `TEXT_PLAIN should have correct type and subtype`() { + val mediaType = CommonMediaTypes.TEXT_PLAIN + assertEquals("text", mediaType.type) + assertEquals("plain", mediaType.subtype) + } + + @Test + fun `TEXT_HTML should have correct type and subtype`() { + val mediaType = CommonMediaTypes.TEXT_HTML + assertEquals("text", mediaType.type) + assertEquals("html", mediaType.subtype) + } + + @Test + fun `TEXT_CSS should have correct type and subtype`() { + val mediaType = CommonMediaTypes.TEXT_CSS + assertEquals("text", mediaType.type) + assertEquals("css", mediaType.subtype) + } + + @Test + fun `TEXT_JAVASCRIPT should have correct type and subtype`() { + val mediaType = CommonMediaTypes.TEXT_JAVASCRIPT + assertEquals("text", mediaType.type) + assertEquals("javascript", mediaType.subtype) + } + + @Test + fun `TEXT_CSV should have correct type and subtype`() { + val mediaType = CommonMediaTypes.TEXT_CSV + assertEquals("text", mediaType.type) + assertEquals("csv", mediaType.subtype) + } + + @Test + fun `APPLICATION_JSON should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_JSON + assertEquals("application", mediaType.type) + assertEquals("json", mediaType.subtype) + } + + @Test + fun `APPLICATION_XML should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_XML + assertEquals("application", mediaType.type) + assertEquals("xml", mediaType.subtype) + } + + @Test + fun `APPLICATION_FORM_URLENCODED should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_FORM_URLENCODED + assertEquals("application", mediaType.type) + assertEquals("x-www-form-urlencoded", mediaType.subtype) + } + + @Test + fun `APPLICATION_OCTET_STREAM should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_OCTET_STREAM + assertEquals("application", mediaType.type) + assertEquals("octet-stream", mediaType.subtype) + } + + @Test + fun `APPLICATION_PDF should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_PDF + assertEquals("application", mediaType.type) + assertEquals("pdf", mediaType.subtype) + } + + @Test + fun `APPLICATION_VND_API_JSON should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_VND_API_JSON + assertEquals("application", mediaType.type) + assertEquals("vnd.api+json", mediaType.subtype) + } + + @Test + fun `APPLICATION_JSON_GRAPHQL should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_JSON_GRAPHQL + assertEquals("application", mediaType.type) + assertEquals("json+graphql", mediaType.subtype) + } + + @Test + fun `APPLICATION_HAL_JSON should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_HAL_JSON + assertEquals("application", mediaType.type) + assertEquals("hal+json", mediaType.subtype) + } + + @Test + fun `APPLICATION_PROBLEM_JSON should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_PROBLEM_JSON + assertEquals("application", mediaType.type) + assertEquals("problem+json", mediaType.subtype) + } + + @Test + fun `APPLICATION_ZIP should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_ZIP + assertEquals("application", mediaType.type) + assertEquals("zip", mediaType.subtype) + } + + @Test + fun `IMAGE_JPEG should have correct type and subtype`() { + val mediaType = CommonMediaTypes.IMAGE_JPEG + assertEquals("image", mediaType.type) + assertEquals("jpeg", mediaType.subtype) + } + + @Test + fun `IMAGE_PNG should have correct type and subtype`() { + val mediaType = CommonMediaTypes.IMAGE_PNG + assertEquals("image", mediaType.type) + assertEquals("png", mediaType.subtype) + } + + @Test + fun `IMAGE_GIF should have correct type and subtype`() { + val mediaType = CommonMediaTypes.IMAGE_GIF + assertEquals("image", mediaType.type) + assertEquals("gif", mediaType.subtype) + } + + @Test + fun `IMAGE_SVG_XML should have correct type and subtype`() { + val mediaType = CommonMediaTypes.IMAGE_SVG_XML + assertEquals("image", mediaType.type) + assertEquals("svg+xml", mediaType.subtype) + } + + @Test + fun `AUDIO_MPEG should have correct type and subtype`() { + val mediaType = CommonMediaTypes.AUDIO_MPEG + assertEquals("audio", mediaType.type) + assertEquals("mpeg", mediaType.subtype) + } + + @Test + fun `AUDIO_WAV should have correct type and subtype`() { + val mediaType = CommonMediaTypes.AUDIO_WAV + assertEquals("audio", mediaType.type) + assertEquals("wav", mediaType.subtype) + } + + @Test + fun `VIDEO_MP4 should have correct type and subtype`() { + val mediaType = CommonMediaTypes.VIDEO_MP4 + assertEquals("video", mediaType.type) + assertEquals("mp4", mediaType.subtype) + } + + @Test + fun `VIDEO_MPEG should have correct type and subtype`() { + val mediaType = CommonMediaTypes.VIDEO_MPEG + assertEquals("video", mediaType.type) + assertEquals("mpeg", mediaType.subtype) + } + + @Test + fun `MULTIPART_FORM_DATA should have correct type and subtype`() { + val mediaType = CommonMediaTypes.MULTIPART_FORM_DATA + assertEquals("multipart", mediaType.type) + assertEquals("form-data", mediaType.subtype) + } + + @Test + fun `MULTIPART_BYTERANGES should have correct type and subtype`() { + val mediaType = CommonMediaTypes.MULTIPART_BYTERANGES + assertEquals("multipart", mediaType.type) + assertEquals("byteranges", mediaType.subtype) + } + + @Test + fun `APPLICATION_JAVASCRIPT should have correct type and subtype`() { + val mediaType = CommonMediaTypes.APPLICATION_JAVASCRIPT + assertEquals("application", mediaType.type) + assertEquals("javascript", mediaType.subtype) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt new file mode 100644 index 00000000..d37a3cb3 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt @@ -0,0 +1,449 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class HeadersTest { + @Test + fun `should return header value when header exists with single value`() { + // Given + val headers = + Headers + .builder() + .add("content-type", "application/json") + .build() + + // When + val headerValue = headers.get("content-type") + + // Expect + assertEquals("application/json", headerValue) + } + + @Test + fun `should return first header value when header exists with multiple values`() { + // Given + val headers = + Headers + .builder() + .add("accept", listOf("application/json", "text/plain")) + .build() + + // When + val headerValue = headers.get("accept") + + // Expect + assertEquals("application/json", headerValue) + } + + @Test + fun `should return null value when header does not exist`() { + // Given + val headers = + Headers + .builder() + .add("content-Type", "application/json") + .build() + + // When + val headerValue = headers.get("accept") + + // Expect + assertNull(headerValue) + } + + @Test + fun `should return the value for the matched header name case-insensitive`() { + // Given + val headers = + Headers + .builder() + .add("content-type", "application/json") + .build() + + // When + val value1 = headers.get("content-type") + val value2 = headers.get("CONTENT-TYPE") + + // Expect + assertEquals("application/json", value1) + assertEquals("application/json", value2) + } + + @Test + fun `should return all values when header exists with multiple values`() { + // Given + val headers = + Headers + .builder() + .add("accept", listOf("application/json", "text/plain")) + .build() + + // When + val headersValues = headers.values("accept") + + // Expect + assertEquals(listOf("application/json", "text/plain"), headersValues) + } + + @Test + fun `should return empty list if the header does not exist`() { + // Given + val headers = + Headers + .builder() + .add("accept", listOf("application/json", "text/plain")) + .build() + + // When + val headersValues = headers.values("content-type") + + // Expect + assertEquals(emptyList(), headersValues) + } + + @Test + fun `should return all values for the matched header name case-insensitive`() { + // Given + val headers = + Headers + .Builder() + .add("accept", listOf("application/json", "text/plain")) + .build() + + // When + val headersNames = headers.values("accept") + + // Expect + assertEquals(listOf("application/json", "text/plain"), headersNames) + } + + @Test + fun `should return all headers names`() { + // Given + val headers = + Headers + .builder() + .add("accept", listOf("application/json", "text/plain")) + .add("content-type", "application/json") + .build() + + // When + val headersNames = headers.names() + + // Expect + assertEquals(setOf("accept", "content-type"), headersNames) + } + + @Test + fun `should return empty set if no headers exist`() { + // Given + val headers = Headers.builder().build() + + // When + val headersNames = headers.names() + + // Expect + assertEquals(emptySet(), headersNames) + } + + @Test + fun `should return all headers entries`() { + // Given + val headers = + Headers + .builder() + .add("accept", listOf("application/json", "text/plain")) + .add("content-type", "application/json") + .build() + + // When + val headersEntries = headers.entries() + + // Expect + val expectedEntries = + mapOf( + "accept" to listOf("application/json", "text/plain"), + "content-type" to listOf("application/json"), + ).entries + + assertEquals(expectedEntries, headersEntries) + } + + @Test + fun `should return empty set of entries if no headers exist`() { + // Given + val headers = Headers.builder().build() + + // When + val headersEntries = headers.entries() + + // Expect + assertEquals(emptySet>>(), headersEntries) + } + + @Test + fun `should create a builder with existing headers`() { + // Given + val originalHeaders = + Headers + .builder() + .add("accept", "application/json") + .add("content-type", "application/xml") + .build() + + // When + val newHeaders = + originalHeaders + .newBuilder() + .add("accept", "text/plain") + .remove("content-type") + .build() + + // Expect + assertEquals("application/json", newHeaders.get("accept")) + assertEquals(listOf("application/json", "text/plain"), newHeaders.values("accept")) + assertNull(newHeaders.get("content-type")) + assertFalse(newHeaders.names().contains("content-type")) + } + + @Test + fun `should return the correct string representation`() { + // Given + val headers = + Headers + .builder() + .add("accept", "application/json") + .add("content-type", "application/xml") + .build() + + // When + val headersString = headers.toString() + + // Expect + assertEquals("{accept=[application/json], content-type=[application/xml]}", headersString) + } + + @Test + fun `should return empty brackets when no headers exist`() { + // Given + val headers = Headers.builder().build() + + // When + val headersString = headers.toString() + + // Expect + assertEquals("{}", headersString) + } + + @Nested + inner class BuilderTests { + @Test + fun `should add single header with valid name and value as expected`() { + // Given + val headers = + Headers + .builder() + .add("content-type", "application/json") + .build() + + // When + val value1 = headers.get("Content-Type") + val value2 = headers.get("content-type") + val value3 = headers.get("CONTENT-TYPE") + val values = headers.values("Content-Type") + + // Expect + assertEquals("application/json", value1) + assertEquals("application/json", value2) + assertEquals("application/json", value3) + assertEquals(listOf("application/json"), values) + } + + @Test + fun `should add multiple values for one header name`() { + // Given + val headers = + Headers + .builder() + .add("accept", "application/json") + .add("accept", "text/plain") + .build() + + // When + val value = headers.get("accept") + val values = headers.values("accept") + + // Expect + assertEquals("application/json", value) + assertEquals(listOf("application/json", "text/plain"), values) + assertEquals(2, values.size) + } + + @Test + fun `should add multiple values with valid name and values`() { + // Given + val headerName = "accept" + val headerValues = listOf("application/json", "text/plain") + + // When + val headers = + Headers + .builder() + .add(headerName, headerValues) + .build() + + // Expect + assertEquals("application/json", headers.get("accept")) + assertEquals(headerValues, headers.values("accept")) + } + + @Test + fun `should set (override) single header with valid name and value`() { + // Given + val headerName = "accept" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val headers = + Headers + .builder() + .add(headerName, headerValue1) + .set(headerName, headerValue2) + .build() + + // Expect + assertEquals(headerValue2, headers.get(headerName)) + assertEquals(listOf(headerValue2), headers.values(headerName)) + } + + @Test + fun `should set multiple values with valid name and values`() { + // Given + val headerName = "accept" + val initialHeaderValues = listOf("application/json", "text/plain") + val newHeaderValues = listOf("application/xml", "text/html") + + // When + val headers = + Headers + .builder() + .add(headerName, initialHeaderValues) + .set(headerName, newHeaderValues) + .build() + + // Expect + assertEquals("application/xml", headers.get("accept")) + assertEquals(newHeaderValues, headers.values("accept")) + } + + @Test + fun `should remove existing header as expected`() { + // Given + val headerName = "accept" + val headerValue = "application/json" + + // When + val headers = + Headers + .builder() + .add(headerName, headerValue) + .remove(headerName) + .build() + + // Expect + assertNull(headers.get(headerName)) + assertTrue(headers.values(headerName).isEmpty()) + assertFalse(headers.names().contains(headerName)) + } + + @Test + fun `should handle removing non-existing header as expected`() { + // Given + val headerName = "accept" + val headerValue = "application/json" + + // When + val headers = + Headers + .builder() + .add(headerName, headerValue) + .remove("content-type") + .build() + + // Expect + assertEquals("application/json", headers.get("accept")) + assertEquals(listOf("application/json"), headers.values("accept")) + assertFalse(headers.names().contains("content-type")) + } + + @Test + fun `should build Headers with no headers as expected`() { + // Given + val headersBuilder = Headers.builder() + + // When + val headers = headersBuilder.build() + + // Expect + assertTrue(headers.names().isEmpty()) + assertTrue(headers.entries().isEmpty()) + } + + @Test + fun `should initialize the Builder with existing Headers`() { + // Given + val originalHeaders = + Headers + .builder() + .add("Accept", "application/json") + .add("Content-Type", "application/xml") + .build() + + // When + val newHeaders = + Headers + .builder(originalHeaders) + .add("Accept", "text/plain") + .remove("Content-Type") + .build() + + // Expect + assertEquals("application/json", newHeaders.get("Accept")) + assertEquals(listOf("application/json", "text/plain"), newHeaders.values("Accept")) + assertNull(newHeaders.get("Content-Type")) + assertFalse(newHeaders.names().contains("content-type")) + } + + @Test + fun `should maintain original Headers immutability when instantiating a new builder from it`() { + // Given + val builder = Headers.builder().add("accept", "application/json") + + // When + val headers = builder.build() + builder.add("content-type", "application/xml") + + // Expect + assertEquals("application/json", headers.get("accept")) + assertNull(headers.get("content-type")) + } + + @Test + fun `should lowercase and trim the header name`() { + // Given + val headers = Headers.builder().add(" Accept ", "application/json").build() + + // When + val headerName = headers.names().first() + + // Expect + assertEquals("accept", headerName) + } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt new file mode 100644 index 00000000..b95c0496 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt @@ -0,0 +1,568 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.nio.charset.Charset + +class MediaTypeTest { + @Test + fun `should create MediaType without parameters`() { + // Given + val mediaType = MediaType.of("application", "json") + + // When + val type = mediaType.type + val subtype = mediaType.subtype + + // Expect + assertEquals("application", type) + assertEquals("json", subtype) + assertTrue(mediaType.parameters.isEmpty()) + } + + @Test + fun `should create MediaType with provided parameters`() { + // Given + val mediaType = MediaType.of("text", "html", mapOf("charset" to "UTF-8")) + + // When + val type = mediaType.type + val subtype = mediaType.subtype + val parameters = mediaType.parameters + + // Expect + assertEquals("text", type) + assertEquals("html", subtype) + assertEquals(mapOf("charset" to "UTF-8"), parameters) + } + + @Test + fun `should normalize type, subtype, and parameters to lowercase`() { + // Given + val mediaType = MediaType.of("APPLICATION", "JSON", mapOf("CHARSET" to "UTF-8")) + + // When + val type = mediaType.type + val subtype = mediaType.subtype + val parameters = mediaType.parameters + + // Expect + assertEquals("application", type) + assertEquals("json", subtype) + assertEquals(mapOf("charset" to "UTF-8"), parameters) + } + + @Test + fun `should throw an exception for blank type`() { + // Given + val blankType = "" + + // When + val exception = + assertThrows { + MediaType.of(blankType, "json") + } + + // Expect + assertEquals("Type must not be blank", exception.message) + } + + @Test + fun `should throw an exception for black subtype`() { + // Given + val blankSubType = "" + + // When + val exception = + assertThrows { + MediaType.of("application", blankSubType) + } + + // Expect + assertEquals("Subtype must not be blank", exception.message) + } + + @Test + fun `should return full type`() { + // Given + val mediaType = MediaType.of("application", "json") + + // When + val fullType = mediaType.fullType + + // Expect + assertEquals("application/json", fullType) + } + + @Test + fun `should return charset when present`() { + // Given + val mediaType = MediaType.of("text", "html", mapOf("charset" to "UTF-8")) + + // When + val charset = mediaType.charset + + // Expect + assertEquals(Charset.forName("UTF-8"), charset) + } + + @Test + fun `should return null charset when not present`() { + // Given + val mediaType = MediaType.of("text", "html") + + // When + val charset = mediaType.charset + + // Expect + assertNull(charset) + } + + @Test + fun `should return null charset for invalid charset`() { + // Given + val mediaType = MediaType.of("text", "html", mapOf("charset" to "INVALID")) + + // When + val charset = mediaType.charset + + // Expect + assertNull(charset) + } + + @Test + fun `should include media type with wildcard subtype`() { + // Given + val wildcardSubtype = MediaType.of("application", "*") + val other = MediaType.of("application", "json") + + // When + val includes = wildcardSubtype.includes(other) + + // Expect + assertTrue(includes) + } + + @Test + fun `should include media type with both wildcards`() { + // Given + val wildcardBoth = MediaType.of("*", "*") + val other = MediaType.of("application", "json") + + // When + val includes = wildcardBoth.includes(other) + + // Expect + assertTrue(includes) + } + + @Test + fun `should include media type with exact match`() { + // Given + val exact = MediaType.of("application", "json") + val other = MediaType.of("application", "json") + + // When + val includes = exact.includes(other) + + // Expect + assertTrue(includes) + } + + @Test + fun `should not include media type with mismatched type`() { + // Given + val mediaType = MediaType.of("application", "json") + val other = MediaType.of("text", "json") + + // When + val includes = mediaType.includes(other) + + // Expect + assertFalse(includes) + } + + @Test + fun `should not include media type with mismatched subtype`() { + // Given + val mediaType = MediaType.of("application", "json") + val other = MediaType.of("application", "xml") + + // When + val includes = mediaType.includes(other) + + // Expect + assertFalse(includes) + } + + @Test + fun `should include media type with case-insensitive match`() { + // Given + val mediaType = MediaType.of("APPLICATION", "JSON") + val other = MediaType.of("application", "json") + + // When + val includes = mediaType.includes(other) + + // Expect + assertTrue(includes) + } + + @Test + fun `should not include media type with neither wildcard nor match`() { + // Given + val mediaType = MediaType.of("text", "xml") + val other = MediaType.of("application", "json") + + // When + val includes = mediaType.includes(other) + + // Expect + assertFalse(includes) + } + + @Test + fun `should return formatted media type string without parameters`() { + // Given + val mediaType = MediaType.of("application", "json") + + // When + val stringRepresentation = mediaType.toString() + + // Expect + assertEquals("application/json", stringRepresentation) + } + + @Test + fun `should return formatted media type string with parameters`() { + // Given + val mediaType = MediaType.of("application", "json", mapOf("charset" to "UTF-8", "version" to "1.0")) + + // When + val stringRepresentation = mediaType.toString() + + // Expect + assertEquals("application/json;charset=UTF-8;version=1.0", stringRepresentation) + } + + @Test + fun `should throw exception if type is wildcard with defined subtype`() { + // Given + val type = "*" + val subtype = "html" + + // When + val exception = + assertThrows { + MediaType.of(type, subtype) + } + + // Expect + assertEquals("Invalid media type format: type=*, subtype=html", exception.message) + } + + @Nested + inner class MediaTypeParser { + @Test + fun `should parse valid media type with no parameters`() { + // Given + val mediaType = "text/html" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("text", result.type) + assertEquals("html", result.subtype) + assertTrue(result.parameters.isEmpty()) + } + + @Test + fun `should parse valid media type with one parameter`() { + // Given + val mediaType = "application/json; charset=UTF-8" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("application", result.type) + assertEquals("json", result.subtype) + assertEquals(mapOf("charset" to "utf-8"), result.parameters) + } + + @Test + fun `should parse media type with structured subtype`() { + // Given + val mediaType = "application/json+graphql; charset=UTF-8" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("application", result.type) + assertEquals("json+graphql", result.subtype) + assertEquals(mapOf("charset" to "utf-8"), result.parameters) + } + + @Test + fun `should parse media type with wildcard subtype`() { + // Given + val mediaType = "application/*" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("application", result.type) + assertEquals("*", result.subtype) + assertTrue(result.parameters.isEmpty()) + } + + @Test + fun `should parse media type with wildcard type and subtype`() { + // Given + val mediaType = "*/*" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("*", result.type) + assertEquals("*", result.subtype) + assertTrue(result.parameters.isEmpty()) + } + + @Test + fun `should parse valid media type with multiple parameters`() { + // Given + val mediaType = "multipart/form-data; boundary=abc123; charset=UTF-8" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("multipart", result.type) + assertEquals("form-data", result.subtype) + assertEquals( + mapOf( + "boundary" to "abc123", + "charset" to "utf-8", + ), + result.parameters, + ) + } + + @Test + fun `should ignore extra spaces in media type and parameters`() { + // Given + val mediaType = " text/html ; charset = UTF-8 ; boundary = my-boundary " + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("text", result.type) + assertEquals("html", result.subtype) + assertEquals( + mapOf( + "charset" to "utf-8", + "boundary" to "my-boundary", + ), + result.parameters, + ) + } + + @Test + fun `should parse media type with encoded parameters`() { + // Given + val mediaType = "application/x-www-form-urlencoded; charset=UTF-8" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("application", result.type) + assertEquals("x-www-form-urlencoded", result.subtype) + assertEquals(mapOf("charset" to "utf-8"), result.parameters) + } + + @Test + fun `should parse media type with vendor-specific subtype`() { + // Given + val mediaType = "application/vnd.mspowerpoint" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("application", result.type) + assertEquals("vnd.mspowerpoint", result.subtype) + assertTrue(result.parameters.isEmpty()) + } + + @Test + fun `should handle valid media type with empty parameters`() { + // Given + val mediaType = "text/html;" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("text", result.type) + assertEquals("html", result.subtype) + assertTrue(result.parameters.isEmpty()) + } + + @Test + fun `should parse valid media type with multiple semicolons`() { + // Given + val mediaType = "application/json;;; charset=UTF-8;; boundary=abc123" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("application", result.type) + assertEquals("json", result.subtype) + assertEquals( + mapOf( + "charset" to "utf-8", + "boundary" to "abc123", + ), + result.parameters, + ) + } + + @Test + fun `should handle lowercase conversion for type, subtype, and parameters`() { + // Given + val mediaType = "APPLICATION/JSON; CHARSET=UTF-8; BOUNDARY=ABC" + + // When + val result = MediaType.parse(mediaType) + + // Expect + assertEquals("application", result.type) + assertEquals("json", result.subtype) + assertEquals( + mapOf( + "charset" to "utf-8", + "boundary" to "abc", + ), + result.parameters, + ) + } + + @Test + fun `should throw exception for missing subtype`() { + // Given + val mediaType = "application/" + + // When + val exception = + assertThrows { + MediaType.parse(mediaType) + } + + // Expect + assertEquals("Invalid media type format: application/", exception.message) + } + + @Test + fun `should throw exception for missing type`() { + // Given + val mediaType = "/json" + + // When + val exception = + assertThrows { + MediaType.parse(mediaType) + } + + // Expect + assertEquals("Invalid media type format: /json", exception.message) + } + + @Test + fun `should throw exception for blank media type`() { + // Given + val mediaType = " " + + // When + val exception = + assertThrows { + MediaType.parse(mediaType) + } + + // Expect + assertEquals("Media type must not be blank", exception.message) + } + + @Test + fun `should throw exception for invalid format without slash`() { + // Given + val mediaType = "invalidMediaType" + + // When + val exception = + assertThrows { + MediaType.parse(mediaType) + } + + // Expect + assertEquals("Invalid media type format: invalidMediaType", exception.message) + } + + @Test + fun `should throw exception for malformed parameters`() { + // Given + val mediaType = "text/html; charset; boundary=abc123" + + // When + val exception = + assertThrows { + MediaType.parse(mediaType) + } + + // Expect + assertEquals("Invalid parameter format: charset", exception.message) + } + + @Test + fun `should throw exception for malformed parameter with missing value`() { + // Given + val mediaType = "text/html; charset=" + + // When + val exception = + assertThrows { + MediaType.parse(mediaType) + } + + // Expect + assertEquals("Invalid parameter format: charset=", exception.message) + } + + @Test + fun `should throw exception if type is wildcard with defined subtype`() { + // Given + val mediaType = "*/html" + + // When + val exception = + assertThrows { + MediaType.parse(mediaType) + } + + // Expect + assertEquals("Invalid media type format: */html", exception.message) + } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt new file mode 100644 index 00000000..da8837e7 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt @@ -0,0 +1,53 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MethodTest { + @Test + fun `should return correct string representation`() { + // Given + val method = Method.GET + + // When + val result = method.toString() + + // Expect + assertEquals("GET", result) + } + + @Test + fun `should have correct mapping for all methods`() { + // Given + val expectedMethods = + mapOf( + Method.GET to "GET", + Method.POST to "POST", + Method.PUT to "PUT", + Method.DELETE to "DELETE", + Method.PATCH to "PATCH", + Method.HEAD to "HEAD", + Method.OPTIONS to "OPTIONS", + Method.TRACE to "TRACE", + Method.CONNECT to "CONNECT", + ) + + // When & Expect + expectedMethods.forEach { (enum, stringValue) -> + assertEquals(stringValue, enum.method) + assertEquals(stringValue, enum.toString()) + } + } + + @Test + fun `should handle custom toString consistently`() { + // Given + val method = Method.POST + + // When + val result = method.toString() + + // Expect + assertEquals(method.method, result) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt new file mode 100644 index 00000000..16400929 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt @@ -0,0 +1,43 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class ProtocolTest { + @Test + fun `should return correct string representation for each Protocol`() { + assertEquals("http/1.0", Protocol.HTTP_1_0.toString()) + assertEquals("http/1.1", Protocol.HTTP_1_1.toString()) + assertEquals("http/2", Protocol.HTTP_2.toString()) + assertEquals("h2_prior_knowledge", Protocol.H2_PRIOR_KNOWLEDGE.toString()) + assertEquals("quic", Protocol.QUIC.toString()) + } + + @Test + fun `should return correct Protocol for valid strings`() { + assertEquals(Protocol.HTTP_1_0, Protocol.get("HTTP/1.0")) + assertEquals(Protocol.HTTP_1_1, Protocol.get("HTTP/1.1")) + assertEquals(Protocol.HTTP_2, Protocol.get("HTTP/2")) + assertEquals(Protocol.HTTP_2, Protocol.get("HTTP/2.0")) + assertEquals(Protocol.H2_PRIOR_KNOWLEDGE, Protocol.get("H2_PRIOR_KNOWLEDGE")) + assertEquals(Protocol.QUIC, Protocol.get("QUIC")) + } + + @Test + fun `should throw IllegalArgumentException for invalid protocol strings`() { + val exception = + assertThrows { + Protocol.get("INVALID_PROTOCOL") + } + assertEquals("Unexpected protocol: INVALID_PROTOCOL", exception.message) + } + + @Test + fun `should ignore case when parsing protocol strings`() { + assertEquals(Protocol.HTTP_1_0, Protocol.get("http/1.0")) + assertEquals(Protocol.HTTP_2, Protocol.get("HTTP/2.0")) + assertEquals(Protocol.QUIC, Protocol.get("quic")) + assertEquals(Protocol.H2_PRIOR_KNOWLEDGE, Protocol.get("H2_prior_knowledge")) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt new file mode 100644 index 00000000..23921398 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt @@ -0,0 +1,138 @@ +package com.expediagroup.sdk.core.http + +import okio.Buffer +import okio.BufferedSink +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets + +class RequestBodyTest { + @Test + fun `should create a request body from InputStream with valid data`() { + // Given + val content = "Hello World" + val inputStream = ByteArrayInputStream(content.toByteArray(StandardCharsets.UTF_8)) + val mediaType = CommonMediaTypes.TEXT_PLAIN + val contentLength = content.toByteArray().size.toLong() + val requestBody = RequestBody.create(inputStream, mediaType, contentLength) + + // When + val buffer = Buffer() + requestBody.writeTo(buffer) + + // Expect + assertEquals(mediaType, requestBody.mediaType()) + assertEquals(contentLength, requestBody.contentLength()) + assertEquals(content, buffer.readUtf8()) + } + + @Test + fun `should create a request body from InputStream with empty data`() { + // Given + val inputStream = ByteArrayInputStream(byteArrayOf()) + val mediaType = CommonMediaTypes.TEXT_PLAIN + val requestBody = RequestBody.create(inputStream, mediaType, 0L) + + // When + val buffer = Buffer() + requestBody.writeTo(buffer) + + // Expect + assertEquals(mediaType, requestBody.mediaType()) + assertEquals(0L, requestBody.contentLength()) + assertTrue(buffer.size == 0L) + } + + @Test + fun `should create RequestBody from Source with valid data`() { + // Given + val content = "Hello World" + val source = Buffer().writeUtf8(content) + val mediaType = CommonMediaTypes.TEXT_PLAIN + val contentLength = content.toByteArray().size.toLong() + val requestBody = RequestBody.create(source, mediaType, contentLength) + + // When + val buffer = Buffer() + requestBody.writeTo(buffer) + + // Expect + assertEquals(mediaType, requestBody.mediaType()) + assertEquals(contentLength, requestBody.contentLength()) + assertEquals(content, buffer.readUtf8()) + } + + @Test + fun `should create RequestBody from Source with empty data`() { + // Given + val source = Buffer() + val mediaType = CommonMediaTypes.TEXT_PLAIN + val requestBody = RequestBody.create(source, mediaType, 0L) + + // When + val buffer = Buffer() + requestBody.writeTo(buffer) + + // Expect + assertEquals(mediaType, requestBody.mediaType()) + assertEquals(0L, requestBody.contentLength()) + assertTrue(buffer.size == 0L) + } + + @Test + fun `should create RequestBody from ByteString with valid data`() { + // Given + val content = "Hello World" + val source = Buffer().writeUtf8(content) + val mediaType = CommonMediaTypes.TEXT_PLAIN + val contentLength = content.toByteArray().size.toLong() + val requestBody = RequestBody.create(source.snapshot(), mediaType, contentLength) + + // When + val buffer = Buffer() + requestBody.writeTo(buffer) + + // Expect + assertEquals(mediaType, requestBody.mediaType()) + assertEquals(contentLength, requestBody.contentLength()) + assertEquals(content, buffer.readUtf8()) + } + + @Test + fun `should create RequestBody from ByteString with empty data`() { + // Given + val source = Buffer() + val mediaType = CommonMediaTypes.TEXT_PLAIN + val requestBody = RequestBody.create(source.snapshot(), mediaType, 0L) + + // When + val buffer = Buffer() + requestBody.writeTo(buffer) + + // Expect + assertEquals(mediaType, requestBody.mediaType()) + assertEquals(0L, requestBody.contentLength()) + assertTrue(buffer.size == 0L) + } + + @Test + fun `should be extendable for custom usages`() { + val requestBody = + object : RequestBody() { + val source = Buffer() + + override fun mediaType(): MediaType = CommonMediaTypes.TEXT_PLAIN + + override fun writeTo(sink: BufferedSink) { + source.use { src -> + sink.writeAll(src) + } + } + } + + assertEquals(requestBody.contentLength(), -1) + assertEquals(requestBody.mediaType(), CommonMediaTypes.TEXT_PLAIN) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt new file mode 100644 index 00000000..6644aa94 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt @@ -0,0 +1,327 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.net.MalformedURLException +import java.net.URL + +class RequestTest { + @Test + fun `should build request instance with all properties`() { + // Given + val method = Method.POST + val url = URL("https://example.com") + val headers = Headers.Builder().add("Authorization", "Bearer token").build() + val body = RequestBody.create("Sample body".byteInputStream()) + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .headers(headers) + .body(body) + .build() + + // Expect + assertEquals(method, request.method) + assertEquals(url, request.url) + assertEquals("Bearer token", request.headers.get("Authorization")) + assertEquals(body, request.body) + } + + @Test + fun `should build a new request based on previous instance`() { + // Given + val originalRequest = + Request + .builder() + .method(Method.GET) + .url("https://example.com") + .addHeader("Content-Type", "application/json") + .build() + + // When + val newRequest = + originalRequest + .newBuilder() + .addHeader("Accept", "text/plain") + .build() + + // Expect + assertEquals(Method.GET, newRequest.method) + assertEquals(originalRequest.url, newRequest.url) + + assertEquals("application/json", originalRequest.headers.get("Content-Type")) + assertNull(originalRequest.headers.get("Accept")) + + assertEquals("application/json", newRequest.headers.get("Content-Type")) + assertEquals("text/plain", newRequest.headers.get("Accept")) + } + + @Test + fun `should build a request with valid method and url string`() { + // Given + val urlString = "https://example.com" + + // When + val request = + Request + .builder() + .method(Method.GET) + .url(urlString) + .build() + + // Expect + assertEquals(URL(urlString), request.url) + } + + @Test + fun `should add single header`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName = "content-type" + val headerValue = "application/json" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .addHeader(headerName, headerValue) + .build() + + // Expect + assertEquals(headerValue, request.headers.get(headerName)) + } + + @Test + fun `should add multiple values for one header`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .addHeader(headerName, headerValue1) + .addHeader(headerName, headerValue2) + .build() + + // Expect + assertEquals(listOf(headerValue1, headerValue2), request.headers.values(headerName)) + } + + @Test + fun `should set one single header`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName = "content-type" + val headerValue = "application/json" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .setHeader(headerName, headerValue) + .build() + + // Expect + assertEquals(headerValue, request.headers.get(headerName)) + } + + @Test + fun `should set multiple values for one header`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .setHeader(headerName, headerValue1) + .setHeader(headerName, headerValue2) + .build() + + // Expect + assertEquals(listOf(headerValue2), request.headers.values(headerName)) + } + + @Test + fun `should add multiple values for one header as a list`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .addHeader(headerName, listOf(headerValue1, headerValue2)) + .build() + + // Expect + assertEquals(listOf(headerValue1, headerValue2), request.headers.values(headerName)) + } + + @Test + fun `should set multiple values for one header as a list`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .setHeader(headerName, listOf(headerValue1, headerValue2)) + .setHeader(headerName, listOf(headerValue2)) + .build() + + // Expect + assertEquals(listOf(headerValue2), request.headers.values(headerName)) + } + + @Test + fun `should handle adding and setting headers as expected`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName1 = "Header1" + val headerName2 = "Header2" + val headerName3 = "Header3" + val headerValue1 = "Value1" + val headerValue2 = "Value2" + val headerValue3 = "Value3" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .addHeader(headerName1, headerValue1) + .setHeader(headerName2, headerValue2) + .setHeader(headerName1, headerValue3) + .addHeader(headerName3, listOf(headerValue1, headerValue2)) + .build() + + // Expect + assertEquals(headerValue3, request.headers.get(headerName1)) + assertEquals(headerValue2, request.headers.get(headerName2)) + assertEquals(listOf(headerValue1, headerValue2), request.headers.values(headerName3)) + } + + @Test + fun `should remove headers as expected`() { + // Given + val method = Method.GET + val url = "https://example.com" + val headerName1 = "Header1" + val headerName2 = "Header2" + val headerValue1 = "Value1" + val headerValue2 = "Value2" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .addHeader(headerName1, headerValue1) + .addHeader(headerName2, headerValue2) + .removeHeader(headerName1) + .build() + + // Expect + assertNull(request.headers.get(headerName1)) + assertEquals(headerValue2, request.headers.get(headerName2)) + } + + @Test + fun `should build a request without a body`() { + // Given + val method = Method.GET + val url = "https://example.com" + + // When + val request = + Request + .builder() + .method(method) + .url(url) + .build() + + // Expect + assertNull(request.body) + } + + @Test + fun `should throw an exception if the URL string is invalid`() { + // Given + val invalidUrl = "invalid_url" + + // When + val exception = + assertThrows { + Request + .builder() + .method(Method.GET) + .url(invalidUrl) + .build() + } + + // Expect + assertEquals("no protocol: invalid_url", exception.message) + } + + @Test + fun `should throw an exception if the method is missing`() { + val exception = + assertThrows { + Request.builder().url("https://example.com").build() + } + + assertEquals("Method is required.", exception.message) + } + + @Test + fun `should throw an exception if the URL is missing`() { + val exception = + assertThrows { + Request.builder().method(Method.GET).build() + } + + assertEquals("URL is required.", exception.message) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt new file mode 100644 index 00000000..03830396 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt @@ -0,0 +1,132 @@ +package com.expediagroup.sdk.core.http + +import okio.Buffer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream + +class ResponseBodyTest { + @Test + fun `should return correct media type when using input stream`() { + // Given + val mediaType = CommonMediaTypes.TEXT_PLAIN + val responseBody = ResponseBody.create(ByteArrayInputStream("content".toByteArray()), mediaType) + + // When & Expect + assertEquals(mediaType, responseBody.mediaType()) + } + + @Test + fun `should return correct media type when using buffered source`() { + // Given + val mediaType = CommonMediaTypes.TEXT_PLAIN + val responseBody = ResponseBody.create(Buffer().writeUtf8("content"), mediaType) + + // When & Expect + assertEquals(mediaType, responseBody.mediaType()) + } + + @Test + fun `should return null for unknown media type`() { + // Given + val responseBody = ResponseBody.create(ByteArrayInputStream("content".toByteArray())) + + // When & Expect + assertNull(responseBody.mediaType()) + } + + @Test + fun `should return correct content length when using input stream`() { + // Given + val content = "content" + val responseBody = + ResponseBody.create( + ByteArrayInputStream(content.toByteArray()), + contentLength = content.length.toLong(), + ) + + // When & Expect + assertEquals(content.length.toLong(), responseBody.contentLength()) + } + + @Test + fun `should return correct content length when using buffered source`() { + // Given + val content = "content" + val responseBody = + ResponseBody.create( + Buffer().writeUtf8("content"), + contentLength = content.length.toLong(), + ) + + // When & Expect + assertEquals(content.length.toLong(), responseBody.contentLength()) + } + + @Test + fun `should return -1 for unknown content length`() { + // Given + val responseBody = ResponseBody.create(ByteArrayInputStream("content".toByteArray())) + + // When & Expect + assertEquals(-1, responseBody.contentLength()) + } + + @Test + fun `should return the expected buffered source content when using input stream`() { + // Given + val content = "content" + val responseBody = ResponseBody.create(ByteArrayInputStream(content.toByteArray())) + + // When + val source = responseBody.source() + + // Expect + assertEquals(content, source.readUtf8()) + } + + @Test + fun `should return the expected buffered source content when using source`() { + // Given + val content = "content" + val buffer = Buffer().writeUtf8(content) + val responseBody = ResponseBody.create(buffer) + + // When + val source = responseBody.source() + + // Expect + assertEquals(content, source.readUtf8()) + } + + @Test + fun `should return empty content after source is fully read`() { + // Given + val content = "Hello, world!" + val responseBody = ResponseBody.create(ByteArrayInputStream(content.toByteArray())) + + // When + val source = responseBody.source() + + // Read the content fully + assertEquals(content, source.readUtf8()) + + // Attempt to read again + assertEquals("", source.readUtf8()) // Subsequent reads return empty data + } + + @Test + fun `should close response body source when close() is called`() { + // Given + val content = "Hello, world!" + val responseBody = ResponseBody.create(ByteArrayInputStream(content.toByteArray())) + + // When + responseBody.close() + + // Expect + assertFalse(responseBody.source().isOpen) + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt new file mode 100644 index 00000000..d2ef93ec --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt @@ -0,0 +1,409 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows + +class ResponseTest { + @Test + fun `should build response instance with all properties`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + + val protocol = Protocol.HTTP_1_1 + val status = Status.OK + val headers = Headers.Builder().add("Content-Type", "application/json").build() + val body = ResponseBody.create("Response body".byteInputStream()) + + // When + val response = + Response + .builder() + .request(request) + .protocol(protocol) + .status(status) + .message("OK") + .headers(headers) + .body(body) + .build() + + // Expect + assertEquals(request, response.request) + assertEquals(protocol, response.protocol) + assertEquals(status, response.status) + assertEquals("OK", response.message) + assertEquals("application/json", response.headers.get("Content-Type")) + assertEquals(body, response.body) + } + + @Test + fun `should build a new response based on previous instance`() { + // Given + val originalResponse = + Response + .Builder() + .request( + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build(), + ).protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .addHeader("Accept", "application/json") + .body(ResponseBody.create("Original body".byteInputStream())) + .build() + + // When + val newResponse = + originalResponse + .newBuilder() + .addHeader("Content-Type", "text/plain") + .message("Updated OK") + .build() + + // Expect + assertEquals("application/json", originalResponse.headers.get("accept")) + assertNull(originalResponse.headers.get("content-type")) + + assertEquals("text/plain", newResponse.headers.get("content-type")) + assertEquals("application/json", newResponse.headers.get("accept")) + + assertEquals("OK", originalResponse.message) + assertEquals("Updated OK", newResponse.message) + } + + @Test + fun `should add single header`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + val headerName = "content-type" + val headerValue = "application/json" + + // When + val response = + Response + .builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .addHeader(headerName, headerValue) + .build() + + // Expect + assertEquals(headerValue, response.headers.get(headerName)) + } + + @Test + fun `should add multiple values for one header`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val response = + Response + .builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .addHeader(headerName, headerValue1) + .addHeader(headerName, headerValue2) + .build() + + // Expect + assertEquals(listOf(headerValue1, headerValue2), response.headers.values(headerName)) + } + + @Test + fun `should set one single header`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + val headerName = "content-type" + val headerValue = "application/json" + + // When + val response = + Response + .builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .setHeader(headerName, headerValue) + .build() + + // Expect + assertEquals(headerValue, response.headers.get(headerName)) + } + + @Test + fun `should set multiple values for one header`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val response = + Response + .builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .setHeader(headerName, headerValue1) + .setHeader(headerName, headerValue2) + .build() + + // Expect + assertEquals(listOf(headerValue2), response.headers.values(headerName)) + } + + @Test + fun `should add multiple values for one header as a list`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val response = + Response + .builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .addHeader(headerName, listOf(headerValue1, headerValue2)) + .build() + + // Expect + assertEquals(listOf(headerValue1, headerValue2), response.headers.values(headerName)) + } + + @Test + fun `should set multiple values for one header as a list`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + val headerName = "content-type" + val headerValue1 = "application/json" + val headerValue2 = "text/plain" + + // When + val response = + Response + .builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .status(Status.OK) + .setHeader(headerName, listOf(headerValue1, headerValue2)) + .setHeader(headerName, listOf(headerValue2, headerValue1)) + .build() + + // When + assertEquals(listOf(headerValue2, headerValue1), response.headers.values(headerName)) + } + + @Test + fun `should remove headers when removeHeader is called on an existing header`() { + // Given + val request = + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build() + val responseBuilder = + Response + .builder() + .request(request) + .status(Status.OK) + .message("OK") + .protocol(Protocol.HTTP_1_1) + .addHeader("Header1", "Value1") + .addHeader("Header2", "Value2") + + // When + val response = responseBuilder.removeHeader("Header1").build() + + // Expect + assertNull(response.headers.get("Header1")) + assertEquals("Value2", response.headers.get("Header2")) + } + + @Test + fun `should return true if the response is successful or false otherwise`() { + // Given + val responseStatus200 = + Response + .builder() + .request( + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build(), + ).protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .build() + + val responseStatus100 = + Response + .builder() + .request( + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build(), + ).protocol(Protocol.HTTP_1_1) + .status(Status.CONTINUE) + .message("OK") + .build() + + val responseStatus400 = + Response + .builder() + .request( + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build(), + ).protocol(Protocol.HTTP_1_1) + .status(Status.NOT_FOUND) + .message("Not Found") + .build() + + // When & Expect + assertTrue(responseStatus200.isSuccessful) + assertFalse(responseStatus400.isSuccessful) + assertFalse(responseStatus100.isSuccessful) + } + + @Test + fun `should throw exception when required fields are missing`() { + // Given + val builder = Response.builder() + + // When & Expect + assertThrows { builder.build() }.also { + assertEquals("request is required", it.message) + } + + builder.request( + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build(), + ) + assertThrows { builder.build() }.also { + assertEquals("protocol is required", it.message) + } + + builder.protocol(Protocol.HTTP_1_1) + assertThrows { builder.build() }.also { + assertEquals("status is required", it.message) + } + + builder.status(Status.OK) + assertDoesNotThrow { builder.build() } + } + + @Test + fun `should close response body`() { + // Given + val body = ResponseBody.create("Test body".byteInputStream()) + val response = + Response + .Builder() + .request( + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build(), + ).protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .body(body) + .build() + + // When + response.close() + + // Expect + assertFalse(response.body?.source()?.isOpen == true) + } + + @Test + fun `should not throw an exception if attempted to close null response body`() { + // Given + val response = + Response + .Builder() + .request( + Request + .Builder() + .method(Method.GET) + .url("https://example.com") + .build(), + ).protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .message("OK") + .build() + + // When & Expect + assertDoesNotThrow { response.close() } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt new file mode 100644 index 00000000..6f0e0c39 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt @@ -0,0 +1,62 @@ +package com.expediagroup.sdk.core.http + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class StatusTest { + @Test + fun `should return correct Status for valid codes`() { + // Informational responses + assertEquals(Status.CONTINUE, Status.fromCode(100)) + assertEquals(Status.SWITCHING_PROTOCOLS, Status.fromCode(101)) + assertEquals(Status.PROCESSING, Status.fromCode(102)) + assertEquals(Status.EARLY_HINTS, Status.fromCode(103)) + + // Successful responses + assertEquals(Status.OK, Status.fromCode(200)) + assertEquals(Status.CREATED, Status.fromCode(201)) + assertEquals(Status.ACCEPTED, Status.fromCode(202)) + assertEquals(Status.NO_CONTENT, Status.fromCode(204)) + + // Redirection messages + assertEquals(Status.MOVED_PERMANENTLY, Status.fromCode(301)) + assertEquals(Status.FOUND, Status.fromCode(302)) + assertEquals(Status.SEE_OTHER, Status.fromCode(303)) + assertEquals(Status.NOT_MODIFIED, Status.fromCode(304)) + assertEquals(Status.TEMPORARY_REDIRECT, Status.fromCode(307)) + + // Client error responses + assertEquals(Status.BAD_REQUEST, Status.fromCode(400)) + assertEquals(Status.UNAUTHORIZED, Status.fromCode(401)) + assertEquals(Status.FORBIDDEN, Status.fromCode(403)) + assertEquals(Status.NOT_FOUND, Status.fromCode(404)) + assertEquals(Status.METHOD_NOT_ALLOWED, Status.fromCode(405)) + + // Server error responses + assertEquals(Status.INTERNAL_SERVER_ERROR, Status.fromCode(500)) + assertEquals(Status.NOT_IMPLEMENTED, Status.fromCode(501)) + assertEquals(Status.BAD_GATEWAY, Status.fromCode(502)) + assertEquals(Status.SERVICE_UNAVAILABLE, Status.fromCode(503)) + assertEquals(Status.GATEWAY_TIMEOUT, Status.fromCode(504)) + + // Non-standard status codes + assertEquals(Status.THIS_IS_FINE, Status.fromCode(218)) + } + + @Test + fun `should throw IllegalArgumentException for invalid codes`() { + val exception = + assertThrows { + Status.fromCode(999) + } + assertEquals("Invalid status code: 999", exception.message) + } + + @Test + fun `should match all enum entries with their codes`() { + Status.entries.forEach { status -> + assertEquals(status, Status.fromCode(status.code)) + } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt new file mode 100644 index 00000000..c4777781 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt @@ -0,0 +1,228 @@ +package com.expediagroup.sdk.core.logging + +import com.expediagroup.sdk.core.http.CommonMediaTypes +import com.expediagroup.sdk.core.http.Headers +import com.expediagroup.sdk.core.http.MediaType +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.RequestBody +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okio.Buffer +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.URL + +class RequestLoggerTest { + private lateinit var mockLogger: LoggerDecorator + + @BeforeEach + fun setUp() { + mockLogger = mockk(relaxed = true) + } + + @Test + fun `should log basic details at info level`() { + // Given + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.GET) + .addHeader("Content-Type", "application/json") + .build() + + every { mockLogger.isDebugEnabled } returns false + + // When + RequestLogger.log(mockLogger, testRequest) + + // Expect + val expectedLogMessage = "URL=https://example.com, Method=GET, Headers=[{content-type=[application/json]}]" + verify { mockLogger.info(expectedLogMessage, "Outgoing", *anyVararg()) } + verify(exactly = 0) { mockLogger.debug(any(), *anyVararg()) } + } + + @Test + fun `should include request body at debug level`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .addHeader("Content-Type", "application/json") + .body(RequestBody.create(buffer, mediaType = MediaType.parse("application/json"))) + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + RequestLogger.log(mockLogger, testRequest) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Method=POST, Headers=[{content-type=[application/json]}], Body={"key":"value"} + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Outgoing", *anyVararg()) } + } + + @Test + fun `should read request body string with the expected charset`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .addHeader("Content-Type", "application/json") + .body(RequestBody.create(buffer, mediaType = MediaType.parse("application/json; charset=UTF-16"))) + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + RequestLogger.log(mockLogger, testRequest) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Method=POST, Headers=[{content-type=[application/json]}], Body=笢步礢㨢癡汵攢� + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Outgoing", *anyVararg()) } + } + + @Test + fun `should handle null request body`() { + // Given + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.GET) + .addHeader("Content-Type", "application/json") + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + RequestLogger.log(mockLogger, testRequest) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Method=GET, Headers=[{content-type=[application/json]}], Body=null + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Outgoing", *anyVararg()) } + } + + @Test + fun `should log error if exception occurs`() { + // Given + val mockRequest = mockk() + + every { mockRequest.url } returns URL("https://example.com") + every { mockRequest.method } returns Method.POST + every { mockRequest.headers } returns Headers.builder().build() + every { mockRequest.body } throws IOException("Failed to read body") + + every { mockLogger.isDebugEnabled } returns true + + // When + RequestLogger.log(mockLogger, mockRequest) + + // Expect + verify { mockLogger.error("Failed to log request") } + verify(exactly = 0) { mockLogger.debug(any(), any(), *anyVararg()) } + } + + @Test + fun `should not log requests with unknown media type`() { + // Given + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.GET) + .body(RequestBody.create(Buffer(), mediaType = null)) + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + RequestLogger.log(mockLogger, testRequest) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Method=GET, Headers=[{}], Body=Request body of unknown media type cannot be logged + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Outgoing", *anyVararg()) } + } + + @Test + fun `should handle request body with non-loggable media type`() { + // Given + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.GET) + .body(RequestBody.create(Buffer(), mediaType = CommonMediaTypes.APPLICATION_OCTET_STREAM)) + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + RequestLogger.log(mockLogger, testRequest) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Method=GET, Headers=[{}], Body=Request body of type application/octet-stream cannot be logged + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Outgoing", *anyVararg()) } + } + + @Test + fun `should respect max log size for request body`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.GET) + .body(RequestBody.create(buffer, mediaType = MediaType.parse("application/json"))) + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + RequestLogger.log(mockLogger, testRequest, maxBodyLogSize = 1L) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Method=GET, Headers=[{}], Body={ + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Outgoing", *anyVararg()) } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt new file mode 100644 index 00000000..a17a1002 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt @@ -0,0 +1,124 @@ +package com.expediagroup.sdk.core.transport + +import com.expediagroup.sdk.core.exception.client.ExpediaGroupConfigurationException +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.pipeline.ExecutionPipeline +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.ServiceLoader +import java.util.concurrent.CompletableFuture + +class AbstractAsyncRequestExecutorTest { + @Test + fun `should load the available AsyncTransport implementation in the classpath if not provided`() { + // Given + val mockAsyncTransport = mockk() + val mockLoader = mockk>().also { mockkStatic(ServiceLoader::class) } + + every { mockLoader.iterator() } returns mutableListOf(mockAsyncTransport).iterator() + every { ServiceLoader.load(AsyncTransport::class.java) } returns mockLoader + + // When + val asyncExecutor = + object : AbstractAsyncRequestExecutor() { + override val executionPipeline = mockk() + } + + val asyncTransportField = AbstractAsyncRequestExecutor::class.java.getDeclaredField("asyncTransport") + asyncTransportField.isAccessible = true + val loadedAsyncTransport = asyncTransportField.get(asyncExecutor) + + // Expect + assertNotNull(loadedAsyncTransport) + assertEquals(mockAsyncTransport, loadedAsyncTransport) + + unmockkStatic(ServiceLoader::class) + } + + @Test + fun `should use provided AsyncTransport if given`() { + // Given + val mockAsyncTransport = mockk() + + // When + val executor = + object : AbstractAsyncRequestExecutor(mockAsyncTransport) { + override val executionPipeline = mockk() + } + + val asyncTransportField = AbstractAsyncRequestExecutor::class.java.getDeclaredField("asyncTransport") + asyncTransportField.isAccessible = true + val loadedAsyncTransport = asyncTransportField.get(executor) + + // Expect + assertNotNull(loadedAsyncTransport) + assertEquals(mockAsyncTransport, loadedAsyncTransport) + } + + @Test + fun `should throw an exception if unable to load AsyncTransport`() { + // Given + val mockLoader = mockk>().also { mockkStatic(ServiceLoader::class) } + + every { mockLoader.iterator() } returns mutableListOf().iterator() + every { ServiceLoader.load(AsyncTransport::class.java) } returns mockLoader + + // When & Expect + assertThrows { + object : AbstractAsyncRequestExecutor() { + override val executionPipeline = mockk() + } + } + + unmockkStatic(ServiceLoader::class) + } + + @Test + fun `should apply the execution pipeline`() { + val mockAsyncTransport = mockk() + val mockExecutionPipeline = mockk() + + val asyncExecutor = + object : AbstractAsyncRequestExecutor(mockAsyncTransport) { + override val executionPipeline = mockExecutionPipeline + } + + every { mockAsyncTransport.execute(any()) } returns CompletableFuture.completedFuture(mockk()) + every { mockExecutionPipeline.startRequestPipeline(any()) } returns mockk() + every { mockExecutionPipeline.startResponsePipeline(any()) } returns mockk() + + asyncExecutor.execute(mockk()) + + verify(exactly = 1) { mockExecutionPipeline.startRequestPipeline(any()) } + verify(exactly = 1) { mockExecutionPipeline.startResponsePipeline(any()) } + verify(exactly = 1) { mockAsyncTransport.execute(any()) } + } + + @Test + fun `should dispose the underlying AsyncTransport`() { + // Given + val mockAsyncTransport = mockk() + val asyncExecutor = + object : AbstractAsyncRequestExecutor(mockAsyncTransport) { + override val executionPipeline = mockk() + } + + every { mockAsyncTransport.dispose() } just Runs + + // When + asyncExecutor.dispose() + + // Expect + verify(exactly = 1) { mockAsyncTransport.dispose() } + } +} diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt b/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt new file mode 100644 index 00000000..601edec8 --- /dev/null +++ b/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt @@ -0,0 +1,123 @@ +package com.expediagroup.sdk.core.transport + +import com.expediagroup.sdk.core.exception.client.ExpediaGroupConfigurationException +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.pipeline.ExecutionPipeline +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.ServiceLoader + +class AbstractRequestExecutorTest { + @Test + fun `should load the available Transport implementation in the classpath if not provided`() { + // Given + val mockTransport = mockk() + val mockLoader = mockk>().also { mockkStatic(ServiceLoader::class) } + + every { mockLoader.iterator() } returns mutableListOf(mockTransport).iterator() + every { ServiceLoader.load(Transport::class.java) } returns mockLoader + + // When + val executor = + object : AbstractRequestExecutor() { + override val executionPipeline = mockk() + } + + val transportField = AbstractRequestExecutor::class.java.getDeclaredField("transport") + transportField.isAccessible = true + val loadedTransport = transportField.get(executor) + + // Expect + assertNotNull(loadedTransport) + assertEquals(mockTransport, loadedTransport) + + unmockkStatic(ServiceLoader::class) + } + + @Test + fun `should use provided Transport if given`() { + // Given + val mockTransport = mockk() + + // When + val executor = + object : AbstractRequestExecutor(mockTransport) { + override val executionPipeline = mockk() + } + + val transportField = AbstractRequestExecutor::class.java.getDeclaredField("transport") + transportField.isAccessible = true + val loadedTransport = transportField.get(executor) + + // Expect + assertNotNull(loadedTransport) + assertEquals(mockTransport, loadedTransport) + } + + @Test + fun `should throw an exception if unable to load Transport`() { + // Given + val mockLoader = mockk>().also { mockkStatic(ServiceLoader::class) } + + every { mockLoader.iterator() } returns mutableListOf().iterator() + every { ServiceLoader.load(Transport::class.java) } returns mockLoader + + // When & Expect + assertThrows { + object : AbstractRequestExecutor() { + override val executionPipeline = mockk() + } + } + + unmockkStatic(ServiceLoader::class) + } + + @Test + fun `should apply the execution pipeline`() { + val mockTransport = mockk() + val mockExecutionPipeline = mockk() + + val executor = + object : AbstractRequestExecutor(mockTransport) { + override val executionPipeline = mockExecutionPipeline + } + + every { mockTransport.execute(any()) } returns mockk() + every { mockExecutionPipeline.startRequestPipeline(any()) } returns mockk() + every { mockExecutionPipeline.startResponsePipeline(any()) } returns mockk() + + executor.execute(mockk()) + + verify(exactly = 1) { mockExecutionPipeline.startRequestPipeline(any()) } + verify(exactly = 1) { mockExecutionPipeline.startResponsePipeline(any()) } + verify(exactly = 1) { mockTransport.execute(any()) } + } + + @Test + fun `should dispose the underlying Transport`() { + // Given + val mockTransport = mockk() + val executor = + object : AbstractRequestExecutor(mockTransport) { + override val executionPipeline = mockk() + } + + every { mockTransport.dispose() } just Runs + + // When + executor.dispose() + + // Expect + verify(exactly = 1) { mockTransport.dispose() } + } +} diff --git a/core/src/test/resources/sdk.properties b/core/src/test/resources/sdk.properties new file mode 100644 index 00000000..2bcb07e8 --- /dev/null +++ b/core/src/test/resources/sdk.properties @@ -0,0 +1,3 @@ +artifactName=expedia-group-test-sdk +version=1.0.0 +groupId=com.expediagroup diff --git a/settings.gradle b/settings.gradle index ad364ed7..cbe1cf0a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,4 +7,5 @@ rootProject.name = "lodging-connectivity-sdk" // Used as published artifactId include 'code' include 'examples' include 'apollo-compiler-plugin' +include 'core' From 05a5a3cbc0486c78dbe832512d36be2a3f5e79b9 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Sun, 12 Jan 2025 16:17:40 +0300 Subject: [PATCH 02/15] chore: format --- core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/build.gradle b/core/build.gradle index 01a636f6..3ae360b5 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -86,4 +86,4 @@ kover { } } } -} \ No newline at end of file +} From 6d2969a75895fa54228cd5630fb35fbb1a30df34 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Sun, 12 Jan 2025 17:48:58 +0300 Subject: [PATCH 03/15] chore: cleanup --- .github/workflows/code-quality-checks.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/code-quality-checks.yaml b/.github/workflows/code-quality-checks.yaml index d342734b..f5bbe912 100644 --- a/.github/workflows/code-quality-checks.yaml +++ b/.github/workflows/code-quality-checks.yaml @@ -17,9 +17,3 @@ jobs: - name: Run Checks id: build run: gradle clean build - - name: Upload Coverage Report - if: always() - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: code/build/reports/kover From d6feecdb8a9c606b4c6b0c41f9266e7783cb5927 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 13:41:10 +0300 Subject: [PATCH 04/15] feat: rename core module --- {core => expediagroup-sdk-core}/build.gradle | 2 +- .../bearer/AbstractBearerAuthenticationManager.kt | 0 .../authentication/bearer/BearerAuthenticationAsyncManager.kt | 0 .../core/authentication/bearer/BearerAuthenticationManager.kt | 0 .../sdk/core/authentication/bearer/BearerTokenResponse.kt | 0 .../sdk/core/authentication/bearer/BearerTokenStorage.kt | 0 .../sdk/core/authentication/common/AuthenticationManager.kt | 0 .../expediagroup/sdk/core/authentication/common/Credentials.kt | 0 .../main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt | 0 .../kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt | 0 .../expediagroup/sdk/core/exception/ExpediaGroupException.kt | 0 .../sdk/core/exception/client/ExpediaGroupClientException.kt | 0 .../core/exception/client/ExpediaGroupConfigurationException.kt | 0 .../exception/client/ExpediaGroupResponseParsingException.kt | 0 .../sdk/core/exception/service/ExpediaGroupAuthException.kt | 0 .../sdk/core/exception/service/ExpediaGroupNetworkException.kt | 0 .../sdk/core/exception/service/ExpediaGroupServiceException.kt | 0 .../kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt | 0 .../src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt | 0 .../src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt | 0 .../src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt | 0 .../src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt | 0 .../src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt | 0 .../main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt | 0 .../src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt | 0 .../main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt | 0 .../src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt | 0 .../main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt | 0 .../com/expediagroup/sdk/core/logging/LoggableContentTypes.kt | 0 .../kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt | 0 .../kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt | 0 .../kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt | 0 .../com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt | 0 .../sdk/core/pipeline/step/BearerAuthenticationStep.kt | 0 .../expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt | 0 .../expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt | 0 .../expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt | 0 .../sdk/core/transport/AbstractAsyncRequestExecutor.kt | 0 .../expediagroup/sdk/core/transport/AbstractRequestExecutor.kt | 0 .../com/expediagroup/sdk/core/transport/AsyncTransport.kt | 0 .../kotlin/com/expediagroup/sdk/core/transport/Disposable.kt | 0 .../kotlin/com/expediagroup/sdk/core/transport/Transport.kt | 0 .../java/com/expediagroup/sdk/core/http/HeadersJavaTest.java | 0 .../java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java | 0 .../java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java | 0 .../com/expediagroup/sdk/core/http/RequestBodyJavaTest.java | 0 .../java/com/expediagroup/sdk/core/http/RequestJavaTest.java | 0 .../com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java | 0 .../java/com/expediagroup/sdk/core/http/ResponseJavaTest.java | 0 .../java/com/expediagroup/sdk/core/http/StatusJavaTest.java | 0 .../bearer/AbstractBearerAuthenticationManagerTest.kt | 0 .../bearer/BearerAuthenticationAsyncManagerTest.kt | 0 .../authentication/bearer/BearerAuthenticationManagerTest.kt | 0 .../sdk/core/authentication/bearer/BearerTokenResponseTest.kt | 0 .../sdk/core/authentication/bearer/BearerTokenStorageTest.kt | 0 .../sdk/core/authentication/common/CredentialsTest.kt | 0 .../kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt | 0 .../com/expediagroup/sdk/core/common/MetadataLoaderTest.kt | 0 .../com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt | 0 .../test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt | 0 .../test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt | 0 .../test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt | 0 .../test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt | 0 .../kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt | 0 .../test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt | 0 .../kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt | 0 .../test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt | 0 .../test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt | 0 .../com/expediagroup/sdk/core/logging/RequestLoggerTest.kt | 0 .../sdk/core/transport/AbstractAsyncRequestExecutorTest.kt | 0 .../sdk/core/transport/AbstractRequestExecutorTest.kt | 0 .../src/test/resources/sdk.properties | 0 settings.gradle | 2 +- 73 files changed, 2 insertions(+), 2 deletions(-) rename {core => expediagroup-sdk-core}/build.gradle (97%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt (100%) rename {core => expediagroup-sdk-core}/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt (100%) rename {core => expediagroup-sdk-core}/src/test/resources/sdk.properties (100%) diff --git a/core/build.gradle b/expediagroup-sdk-core/build.gradle similarity index 97% rename from core/build.gradle rename to expediagroup-sdk-core/build.gradle index 3ae360b5..135a9eca 100644 --- a/core/build.gradle +++ b/expediagroup-sdk-core/build.gradle @@ -5,7 +5,7 @@ plugins { id 'org.jetbrains.kotlin.jvm' version '2.1.0' /* Test Reporting */ - id 'org.jetbrains.kotlinx.kover' version "0.9.0" + id 'org.jetbrains.kotlinx.kover' version "0.9.1" /* Linting */ id "org.jlleitschuh.gradle.ktlint" version "12.1.2" diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManager.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManager.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManager.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponse.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorage.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/AuthenticationManager.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/authentication/common/Credentials.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/common/Extensions.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/common/MetadataLoader.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/ExpediaGroupException.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupClientException.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupConfigurationException.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/client/ExpediaGroupResponseParsingException.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupAuthException.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupNetworkException.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/exception/service/ExpediaGroupServiceException.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypes.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Headers.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/MediaType.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Method.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Protocol.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Request.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Response.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/ResponseBody.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/Status.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/Constant.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypes.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/RequestLogger.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStep.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStep.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStep.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStep.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutor.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutor.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/AsyncTransport.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/Disposable.kt diff --git a/core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt similarity index 100% rename from core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt rename to expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/transport/Transport.kt diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/HeadersJavaTest.java diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/MediaTypeJavaTest.java diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/ProtocolJavaTest.java diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/RequestBodyJavaTest.java diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/RequestJavaTest.java diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/ResponseBodyJavaTest.java diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/ResponseJavaTest.java diff --git a/core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java b/expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java similarity index 100% rename from core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java rename to expediagroup-sdk-core/src/test/java/com/expediagroup/sdk/core/http/StatusJavaTest.java diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/AbstractBearerAuthenticationManagerTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationAsyncManagerTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerAuthenticationManagerTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenResponseTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/bearer/BearerTokenStorageTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/authentication/common/CredentialsTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/common/ExtensionsTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/common/MetadataLoaderTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/CommonMediaTypesTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/HeadersTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/MediaTypeTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/MethodTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/ProtocolTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestBodyTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/RequestTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseBodyTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/ResponseTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/http/StatusTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/RequestLoggerTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractAsyncRequestExecutorTest.kt diff --git a/core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt similarity index 100% rename from core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt rename to expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/transport/AbstractRequestExecutorTest.kt diff --git a/core/src/test/resources/sdk.properties b/expediagroup-sdk-core/src/test/resources/sdk.properties similarity index 100% rename from core/src/test/resources/sdk.properties rename to expediagroup-sdk-core/src/test/resources/sdk.properties diff --git a/settings.gradle b/settings.gradle index cbe1cf0a..a6516a0d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,5 +7,5 @@ rootProject.name = "lodging-connectivity-sdk" // Used as published artifactId include 'code' include 'examples' include 'apollo-compiler-plugin' -include 'core' +include 'expediagroup-sdk-core' From 1d26f86e87688d61d8ebed944ec0179c7ed912bc Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 13:53:50 +0300 Subject: [PATCH 05/15] fix: remove extra encoding replacements --- .../com/expediagroup/sdk/core/http/RequestBody.kt | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt index d4859f5c..6db0af3b 100644 --- a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt +++ b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/http/RequestBody.kt @@ -150,22 +150,12 @@ abstract class RequestBody { val encodedForm = formData .map { (key, value) -> - "${encode(key, charset)}=${encode(value, charset)}" + "${URLEncoder.encode(key, charset.name())}=${URLEncoder.encode(value, charset.name())}" }.joinToString("&") val contentBytes = encodedForm.toByteArray(charset) return create(contentBytes.inputStream(), CommonMediaTypes.APPLICATION_FORM_URLENCODED) } - - private fun encode( - value: String, - charset: Charset, - ): String = - URLEncoder - .encode(value, charset.name()) - .replace("+", "%20") - .replace("*", "%2A") - .replace("%7E", "~") } } From 7677fea727aa7e984819ec780ac907722a9ccf61 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 15:49:25 +0300 Subject: [PATCH 06/15] chore: add tests for logging package --- expediagroup-sdk-core/build.gradle | 7 + .../sdk/core/logging/LoggerDecorator.kt | 8 +- .../sdk/core/logging/ResponseLogger.kt | 16 +- .../core/logging/LoggableContentTypesTest.kt | 68 ++++ .../sdk/core/logging/LoggerDecoratorTest.kt | 192 ++++++++++ .../sdk/core/logging/ResponseLoggerTest.kt | 329 ++++++++++++++++++ 6 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypesTest.kt create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggerDecoratorTest.kt create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/ResponseLoggerTest.kt diff --git a/expediagroup-sdk-core/build.gradle b/expediagroup-sdk-core/build.gradle index 135a9eca..921bb692 100644 --- a/expediagroup-sdk-core/build.gradle +++ b/expediagroup-sdk-core/build.gradle @@ -69,6 +69,13 @@ tasks.named("check") { kover { reports { + filters { + excludes { + packages( + "com.expediagroup.sdk.core.exception" + ) + } + } total { verify { rule { diff --git a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt index 5a702acd..b8e1d04f 100644 --- a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt +++ b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/LoggerDecorator.kt @@ -63,9 +63,11 @@ class LoggerDecorator( buildString { append("[${Constant.EXPEDIA_GROUP_SDK}] - ") tags?.let { - append("[") - append(it.joinToString(", ")) - append("] - ") + if (tags.isNotEmpty()) { + append("[") + append(it.joinToString(", ")) + append("] - ") + } } append(msg.trim()) } diff --git a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt index 565d34e7..93b02156 100644 --- a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt +++ b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/logging/ResponseLogger.kt @@ -27,12 +27,12 @@ internal object ResponseLogger { logger: LoggerDecorator, response: Response, vararg tags: String, - maxBodyLogSize: Long = DEFAULT_MAX_BODY_SIZE, + maxBodyLogSize: Long? = null, ) { try { var logString = buildString { - append("[URL=${response.request.url}, Code=${response.status.code}, Headers=[${response.headers}]") + append("URL=${response.request.url}, Code=${response.status.code}, Headers=[${response.headers}]") } if (logger.isDebugEnabled) { @@ -41,19 +41,19 @@ internal object ResponseLogger { it.readLoggableBody(maxBodyLogSize, it.mediaType()?.charset) } - logString += ", Body=$responseBodyString]" + logString += ", Body=$responseBodyString" logger.debug(logString, "Incoming", *tags) } else { logger.info(logString, "Incoming", *tags) } } catch (e: Exception) { - logger.warn("Failed to log response") + logger.error("Failed to log response") } } private fun ResponseBody.readLoggableBody( - maxSize: Long, + maxBodyLogSize: Long?, charset: Charset?, ): String { this.mediaType().also { @@ -66,8 +66,12 @@ internal object ResponseLogger { } } + if (this.contentLength() == -1L) { + return "Response body with unknown content length cannot be logged" + } + val buffer = Buffer() - val bytesToRead = minOf(maxSize, this.contentLength()) + val bytesToRead = minOf(maxBodyLogSize ?: DEFAULT_MAX_BODY_SIZE, contentLength()) this.source().peek().read(buffer, bytesToRead) return buffer.readString(charset ?: Charsets.UTF_8) } diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypesTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypesTest.kt new file mode 100644 index 00000000..8b8bcdd3 --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggableContentTypesTest.kt @@ -0,0 +1,68 @@ +package com.expediagroup.sdk.core.logging + +import com.expediagroup.sdk.core.http.MediaType +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class LoggableContentTypesTest { + @Test + fun `LOGGABLE_CONTENT_TYPES should contain specific MIME types`() { + val expectedContentTypes = + listOf( + MediaType.of("text", "plain"), + MediaType.of("text", "html"), + MediaType.of("text", "css"), + MediaType.of("text", "javascript"), + MediaType.of("text", "csv"), + MediaType.of("text", "*"), + MediaType.of("application", "json"), + MediaType.of("application", "xml"), + MediaType.of("application", "x-www-form-urlencoded"), + MediaType.of("application", "json+graphql"), + MediaType.of("application", "hal+json"), + ) + + assertTrue(LOGGABLE_CONTENT_TYPES.containsAll(expectedContentTypes)) + } + + @Test + fun `isLoggable should return true for loggable MIME types`() { + val loggableTypes = + listOf( + MediaType.of("text", "plain"), + MediaType.of("application", "json"), + MediaType.of("text", "html"), + MediaType.of("application", "x-www-form-urlencoded"), + MediaType.of("application", "hal+json"), + ) + + // Act & Assert + loggableTypes.forEach { mediaType -> assertTrue(isLoggable(mediaType)) } + } + + @Test + fun `isLoggable should return false for non-loggable MIME types`() { + val nonLoggableTypes = + listOf( + MediaType.of("image", "png"), + MediaType.of("application", "octet-stream"), + MediaType.of("application", "zip"), + MediaType.of("video", "mp4"), + MediaType.of("audio", "mpeg"), + ) + + nonLoggableTypes.forEach { mediaType -> assertFalse(isLoggable(mediaType)) } + } + + @Test + fun `isLoggable should return true for wildcard loggable MIME types`() { + val wildcardTypes = + listOf( + MediaType.of("text", "markdown"), + MediaType.of("text", "richtext"), + ) + + wildcardTypes.forEach { mediaType -> assertTrue(isLoggable(mediaType)) } + } +} diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggerDecoratorTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggerDecoratorTest.kt new file mode 100644 index 00000000..ae95aedf --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/LoggerDecoratorTest.kt @@ -0,0 +1,192 @@ +package com.expediagroup.sdk.core.logging + +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.slf4j.Logger + +class LoggerDecoratorTest { + private lateinit var mockLogger: Logger + private lateinit var loggerDecorator: LoggerDecorator + + companion object { + private const val BASE_DECORATION = "[${Constant.EXPEDIA_GROUP_SDK}] - " + } + + @BeforeEach + fun setUp() { + mockLogger = mockk(relaxed = true) + loggerDecorator = LoggerDecorator(mockLogger) + } + + @Test + fun `info should decorate message and call logger info`() { + // Arrange + val message = "Test info message" + val decoratedMessage = "$BASE_DECORATION$message" + + // Act + loggerDecorator.info(message) + + // Assert + verify { mockLogger.info(decoratedMessage) } + } + + @Test + fun `warn should decorate message and call logger warn`() { + // Arrange + val message = "Test warn message" + val decoratedMessage = "$BASE_DECORATION$message" + + // Act + loggerDecorator.warn(message) + + // Assert + verify { mockLogger.warn(decoratedMessage) } + } + + @Test + fun `debug should decorate message and call logger debug`() { + // Arrange + val message = "Test debug message" + val decoratedMessage = "$BASE_DECORATION$message" + + // Act + loggerDecorator.debug(message) + + // Assert + verify { mockLogger.debug(decoratedMessage) } + } + + @Test + fun `error should decorate message and call logger error`() { + // Arrange + val message = "Test error message" + val decoratedMessage = "$BASE_DECORATION$message" + + // Act + loggerDecorator.error(message) + + // Assert + verify { mockLogger.error(decoratedMessage) } + } + + @Test + fun `trace should decorate message and call logger trace`() { + // Arrange + val message = "Test trace message" + val decoratedMessage = "$BASE_DECORATION$message" + + // Act + loggerDecorator.trace(message) + + // Assert + verify { mockLogger.trace(decoratedMessage) } + } + + @Test + fun `info with tags should decorate message with tags and call logger info`() { + // Arrange + val message = "Test info message" + val tags = arrayOf("TAG1", "TAG2") + val decoratedMessage = "$BASE_DECORATION[${tags.joinToString(", ")}] - $message" + + // Act + loggerDecorator.info(message, *tags) + + // Assert + verify { mockLogger.info(decoratedMessage) } + } + + @Test + fun `warn with tags should decorate message with tags and call logger warn`() { + // Arrange + val message = "Test warn message" + val tags = arrayOf("TAG1") + val decoratedMessage = "$BASE_DECORATION[${tags.joinToString(", ")}] - $message" + + // Act + loggerDecorator.warn(message, *tags) + + // Assert + verify { mockLogger.warn(decoratedMessage) } + } + + @Test + fun `decorate should handle null tags`() { + // Arrange + val message = "Test message" + val expectedDecoration = "$BASE_DECORATION$message" + + // Act + val result = + loggerDecorator::class.java + .getDeclaredMethod("decorate", String::class.java, Set::class.java) + .apply { isAccessible = true } + .invoke(loggerDecorator, message, null) as String + + // Assert + assertEquals(expectedDecoration, result) + } + + @Test + fun `decorate should handle empty tags`() { + // Arrange + val message = "Test message" + val expectedDecoration = "$BASE_DECORATION$message" + + // Act + val result = + loggerDecorator::class.java + .getDeclaredMethod("decorate", String::class.java, Set::class.java) + .apply { isAccessible = true } + .invoke(loggerDecorator, message, emptySet()) as String + + // Assert + assertEquals(expectedDecoration, result) + } + + @Test + fun `debug with tags should decorate message with tags and call logger debug`() { + // Arrange + val message = "Test debug message" + val tags = arrayOf("DEBUG_TAG1", "DEBUG_TAG2") + val decoratedMessage = "$BASE_DECORATION[${tags.joinToString(", ")}] - $message" + + // Act + loggerDecorator.debug(message, *tags) + + // Assert + verify { mockLogger.debug(decoratedMessage) } + } + + @Test + fun `error with tags should decorate message with tags and call logger error`() { + // Arrange + val message = "Test error message" + val tags = arrayOf("ERROR_TAG1", "ERROR_TAG2") + val decoratedMessage = "$BASE_DECORATION[${tags.joinToString(", ")}] - $message" + + // Act + loggerDecorator.error(message, *tags) + + // Assert + verify { mockLogger.error(decoratedMessage) } + } + + @Test + fun `trace with tags should decorate message with tags and call logger trace`() { + // Arrange + val message = "Test trace message" + val tags = arrayOf("TRACE_TAG1", "TRACE_TAG2") + val decoratedMessage = "$BASE_DECORATION[${tags.joinToString(", ")}] - $message" + + // Act + loggerDecorator.trace(message, *tags) + + // Assert + verify { mockLogger.trace(decoratedMessage) } + } +} diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/ResponseLoggerTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/ResponseLoggerTest.kt new file mode 100644 index 00000000..88c95f98 --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/logging/ResponseLoggerTest.kt @@ -0,0 +1,329 @@ +package com.expediagroup.sdk.core.logging + +import com.expediagroup.sdk.core.http.CommonMediaTypes +import com.expediagroup.sdk.core.http.MediaType +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Protocol +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.http.ResponseBody +import com.expediagroup.sdk.core.http.Status +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okio.Buffer +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException + +class ResponseLoggerTest { + private lateinit var mockLogger: LoggerDecorator + + @BeforeEach + fun setUp() { + mockLogger = mockk(relaxed = true) + } + + @Test + fun `should log basic details at info level`() { + // Given + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.GET) + .build(), + ).addHeader("Content-Type", "application/json") + .build() + + every { mockLogger.isDebugEnabled } returns false + + // When + ResponseLogger.log(mockLogger, testResponse) + + // Expect + val expectedLogMessage = "URL=https://example.com, Code=200, Headers=[{content-type=[application/json]}]" + verify { mockLogger.info(expectedLogMessage, "Incoming", *anyVararg()) } + verify(exactly = 0) { mockLogger.debug(any(), *anyVararg()) } + } + + @Test + fun `should include response body at debug level`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build(), + ).addHeader("Content-Type", "application/json") + .body( + ResponseBody.create( + buffer, + mediaType = MediaType.parse("application/json"), + contentLength = buffer.size, + ), + ).build() + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, testResponse) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Code=200, Headers=[{content-type=[application/json]}], Body={"key":"value"} + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Incoming", *anyVararg()) } + } + + @Test + fun `should read response body string with the expected charset`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build(), + ).addHeader("Content-Type", "application/json") + .body( + ResponseBody.create( + buffer, + mediaType = MediaType.parse("application/json; charset=UTF-16"), + contentLength = buffer.size, + ), + ).build() + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, testResponse) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Code=200, Headers=[{content-type=[application/json]}], Body=笢步礢㨢癡汵攢� + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Incoming", *anyVararg()) } + } + + @Test + fun `should handle null response body`() { + // Given + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build(), + ).addHeader("Content-Type", "application/json") + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, testResponse) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Code=200, Headers=[{content-type=[application/json]}], Body=null + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Incoming", *anyVararg()) } + } + + @Test + fun `should log error if exception occurs`() { + // Given + val mockResponse = mockk() + + every { mockResponse.status } returns Status.OK + every { mockResponse.protocol } returns Protocol.HTTP_1_1 + every { mockResponse.request } returns mockk() + every { mockResponse.body } throws IOException("Failed to read body") + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, mockResponse) + + // Expect + verify { mockLogger.error("Failed to log response") } + verify(exactly = 0) { mockLogger.debug(any(), any(), *anyVararg()) } + } + + @Test + fun `should not log responses with unknown media type`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build(), + ).body( + ResponseBody.create( + buffer, + mediaType = null, + contentLength = buffer.size, + ), + ).build() + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, testResponse) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Code=200, Headers=[{}], Body=Response body of unknown media type cannot be logged + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Incoming", *anyVararg()) } + } + + @Test + fun `should not log responses with unknown content length`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build(), + ).body( + ResponseBody.create( + buffer, + mediaType = CommonMediaTypes.APPLICATION_JSON, + contentLength = -1, + ), + ).build() + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, testResponse) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Code=200, Headers=[{}], Body=Response body with unknown content length cannot be logged + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Incoming", *anyVararg()) } + } + + @Test + fun `should handle response body with non-loggable media type`() { + // Given + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build(), + ).body(ResponseBody.create(Buffer(), mediaType = CommonMediaTypes.APPLICATION_OCTET_STREAM)) + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, testResponse) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Code=200, Headers=[{}], Body=Response body of type application/octet-stream cannot be logged + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Incoming", *anyVararg()) } + } + + @Test + fun `should respect max log size for response body`() { + // Given + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + // Given + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build(), + ).body(ResponseBody.create(buffer, mediaType = MediaType.parse("application/json"), contentLength = buffer.size)) + .build() + + every { mockLogger.isDebugEnabled } returns true + + // When + ResponseLogger.log(mockLogger, testResponse, maxBodyLogSize = 1L) + + // Expect + val expectedLogMessage = + """ + URL=https://example.com, Code=200, Headers=[{}], Body={ + """.trimIndent() + + verify { mockLogger.debug(expectedLogMessage, "Incoming", *anyVararg()) } + } +} From 2a073fd3746c43d1546b6dc2ed7504f2d111140e Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 15:55:04 +0300 Subject: [PATCH 07/15] chore: add tests for ExecutionPipeline --- .../core/pipeline/ExecutionPipelineTest.kt | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt new file mode 100644 index 00000000..cfecd547 --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt @@ -0,0 +1,115 @@ +package com.expediagroup.sdk.core.pipeline + +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import io.mockk.every +import io.mockk.mockk +import io.mockk.verifyOrder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ExecutionPipelineTest { + private lateinit var mockRequestStep1: RequestPipelineStep + private lateinit var mockRequestStep2: RequestPipelineStep + private lateinit var mockResponseStep1: ResponsePipelineStep + private lateinit var mockResponseStep2: ResponsePipelineStep + + @BeforeEach + fun setUp() { + mockRequestStep1 = mockk() + mockRequestStep2 = mockk() + mockResponseStep1 = mockk() + mockResponseStep2 = mockk() + } + + @Test + fun `startRequestPipeline should apply all request pipeline steps`() { + // Arrange + val initialRequest = mockk() + val intermediateRequest = mockk() + val finalRequest = mockk() + + every { mockRequestStep1.invoke(initialRequest) } returns intermediateRequest + every { mockRequestStep2.invoke(intermediateRequest) } returns finalRequest + + val executionPipeline = + ExecutionPipeline( + requestPipeline = listOf(mockRequestStep1, mockRequestStep2), + responsePipeline = emptyList(), + ) + + // Act + val result = executionPipeline.startRequestPipeline(initialRequest) + + // Assert + assertEquals(finalRequest, result) + verifyOrder { + mockRequestStep1.invoke(initialRequest) + mockRequestStep2.invoke(intermediateRequest) + } + } + + @Test + fun `startRequestPipeline should return initial request if pipeline is empty`() { + // Arrange + val initialRequest = mockk() + + val executionPipeline = + ExecutionPipeline( + requestPipeline = emptyList(), + responsePipeline = emptyList(), + ) + + // Act + val result = executionPipeline.startRequestPipeline(initialRequest) + + // Assert + assertEquals(initialRequest, result) + } + + @Test + fun `startResponsePipeline should apply all response pipeline steps`() { + // Arrange + val initialResponse = mockk() + val intermediateResponse = mockk() + val finalResponse = mockk() + + every { mockResponseStep1.invoke(initialResponse) } returns intermediateResponse + every { mockResponseStep2.invoke(intermediateResponse) } returns finalResponse + + val executionPipeline = + ExecutionPipeline( + requestPipeline = emptyList(), + responsePipeline = listOf(mockResponseStep1, mockResponseStep2), + ) + + // Act + val result = executionPipeline.startResponsePipeline(initialResponse) + + // Assert + assertEquals(finalResponse, result) + verifyOrder { + mockResponseStep1.invoke(initialResponse) + mockResponseStep2.invoke(intermediateResponse) + } + } + + @Test + fun `startResponsePipeline should return initial response if pipeline is empty`() { + // Arrange + val initialResponse = mockk() + + val executionPipeline = + ExecutionPipeline( + requestPipeline = emptyList(), + responsePipeline = emptyList(), + ) + + // Act + val result = executionPipeline.startResponsePipeline(initialResponse) + + // Assert + assertEquals(initialResponse, result) + } +} From 43f4c4b10cb266bef20fb3c467e3d6be35651f6d Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 15:55:57 +0300 Subject: [PATCH 08/15] chore: cleanup --- .../sdk/core/pipeline/ExecutionPipelineTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt index cfecd547..7f81a917 100644 --- a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipelineTest.kt @@ -24,7 +24,7 @@ class ExecutionPipelineTest { } @Test - fun `startRequestPipeline should apply all request pipeline steps`() { + fun `should apply all request pipeline steps`() { // Arrange val initialRequest = mockk() val intermediateRequest = mockk() @@ -51,7 +51,7 @@ class ExecutionPipelineTest { } @Test - fun `startRequestPipeline should return initial request if pipeline is empty`() { + fun `should return initial request if request pipeline is empty`() { // Arrange val initialRequest = mockk() @@ -69,7 +69,7 @@ class ExecutionPipelineTest { } @Test - fun `startResponsePipeline should apply all response pipeline steps`() { + fun `should apply all response pipeline steps`() { // Arrange val initialResponse = mockk() val intermediateResponse = mockk() @@ -96,7 +96,7 @@ class ExecutionPipelineTest { } @Test - fun `startResponsePipeline should return initial response if pipeline is empty`() { + fun `should return initial response if response pipeline is empty`() { // Arrange val initialResponse = mockk() From 89787eff8e0932e29f205372715e8db319b90af1 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 16:21:35 +0300 Subject: [PATCH 09/15] chore: add RequestLoggingStepTest --- .../pipeline/step/RequestLoggingStepTest.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt new file mode 100644 index 00000000..565e1c49 --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt @@ -0,0 +1,82 @@ +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.http.MediaType +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.RequestBody +import com.expediagroup.sdk.core.logging.LoggerDecorator +import com.expediagroup.sdk.core.logging.RequestLogger +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okio.Buffer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.nio.charset.Charset + +class RequestLoggingStepTest { + private lateinit var mockLogger: LoggerDecorator + private lateinit var mockRequest: Request + private lateinit var mockRequestBody: RequestBody + private lateinit var loggingStep: RequestLoggingStep + + @BeforeEach + fun setUp() { + mockLogger = mockk(relaxed = true) + mockRequest = mockk(relaxed = true) + mockRequestBody = mockk(relaxed = true) + + loggingStep = RequestLoggingStep(logger = mockLogger, maxRequestBodySize = 1024L) + } + + @Test + fun `should log request and return modified a new request with reusable body`() { + // Arrange + val bodyContent = """{"key":"value"}""" + val buffer = Buffer().write(bodyContent.toByteArray()) + + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .addHeader("Content-Type", "application/json") + .body(RequestBody.create(buffer, mediaType = MediaType.parse("application/json"))) + .build() + + // Act + val result = loggingStep.invoke(testRequest) + val resultRequestStringBody = Buffer().apply { result.body?.writeTo(this) }.readString(Charset.defaultCharset()) + + // Assert + verify { RequestLogger.log(mockLogger, testRequest) } + + assertNotNull(result) + assertEquals(testRequest.url, result.url) + assertEquals(testRequest.method, result.method) + assertEquals(testRequest.headers, result.headers) + assertEquals(bodyContent, resultRequestStringBody) + } + + @Test + fun `should log request without modifying body when body is null`() { + // Arrange + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build() + + every { mockRequest.body } returns null + + // Act + val result = loggingStep.invoke(testRequest) + + // Assert + verify { RequestLogger.log(mockLogger, testRequest) } + assertEquals(testRequest, result) + } +} From eeea3d52fbeae62f54fc638105eec4a946520965 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 16:38:30 +0300 Subject: [PATCH 10/15] chore: add RequestLoggingStepTest & ResponseLoggingStepTest --- .../pipeline/step/RequestHeadersStepTest.kt | 33 +++++++++++ .../pipeline/step/RequestLoggingStepTest.kt | 8 --- .../pipeline/step/ResponseLoggingStepTest.kt | 55 +++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStepTest.kt diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt new file mode 100644 index 00000000..7b85cc2c --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt @@ -0,0 +1,33 @@ +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Request +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class RequestHeadersStepTest { + private lateinit var requestHeadersStep: RequestHeadersStep + + @BeforeEach + fun setUp() { + requestHeadersStep = RequestHeadersStep() + } + + @Test + fun `should add user-agent header`() { + val testRequest = + Request + .builder() + .url("https://example.com") + .method(Method.POST) + .build() + + val result = requestHeadersStep.invoke(testRequest) + + assertEquals( + result.headers.values("user-agent"), + listOf("expedia-group-test-sdk/1.0.0 (Provider/com.expediagroup; Java/1.8.0_432; Vendor/Azul Systems, Inc.; OS/Mac OS X - 15.2; Arch/aarch64; Locale/en_AE)"), + ) + } +} diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt index 565e1c49..7eac0981 100644 --- a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestLoggingStepTest.kt @@ -6,7 +6,6 @@ import com.expediagroup.sdk.core.http.Request import com.expediagroup.sdk.core.http.RequestBody import com.expediagroup.sdk.core.logging.LoggerDecorator import com.expediagroup.sdk.core.logging.RequestLogger -import io.mockk.every import io.mockk.mockk import io.mockk.verify import okio.Buffer @@ -18,16 +17,11 @@ import java.nio.charset.Charset class RequestLoggingStepTest { private lateinit var mockLogger: LoggerDecorator - private lateinit var mockRequest: Request - private lateinit var mockRequestBody: RequestBody private lateinit var loggingStep: RequestLoggingStep @BeforeEach fun setUp() { mockLogger = mockk(relaxed = true) - mockRequest = mockk(relaxed = true) - mockRequestBody = mockk(relaxed = true) - loggingStep = RequestLoggingStep(logger = mockLogger, maxRequestBodySize = 1024L) } @@ -70,8 +64,6 @@ class RequestLoggingStepTest { .method(Method.POST) .build() - every { mockRequest.body } returns null - // Act val result = loggingStep.invoke(testRequest) diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStepTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStepTest.kt new file mode 100644 index 00000000..c8216d0d --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/ResponseLoggingStepTest.kt @@ -0,0 +1,55 @@ +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.http.Method +import com.expediagroup.sdk.core.http.Protocol +import com.expediagroup.sdk.core.http.Request +import com.expediagroup.sdk.core.http.Response +import com.expediagroup.sdk.core.http.Status +import com.expediagroup.sdk.core.logging.LoggerDecorator +import com.expediagroup.sdk.core.logging.ResponseLogger +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ResponseLoggingStepTest { + private lateinit var mockLogger: LoggerDecorator + private lateinit var loggingStep: ResponseLoggingStep + + @BeforeEach + fun setUp() { + mockLogger = mockk(relaxed = true) + loggingStep = ResponseLoggingStep(logger = mockLogger) + } + + @Test + fun `invoke should log response and return the same response`() { + // Arrange + // Given + val testResponse = + Response + .builder() + .protocol(Protocol.HTTP_1_1) + .status(Status.OK) + .request( + Request + .builder() + .url("https://example.com") + .method(Method.GET) + .build(), + ).build() + + every { ResponseLogger.log(mockLogger, testResponse) } just Runs + + // Act + val result = loggingStep.invoke(testResponse) + + // Assert + verify { ResponseLogger.log(mockLogger, testResponse) } + assertEquals(testResponse, result) + } +} From 67d9fe317db80e20b0d928abc5789bc43419dda1 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 16:40:20 +0300 Subject: [PATCH 11/15] chore: add RequestLoggingStepTest & ResponseLoggingStepTest --- .../sdk/core/pipeline/step/RequestHeadersStepTest.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt index 7b85cc2c..2be7a0cc 100644 --- a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/RequestHeadersStepTest.kt @@ -2,7 +2,7 @@ package com.expediagroup.sdk.core.pipeline.step import com.expediagroup.sdk.core.http.Method import com.expediagroup.sdk.core.http.Request -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -25,9 +25,8 @@ class RequestHeadersStepTest { val result = requestHeadersStep.invoke(testRequest) - assertEquals( - result.headers.values("user-agent"), - listOf("expedia-group-test-sdk/1.0.0 (Provider/com.expediagroup; Java/1.8.0_432; Vendor/Azul Systems, Inc.; OS/Mac OS X - 15.2; Arch/aarch64; Locale/en_AE)"), + assertTrue( + result.headers.values("user-agent")[0].contains("expedia-group-test-sdk/1.0.0 (Provider/com.expediagroup;"), ) } } From 93840ae12abc3d87076eb590a24904014742aef0 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 21:02:19 +0300 Subject: [PATCH 12/15] chore: complete tests --- expediagroup-sdk-core/build.gradle | 4 +- .../sdk/core/pipeline/ExecutionPipeline.kt | 22 +++ .../step/BearerAuthenticationStepTest.kt | 178 ++++++++++++++++++ 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStepTest.kt diff --git a/expediagroup-sdk-core/build.gradle b/expediagroup-sdk-core/build.gradle index 921bb692..4dd08a07 100644 --- a/expediagroup-sdk-core/build.gradle +++ b/expediagroup-sdk-core/build.gradle @@ -82,12 +82,12 @@ kover { bound { aggregationForGroup = AggregationType.COVERED_PERCENTAGE coverageUnits = CoverageUnit.LINE - minValue = 92 + minValue = 100 } bound { aggregationForGroup = AggregationType.COVERED_PERCENTAGE coverageUnits = CoverageUnit.BRANCH - minValue = 86 + minValue = 100 } } } diff --git a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt index f14d3c79..b5496926 100644 --- a/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt +++ b/expediagroup-sdk-core/src/main/kotlin/com/expediagroup/sdk/core/pipeline/ExecutionPipeline.kt @@ -19,10 +19,32 @@ package com.expediagroup.sdk.core.pipeline import com.expediagroup.sdk.core.http.Request import com.expediagroup.sdk.core.http.Response +/** + * A functional interface representing a single step in the request pipeline. + * Implementations of this interface define transformations or actions + * to be applied to a [Request] object. + */ fun interface RequestPipelineStep : (Request) -> Request +/** + * A functional interface representing a single step in the response pipeline. + * Implementations of this interface define transformations or actions + * to be applied to a [Response] object. + */ fun interface ResponsePipelineStep : (Response) -> Response +/** + * A class representing a processing pipeline for requests and responses. + * + * The `ExecutionPipeline` orchestrates the execution of a series of transformations + * (steps) on both HTTP requests and responses. + * + * @property requestPipeline A list of [RequestPipelineStep]s to be executed on the outgoing [Request]. + * Each step is applied sequentially, with the output of one step becoming the input for the next. + * + * @property responsePipeline A list of [ResponsePipelineStep]s to be executed on the incoming [Response]. + * Each step is applied sequentially, with the output of one step becoming the input for the next. + */ class ExecutionPipeline( private val requestPipeline: List, private val responsePipeline: List, diff --git a/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStepTest.kt b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStepTest.kt new file mode 100644 index 00000000..a6c996a6 --- /dev/null +++ b/expediagroup-sdk-core/src/test/kotlin/com/expediagroup/sdk/core/pipeline/step/BearerAuthenticationStepTest.kt @@ -0,0 +1,178 @@ +package com.expediagroup.sdk.core.pipeline.step + +import com.expediagroup.sdk.core.authentication.bearer.AbstractBearerAuthenticationManager +import com.expediagroup.sdk.core.exception.service.ExpediaGroupAuthException +import com.expediagroup.sdk.core.http.Request +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.IOException +import java.net.URL +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class BearerAuthenticationStepTest { + private lateinit var mockAuthenticationManager: AbstractBearerAuthenticationManager + private lateinit var mockRequest: Request + private lateinit var mockRequestBuilder: Request.Builder + private lateinit var authenticationStep: BearerAuthenticationStep + + @BeforeEach + fun setUp() { + mockAuthenticationManager = mockk() + mockRequest = mockk(relaxed = true) + mockRequestBuilder = mockk(relaxed = true) + + every { mockRequest.newBuilder() } returns mockRequestBuilder + + authenticationStep = BearerAuthenticationStep(mockAuthenticationManager) + } + + @Test + fun `should add Authorization header to request without reauthenticate when token is valid`() { + // Arrange + val authHeaderValue = "Bearer valid_token" + every { mockAuthenticationManager.isTokenAboutToExpire() } returns false + every { mockAuthenticationManager.getAuthorizationHeaderValue() } returns authHeaderValue + every { mockRequestBuilder.addHeader("Authorization", authHeaderValue) } returns mockRequestBuilder + every { mockRequestBuilder.build() } returns mockRequest + + // Act + val result = authenticationStep.invoke(mockRequest) + + // Assert + verify(exactly = 0) { mockAuthenticationManager.authenticate() } + verify { mockAuthenticationManager.getAuthorizationHeaderValue() } + verify { mockRequestBuilder.addHeader("Authorization", authHeaderValue) } + verify { mockRequestBuilder.build() } + assertEquals(mockRequest, result) + } + + @Test + fun `should reauthenticate if token is about to expire`() { + // Arrange + val authHeaderValue = "Bearer new_token" + every { mockAuthenticationManager.isTokenAboutToExpire() } returns true + every { mockAuthenticationManager.authenticate() } just Runs + every { mockAuthenticationManager.getAuthorizationHeaderValue() } returns authHeaderValue + every { mockRequestBuilder.addHeader("Authorization", authHeaderValue) } returns mockRequestBuilder + every { mockRequestBuilder.build() } returns mockRequest + + // Act + val result = authenticationStep.invoke(mockRequest) + + // Assert + verifyOrder { + mockAuthenticationManager.isTokenAboutToExpire() + mockAuthenticationManager.authenticate() + mockAuthenticationManager.getAuthorizationHeaderValue() + } + verify(exactly = 1) { mockAuthenticationManager.authenticate() } + verify { mockRequestBuilder.addHeader("Authorization", authHeaderValue) } + verify { mockRequestBuilder.build() } + assertEquals(mockRequest, result) + } + + @Test + fun `should throw ExpediaGroupAuthException if authentication fails`() { + // Arrange + every { mockAuthenticationManager.isTokenAboutToExpire() } returns true + every { mockAuthenticationManager.authenticate() } throws IOException("Mocked authentication failure") + + // Act & Assert + val exception = + assertThrows { + authenticationStep.invoke(mockRequest) + } + + assertEquals("Failed to authenticate", exception.message) + assert(exception.cause is IOException) + } + + @Test + fun `should not reauthenticate if token is valid`() { + // Arrange + val authHeaderValue = "Bearer valid_token" + every { mockAuthenticationManager.isTokenAboutToExpire() } returns false + every { mockAuthenticationManager.getAuthorizationHeaderValue() } returns authHeaderValue + every { mockRequestBuilder.addHeader("Authorization", authHeaderValue) } returns mockRequestBuilder + every { mockRequestBuilder.build() } returns mockRequest + + // Act + val result = authenticationStep.invoke(mockRequest) + + // Assert + verify(exactly = 0) { mockAuthenticationManager.authenticate() } + verify { mockAuthenticationManager.getAuthorizationHeaderValue() } + verify { mockRequestBuilder.addHeader("Authorization", authHeaderValue) } + verify { mockRequestBuilder.build() } + assertEquals(mockRequest, result) + } + + @Test + fun `should authenticate only once when multiple threads detect token expiration`() { + // Arrange + val authManager = mockk(relaxed = true) + val authenticationStep = BearerAuthenticationStep(authManager) + val numberOfThreads = 5 + val latch = CountDownLatch(numberOfThreads) + val executor = Executors.newFixedThreadPool(numberOfThreads) + + val requestBuilder = mockk(relaxed = true) + val originalRequest = mockk(relaxed = true) + val authorizedRequest = mockk(relaxed = true) + val requestUrl = "https://api.example.com/data" + + every { originalRequest.url } returns URL(requestUrl) + every { originalRequest.newBuilder() } returns requestBuilder + every { requestBuilder.addHeader("Authorization", "Bearer refreshed_token") } returns requestBuilder + every { requestBuilder.build() } returns authorizedRequest + + // Use AtomicBoolean to simulate token expiration + val tokenExpired = AtomicBoolean(true) + + every { authManager.isTokenAboutToExpire() } answers { + tokenExpired.get() + } + + every { authManager.authenticate() } answers { + // Simulate some delay in authentication + Thread.sleep(100) + tokenExpired.set(false) + } + + every { authManager.getAuthorizationHeaderValue() } returns "Bearer refreshed_token" + + // Act + repeat(numberOfThreads) { + executor.submit { + try { + authenticationStep.invoke(originalRequest) + } finally { + latch.countDown() + } + } + } + + // Wait for all threads to complete + val completed = latch.await(3, TimeUnit.SECONDS) + executor.shutdown() + + // Assert + assertTrue(completed) + verify(exactly = 1) { authManager.authenticate() } + verify(atLeast = numberOfThreads) { authManager.isTokenAboutToExpire() } + verify(exactly = numberOfThreads) { authManager.getAuthorizationHeaderValue() } + verify(exactly = numberOfThreads) { requestBuilder.addHeader("Authorization", "Bearer refreshed_token") } + } +} From db6120978baba1d482e784e1b0ad53347e462cff Mon Sep 17 00:00:00 2001 From: mdwairi Date: Tue, 14 Jan 2025 21:10:06 +0300 Subject: [PATCH 13/15] chore: complete tests --- expediagroup-sdk-core/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/expediagroup-sdk-core/build.gradle b/expediagroup-sdk-core/build.gradle index 4dd08a07..32996b5a 100644 --- a/expediagroup-sdk-core/build.gradle +++ b/expediagroup-sdk-core/build.gradle @@ -82,12 +82,12 @@ kover { bound { aggregationForGroup = AggregationType.COVERED_PERCENTAGE coverageUnits = CoverageUnit.LINE - minValue = 100 + minValue = 99 } bound { aggregationForGroup = AggregationType.COVERED_PERCENTAGE coverageUnits = CoverageUnit.BRANCH - minValue = 100 + minValue = 99 } } } From 6bf60b962c80397e354dd2212766be4e3ec33c5f Mon Sep 17 00:00:00 2001 From: mdwairi Date: Wed, 15 Jan 2025 19:37:13 +0300 Subject: [PATCH 14/15] chore: cleanup --- .github/workflows/code-quality-checks.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/code-quality-checks.yaml b/.github/workflows/code-quality-checks.yaml index f5bbe912..1a49e81d 100644 --- a/.github/workflows/code-quality-checks.yaml +++ b/.github/workflows/code-quality-checks.yaml @@ -17,3 +17,9 @@ jobs: - name: Run Checks id: build run: gradle clean build + - name: Upload Coverage Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: code/build/reports/kover \ No newline at end of file From 57f7988d29ffe3d9ca007616a1694573ddbf4890 Mon Sep 17 00:00:00 2001 From: mdwairi Date: Wed, 15 Jan 2025 19:37:32 +0300 Subject: [PATCH 15/15] chore: cleanup --- .github/workflows/code-quality-checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-quality-checks.yaml b/.github/workflows/code-quality-checks.yaml index 1a49e81d..d342734b 100644 --- a/.github/workflows/code-quality-checks.yaml +++ b/.github/workflows/code-quality-checks.yaml @@ -22,4 +22,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: coverage-report - path: code/build/reports/kover \ No newline at end of file + path: code/build/reports/kover