RizTech Academy logo
RizTech Academy
NetworkingLesson 1 of 635 min

Retrofit and typed API calls

Mandi Bhav has been honest about its data since module 3: realistic, not real, and fetched from a one-second delay. Module 8 put a database behind the screens and left one class to replace, DemoPriceNetworkDataSource, and one promise to keep: whatever key a prices server needs stays on a server. This lesson keeps it. It decides where the prices come from, writes the JSON the server sends, describes that server to Retrofit as a Kotlin interface, and runs the first real download on the emulator, through four mistakes that each took one character or one line to make.

Where mandi prices actually come from

Real APMC prices exist. The Government of India's open data platform, data.gov.in, publishes daily commodity prices from Agmarknet, the agricultural marketing network: state, district, market, commodity, and the minimum, maximum and modal price in rupees per quintal. Its APIs ask you to register for a key.

That key is the problem module 8 described. Put it in the APK and anyone who installs the app can read it with apkanalyzer in a minute, then use your quota or get your key blocked. So a real Mandi Bhav would not call data.gov.in. It would call a small backend of its own, a proxy, which:

  • holds the key, and is the only thing that ever sends it;
  • fetches the day's prices once and serves every phone from that one copy, so ten thousand farmers do not mean ten thousand calls on your quota;
  • turns the source's format into the app's: picks the markets the app covers, converts dates, fixes units, drops what the app does not use;
  • decides the API's shape, so when the source changes, you change the proxy, not every phone in Nashik district.

Building that server is a backend course. This course needs something the app can download today without a key, and something every reader can run. So Mandi Bhav's API is the last bullet without the first three: static JSON files, committed to the Mandi Bhav repository, served by GitHub. The prices are sample data in Agmarknet's shape, realistic and not real, and the files say so.

File What it holds
api/v1/prices.json The newest day's prices at four markets, with each market's district
api/v1/history.json Modal prices for the seven days before that
api/v1/days/2026-11-07.json, …-08.json Each day's file, kept, for trying a new day in lesson 6
api/v1/images/… Pictures of each commodity, in lesson 5

The base URL is https://raw.githubusercontent.com/RizTech-Academy/mandi-bhav/main/api/v1/. The v1 is deliberate: an app on a farmer's phone may not be updated for a year, so the server must keep answering it. Adding a field to version 1 is allowed; renaming or removing one means a v2/ folder, with v1/ left where it is. api/README.md in the repository writes those rules down.

What the static API costs: no server code, but no server logic either. It cannot filter, page or answer "prices near me", and the files change only when someone pushes. GitHub also caches them for five minutes (Cache-Control: max-age=300), so a push is not instant.

One price in prices.json:

{
  "id": "lasalgaon-onion-2026-11-08",
  "commodity": "Onion",
  "market": "Lasalgaon APMC",
  "date": "2026-11-08",
  "min_price": 1250,
  "max_price": 2950,
  "modal_price": 2580,
  "thumbnail": "images/thumb/onion.jpg",
  "photo": "images/photo/onion.jpg"
}

Choosing versions

On 8 November 2026, Maven Central's metadata gave:

Library Coordinates Version
Retrofit com.squareup.retrofit2:retrofit 3.0.0
Its JSON converter com.squareup.retrofit2:converter-kotlinx-serialization 3.0.0
kotlinx.serialization JSON org.jetbrains.kotlinx:kotlinx-serialization-json 1.12.0, the same as the -core module 6 added
OkHttp com.squareup.okhttp3:okhttp-bom 5.5.1

All four built first time with AGP 9.5.1 and Kotlin 2.4.21. Retrofit describes your API and turns calls into requests; OkHttp is the HTTP client underneath that actually opens connections. Retrofit 3.0.0's own metadata asks for OkHttp 4.12.0. That matters because lesson 4's tests use OkHttp's MockWebServer, which exists only for 5.5.1, and Gradle resolves each classpath separately. Without a pin, a scratch build showed:

debugRuntimeClasspath:         com.squareup.okhttp3:okhttp:4.12.0
debugUnitTestRuntimeClasspath: com.squareup.okhttp3:okhttp:4.12.0 -> 5.5.1

The tests would pass against a different OkHttp from the one in the APK. A BOM, bill of materials, sets one version for a family of artifacts, so the app and its tests agree. In gradle/libs.versions.toml:

[versions]
retrofit = "3.0.0"
okhttp = "5.5.1"

[libraries]
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor" }

and in app/build.gradle.kts, with no version on the OkHttp lines because the BOM supplies it:

implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit)
implementation(libs.retrofit.converter.kotlinx.serialization)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
debugImplementation(libs.okhttp.logging.interceptor)

Module 2 said the app would need the network permission here. It goes in AndroidManifest.xml, above <application>:

<!-- Downloads the prices and the commodity pictures. -->
<uses-permission android:name="android.permission.INTERNET" />

The JSON, as Kotlin

The server's names are not the app's. modal_price is a JSON habit; modalPrice is Kotlin's. So the network layer gets its own classes, in data/network/NetworkPrices.kt, named after what the server sends:

@Serializable
data class NetworkPrice(
    val id: String,
    val commodity: String,
    val market: String,
    val date: String,
    @SerialName("min_price") val minPrice: Long,
    @SerialName("max_price") val maxPrice: Long,
    @SerialName("modal_price") val modalPrice: Long,
    /** Paths relative to the API's base URL; optional, so an older server still parses. */
    val thumbnail: String? = null,
    val photo: String? = null,
)

with NetworkPrices for the whole file (schema_version, date, markets, prices) and a mapping function, NetworkPrice.asModel(baseUrl), that parses the date with LocalDate.parse and returns a CommodityPrice. It is module 8's entity pattern again: a third class that looks like the other two. It is what keeps a renamed field on the server a one-file change. The date stays a String in the JSON class and becomes a LocalDate in the mapping, where a bad date fails in one place that lesson 4 can catch.

The API, as an interface

data/network/PricesApi.kt:

interface PricesApi {

    /** Today's prices at every market. */
    @GET("prices.json")
    suspend fun latestPrices(): NetworkPrices

    /** Modal prices for the days before today. Downloaded on first launch, or after a gap. */
    @GET("history.json")
    suspend fun history(): NetworkHistory
}

That is the whole description. Retrofit writes the class that sends a GET to the base URL plus prices.json, reads the body, and hands it to the converter, which builds a NetworkPrices. A suspend function returns the parsed body, or throws.

Annotation or type Means
@GET("path") A GET to the base URL plus path; also @POST, @PUT, @DELETE
@Path("id"), @Query("market") A value in the path, or ?market=…
@Body An object sent as the request's JSON
@Header, @Headers Request headers
suspend fun …: T Returns the body as T; throws on an HTTP error or a failure
suspend fun …: Response<T> Returns status, headers and body; does not throw on an HTTP error
fun …: Call<T> The old style: a call you execute() or enqueue() yourself

Building it with Hilt

Module 7's last Hilt practice asked you to write the module that provides Retrofit, and said it would not compile until now. di/NetworkModule.kt:

@Provides
fun provideBaseUrl(): HttpUrl = BuildConfig.PRICES_API_BASE_URL.toHttpUrl()

@Provides
@Singleton
fun provideJson(): Json = Json { ignoreUnknownKeys = true }

@Provides
@Singleton
fun provideRetrofit(baseUrl: HttpUrl, client: OkHttpClient, json: Json): Retrofit =
    Retrofit.Builder()
        .baseUrl(baseUrl)
        .client(client)
        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
        .build()

@Provides
@Singleton
fun providePricesApi(retrofit: Retrofit): PricesApi = retrofit.create(PricesApi::class.java)

plus a @Singleton OkHttpClient, which lesson 4 gives its timeouts. Singletons because a client owns a connection pool and threads: one per app, shared, is the point. Lesson 3 changes one line of provideRetrofit, for a reason StrictMode found.

data/network/RetrofitPriceNetworkDataSource.kt implements module 8's interface, which now returns the markets as well as the prices:

override suspend fun fetchLatestPrices(): LatestPrices = api.latestPrices().asLatestPrices(baseUrl)

DataModule binds it where DemoPriceNetworkDataSource was, and the demo class is deleted. So is SeedDatabase, and the .addCallback(SeedDatabase) line in DatabaseModule: a new install now starts empty, and lesson 4 gives that first launch a real download.

Three traps, each one character or one line

A base URL without its last slash. A scratch test:

java.lang.IllegalArgumentException: baseUrl must end in /: https://raw.githubusercontent.com/RizTech-Academy/mandi-bhav/main/api/v1

Retrofit refuses at once, because v1 without a slash is a file, and relative paths would replace it.

A leading slash in the path. @GET("/prices.json") compiles, and against a MockWebServer standing in for GitHub, with the same base URL path, the request went to:

retrofit2.HttpException: HTTP 404 Client Error
asked for /prices.json

A leading slash means "from the host's root", so the whole /RizTech-Academy/mandi-bhav/main/api/v1/ disappeared. On GitHub that is a 404 for a file that exists. Base URL ends in /; paths never start with one.

A strict JSON parser. By default kotlinx.serialization refuses any key the class does not declare. A scratch build with Json { } instead of ignoreUnknownKeys = true, and the server's file with one new field, arrivals_tonnes:

E/AndroidRuntime(24310): FATAL EXCEPTION: main
E/AndroidRuntime(24310): kotlinx.serialization.json.JsonDecodingException: Unexpected JSON token at offset 52: Encountered an unknown key 'published_at' at path: $
E/AndroidRuntime(24310): Use 'ignoreUnknownKeys = true' in 'Json {}' builder or '@JsonIgnoreUnknownKeys' annotation to ignore unknown keys.

It did not even reach the new field. published_at, unit and source are in today's file and the app does not model them. With a strict parser, every field the server ever adds breaks every installed copy of the app; ignoreUnknownKeys is what makes "adding a field is allowed in v1" true.

Your own computer as the server

Until the files are pushed, the raw URLs return 404, and while you develop you want to change a file and see it at once. The emulator reaches the computer it runs on at 10.0.2.2. From the Mandi Bhav folder:

python3 -m http.server 8765

Debug builds read their base URL from local.properties, which is not committed, and release builds ignore it. In app/build.gradle.kts:

val releasePricesApiUrl = "https://raw.githubusercontent.com/RizTech-Academy/mandi-bhav/main/api/v1/"
val localProperties = Properties().apply {
    rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
}
val debugPricesApiUrl = localProperties.getProperty("mandibhav.apiBaseUrl") ?: releasePricesApiUrl

with buildConfigField("String", "PRICES_API_BASE_URL", "\"$releasePricesApiUrl\"") in defaultConfig, the same with debugPricesApiUrl in buildTypes { debug { … } }, buildConfig = true, and one line in local.properties:

mandibhav.apiBaseUrl=http://10.0.2.2:8765/api/v1/

The local server speaks plain HTTP, and Android refuses cleartext for apps targeting API 28 and later. A scratch build asking for http://localhost:8765/…:

D/M9 (25243): cleartext: java.net.UnknownServiceException: CLEARTEXT communication to localhost not permitted by network security policy

So debug builds, and only debug builds, get an exception for one address. app/src/debug/res/xml/network_security_config.xml:

<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
    </domain-config>
</network-security-config>

and app/src/debug/AndroidManifest.xml, which the build merges into the debug manifest only:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>

After assembleRelease, the release merged manifest has no networkSecurityConfig at all, and its BuildConfig has the https://raw.githubusercontent.com/… URL. Never set usesCleartextTraffic="true" in the main manifest to make a local server work: it ships.

The first download

DebugNetworkModule, in src/debug, adds OkHttp's HttpLoggingInterceptor at BASIC. Clear the app's storage, open it:

I/OkHttp  (21864): --> GET http://10.0.2.2:8765/api/v1/prices.json
I/OkHttp  (21864): <-- 200 OK http://10.0.2.2:8765/api/v1/prices.json (7089ms, 3535-byte body)
I/OkHttp  (21864): --> GET http://10.0.2.2:8765/api/v1/history.json
I/OkHttp  (21864): <-- 200 OK http://10.0.2.2:8765/api/v1/history.json (7014ms, 8676-byte body)

The 7 seconds are deliberate: the course's test server was told to wait, to see what a slow first launch looks like (lesson 4). Without the wait, the same request took 30 ms. Nine prices, 3,535 bytes, and the Prices tab showed "Onion at Nashik APMC, 2,560 rupees per quintal" from a server for the first time. BASIC logs one line each way; BODY would print every byte, pictures included, and belongs nowhere near a release build.

Check your work

Why the app does not call data.gov.in. Its API needs a key, and a key in an APK is public; a backend proxy would hold it.

What the OkHttp BOM fixed. The app would have shipped OkHttp 4.12.0 while its tests ran 5.5.1.

Why separate JSON classes. The server's names and types stay in the network layer; a server change is a one-file change.

What a leading slash did. Replaced the base URL's path: the request went to /prices.json and got a 404.

Why ignoreUnknownKeys = true. Without it any new field crashes every installed app; today's file already has three the app ignores.

How debug reaches a server on your computer. 10.0.2.2, a local.properties base URL, and cleartext allowed for that address only, in the debug source set.

Practice 4. The first request fails with UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted by network security policy, which is an IOException, so with module 8's catch still in the ViewModel, the screen says "No connection" while the computer is a metre away. A misleading message from a correct catch, which is lesson 4's subject.

Practice

  1. Add the libraries with the BOM, the permission, the JSON classes, PricesApi, NetworkModule, the Retrofit data source and the debug files. Delete DemoPriceNetworkDataSource and SeedDatabase. Build, then run ./gradlew :app:dependencies --configuration debugRuntimeClasspath and find OkHttp.
  2. Serve the repository with python3 -m http.server 8765, set local.properties, clear the app's storage and watch the first download in logcat with the tag OkHttp.
  3. Break it on purpose, one at a time: remove the base URL's last slash; add a leading slash to @GET; build Json { } without ignoreUnknownKeys. Predict each failure, then read it.
  4. Delete app/src/debug/AndroidManifest.xml, rebuild, and refresh. Predict what the screen says.
  5. Run assembleRelease and read the release merged manifest and BuildConfig. Confirm neither the local URL nor the cleartext exception reached it.
  6. Harder: add an endpoint @GET("markets/{name}.json") suspend fun market(@Path("name") name: String): NetworkMarket and decide what Retrofit does with "Pune (Gultekdi) APMC" in a path. Find out with MockWebServer and takeRequest(), then decide whether a market's name should ever be a URL path.

Official documentation

Next: coroutines on Android, where each part of a refresh actually runs, and what happens to it when the user leaves.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship