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
- Add the libraries with the BOM, the permission, the JSON classes,
PricesApi,NetworkModule, the Retrofit data source and the debug files. DeleteDemoPriceNetworkDataSourceandSeedDatabase. Build, then run./gradlew :app:dependencies --configuration debugRuntimeClasspathand find OkHttp. - Serve the repository with
python3 -m http.server 8765, setlocal.properties, clear the app's storage and watch the first download in logcat with the tagOkHttp. - Break it on purpose, one at a time: remove the base URL's last slash; add a leading slash to
@GET; buildJson { }withoutignoreUnknownKeys. Predict each failure, then read it. - Delete
app/src/debug/AndroidManifest.xml, rebuild, and refresh. Predict what the screen says. - Run
assembleReleaseand read the release merged manifest andBuildConfig. Confirm neither the local URL nor the cleartext exception reached it. - Harder: add an endpoint
@GET("markets/{name}.json") suspend fun market(@Path("name") name: String): NetworkMarketand decide what Retrofit does with "Pune (Gultekdi) APMC" in a path. Find out with MockWebServer andtakeRequest(), then decide whether a market's name should ever be a URL path.
Official documentation
- Retrofit — The project's own introduction, with the annotations and converters.
- Retrofit: declarations — Every request annotation, with how paths join the base URL.
- Kotlin serialization —
@Serializable,@SerialName, and the JSON format's options. - Network security configuration — Cleartext, per-domain rules, and debug-only overrides.
- Set up Android Emulator networking — Why the computer is
10.0.2.2from the emulator.
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