Android Consuming RESTful APIs


Android: Consuming RESTful APIs

Modern mobile applications frequently interact with backend services to fetch and send data. RESTful APIs are a common way to structure these interactions over HTTP. In Android, several libraries simplify the process of making HTTP requests and parsing responses.

1. Choosing an HTTP Client Library

While you can use Android's built-in HttpURLConnection, it's generally recommended to use a more robust and feature-rich library for easier API consumption.

  • Retrofit (Recommended): A type-safe HTTP client for Android and Java. It's built on top of OkHttp and makes it easy to declare API endpoints using interfaces and annotations. Handles parsing JSON/XML responses automatically with converters.
  • Volley: Developed by Google, Volley is good for small network operations and handles threading, request queuing, and caching. Less feature-rich for complex APIs compared to Retrofit.
  • OkHttp: A powerful and efficient HTTP client. Retrofit uses OkHttp internally. You can use OkHttp directly for more control, but Retrofit provides a higher-level abstraction.

This tutorial will focus on Retrofit, as it's widely used and highly recommended for its ease of use with REST APIs.

2. Setting up Retrofit

a) Add Dependencies

Add the necessary Retrofit and a converter library (like Gson for JSON) to your module's build.gradle file.

dependencies {
    // ... existing dependencies

    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:2.9.0") // Use the latest version
    implementation("com.squareup.retrofit2:converter-gson:2.9.0") // Converter for JSON using Gson

    // Optional: OkHttp logging interceptor for debugging
    implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") // Use the latest version
}

Sync your project after adding the dependencies.

b) Add Internet Permission

Your application needs the INTERNET permission to make network requests. Add this to your AndroidManifest.xml file, outside the <application> tag.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        ...>
        ...
    </application>
</manifest>

c) Create Data Models

Define Kotlin data classes or Java POJOs that represent the structure of the JSON (or other format) data you expect to receive from the API.

Example (assuming a weather API returns JSON like: {"name": "London", "main": {"temp": 15.5}}):

import com.google.gson.annotations.SerializedName

data class WeatherResponse(
    @SerializedName("name")
    val name: String,
    @SerializedName("main")
    val main: MainInfo
)

data class MainInfo(
    @SerializedName("temp")
    val temp: Double
)

@SerializedName is useful if the JSON key names don't match your class property names.

d) Create an API Interface

Define a Kotlin interface (or Java interface) that declares the API endpoints using Retrofit annotations.

import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Query

interface WeatherApi {

    @GET("weather") // Relative URL path
    fun getCurrentWeather(
        @Query("q") city: String, // Query parameter for the city
        @Query("appid") apiKey: String // Query parameter for the API key
    ): Call<WeatherResponse> // Retrofit Call object that will return WeatherResponse
}
  • @GET, @POST, @PUT, @DELETE, etc., define the HTTP method and the relative path.
  • @Query adds query parameters to the URL (e.g., ?q=London&appid=YOUR_KEY).
  • @Path includes variable segments in the URL (e.g., @GET("users/{id}")).
  • @Body sends a request body (used with POST, PUT, etc.).
  • @Header adds request headers.

The return type is typically a Call<YourDataType>. If using Kotlin Coroutines, you can make the function suspend and return YourDataType directly.

e) Create a Retrofit Instance

Create a single instance of the Retrofit client. This is typically done once in your application (e.g., in an Application class, a Singleton object, or using dependency injection).

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor

object RetrofitClient {

    private const val BASE_URL = "https://api.openweathermap.org/data/2.5/" // Example base URL
    const val API_KEY = "YOUR_ACTUAL_API_KEY" // Replace with your API key

    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY // Log request and response bodies
    }

    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor) // Add logging interceptor for debugging
        .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) // Set connection timeout
        .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) // Set read timeout
        .build()

    private val retrofit: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient) // Set the OkHttpClient
            .addConverterFactory(GsonConverterFactory.create()) // Add the Gson converter
            .build()
    }

    val instance: WeatherApi by lazy {
        retrofit.create(WeatherApi::class.java)
    }
}

lazy ensures the Retrofit instance is created only when it's first accessed.

3. Making API Calls

a) Using Callbacks (Java/Kotlin)

This is the traditional asynchronous way to make calls with Retrofit.

import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import android.util.Log

// In an Activity, Fragment, ViewModel, etc.
fun fetchWeatherCallback(city: String) {
    val call = RetrofitClient.instance.getCurrentWeather(city, RetrofitClient.API_KEY)

    call.enqueue(object : Callback<WeatherResponse> {
        override fun onResponse(call: Call<WeatherResponse>, response: Response<WeatherResponse>) {
            if (response.isSuccessful) {
                // Request was successful (HTTP status code 2xx)
                val weather = response.body() // Get the parsed data
                if (weather != null) {
                    Log.d("Weather", "City: ${weather.name}, Temp: ${weather.main.temp}°C")
                    // Update UI with weather data
                } else {
                    Log.w("Weather", "Response body is null")
                    // Handle empty response body
                }
            } else {
                // Request failed with an HTTP error (status code 4xx or 5xx)
                val errorCode = response.code()
                val errorMessage = response.errorBody()?.string() // Get error body if available
                Log.e("Weather", "HTTP Error $errorCode: $errorMessage")
                // Show error message to user
            }
        }

        override fun onFailure(call: Call<WeatherResponse>, t: Throwable) {
            // Request failed due to network issues (no connection, timeout, etc.)
            Log.e("Weather", "Network request failed", t)
            // Show network error message to user
        }
    })
}

enqueue makes the call asynchronously on a background thread. You handle the result in the onResponse or onFailure callbacks, which are executed on the main thread.

b) Using Kotlin Coroutines (Recommended for Kotlin)

Retrofit provides built-in support for Kotlin Coroutines, making asynchronous code much cleaner.

First, update your API interface to use the suspend keyword and return the data type directly:

import retrofit2.http.GET
import retrofit2.http.Query

interface WeatherApi {

    @GET("weather")
    suspend fun getCurrentWeather( // Use suspend and return the data type directly
        @Query("q") city: String,
        @Query("appid") apiKey: String
    ): WeatherResponse
}

Then, call the suspend function within a Coroutine scope (e.g., viewModelScope or lifecycleScope):

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import android.util.Log
import retrofit2.HttpException // Retrofit's HTTP exception

// In a ViewModel or a class with a CoroutineScope
fun fetchWeatherCoroutines(city: String) {
    // Launch a coroutine in a suitable scope (e.g., viewModelScope)
    viewModelScope.launch {
        // Switch to the IO dispatcher for network operations
        withContext(Dispatchers.IO) {
            try {
                // Make the network call - it suspends until the result is ready
                val weather = RetrofitClient.instance.getCurrentWeather(city, RetrofitClient.API_KEY)

                // Switch back to the Main dispatcher to update UI
                withContext(Dispatchers.Main) {
                    Log.d("Weather", "City: ${weather.name}, Temp: ${weather.main.temp}°C")
                    // Update UI with weather data
                }
            } catch (e: HttpException) {
                // Handle HTTP errors (status codes 4xx, 5xx)
                val errorCode = e.code()
                val errorMessage = e.response()?.errorBody()?.string()
                Log.e("Weather", "HTTP Error $errorCode: $errorMessage")
                // Show HTTP error message to user on the Main thread
                withContext(Dispatchers.Main) {
                    // Update UI to show error
                }
            } catch (e: java.io.IOException) {
                // Handle network errors (no connection, timeout, etc.)
                Log.e("Weather", "Network request failed", e)
                // Show network error message to user on the Main thread
                withContext(Dispatchers.Main) {
                    // Update UI to show network error
                }
            } catch (e: Exception) {
                // Handle other potential exceptions
                Log.e("Weather", "An unexpected error occurred", e)
                 withContext(Dispatchers.Main) {
                    // Update UI to show generic error
                }
            }
        }
    }
}

Coroutines simplify error handling using try-catch blocks. Network errors and HTTP errors (like 404 or 500) are thrown as exceptions.

4. Handling Different HTTP Methods and Request Bodies

a) POST Requests (Sending Data)

Use the @POST annotation and the @Body annotation to send data in the request body. You'll typically send a data class or a Map.

Example: Creating a User

data class UserRequest(val name: String, val email: String)
data class UserResponse(val id: Int, val name: String, val email: String)

interface UserApi {
    @POST("users")
    suspend fun createUser(@Body user: UserRequest): UserResponse
}

b) PUT, DELETE, etc.

Use the corresponding annotations (@PUT, @DELETE) in a similar way.

interface ItemApi {
    @PUT("items/{id}")
    suspend fun updateItem(@Path("id") itemId: Int, @Body item: Item): Item

    @DELETE("items/{id}")
    suspend fun deleteItem(@Path("id") itemId: Int): Unit // Or a response indicating success/failure
}

5. Adding Request Headers

Use the @Header or @Headers annotations to add custom headers (e.g., for authentication tokens).

interface AuthenticatedApi {
    @GET("profile")
    suspend fun getUserProfile(@Header("Authorization") authToken: String): UserProfile

    // Or multiple headers
    @Headers("Cache-Control: max-age=640000", "User-Agent: My-App")
    @GET("cached-data")
    suspend fun getCachedData(): CachedData
}

For headers that need to be added to every request (like an authentication token), it's better to use an OkHttp Interceptor.

Example: Adding an Auth Token Interceptor

import okhttp3.Interceptor
import okhttp3.Response

class AuthInterceptor(private val authToken: String) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        val requestWithHeader = originalRequest.newBuilder()
            .header("Authorization", "Bearer $authToken") // Add your token here
            .build()
        return chain.proceed(requestWithHeader)
    }
}

// In your RetrofitClient:
/*
private val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor("your_dynamic_auth_token")) // Add your interceptor
    // ... other configurations
    .build()
*/

6. Error Handling Revisited

As discussed in the Handling Network Errors section, robust error handling is crucial. With Retrofit and Coroutines, you handle errors using try-catch. With Callbacks, you handle errors in the onResponse (for HTTP errors) and onFailure (for network errors) methods.

7. Best Practices for API Consumption

  • Use a dedicated library: Retrofit or Volley simplify network tasks significantly.
  • Define data models: Create data classes that accurately reflect the API response structure.
  • Use interfaces for APIs: Retrofit interfaces provide a clean way to define endpoints.
  • Use Coroutines (if using Kotlin): They make asynchronous code much more readable and maintainable.
  • Handle errors gracefully: Implement comprehensive error handling for network issues, HTTP errors, and parsing errors.
  • Use Interceptors: For tasks like logging, adding headers, or caching, use OkHttp Interceptors.
  • Manage the Retrofit instance: Create a single instance of Retrofit using a Singleton or dependency injection.
  • Consider caching: Implement caching strategies (HTTP caching, database caching) to improve performance and support offline scenarios.
  • Handle background tasks: Use WorkManager for API calls that need to happen in the background or when the app is offline.

By following these steps and best practices, you can effectively consume RESTful APIs in your Android applications using Retrofit, leading to cleaner, more maintainable, and more robust code.