Unit 3 - Notes

CSE227 12 min read

Unit 3: Advanced Networking with Retrofit

1. Retrofit Basics and Integration

1.1 What is Retrofit?

Retrofit is a type-safe HTTP client for Android, Java, and Kotlin, developed by Square. It simplifies the process of consuming RESTful web services by turning an HTTP API into a simple Kotlin/Java interface.

Key Features:

  • Declarative: You define your API endpoints in an interface using annotations.
  • Type-Safe: Reduces runtime errors by validating API definitions at compile time.
  • Pluggable: Supports various converters for serialization/deserialization (e.g., JSON, XML) and call adapters for integration with asynchronous frameworks (e.g., Kotlin Coroutines, RxJava).
  • Built on OkHttp: It leverages OkHttp, a powerful and efficient HTTP client, for all its networking operations. This means you can use OkHttp features like interceptors, caching, and more.

1.2 Core Components

  1. Retrofit Class: The main entry point for your application. You use Retrofit.Builder() to configure and create a Retrofit instance. Configuration includes setting the base URL, adding converters, and attaching a custom OkHttpClient.

  2. API Interface: A Kotlin/Java interface where you define the API's endpoints. Each method in the interface represents a single API call.

  3. Annotations: Used within the API interface to describe the request details.

    • HTTP Method Annotations: @GET, @POST, @PUT, @DELETE, @PATCH, @HEAD.
    • URL Manipulation Annotations: @Path, @Query, @QueryMap, @Url.
    • Request Body Annotation: @Body.
    • Header Annotations: @Headers, @Header.
  4. Converters (Converter.Factory): Responsible for serializing request bodies and deserializing response bodies. They convert data between Kotlin/Java objects and a format the API understands (like JSON).

    • Common Converters:
      • GsonConverterFactory: For Google's Gson library.
      • MoshiConverterFactory: For Square's Moshi library (often preferred for Kotlin due to better null safety and adapter support).
      • SimpleXmlConverterFactory: For XML.
  5. Call Adapters (CallAdapter.Factory): Adapt the Call<T> object into other types. This is crucial for integrating with asynchronous frameworks. Retrofit has built-in support for Kotlin Coroutines' suspend functions, which is the modern standard.

1.3 Integration Steps

Step 1: Add Dependencies

Add the necessary dependencies to your app-level build.gradle.kts (or build.gradle) file.

KOTLIN
// build.gradle.kts

dependencies {
    // Retrofit
    implementation("com.squareup.retrofit2:retrofit:2.9.0")

    // Converter (choose one, Moshi is recommended for Kotlin)
    implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
    // Or for Gson
    // implementation("com.squareup.retrofit2:converter-gson:2.9.0")

    // OkHttp (usually included by Retrofit, but good to have for interceptors)
    implementation("com.squareup.okhttp3:okhttp3:4.10.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.10.0") // Optional, for logging
}

Step 2: Add Internet Permission

Add the INTERNET permission to your AndroidManifest.xml.

XML
<!-- AndroidManifest.xml -->
<manifest ...>
    <uses-permission android:name="android.permission.INTERNET" />
    <application ...>
        ...
    </application>
</manifest>

Step 3: Create a Data Class (Model)

Define a Kotlin data class that matches the structure of the JSON response from the API.

Example JSON Response:

JSON
{
  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit..."
}

Corresponding Kotlin Data Class:

KOTLIN
// Using Moshi
import com.squareup.moshi.Json

data class Post(
    @Json(name = "userId")
    val userId: Int,

    @Json(name = "id")
    val id: Int,

    @Json(name = "title")
    val title: String,

    @Json(name = "body")
    val body: String
)

/*
// Using Gson
import com.google.gson.annotations.SerializedName

data class Post(
    @SerializedName("userId")
    val userId: Int,
    ...
)
*/

Note: The @Json (Moshi) or @SerializedName (Gson) annotation is only necessary if your Kotlin property name differs from the JSON key.

Step 4: Create the API Service Interface

Define an interface with methods that map to the API endpoints.

KOTLIN
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path

interface ApiService {
    @GET("posts")
    suspend fun getPosts(): Response<List<Post>>

    @GET("posts/{id}")
    suspend fun getPostById(@Path("id") postId: Int): Response<Post>
}

Step 5: Create the Retrofit Instance

It's a best practice to create a singleton instance of Retrofit to be used throughout the app.

KOTLIN
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory

object RetrofitClient {

    private const val BASE_URL = "https://jsonplaceholder.typicode.com/"

    // Moshi instance for JSON parsing
    private val moshi = Moshi.Builder()
        .add(KotlinJsonAdapterFactory())
        .build()

    // OkHttp logging interceptor for debugging
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY
    }

    // OkHttpClient with the logger
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .build()
        
    // Lazy-initialized Retrofit instance
    val instance: ApiService by lazy {
        val retrofit = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(MoshiConverterFactory.create(moshi))
            .build()
            
        retrofit.create(ApiService::class.java)
    }
}


2. Handling API Requests

Retrofit uses annotations to define the parameters of an HTTP request.

2.1 URL Manipulation

  • @Path: Replaces a placeholder in the URL path. The placeholder is defined by {...}.

    KOTLIN
        @GET("users/{userId}/posts")
        suspend fun getPostsByUser(@Path("userId") id: Int): List<Post>
        

  • @Query: Appends a parameter to the URL with a specified name.

    KOTLIN
        // Request will be: /posts?userId=1&_sort=id&_order=desc
        @GET("posts")
        suspend fun getSortedPostsByUser(
            @Query("userId") userId: Int,
            @Query("_sort") sortBy: String,
            @Query("_order") order: String
        ): List<Post>
        

  • @QueryMap: Appends multiple query parameters from a Map.

    KOTLIN
        // options = mapOf("userId" to "1", "_sort" to "id")
        @GET("posts")
        suspend fun getPostsWithFilters(@QueryMap options: Map<String, String>): List<Post>
        

2.2 Request Body

  • @Body: Used with methods like POST or PUT to send a Kotlin/Java object as the request body. The converter (e.g., Moshi) will serialize the object into JSON.
    KOTLIN
        // Defines a request to create a new post
        @POST("posts")
        suspend fun createPost(@Body post: Post): Response<Post>
        

2.3 Other HTTP Methods

  • @PUT: To completely update an existing resource.
    KOTLIN
        @PUT("posts/{id}")
        suspend fun updatePost(@Path("id") id: Int, @Body post: Post): Response<Post>
        
  • @PATCH: To partially update an existing resource. Often used with a Map for dynamic fields.
    KOTLIN
        @PATCH("posts/{id}")
        suspend fun patchPost(@Path("id") id: Int, @Body fields: Map<String, Any>): Response<Post>
        
  • @DELETE: To delete a resource. Usually doesn't have a response body.
    KOTLIN
        // A 200 or 204 No Content response indicates success
        @DELETE("posts/{id}")
        suspend fun deletePost(@Path("id") id: Int): Response<Unit>
        

2.4 Headers

  • @Header: Adds a single, dynamic header to a request.
    KOTLIN
        @GET("user")
        suspend fun getUserProfile(@Header("Authorization") token: String): User
        
  • @Headers: Adds static headers to a request method.
    KOTLIN
        @Headers(
            "Cache-Control: max-age=640000",
            "User-Agent: My-Awesome-App"
        )
        @GET("posts")
        suspend fun getPosts(): List<Post>
        

    Best Practice: For headers that are required on every request (like Authorization), use an OkHttp Interceptor instead of annotating every method. This is covered in the Authentication section.


3. Parsing and Displaying Data

3.1 Parsing with Converters

When you add a converter factory like MoshiConverterFactory to your Retrofit instance, the magic happens automatically.

  1. Outbound (Serialization): When you use @Body, Retrofit passes your Kotlin object to the converter. The converter (Moshi/Gson) serializes it into a JSON string for the HTTP request body.
  2. Inbound (Deserialization): When an HTTP response is received, Retrofit gives the response body to the converter. The converter parses the JSON and creates instances of your specified Kotlin data class (e.g., List<Post>).

3.2 Handling API Responses (Response<T>)

Wrapping your return type with Response<T> gives you more metadata about the response, which is crucial for robust error handling.

  • isSuccessful: Returns true if the HTTP response code is in the range [200..300).
  • code(): Returns the HTTP status code (e.g., 200, 404, 500).
  • body(): Returns the deserialized response body if the request was successful (isSuccessful is true). It's nullable.
  • errorBody(): Returns the raw error body if the request was not successful. You can use this to parse custom error messages from the API.

Example: Error Handling in a ViewModel

KOTLIN
class PostViewModel : ViewModel() {
    // ...
    fun fetchPost(id: Int) {
        viewModelScope.launch {
            try {
                val response = RetrofitClient.instance.getPostById(id)
                if (response.isSuccessful) {
                    val post = response.body()
                    // Update UI with success state and post data
                } else {
                    val errorCode = response.code()
                    val errorBody = response.errorBody()?.string()
                    // Update UI with error state (e.g., "Post not found (404)")
                }
            } catch (e: Exception) {
                // Handle network errors (e.g., IOException)
                // Update UI with network error state
            }
        }
    }
}

3.3 Displaying Data in the UI (MVVM Pattern)

A common pattern is to use a ViewModel with StateFlow or LiveData to communicate the network result to the UI (Activity/Fragment).

1. Define a UI State:

KOTLIN
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

2. ViewModel Fetches Data and Updates State:

KOTLIN
class PostsViewModel : ViewModel() {
    private val _postsState = MutableStateFlow<UiState<List<Post>>>(UiState.Loading)
    val postsState: StateFlow<UiState<List<Post>>> = _postsState

    init {
        fetchPosts()
    }

    private fun fetchPosts() {
        _postsState.value = UiState.Loading
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val response = RetrofitClient.instance.getPosts()
                if (response.isSuccessful && response.body() != null) {
                    _postsState.value = UiState.Success(response.body()!!)
                } else {
                    _postsState.value = UiState.Error("Error: ${response.code()}")
                }
            } catch (e: Exception) {
                _postsState.value = UiState.Error("Network error: ${e.message}")
            }
        }
    }
}

3. Fragment/Activity Observes State and Updates UI:

KOTLIN
class PostsFragment : Fragment() {
    private val viewModel: PostsViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.postsState.collect { state ->
                when (state) {
                    is UiState.Loading -> {
                        // Show progress bar
                    }
                    is UiState.Success -> {
                        // Hide progress bar, update RecyclerView with state.data
                    }
                    is UiState.Error -> {
                        // Hide progress bar, show error message
                    }
                }
            }
        }
    }
}


4. Asynchronous Networking

Network operations are long-running and must never be performed on the main (UI) thread, as this would freeze the app and lead to an NetworkOnMainThreadException.

4.1 The Old Way: Call.enqueue()

Before coroutines became standard, Retrofit used a callback-based approach.

KOTLIN
// Deprecated approach, shown for context
val call = someApiService.getPosts() // Returns Call<List<Post>>
call.enqueue(object : Callback<List<Post>> {
    override fun onResponse(call: Call<List<Post>>, response: Response<List<Post>>) {
        // Handle success or failure on the main thread
    }

    override fun onFailure(call: Call<List<Post>>, t: Throwable) {
        // Handle network failure on the main thread
    }
})

This can lead to nested callbacks ("callback hell") and makes sequential network calls difficult to manage.

4.2 The Modern Way: Kotlin Coroutines

Coroutines provide a much cleaner, sequential way to write asynchronous code. Retrofit has first-class support for them.

Key Concepts:

  • suspend Functions: A suspend function can be paused and resumed later. Retrofit service methods marked with suspend will perform their network request on a background thread without blocking the calling thread.
  • viewModelScope: A CoroutineScope tied to a ViewModel's lifecycle. Any coroutine launched in this scope is automatically cancelled when the ViewModel is cleared.
  • Structured Concurrency: Coroutines are launched within a scope, ensuring they are not lost and can be cancelled together.

Implementation:

  1. Modify the API Interface: Make your service methods suspend functions. They can now directly return the data type (List<Post>) or a Response<List<Post>>.

    KOTLIN
        interface ApiService {
            // Directly returns the body, throws HttpException on failure
            @GET("posts")
            suspend fun getPosts(): List<Post>
    
            // Returns Response object for manual status code checking
            @GET("posts/{id}")
            suspend fun getPostById(@Path("id") id: Int): Response<Post>
        }
        

  2. Call from a CoroutineScope: Use viewModelScope.launch to call the suspend function from your ViewModel.

    KOTLIN
        class PostViewModel : ViewModel() {
            // ...
            fun fetchPost(id: Int) {
                viewModelScope.launch { // Coroutine is launched on the main thread
                    // Retrofit handles switching to a background thread for the network call
                    try {
                        // This call suspends the coroutine, but does NOT block the main thread
                        val post = RetrofitClient.instance.getPostById(id) 
                        // Code here resumes on the main thread when the network call completes
                        // Update UI with the result
                    } catch (e: HttpException) {
                        // Handle HTTP errors (e.g., 404, 500)
                    } catch (e: IOException) {
                        // Handle network errors (no internet connection)
                    } catch (e: Exception) {
                        // Handle other exceptions (e.g., parsing errors)
                    }
                }
            }
        }
        

This approach is significantly more readable and easier to reason about than callbacks, especially for complex logic like chaining multiple API calls.


5. Authentication

Most APIs require some form of authentication. Retrofit, combined with OkHttp's Interceptors, provides a powerful and centralized way to handle this.

5.1 Common Authentication Strategies

  • API Key: A simple key passed either as a query parameter or a request header.
  • Basic Authentication: A username and password encoded in Base64 and sent in the Authorization header.
  • OAuth 2.0 (Bearer Token): The most common method for modern apps. The app first obtains an access token (e.g., after user login) and then includes it in the Authorization header for all subsequent API requests, prefixed with Bearer.

5.2 Using an OkHttp Interceptor for Authentication

An Interceptor is a powerful OkHttp mechanism that can observe, modify, and even short-circuit requests and responses. It's the ideal place to add an Authorization header to every outgoing request.

Step 1: Create the Interceptor Class

Create a class that implements okhttp3.Interceptor. Its job is to grab the original request, add the auth header, and then let the request proceed.

KOTLIN
import okhttp3.Interceptor
import okhttp3.Response

// Assume you have a class to manage the session token
// object SessionManager { var authToken: String? = "your_initial_token" }

class AuthInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        // 1. Get the original request from the chain.
        val originalRequest = chain.request()

        // 2. Retrieve the auth token. In a real app, this would come from
        //    SharedPreferences, a database, or a session manager.
        val authToken = "YOUR_BEARER_TOKEN_HERE" // Replace with actual token retrieval logic

        // 3. If a token exists, add the Authorization header.
        val newRequest = if (authToken != null) {
            originalRequest.newBuilder()
                .header("Authorization", "Bearer $authToken")
                .build()
        } else {
            originalRequest
        }
        
        // 4. Let the modified request proceed and return the response.
        return chain.proceed(newRequest)
    }
}

Step 2: Add the Interceptor to OkHttpClient

Modify your Retrofit client setup to use an OkHttpClient instance that includes your custom interceptor.

KOTLIN
// In your RetrofitClient object
object RetrofitClient {
    // ...
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(AuthInterceptor()) // Add your custom auth interceptor
        .addInterceptor(loggingInterceptor) // The logging interceptor is still useful
        .build()
        
    val instance: ApiService by lazy {
        val retrofit = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient) // Use the custom client
            .addConverterFactory(...)
            .build()
            
        retrofit.create(ApiService::class.java)
    }
}

Now, every API call made through this Retrofit instance will automatically have the Authorization header added by the AuthInterceptor, keeping your API service interface clean and centralizing your authentication logic.

5.3 Handling Token Refresh (Advanced Concept)

For OAuth 2.0, access tokens expire. A robust app needs to handle this by refreshing the token. OkHttp provides a specialized Authenticator for this.

  • An Authenticator is like an interceptor that runs only when the server responds with a 401 Unauthorized status code.
  • Its job is to:
    1. Make a synchronous API call to the token refresh endpoint.
    2. If successful, get the new access token and save it.
    3. Create a new request identical to the failed one, but with the new token in the Authorization header.
    4. Return this new request. OkHttp will then automatically re-try it.
  • This is a more advanced pattern but is the standard for building production-quality apps with token-based authentication.