Skip to content

Commit

Permalink
Merge pull request aws-geospatial#8 from makeen-project/ALMS-133
Browse files Browse the repository at this point in the history
ALMS-133, ALMS-141 Generic HTTP client created and reverse geocoding API added
  • Loading branch information
olegfilimonov authored May 28, 2024
2 parents 5becf55 + 78d8d21 commit 962e7d3
Show file tree
Hide file tree
Showing 23 changed files with 403 additions and 25 deletions.
4 changes: 3 additions & 1 deletion library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.10.0")
implementation("com.squareup.okhttp3:okhttp:4.9.2")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.20")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import software.amazon.location.auth.data.response.Credentials
import software.amazon.location.auth.utils.Constants
import software.amazon.location.auth.utils.Constants.HEADER_HOST
import software.amazon.location.auth.utils.Constants.HEADER_X_AMZ_CONTENT_SHA256
Expand All @@ -24,20 +24,25 @@ import software.amazon.location.auth.utils.awsAuthorizationHeader
class AwsSignerInterceptor(
private val serviceName: String,
private val region: String,
private val credentialsProvider: Credentials?
private val credentialsProvider: LocationCredentialsProvider?
) : Interceptor {

private val sdfMap = HashMap<String, SimpleDateFormat>()

override fun intercept(chain: Interceptor.Chain): Response {
val accessKeyId = credentialsProvider?.accessKeyId
val secretKey = credentialsProvider?.secretKey
val sessionToken = credentialsProvider?.sessionToken
val originalRequest = chain.request()
if (!accessKeyId.isNullOrEmpty() && !secretKey.isNullOrEmpty() && !sessionToken.isNullOrEmpty() && region.isNotEmpty() && originalRequest.url.host.contains(
"amazonaws.com"
)
) {
if (!originalRequest.url.host.contains("amazonaws.com")) {
return chain.proceed(originalRequest)
}
runBlocking {
if (credentialsProvider != null && !credentialsProvider.isCredentialsValid(credentialsProvider.getCredentialsProvider())) {
credentialsProvider.checkCredentials()
}
}
val accessKeyId = credentialsProvider?.getCredentialsProvider()?.accessKeyId
val secretKey = credentialsProvider?.getCredentialsProvider()?.secretKey
val sessionToken = credentialsProvider?.getCredentialsProvider()?.sessionToken
if (!accessKeyId.isNullOrEmpty() && !secretKey.isNullOrEmpty() && !sessionToken.isNullOrEmpty() && region.isNotEmpty()) {
val dateMilli = Date().time
val host = extractHostHeader(originalRequest.url.toString())
val timeStamp = getTimeStamp(dateMilli)
Expand Down Expand Up @@ -95,7 +100,8 @@ class AwsSignerInterceptor(
@Throws(NoSuchAlgorithmException::class)
private fun sha256Hex(data: String): String {
return bytesToHex(
MessageDigest.getInstance(HASHING_ALGORITHM).digest(data.toByteArray(StandardCharsets.UTF_8))
MessageDigest.getInstance(HASHING_ALGORITHM)
.digest(data.toByteArray(StandardCharsets.UTF_8))
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package software.amazon.location.auth

import android.content.Context
import software.amazon.location.auth.data.response.Credentials
import software.amazon.location.auth.data.model.response.Credentials
import software.amazon.location.auth.utils.Constants.ACCESS_KEY_ID
import software.amazon.location.auth.utils.Constants.EXPIRATION
import software.amazon.location.auth.utils.Constants.SECRET_KEY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package software.amazon.location.auth

import android.content.Context
import java.util.Date
import software.amazon.location.auth.data.response.Credentials
import software.amazon.location.auth.data.model.response.Credentials
import software.amazon.location.auth.data.network.AwsRetrofitClient
import software.amazon.location.auth.utils.AwsRegions
import software.amazon.location.auth.utils.AwsRegions.Companion.DEFAULT_REGION
import software.amazon.location.auth.utils.CognitoCredentialsClient
import software.amazon.location.auth.utils.Constants.API_KEY
import software.amazon.location.auth.utils.Constants.BASE_URL
import software.amazon.location.auth.utils.Constants.IDENTITY_POOL_ID
import software.amazon.location.auth.utils.Constants.METHOD
import software.amazon.location.auth.utils.Constants.REGION
import software.amazon.location.auth.utils.Constants.SERVICE_NAME

const val PREFS_NAME = "software.amazon.location.auth"

Expand Down Expand Up @@ -108,12 +112,20 @@ class LocationCredentialsProvider {
val credentials = cognitoCredentialsProvider?.getCachedCredentials()
credentials?.let {
if (!isCredentialsValid(it)) {
AwsRetrofitClient.clearApiService()
generateCredentials(region, identityPoolId)
} else {
initAwsRetrofitClient()
}
}
}
}

private fun initAwsRetrofitClient() {
val region = securePreferences.get(REGION) ?: DEFAULT_REGION.regionName
AwsRetrofitClient.init(getUrl(region), SERVICE_NAME, region, this)
}

/**
* Generates new AWS credentials using the specified region and identity pool ID.
*
Expand All @@ -130,7 +142,9 @@ class LocationCredentialsProvider {
val identityId = cognitoCredentialsHttpHelper.getIdentityId(identityPoolId)
if (identityId.isNotEmpty()) {
val credentials = cognitoCredentialsHttpHelper.getCredentials(identityId)
cognitoCredentialsProvider = CognitoCredentialsProvider(context, credentials.credentials)
cognitoCredentialsProvider =
CognitoCredentialsProvider(context, credentials.credentials)
initAwsRetrofitClient()
}
} catch (e: Exception) {
throw Exception("Credentials generation failed")
Expand All @@ -145,7 +159,7 @@ class LocationCredentialsProvider {
* @param credentials The AWS credentials to validate.
* @return True if the credentials are valid (i.e., not expired), false otherwise.
*/
private fun isCredentialsValid(credentials: Credentials): Boolean {
fun isCredentialsValid(credentials: Credentials): Boolean {
val expirationTime = credentials.expiration.toLong() * 1000
val expirationDate = Date(expirationTime)
val currentDate = Date()
Expand Down Expand Up @@ -178,6 +192,7 @@ class LocationCredentialsProvider {
*/
suspend fun refresh() {
if (cognitoCredentialsProvider === null) throw Exception("Refresh is only supported for Cognito credentials. Make sure to use the cognito constructor.")
AwsRetrofitClient.clearApiService()
cognitoCredentialsProvider?.clearCredentials()
checkCredentials()
}
Expand All @@ -190,4 +205,9 @@ class LocationCredentialsProvider {
if (cognitoCredentialsProvider === null) throw Exception("Clear is only supported for Cognito credentials. Make sure to use the cognito constructor.")
cognitoCredentialsProvider?.clearCredentials()
}

private fun getUrl(region: String): String {
val urlBuilder = StringBuilder(BASE_URL.format(region))
return urlBuilder.toString()
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package software.amazon.location.auth.data.request
package software.amazon.location.auth.data.model.request

import com.google.gson.annotations.SerializedName

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package software.amazon.location.auth.data.request
package software.amazon.location.auth.data.model.request

import com.google.gson.annotations.SerializedName

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package software.amazon.location.auth.data.model.request

import com.google.gson.annotations.SerializedName

data class ReverseGeocodeRequest(
@SerializedName("Language")
val language: String,
@SerializedName("MaxResults")
val maxResults: Int,
@SerializedName("Position")
val position: List<Double>
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package software.amazon.location.auth.data.response
package software.amazon.location.auth.data.model.response

import com.google.gson.annotations.SerializedName

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package software.amazon.location.auth.data.response
package software.amazon.location.auth.data.model.response

import com.google.gson.annotations.SerializedName

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package software.amazon.location.auth.data.response
package software.amazon.location.auth.data.model.response

import com.google.gson.annotations.SerializedName

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package software.amazon.location.auth.data.model.response

import com.google.gson.annotations.SerializedName

data class ReverseGeocodeLabel(
@SerializedName("Label")
val label: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package software.amazon.location.auth.data.model.response

import com.google.gson.annotations.SerializedName

data class ReverseGeocodePlace(
@SerializedName("Place")
val place: ReverseGeocodeLabel
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package software.amazon.location.auth.data.model.response

import com.google.gson.annotations.SerializedName

data class ReverseGeocodeResponse(
@SerializedName("Results")
val results: List<ReverseGeocodePlace>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package software.amazon.location.auth.data.network

import retrofit2.http.Body
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Path
import software.amazon.location.auth.data.model.request.ReverseGeocodeRequest
import software.amazon.location.auth.data.model.response.ReverseGeocodeResponse

interface AwsApiService {
@POST("places/v0/indexes/{indexName}/search/position")
@Headers("Content-Type: application/json")
suspend fun reverseGeocode(
@Path("indexName") indexName: String,
@Body request: ReverseGeocodeRequest
): ReverseGeocodeResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package software.amazon.location.auth.data.network

import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import software.amazon.location.auth.AwsSignerInterceptor
import software.amazon.location.auth.LocationCredentialsProvider
import software.amazon.location.auth.utils.Constants.CONNECTION_TIMEOUT
import software.amazon.location.auth.utils.Constants.READ_TIMEOUT

internal class AwsOkHttpClient {

companion object {
/**
* Creates and returns an OkHttpClient configured with AWS request signing.
*
* @param serviceName The name of the AWS service (e.g., "execute-api").
* @param region The AWS region (e.g., "us-west-2").
* @param credentialsProvider The provider for obtaining AWS credentials.
* @return An OkHttpClient instance with AWS signing interceptor.
*/
fun getClient(
serviceName: String,
region: String,
credentialsProvider: LocationCredentialsProvider?
): OkHttpClient {
val awsSignerInterceptor = AwsSignerInterceptor(serviceName, region, credentialsProvider)

return OkHttpClient.Builder()
.connectTimeout(CONNECTION_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
.addInterceptor(awsSignerInterceptor)
.build()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package software.amazon.location.auth.data.network

import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import software.amazon.location.auth.LocationCredentialsProvider
import software.amazon.location.auth.utils.Constants.RESPONSE_CODE_CREDENTIAL_EXPIRED

/**
* A singleton object that manages the Retrofit client and API service for AWS requests.
*/
object AwsRetrofitClient {
private lateinit var retrofit: Retrofit
private var _apiService: AwsApiService? = null

/**
* Initializes the Retrofit client with the given parameters.
*
* @param baseUrl The base URL for the Retrofit client.
* @param serviceName The name of the AWS service (e.g., "execute-api").
* @param region The AWS region (e.g., "us-west-2").
* @param credentialsProvider The provider for obtaining AWS credentials.
*/
fun init(baseUrl: String, serviceName: String, region: String, credentialsProvider: LocationCredentialsProvider?) {
val client = AwsOkHttpClient.getClient(serviceName, region, credentialsProvider)
retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
}

/**
* Retrieves the API service instance. If it doesn't exist, it will be created.
*
* @return The API service instance.
*/
val apiService: AwsApiService
get() {
if (_apiService == null) {
createApiService()
}
return _apiService!!
}

/**
* Creates the API service instance using the Retrofit client.
*/
private fun createApiService() {
_apiService = retrofit.create(AwsApiService::class.java)
}

/**
* Clears the current API service instance, forcing it to be recreated on the next access.
*/
@Synchronized
fun clearApiService() {
_apiService = null
}

/**
* Checks if the given exception corresponds to an expired credentials error.
*
* @param e The exception to check.
* @return True if the exception is a HttpException with a status code indicating expired credentials, false otherwise.
*/
fun isHttpStatusCodeCredentialExpired(e: Exception): Boolean {
if (e is HttpException) {
return e.code() == RESPONSE_CODE_CREDENTIAL_EXPIRED
}
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ enum class AwsRegions(val regionName: String) {
AP_SOUTH_2("ap-south-2"),
IL_CENTRAL_1("il-central-1");


companion object {
val DEFAULT_REGION: AwsRegions = US_WEST_2
fun fromName(regionName: String): AwsRegions {
return entries.find { it.regionName == regionName }
?: throw IllegalArgumentException("Cannot create enum from $regionName value!")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import software.amazon.location.auth.data.request.GetCredentialRequest
import software.amazon.location.auth.data.request.GetIdentityIdRequest
import software.amazon.location.auth.data.response.GetCredentialResponse
import software.amazon.location.auth.data.response.GetIdentityIdResponse
import software.amazon.location.auth.data.model.request.GetCredentialRequest
import software.amazon.location.auth.data.model.request.GetIdentityIdRequest
import software.amazon.location.auth.data.model.response.GetCredentialResponse
import software.amazon.location.auth.data.model.response.GetIdentityIdResponse
import software.amazon.location.auth.utils.Constants.HEADER_X_AMZ_TARGET
import software.amazon.location.auth.utils.Constants.MEDIA_TYPE
import software.amazon.location.auth.utils.Constants.URL
Expand Down
Loading

0 comments on commit 962e7d3

Please sign in to comment.