GET, POST, PUT, PATCH, DELETE (plus HEAD, OPTIONS)GET https://www.omdbapi.com/?apikey=YOURKEY&s=Matrix| HTTP method | Collection resource, e.g. https://api.example.com/movies/ |
Member resource, e.g. https://api.example.com/movies/tt0133093 |
|---|---|---|
| GET | List the members of the collection | Retrieve a representation of the member |
| POST | Create a new member; the server assigns the URI and returns it in Location |
Rarely used; creates a sub-resource |
| PUT | Replace the whole collection | Replace the member, or create it if it does not exist |
| PATCH | Update parts of the collection | Update parts of the member |
| DELETE | Delete the whole collection | Delete the member |
Source: Wikipedia: REST
200 OK, 201 Created, 204 No Content)301 Moved Permanently, 304 Not Modified)400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found,
429 Too Many Requests)500 Internal Server Error, 503 Service Unavailable)200 OK and put the error into the JSON body. OMDb does exactly that for
"Movie not found!"application/jsonobject ({ "key": value }), array ([ ... ]), string, number, boolean, nullRequest: https://www.omdbapi.com/?apikey=YOURKEY&s=Matrix
{
"Search": [
{
"Title": "The Matrix",
"Year": "1999",
"imdbID": "tt0133093",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BN2NmN2VhMTQtMDNiOS00NDlhLTliMjgtODE2ZTY0ODQyNDRhXkEyXkFqcGc@._V1_SX300.jpg"
},
{
"Title": "The Matrix Reloaded",
"Year": "2003",
"imdbID": "tt0234215",
"Type": "movie",
"Poster": "N/A"
}
],
"totalResults": "2",
"Response": "True"
}
totalResults is a string, not a number"Poster" can be the string "N/A" instead of a URLRequest: https://www.omdbapi.com/?apikey=YOURKEY&s=xyzxyzxyz
{
"Response": "False",
"Error": "Movie not found!"
}
200 OK, the Search array is simply missingval search: List<Movie>? (nullable), otherwise the app
crashes with a NullPointerException on the first empty result401 Unauthorized and
{ "Response": "False", "Error": "Invalid API key!" }"N/A")minSdk 26)suspend functions are supported out of the box: no callbacks, no threading code, main-safe@GET, @POST, @Path, @Query, @Body, @HeadertoJson() and fromJson() convert between objects and JSON text@SerializedName("Title") when they differList<Movie>) and nested objectsnull. Gson does not know Kotlin's null-safety, so declare optional
fields as nullable types (List<Movie>?) or you get a NullPointerException laterretrofit2.converter.gson.GsonConverterFactoryAlternatives: kotlinx.serialization (Kotlin compiler plugin, null-safe, multiplatform) and Moshi (Square, Kotlin code generation). Both are not allowed in the project, Gson is.
gradle/libs.versions.toml:
[versions]
retrofit = "3.0.0"
gson = "2.14.0"
[libraries]
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
app/build.gradle.kts:
dependencies {
implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson)
implementation(libs.gson)
}
AndroidManifest.xml, outside of <application>:
<!-- Needed to call the OMDb API and to load the poster images -->
<uses-permission android:name="android.permission.INTERNET" />
Without it every request fails with SocketException: Permission denied. It is a normal permission,
granted at install time, no runtime dialog.
// model/Movie.kt: one item of the "Search" array, and at the same time the Room entity
@Entity(indices = [Index(value = ["imdbId"], unique = true)])
data class Movie(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
@SerializedName("imdbID") val imdbId: String,
@SerializedName("Title") val title: String,
@SerializedName("Year") val year: String,
@SerializedName("Poster") val poster: String
)
// network/SearchResponse.kt: the whole response object
data class SearchResponse(
// Missing in the JSON when there is no result, therefore nullable
@SerializedName("Search") val search: List<Movie>?,
@SerializedName("totalResults") val totalResults: String?,
@SerializedName("Response") val response: String,
@SerializedName("Error") val error: String?
)
@SerializedName maps the capitalised JSON keys to Kotlin naming conventions"Type"), and the id is not in the JSON, so it keeps its default 0// network/MovieApi.kt
/**
* Retrofit turns this interface into an HTTP client. Example request:
* https://www.omdbapi.com/?s=Matrix&apikey=YOURKEY
*/
interface MovieApi {
@GET("/")
suspend fun searchMovies(
@Query("s") searchString: String,
// The key comes from local.properties, see app/build.gradle.kts
@Query("apikey") apiKey: String = BuildConfig.OMDB_API_KEY
): SearchResponse
companion object {
const val BASE_URL = "https://www.omdbapi.com/"
}
}
@GET("/"): the path relative to the base URL; OMDb has a single endpoint@Query("s") appends ?s=Matrix (URL-encoded), several @Query parameters are joined with &suspend makes Retrofit run the request on its own
background thread and resume the caller with the result401, 404, 500) is thrown as retrofit2.HttpException, a network
problem as java.io.IOException// MovieTrackerApplication.kt
interface AppContainer {
val movieRepository: MovieRepository
val movieApi: MovieApi
}
class AppDataContainer(private val context: Context) : AppContainer {
override val movieRepository: MovieRepository by lazy {
OfflineMovieRepository(MovieDatabase.getDatabase(context).movieDao())
}
override val movieApi: MovieApi by lazy {
Retrofit.Builder()
.baseUrl(MovieApi.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(MovieApi::class.java)
}
}
Retrofit instance is expensive (OkHttp client, thread pool): create it once per
app, in the AppContainer, like the databasecreate(MovieApi::class.java) returns the generated implementation of the interfaceAppViewModelProvider.Factory passes container.movieApi to the SearchViewModel// ui/search/SearchViewModel.kt
class SearchViewModel(
private val movieRepository: MovieRepository,
private val movieApi: MovieApi
) : ViewModel() {
var searchUiState by mutableStateOf(SearchUiState())
private set
fun searchMovies(searchString: String) {
viewModelScope.launch {
searchUiState = SearchUiState(isLoading = true)
searchUiState = try {
// Network call: Retrofit runs it on a background thread, we only suspend here
val response = movieApi.searchMovies(searchString)
SearchUiState(movieList = response.search ?: emptyList(), errorMessage = response.error)
} catch (e: IOException) {
SearchUiState(errorMessage = e.message ?: "Network error") // offline, timeout
} catch (e: HttpException) {
SearchUiState(errorMessage = "HTTP ${e.code()} ${e.message()}") // e.g. 401 wrong key
}
}
}
}
data class SearchUiState(
val movieList: List<Movie> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null
)
The SearchScreen shows a LinearProgressIndicator while isLoading and the errorMessage as red text.
An API key in the source code ends up in GitLab, in every clone and in the APK. Read it from a file that Git ignores and let the build generate a constant:
// app/build.gradle.kts
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
val omdbApiKey: String = localProperties.getProperty("OMDB_API_KEY") ?: ""
android {
defaultConfig {
// Exposes the key to the app as BuildConfig.OMDB_API_KEY
buildConfigField("String", "OMDB_API_KEY", "\"$omdbApiKey\"")
}
buildFeatures {
buildConfig = true
}
}
local.properties in the project root: OMDB_API_KEY=your_key_here. It is in .gitignore
(/local.properties) because it also holds the local SDK pathBuildConfig.OMDB_API_KEY, see MovieApicurl first. Compare it with the request your app sendscom.squareup.okhttp3:logging-interceptor (same version as Retrofit's OkHttp, 4.12.0). Only in
debug builds: level BODY logs the API keyval client = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY })
.build()
Retrofit.Builder()
.baseUrl(MovieApi.BASE_URL)
.client(client) // only in debug builds
.addConverterFactory(GsonConverterFactory.create())
.build()
401 missing or invalid API key (also when the free key's daily limit is
reached), 404 wrong base URL or path, 429 is what most other APIs send when you call them too oftenhttp:// is blocked by default since API 28 (CLEARTEXT communication not permitted): use https://Response: "False" comes with status 200: check response.error, not only the status codeLazyColumn, where items scroll in and out while their posters are still loadinggradle/libs.versions.toml:
[versions]
coil = "3.5.0"
[libraries]
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
app/build.gradle.kts:
dependencies {
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) // Coil needs a network client to load URLs
}
coil-network-okhttp
(or coil-network-ktor3) every URL fails silently and only the error painter is showncoil-network-okhttp reuses OkHttp, which Retrofit already brings into the appcoil3.compose.AsyncImage. Coil 2 used coil.compose.AsyncImage, old tutorials still show that// ui/composables/MovieItem.kt
@Composable
fun MovieItem(movie: Movie, onMovieClick: (Movie) -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth().testTag("movieItem").clickable { onMovieClick(movie) }) {
Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
// Coil loads the poster in the background and caches it. OMDb returns "N/A" when there
// is no poster, the error painter is shown in that case.
AsyncImage(
model = movie.poster,
contentDescription = null,
contentScale = ContentScale.Crop,
error = painterResource(R.drawable.ic_movie_placeholder),
modifier = Modifier
.width(80.dp)
.height(120.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(text = movie.title, style = MaterialTheme.typography.titleMedium)
Text(text = movie.year, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider()
}
}
model accepts a URL string, a Uri, a File, a drawable resource id or an ImageRequest// painters: placeholder while loading, error when the URL fails ("N/A", 404, offline)
AsyncImage(
model = movie.poster,
contentDescription = null,
placeholder = painterResource(R.drawable.ic_movie_placeholder),
error = painterResource(R.drawable.ic_movie_placeholder),
onError = { state -> Log.w("Coil", "No poster for ${movie.title}", state.result.throwable) },
modifier = Modifier.width(80.dp).height(120.dp)
)
// composables instead of painters, e.g. a progress indicator while loading
SubcomposeAsyncImage(
model = movie.poster,
contentDescription = null,
loading = { CircularProgressIndicator() },
error = { Image(painterResource(R.drawable.ic_movie_placeholder), contentDescription = null) },
modifier = Modifier.width(80.dp).height(120.dp)
)
onLoading, onSuccess, onError callbacks or a single onState with the AsyncImagePainter.StateSubcomposeAsyncImage is more flexible but slower (subcomposition); prefer painters in long listsfallback painter: shown when model itself is nullImageLoader singleton. Opt out per request with
ImageRequest.Builder(context).data(url).memoryCachePolicy(CachePolicy.DISABLED).build(),
configure globally by implementing SingletonImageLoader.Factory in the ApplicationAssignment 5: Introduction to API calls and image loading
What you need from this lecture for it:
local.properties (OMDB_API_KEY=...), the build turns it into
BuildConfig.OMDB_API_KEY. Never commit itMovieApi.searchMovies(): the two @Query parameters s and apikey, the key as default valueSearchViewModel.searchMovies(): viewModelScope.launch, isLoading before the call,
try/catch for IOException and HttpException, response.search ?: emptyList() and response.errorMovieItem: AsyncImage(model = movie.poster, error = painterResource(...)) so that "N/A" posters
show the placeholder; coil-network-okhttp and the INTERNET permission are already in the startercurl before writing code, including the error casesRetrofit instance once in the AppContainer, call suspend API functions from
viewModelScope and catch IOException and HttpExceptionlocal.properties and reach the code as BuildConfig constants, never in Githttps://coil-compose + coil-network-okhttp) loads, caches and cancels images; error and
placeholder painters handle missing postersSearchResponse.search be nullable? What happens if it is not?@GET, @Query and the suspend modifier do in the MovieApi interface?Retrofit instance created in the AppContainer and not in the ViewModel?coil-network-okhttp, and how do you handle "Poster": "N/A"?