REST services
Changes for REST
Be sure to watch the videos on the previous page before looking at these code changes.
Here you can see the changes made from our movie example so far to include the REST support. I've added comments to some sections to explain things that might not be obvious.
A few notes:
RestServer module
I added a restserver
module. Normally this wouldn't be included with your application code,
but it's here to keep things together and simple. This module uses Jersey to create a simple
REST server.
MovieRestRepository
I added a MovieRestRepository
that uses MovieApiService
(using Retrofit as a REST client).
Note that we need a coroutine scope here. We want to use the viewModelScope so the coroutines
stay alive as long as the view model, but we end up with a chicken-and-egg problem:
- We can't create the view model without a repository
- We can't create the repository without the viewModelScope
There are a few ways to handle this. One approach would be to allow the view model to set the coroutine scope in the passed-in repository. That would keep things pretty much the same as they are with delegation and passing-in of the repository.
An alternate approach is to pass in a factory to create the view model instance. I chose to
demonstrate this approach as it ensures the repository must have a scope,
and has the side effect that you must delegate from the view model to the repository explicitly
(rather than using MovieRepository by repository
) as we did before.
See the comments in the MovieViewModel
for more details.
Code Changes
CHANGED: /app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">//<!-- android:launchMode="singleInstance" --><!-- requires internet permission to talk with server --> <uses-permission android:name="android.permission.INTERNET" /> <!-- we're cheating here and using "usesClearTextTraffic" instead of setting up SSL. I didn't want to get you bogged down in SSL details here --> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:usesCleartextTraffic="true" android:theme="@style/Theme.MovieUi1" tools:targetApi="31"> <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleInstance" android:theme="@style/Theme.MovieUi1"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> <intent-filter> <action android:name="android.intent.action.VIEW" /> <data android:scheme="https" /> <data android:scheme="http" /> <category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.DEFAULT" /> </intent-filter> </activity> <receiver android:name=".glance.MovieAppWidgetReceiver" android:exported="true"> <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> </intent-filter> <meta-data android:name="android.appwidget.provider" android:resource="@xml/movie_app_widget_info" /> </receiver> </application> </manifest>
CHANGED: /app/src/main/java/com/androidbyexample/movie/MovieViewModel.kt
package com.androidbyexample.movie import android.app.Application import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.glance.appwidget.updateAll import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import com.androidbyexample.movie.glance.MovieAppWidget import com.androidbyexample.movie.repository.ActorDto//import com.androidbyexample.movie.repository.MovieDatabaseRepositoryimport com.androidbyexample.movie.repository.MovieDto import com.androidbyexample.movie.repository.MovieRepository import com.androidbyexample.movie.repository.MovieRestRepositoryimport com.androidbyexample.movie.repository.RatingDto import com.androidbyexample.movie.screens.MovieList import com.androidbyexample.movie.screens.Screen import kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch @OptIn(FlowPreview::class) class MovieViewModel( application: Application,// private val repository: MovieRepository,//): AndroidViewModel(application), MovieRepository by repository {// The rest repository needs a coroutine scope // We want to use the viewModelScope, but that's not // available until the view model has been created // The view model couldn't be created without a repository // To fix this, instead of passing a repository instance, // we pass a factory function to create one, so we can // pass our viewModelScope into it repositoryFactory: (CoroutineScope) -> MovieRepository): AndroidViewModel(application) { // Here we create the repository instance by passing the viewModelScope // This creates a problem - because we cannot pass the repository // as a parameter to the view model, we cannot implement the // MovieRepository interface and delegate to it using "by" // This means we need to explicitly define any functions we // need to delegate to the repository. private val repository = repositoryFactory(viewModelScope) private val _selectedIdsFlow = MutableStateFlow<Set<String>>(emptySet()) val selectedIdsFlow: Flow<Set<String>> = _selectedIdsFlow.asStateFlow() fun clearSelectedIds() { _selectedIdsFlow.value = emptySet() } fun toggleSelection(id: String) { if (id in _selectedIdsFlow.value) { _selectedIdsFlow.value -= id } else { _selectedIdsFlow.value += id } } private var screenStack = listOf<Screen>(MovieList) set(value) { field = value clearSelectedIds() currentScreen = value.lastOrNull() } // NOTE: We're keep this as a Compose State for comparison. // You can use Compose state to expose anything from the view model, // but our example will be using Flow from now on to demonstrate how // the view model can be used without Compose, perhaps for other // platforms such as iOS, desktop, web or command line var currentScreen by mutableStateOf<Screen?>(MovieList) private set fun pushScreen(screen: Screen) { screenStack = screenStack + screen } fun popScreen() { screenStack = screenStack.dropLast(1) } fun setScreen(screen: Screen) { screenStack = listOf(screen) } fun setScreens(vararg screens: Screen) { screenStack = screens.toList() } // explicitly delegate the flows to the repository val moviesFlow = repository.moviesFlow val actorsFlow = repository.actorsFlow val ratingsFlow = repository.ratingsFlow fun update(movie: MovieDto) { movieUpdateFlow.value = movie } fun update(actor: ActorDto) { actorUpdateFlow.value = actor } fun update(rating: RatingDto) { ratingUpdateFlow.value = rating } // using a debounced flow as a person-update queue private val movieUpdateFlow = MutableStateFlow<MovieDto?>(null) private val actorUpdateFlow = MutableStateFlow<ActorDto?>(null) private val ratingUpdateFlow = MutableStateFlow<RatingDto?>(null) init { viewModelScope.launch { repository.moviesFlow.collectLatest { MovieAppWidget().updateAll(getApplication()) } } viewModelScope.launch { movieUpdateFlow.debounce(500).collect { movie -> movie?.let { repository.upsert(it) } } } viewModelScope.launch { actorUpdateFlow.debounce(500).collect { actor -> actor?.let { repository.upsert(it) } } } viewModelScope.launch { ratingUpdateFlow.debounce(500).collect { rating -> rating?.let { repository.upsert(it) } } } } // explicitly delegate these functions to the repository suspend fun getMovieWithCast(id: String) = repository.getMovieWithCast(id) suspend fun getMovie(id: String) = repository.getMovie(id) suspend fun getActorWithFilmography(id: String) = repository.getActorWithFilmography(id) fun getRatingWithMoviesFlow(id: String) = repository.getRatingWithMoviesFlow(id) suspend fun deleteActorsById(ids: Set<String>) { repository.deleteActorsById(ids) } suspend fun deleteMoviesById(ids: Set<String>) { repository.deleteMoviesById(ids) } suspend fun deleteRatingsById(ids: Set<String>) { repository.deleteRatingsById(ids) } suspend fun resetDatabase() { repository.resetDatabase() } fun deleteSelectedMovies() { viewModelScope.launch { deleteMoviesById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedActors() { viewModelScope.launch { deleteActorsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } fun deleteSelectedRatings() { viewModelScope.launch { deleteRatingsById(_selectedIdsFlow.value) _selectedIdsFlow.value = emptySet() } } companion object { val Factory: ViewModelProvider.Factory = object: ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create( modelClass: Class<T>, extras: CreationExtras ): T { // Get the Application object from extras val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY])// return MovieViewModel(// application,// MovieDatabaseRepository.create(application)// ) as Treturn MovieViewModel(application) { scope -> MovieRestRepository(scope) } as T } } } }
CHANGED: /build.gradle.kts
// Top-level build file where you can add configuration options common to all sub-projects/modules. @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.kotlinAndroid) apply false alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.org.jetbrains.kotlin.jvm) apply false} true // Needed to make the Suppress annotation work for the plugins block
CHANGED: /gradle/libs.versions.toml
[versions] agp = "8.2.0-beta06" kotlin = "1.9.10" core-ktx = "1.12.0" junit = "4.13.2" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" lifecycle-runtime-ktx = "2.6.2" activity-compose = "1.8.0" lifecycle-compose = "2.6.2" compose-bom = "2023.10.00" appcompat = "1.6.1" material = "1.10.0" room = "2.5.2" ksp = "1.9.10-1.0.13" icons-extended = "1.6.0-alpha07" glance = "1.0.0" org-jetbrains-kotlin-jvm = "1.9.10"jersey="3.0.2"activation="2.0.1"retrofit = "2.9.0" [libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } material3 = { group = "androidx.compose.material3", name = "material3" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } icons-extended = { group = "androidx.compose.material", name = "material-icons-extended-android", version.ref = "icons-extended"} glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } jersey-grizzly2 = { group = "org.glassfish.jersey.containers", name = "jersey-container-grizzly2-http", version.ref = "jersey" }jersey-jetty = { group = "org.glassfish.jersey.containers", name = "jersey-container-jetty-http", version.ref = "jersey" }jersey-servlet = { group = "org.glassfish.jersey.containers", name = "jersey-container-servlet-core", version.ref = "jersey" }jersey-jackson = { group = "org.glassfish.jersey.media", name = "jersey-media-json-jackson", version.ref = "jersey" }jersey-server = { group = "org.glassfish.jersey.core", name = "jersey-server", version.ref = "jersey" }jersey-hk2 = { group = "org.glassfish.jersey.inject", name = "jersey-hk2", version.ref = "jersey" }activation = { group = "jakarta.activation", name = "jakarta.activation-api", version.ref = "activation" }retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }retrofit-json = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }## Bundles allow you to create common groupings of dependencies[bundles]retrofit = [ "retrofit", "retrofit-json" ]server = [ "jersey-grizzly2", "jersey-jetty", "jersey-servlet", "jersey-jackson", "jersey-server", "jersey-hk2", "activation"][plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } androidLibrary = { id = "com.android.library", version.ref = "agp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } org-jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "org-jetbrains-kotlin-jvm" }
CHANGED: /repository/build.gradle.kts
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) } android { namespace = "com.androidbyexample.movie.repository" compileSdk = 34 defaultConfig { minSdk = 24 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" } } dependencies { implementation(project(":data")) // example of using a version catalog bundle to pull in multiple deps implementation(libs.bundles.retrofit) implementation(libs.core.ktx) implementation(libs.appcompat) implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) }
ADDED: /repository/src/main/java/com/androidbyexample/movie/repository/MovieApiService.kt
package com.androidbyexample.movie.repositoryimport retrofit2.Responseimport retrofit2.Retrofitimport retrofit2.converter.gson.GsonConverterFactoryimport retrofit2.http.Bodyimport retrofit2.http.DELETEimport retrofit2.http.GETimport retrofit2.http.POSTimport retrofit2.http.PUTimport retrofit2.http.Pathconst val BASE_URL = "http://10.0.2.2:8080" // host computer for emulator// NOTE: In a real app, you might allow the user to set this via settingsinterface MovieApiService { @GET("movie") suspend fun getMovies(): Response<List<MovieDto>> @GET("actor") suspend fun getActors(): Response<List<ActorDto>> @GET("rating") suspend fun getRatings(): Response<List<RatingDto>> @GET("rating/{id}/movies") suspend fun getRatingWithMovies(@Path("id") id: String): Response<RatingWithMoviesDto?> @GET("movie/{id}/cast") suspend fun getMovieWithCast(@Path("id") id: String): Response<MovieWithCastDto?> @GET("actor/{id}/filmography") suspend fun getActorWithFilmography(@Path("id") id: String): Response<ActorWithFilmographyDto?> @GET("movie/{id}") suspend fun getMovie(@Path("id") id: String): Response<MovieDto?> @POST("movie") suspend fun createMovie(@Body movie: MovieDto): Response<MovieDto> @POST("actor") suspend fun createActor(@Body actor: ActorDto): Response<ActorDto> @POST("rating") suspend fun createRating(@Body rating: RatingDto): Response<RatingDto> @PUT("movie/{id}") suspend fun updateMovie( @Path("id") id: String, @Body movie: MovieDto ): Response<Int> // number updated @PUT("actor/{id}") suspend fun updateActor( @Path("id") id: String, @Body actor: ActorDto ): Response<Int> // number updated @PUT("rating/{id}") suspend fun updateRating( @Path("id") id: String, @Body rating: RatingDto ): Response<Int> // number updated @DELETE("rating/{id}") suspend fun deleteRating( @Path("id") id: String, ): Response<Int> // number deleted @DELETE("movie/{id}") suspend fun deleteMovie( @Path("id") id: String, ): Response<Int> // number deleted @DELETE("actor/{id}") suspend fun deleteActor( @Path("id") id: String, ): Response<Int> // number deleted @GET("reset") suspend fun reset(): Response<Int> companion object { fun create() = Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create()) .baseUrl(BASE_URL) .build() .create(MovieApiService::class.java) }}
CHANGED: /repository/src/main/java/com/androidbyexample/movie/repository/MovieRepository.kt
package com.androidbyexample.movie.repository import kotlinx.coroutines.flow.Flow interface MovieRepository { val ratingsFlow: Flow<List<RatingDto>> val moviesFlow: Flow<List<MovieDto>> val actorsFlow: Flow<List<ActorDto>>// fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto>fun getRatingWithMoviesFlow(id: String): Flow<RatingWithMoviesDto?> suspend fun getMovie(id: String): MovieDto suspend fun getRatingWithMovies(id: String): RatingWithMoviesDto suspend fun getMovieWithCast(id: String): MovieWithCastDto suspend fun getActorWithFilmography(id: String): ActorWithFilmographyDto suspend fun insert(movie: MovieDto) suspend fun insert(actor: ActorDto) suspend fun insert(rating: RatingDto) suspend fun upsert(movie: MovieDto) suspend fun upsert(actor: ActorDto) suspend fun upsert(rating: RatingDto) suspend fun deleteMoviesById(ids: Set<String>) suspend fun deleteActorsById(ids: Set<String>) suspend fun deleteRatingsById(ids: Set<String>) suspend fun resetDatabase() }
ADDED: /repository/src/main/java/com/androidbyexample/movie/repository/MovieRestRepository.kt
package com.androidbyexample.movie.repositoryimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.launchimport kotlinx.coroutines.withContextimport retrofit2.Responseclass MovieRestRepository( val coroutineScope: CoroutineScope,): MovieRepository { // I abstracted this a bit more from the ListFlowManager in the video // It now works for lists and single items inner class FlowManager<T>( private val defaultValue: T, private val fetcher: suspend () -> Response<T> ) { private val _flow = MutableStateFlow(defaultValue) init { fetch() } val flow: Flow<T> get() = _flow fun fetch() = coroutineScope.launch(Dispatchers.IO) { _flow.value = fetcher().takeIf { it.isSuccessful }?.body() ?: defaultValue } } private val movieApiService = MovieApiService.create() private val moviesFlowManager = FlowManager(emptyList()) { movieApiService.getMovies() } private val actorsFlowManager = FlowManager(emptyList()) { movieApiService.getActors() } private val ratingsFlowManager = FlowManager(emptyList()) { movieApiService.getRatings() } override val ratingsFlow: Flow<List<RatingDto>> = ratingsFlowManager.flow override val moviesFlow: Flow<List<MovieDto>> = moviesFlowManager.flow override val actorsFlow: Flow<List<ActorDto>> = actorsFlowManager.flow // NOTE: The following assume that only one of each is active at a time // For our app, that should be the case (the edit screens don't allow // any deeper navigation), but if you wanted to be more general, you // could set up a WeakHashMap where the key is the flow and the value // is the manager (which also has a weak reference to the Flow). // Weak references allow the object they point to to be garbage collected // when there are no remaining strong references to it, and they'll be removed // from the map when no longer referenced. You would then walk through all the // values in the map and call their fetch() functions. private var ratingWithMoviesFlowManager: FlowManager<RatingWithMoviesDto?>? = null // not implementing the ActorWithFilmographyDto and MovieWithCastDto versions as we // don't use them in the app right now override fun getRatingWithMoviesFlow(id: String) = FlowManager(null) { movieApiService.getRatingWithMovies(id) }.let { ratingWithMoviesFlowManager = it it.flow } private suspend fun <T> getOrError( id: String, fetch: suspend MovieApiService.(String) -> Response<T?> ): T = withContext(Dispatchers.IO) { movieApiService.fetch(id).takeIf { it.isSuccessful }?.body() ?: throw RuntimeException("$id not found") } override suspend fun getRatingWithMovies(id: String) = getOrError(id) { getRatingWithMovies(it) } override suspend fun getMovieWithCast(id: String) = getOrError(id) { getMovieWithCast(it) } override suspend fun getActorWithFilmography(id: String) = getOrError(id) { getActorWithFilmography(it) } override suspend fun getMovie(id: String) = getOrError(id) { getMovie(it) } override suspend fun insert(movie: MovieDto) { movieApiService.createMovie(movie) moviesFlowManager.fetch() ratingWithMoviesFlowManager?.fetch() } override suspend fun insert(actor: ActorDto) { movieApiService.createActor(actor) actorsFlowManager.fetch() } override suspend fun insert(rating: RatingDto) { movieApiService.createRating(rating) ratingsFlowManager.fetch() } override suspend fun upsert(movie: MovieDto) { movieApiService.updateMovie(movie.id, movie) moviesFlowManager.fetch() ratingWithMoviesFlowManager?.fetch() } override suspend fun upsert(actor: ActorDto) { movieApiService.updateActor(actor.id, actor) actorsFlowManager.fetch() } override suspend fun upsert(rating: RatingDto) { movieApiService.updateRating(rating.id, rating) ratingsFlowManager.fetch() } private suspend fun deleteById( ids: Set<String>, delete: suspend MovieApiService.(String) -> Unit, vararg flowManagersToFetch: FlowManager<*>?, ) { ids.forEach { id -> movieApiService.delete(id) } flowManagersToFetch.forEach { it?.fetch() } } override suspend fun deleteMoviesById(ids: Set<String>) { deleteById(ids, { deleteMovie(it) }, moviesFlowManager, ratingWithMoviesFlowManager) } override suspend fun deleteActorsById(ids: Set<String>) { deleteById(ids, { deleteActor(it) }, actorsFlowManager) } override suspend fun deleteRatingsById(ids: Set<String>) { deleteById(ids, { deleteRating(it) }, ratingsFlowManager, moviesFlowManager) } override suspend fun resetDatabase() { movieApiService.reset() actorsFlowManager.fetch() moviesFlowManager.fetch() ratingsFlowManager.fetch() }}
ADDED: /restserver/build.gradle.kts
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixedplugins { application alias(libs.plugins.org.jetbrains.kotlin.jvm)}kotlin { jvmToolchain(17)}dependencies { // example of using a version catalog bundle to pull in multiple deps implementation(libs.bundles.server)}application { mainClass.set("com.androidbyexample.movie.restserver.RunServerKt")}
ADDED: /restserver/src/main/java/com/androidbyexample/movie/restserver/Actor.kt
package com.androidbyexample.movie.restserverimport com.fasterxml.jackson.annotation.JsonPropertyimport java.util.UUIDimport javax.xml.bind.annotation.XmlRootElement@XmlRootElementdata class Actor( @JsonProperty("id") var id: String = UUID.randomUUID().toString(), @JsonProperty("name") var name: String)@XmlRootElementdata class ActorWithFilmography( @JsonProperty("actor") val actor: Actor, @JsonProperty("filmography") val filmography: List<RoleWithMovie>,)@XmlRootElementdata class RoleWithMovie( @JsonProperty("movie") val movie: Movie, @JsonProperty("character") val character: String, @JsonProperty("orderInCredits") val orderInCredits: Int,)
ADDED: /restserver/src/main/java/com/androidbyexample/movie/restserver/Movie.kt
package com.androidbyexample.movie.restserverimport com.fasterxml.jackson.annotation.JsonPropertyimport java.util.UUIDimport javax.xml.bind.annotation.XmlRootElement@XmlRootElementdata class Movie( @JsonProperty("id") val id: String = UUID.randomUUID().toString(), @JsonProperty("title") val title: String, @JsonProperty("description") val description: String, @JsonProperty("ratingId") val ratingId: String,)@XmlRootElementdata class MovieWithCast( @JsonProperty("movie") val movie: Movie, @JsonProperty("cast") val cast: List<RoleWithActor>,)@XmlRootElementdata class RoleWithActor( @JsonProperty("actor") val actor: Actor, @JsonProperty("character") val character: String, @JsonProperty("orderInCredits") val orderInCredits: Int,)
ADDED: /restserver/src/main/java/com/androidbyexample/movie/restserver/Rating.kt
package com.androidbyexample.movie.restserverimport com.fasterxml.jackson.annotation.JsonPropertyimport java.util.UUIDimport javax.xml.bind.annotation.XmlRootElement@XmlRootElementdata class Rating( @JsonProperty("id") val id: String = UUID.randomUUID().toString(), @JsonProperty("name") val name: String, @JsonProperty("description") val description: String)@XmlRootElementdata class RatingWithMovies( @JsonProperty("rating") var rating: Rating, @JsonProperty("movies") var movies: List<Movie>,)
ADDED: /restserver/src/main/java/com/androidbyexample/movie/restserver/RestController.kt
package com.androidbyexample.movie.restserverimport jakarta.ws.rs.Consumesimport jakarta.ws.rs.DELETEimport jakarta.ws.rs.GETimport jakarta.ws.rs.POSTimport jakarta.ws.rs.PUTimport jakarta.ws.rs.Pathimport jakarta.ws.rs.PathParamimport jakarta.ws.rs.Producesimport jakarta.ws.rs.core.Contextimport jakarta.ws.rs.core.MediaTypeimport jakarta.ws.rs.core.Responseimport jakarta.ws.rs.core.UriInfo// NOTE: If Room were cross platform I would have directly used// our data module to store things in a database in this// server. Unfortunately, it's Android-only, so I'm// implementing an in-memory set of maps to track the// data. Kinda gross, but developing a real server isn't// important for the class. The important thing is how we// can communicate with the serverprivate fun <T> response(status: Response.Status, entity: T) = Response.status(status).entity(entity).build()private fun <T> ok(entity: T) = response(Response.Status.OK, entity)private fun <T> notFound(entity: T) = response(Response.Status.NOT_FOUND, entity)private fun <T> created(entity: T) = response(Response.Status.CREATED, entity)private val moviesByIdIndex = mutableMapOf<String, Movie>()private val actorsByIdIndex = mutableMapOf<String, Actor>()private val ratingsByIdIndex = mutableMapOf<String, Rating>()private val rolesByMovieIdIndex = mutableMapOf<String, MutableList<Role>>()private val rolesByActorIdIndex = mutableMapOf<String, MutableList<Role>>()private val moviesByRatingIdIndex = mutableMapOf<String, MutableList<Movie>>()private val notFoundRating = Rating("-", "NOT FOUND", "NOT FOUND")private val notFoundMovie = Movie("-", "NOT FOUND", "NOT FOUND", "--")private val notFoundMovieWithRoles = MovieWithCast(notFoundMovie, emptyList())private val notFoundActor = Actor("-", "NOT FOUND")private val notFoundActorWithRoles = ActorWithFilmography(notFoundActor, emptyList())private val notFoundRatingWithMovies = RatingWithMovies(notFoundRating, emptyList())@Path("/")class RestController { @GET @Path("rating") @Produces(MediaType.APPLICATION_JSON) fun getRatings(): Response = ok(ratingsByIdIndex.values.sortedBy { it.id }) @GET @Path("movie") @Produces(MediaType.APPLICATION_JSON) fun getMovies(): Response = ok(moviesByIdIndex.values.sortedBy { it.title }) @GET @Path("actor") @Produces(MediaType.APPLICATION_JSON) fun getActors(): Response = ok(actorsByIdIndex.values.sortedBy { it.name }) @GET @Path("rating/{id}/movies") @Produces(MediaType.APPLICATION_JSON) fun getRatingWithMovies(@PathParam("id") id: String): RatingWithMovies = ratingsByIdIndex[id]?.let { rating -> val movies = moviesByRatingIdIndex[id] ?: emptyList() RatingWithMovies(rating, movies) } ?: notFoundRatingWithMovies @GET @Path("movie/{id}/cast") @Produces(MediaType.APPLICATION_JSON) fun getMovieWithRoles(@PathParam("id") id: String): MovieWithCast = moviesByIdIndex[id]?.let { movie -> val roles = rolesByMovieIdIndex[id] ?.map { role -> RoleWithActor( actor = actorsByIdIndex[role.actorId] ?: throw IllegalStateException(), character = role.character, orderInCredits = role.orderInCredits, ) } ?: emptyList() MovieWithCast(movie, roles) } ?: notFoundMovieWithRoles @GET @Path("actor/{id}/filmography") @Produces(MediaType.APPLICATION_JSON) fun getActorWithRoles(@PathParam("id") id: String): ActorWithFilmography = actorsByIdIndex[id]?.let { actor -> val roles = rolesByActorIdIndex[id] ?.map { role -> RoleWithMovie( movie = moviesByIdIndex[role.movieId] ?: throw IllegalStateException(), character = role.character, orderInCredits = role.orderInCredits, ) } ?: emptyList() ActorWithFilmography(actor, roles) } ?: notFoundActorWithRoles @PUT @Path("movie/{id}") @Produces(MediaType.TEXT_PLAIN) fun updateMovie(@PathParam("id") id: String, movie: Movie): Response { moviesByIdIndex[id] = movie moviesByRatingIdIndex.getOrPut(movie.ratingId) { mutableListOf() }.add(movie) return ok(1) } @PUT @Path("actor/{id}") @Produces(MediaType.TEXT_PLAIN) fun updateActor(@PathParam("id") id: String, actor: Actor): Response { actorsByIdIndex[id] = actor return ok(1) } @PUT @Path("rating/{id}") @Produces(MediaType.TEXT_PLAIN) fun updateRating(@PathParam("id") id: String, rating: Rating): Response { ratingsByIdIndex[id] = rating return ok(1) } private operator fun MutableMap<String, MutableMap<String, Role>>.set(id1: String, id2: String, role: Role) { val roles = this[id1] ?: mutableMapOf<String, Role>().apply { this@set[id1] = this } roles[id2] = role } private operator fun MutableMap<String, MutableMap<String, Role>>.get(id1: String, id2: String) = this[id1]?.get(id2) @POST @Path("rating/{id}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.TEXT_PLAIN) fun createRating(@Context uriInfo: UriInfo, rating: Rating): Response { ratingsByIdIndex[rating.id] = rating return created(rating) } @POST @Path("movie/{id}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.TEXT_PLAIN) fun createMovie(@Context uriInfo: UriInfo, movie: Movie): Response { moviesByIdIndex[movie.id] = movie return created(movie) } @POST @Path("actor/{id}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.TEXT_PLAIN) fun createActor(@Context uriInfo: UriInfo, actor: Actor): Response { actorsByIdIndex[actor.id] = actor return created(actor) } @DELETE @Path("movie/{id}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.TEXT_PLAIN) fun deleteMovie(@PathParam("id") id: String): Response = if (moviesByIdIndex[id] == null) { notFound(0) } else { moviesByIdIndex.remove(id) rolesByMovieIdIndex.remove(id) // remove all roles rolesByActorIdIndex.values.forEach { roles -> roles.removeIf { it.movieId == id } } ok(1) } @DELETE @Path("actor/{id}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.TEXT_PLAIN) fun deleteActor(@PathParam("id") id: String): Response = if (actorsByIdIndex[id] == null) { notFound(0) } else { actorsByIdIndex.remove(id) rolesByActorIdIndex.remove(id) // remove all roles rolesByMovieIdIndex.values.forEach { roles -> // remove filmography for those movies roles.removeIf { it.actorId == id } } ok(1) } @DELETE @Path("rating/{id}") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.TEXT_PLAIN) fun deleteRating(@PathParam("id") id: String): Response = if (ratingsByIdIndex[id] == null) { notFound(0) } else { ratingsByIdIndex.remove(id) // delete associated movies moviesByRatingIdIndex[id]?.forEach { moviesByIdIndex.remove(it.id) } moviesByRatingIdIndex.remove(id) ok(1) } private fun insertMovies(vararg newMovies: Movie) { newMovies.forEach { movie -> moviesByIdIndex[movie.id] = movie moviesByRatingIdIndex.getOrCreate(movie.ratingId).add(movie) } } private fun insertActors(vararg newActors: Actor) { newActors.forEach { actor -> actorsByIdIndex[actor.id] = actor } } private fun insertRatings(vararg newRatings: Rating) { newRatings.forEach { rating -> ratingsByIdIndex[rating.id] = rating } } private fun insertRoles(vararg newRoles: Role) { newRoles.forEach { role -> rolesByActorIdIndex.getOrCreate(role.actorId).add(role) rolesByMovieIdIndex.getOrCreate(role.movieId).add(role) } } private fun <K, V> MutableMap<K, MutableList<V>>.getOrCreate(key: K) = this[key] ?: mutableListOf<V>().apply { this@getOrCreate[key] = this } @GET @Path("reset") @Produces(MediaType.TEXT_PLAIN) fun resetDatabase(): Response { ratingsByIdIndex.clear() moviesByIdIndex.clear() actorsByIdIndex.clear() rolesByActorIdIndex.clear() rolesByMovieIdIndex.clear() insertRatings( Rating(id = "r0", name = "Not Rated", description = "Not yet rated"), Rating(id = "r1", name = "G", description = "General Audiences"), Rating(id = "r2", name = "PG", description = "Parental Guidance Suggested"), Rating(id = "r3", name = "PG-13", description = "Unsuitable for those under 13"), Rating(id = "r4", name = "R", description = "Restricted - 17 and older"), ) insertMovies( Movie("m1", "The Transporter", "Jason Statham kicks a guy in the face", "r3"), Movie("m2", "Transporter 2", "Jason Statham kicks a bunch of guys in the face", "r4"), Movie("m3", "Hobbs and Shaw", "Cars, Explosions and Stuff", "r3"), Movie("m4", "Jumanji", "The Rock smolders", "r3"), ) insertActors( Actor("a1", "Jason Statham"), Actor("a2", "The Rock"), Actor("a3", "Shu Qi"), Actor("a4", "Amber Valletta"), Actor("a5", "Kevin Hart"), ) insertRoles( Role("m1", "a1", "Frank Martin", 1), Role("m1", "a3", "Lai", 2), Role("m2", "a1", "Frank Martin", 1), Role("m2", "a4", "Audrey Billings", 2), Role("m3", "a2", "Hobbs", 1), Role("m3", "a1", "Shaw", 2), Role("m4", "a2", "Spencer", 1), Role("m4", "a5", "Fridge", 2), ) return ok(1) }}
ADDED: /restserver/src/main/java/com/androidbyexample/movie/restserver/Role.kt
package com.androidbyexample.movie.restserverimport com.androidbyexample.movie.restserver.Actorimport com.androidbyexample.movie.restserver.Movieimport com.fasterxml.jackson.annotation.JsonPropertyimport javax.xml.bind.annotation.XmlRootElement@XmlRootElementclass Role( @JsonProperty("movieId") var movieId: String, @JsonProperty("actorId") var actorId: String, @JsonProperty("character") var character: String, @JsonProperty("orderInCredits") var orderInCredits: Int)@XmlRootElementclass ExpandedRole( @JsonProperty("movie") var movie: Movie, @JsonProperty("actor") var actor: Actor, @JsonProperty("character") var character: String, @JsonProperty("orderInCredits") var orderInCredits: Int)
ADDED: /restserver/src/main/java/com/androidbyexample/movie/restserver/RunServer.kt
package com.androidbyexample.movie.restserverimport org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactoryimport org.glassfish.jersey.server.ResourceConfigimport java.net.URIimport com.fasterxml.jackson.databind.ObjectMapperimport com.fasterxml.jackson.databind.SerializationFeatureimport jakarta.ws.rs.ext.ContextResolverimport jakarta.ws.rs.ext.Provider// from https://mkyong.com/webservices/jax-rs/json-example-with-jersey-jackson/// MIT License//// Copyright (c) 2020 Mkyong.com//// Permission is hereby granted, free of charge, to any person obtaining a copy// of this software and associated documentation files (the “Software”), to deal// in the Software without restriction, including without limitation the rights// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell// copies of the Software, and to permit persons to whom the Software is// furnished to do so, subject to the following conditions://// The above copyright notice and this permission notice shall be included in all// copies or substantial portions of the Software.//// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE// SOFTWARE.@Providerclass CustomJacksonMapperProvider : ContextResolver<ObjectMapper> { private val mapper = ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT) override fun getContext(type: Class<*>?) = mapper}fun main() { GrizzlyHttpServerFactory .createHttpServer( URI.create("http://localhost:8080"), ResourceConfig().apply { register(RestController::class.java) register(CustomJacksonMapperProvider::class.java) } ).start()}
CHANGED: /settings.gradle.kts
pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "MovieRest" include(":app") include(":data") include(":repository") include(":restserver")