Skip to content

Commit

Permalink
Issue #7 - Move http and serialization to common
Browse files Browse the repository at this point in the history
Move to coroutines.
Create a fake `app-ios-lib` module as a workaround for JetBrains/kotlin-native#2423.
Update `app-ios` project due to changed framework name.
Add Ktor-client.
Add tests.
Use server version "2.0".
Bump self version to "1.0.0".
  • Loading branch information
4ntoine committed Jan 12, 2020
1 parent 34eaf38 commit 5d7fd95
Show file tree
Hide file tree
Showing 50 changed files with 530 additions and 218 deletions.
9 changes: 5 additions & 4 deletions app-android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ android {
}
packagingOptions {
exclude 'META-INF/*.kotlin_module'
exclude 'META-INF/DEPENDENCIES'
}
lintOptions {
checkReleaseBuilds false
Expand All @@ -33,15 +34,15 @@ android {

dependencies {
api project(':app-mvp')
implementation project(':app-infra-rest-retrofit')

implementation 'com.squareup.retrofit2:retrofit:2.6.0'
implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
implementation "io.ktor:ktor-client-android:$rootProject.ktor_version"
implementation project(':app-infra-rest-ktor')
// implementation project(':app-infra-rest-retrofit') // can be used instead of "-ktor" ^

implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation 'com.android.support:recyclerview-v7:28.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines_version"

androidTestImplementation "androidx.test:runner:$test_version"
androidTestImplementation "androidx.test:rules:$test_version"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import android.widget.Toast
import name.antonsmirnov.notes.app.android.R
import name.antonsmirnov.notes.app.controller.rest.AddNoteController
import name.antonsmirnov.notes.app.controller.rest.RestApi
import name.antonsmirnov.notes.app.controller.rest.restApiInstance
import name.antonsmirnov.notes.presenter.Note
import name.antonsmirnov.notes.presenter.addnote.AddNoteModel
import name.antonsmirnov.notes.presenter.addnote.AddNotePresenter
import name.antonsmirnov.notes.presenter.addnote.AddNotePresenterImpl
import name.antonsmirnov.notes.presenter.addnote.AddNoteView
import name.antonsmirnov.notes.presenter.thread.BackgroundThreadManager

class AddNoteActivity : AppCompatActivity(), AddNoteView {

Expand Down Expand Up @@ -54,8 +54,8 @@ class AddNoteActivity : AppCompatActivity(), AddNoteView {
if (lastCustomNonConfigurationInstance != null) {
presenter = lastCustomNonConfigurationInstance as AddNotePresenter
} else {
val model = AddNoteModel(AddNoteController(RestApi.instance), Note("", null))
presenter = AddNotePresenterImpl(model, BackgroundThreadManager())
val model = AddNoteModel(AddNoteController(restApiInstance), Note("", null))
presenter = AddNotePresenterImpl(model)
}
presenter?.attachView(this)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import name.antonsmirnov.notes.app.android.R
import name.antonsmirnov.notes.app.android.adapter.ListNotesAdapter
import name.antonsmirnov.notes.app.controller.rest.ListNotesController
import name.antonsmirnov.notes.app.controller.rest.RestApi
import name.antonsmirnov.notes.app.controller.rest.restApiInstance
import name.antonsmirnov.notes.presenter.listnotes.ListNotesModel
import name.antonsmirnov.notes.presenter.listnotes.ListNotesPresenter
import name.antonsmirnov.notes.presenter.listnotes.ListNotesPresenterImpl
import name.antonsmirnov.notes.presenter.listnotes.ListNotesView
import name.antonsmirnov.notes.presenter.thread.BackgroundThreadManager
import android.view.View as AndroidView

class ListNotesActivity : AppCompatActivity(), ListNotesView {
Expand Down Expand Up @@ -45,8 +45,8 @@ class ListNotesActivity : AppCompatActivity(), ListNotesView {
if (lastCustomNonConfigurationInstance != null) {
presenter = lastCustomNonConfigurationInstance as ListNotesPresenter
} else {
val model = ListNotesModel(ListNotesController(RestApi.instance))
presenter = ListNotesPresenterImpl(model, BackgroundThreadManager())
val model = ListNotesModel(ListNotesController(restApiInstance))
presenter = ListNotesPresenterImpl(model)
}
presenter?.attachView(this)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.AppCompatButton
import android.support.v7.widget.AppCompatEditText
import android.widget.Toast
import com.google.gson.GsonBuilder
import name.antonsmirnov.notes.app.android.R
import name.antonsmirnov.notes.app.controller.rest.RestApi
import name.antonsmirnov.notes.app.controller.rest.buildRestApiImpl
import name.antonsmirnov.notes.app.controller.rest.restApiInstance
import name.antonsmirnov.notes.presenter.serverurl.ServerUrlModel
import name.antonsmirnov.notes.presenter.serverurl.ServerUrlPresenter
import name.antonsmirnov.notes.presenter.serverurl.ServerUrlPresenterImpl
import name.antonsmirnov.notes.presenter.serverurl.ServerUrlView
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class ServerUrlActivity : AppCompatActivity(), ServerUrlView {

Expand Down Expand Up @@ -63,13 +62,7 @@ class ServerUrlActivity : AppCompatActivity(), ServerUrlView {
}

private fun initRestApi(host: String, port: UInt) {
val gson = GsonBuilder().setLenient().create()
val retrofit = Retrofit.Builder()
.baseUrl("http://$host:$port")
.addConverterFactory(GsonConverterFactory.create(gson))
.build()

RestApi.instance = retrofit.create(RestApi::class.java)
restApiInstance = buildRestApiImpl("http", host, port)
}

private fun bindControls() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package name.antonsmirnov.notes.app.controller.rest

lateinit var restApiInstance: RestApi
67 changes: 67 additions & 0 deletions app-infra-rest-ktor/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
plugins {
id 'idea'
id 'kotlin-multiplatform'
id 'kotlinx-serialization'
}

kotlin {
targets {
jvm()
iosX64() // iOS simulator
}

sourceSets {
commonMain {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib'
api "io.ktor:ktor-client-core:$rootProject.ktor_version"
api "io.ktor:ktor-client-serialization:$rootProject.ktor_version"
api "name.antonsmirnov.notes:app-api-metadata:$rootProject.server_module_version"
// 'app-api' ^ has 'kotlinx-coroutines-core-common' and 'kotlinx-coroutines-core' so no need to import here
}
}
jvmMain {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
// some ktor-client engine impl dependency is requires in end module,
// (see https://ktor.io/clients/http-client/engines.html)
implementation "io.ktor:ktor-client-apache:$rootProject.ktor_version"
api "io.ktor:ktor-client-serialization-jvm:$rootProject.ktor_version"
api "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$rootProject.serialization_version"
api "name.antonsmirnov.notes:app-api-jvm:$rootProject.server_module_version"
}
}
jvmTest {
dependencies {
implementation 'org.jetbrains.kotlin:kotlin-test'
implementation 'org.jetbrains.kotlin:kotlin-test-junit'
implementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version"
implementation 'com.github.tomakehurst:wiremock-jre8:2.25.1'
implementation 'org.slf4j:slf4j-simple:1.7.30' // for wiremock
}
}
iosX64Main {
dependencies {
implementation "io.ktor:ktor-client-ios:$rootProject.ktor_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-native:$rootProject.serialization_version"
implementation "io.ktor:ktor-client-serialization-native:$rootProject.ktor_version"
api "name.antonsmirnov.notes:app-api-iosx64:$rootProject.server_module_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core-native:$rootProject.coroutines_version"
}
}
}

// All exceptions in Kotlin are not checked, but in Swift they are checked.
// So we need @Throws annotation for iOS compatibility to generate swift signatures with `.. throws -> ..`
// This requires @ExperimentalMultiplatform annotation in all methods with @Throws.
// In order to prevent adding @ExperimentalMultiplatform every here and there we can use compiler option:
targets.all {
compilations.all {
kotlinOptions {
freeCompilerArgs += '-Xuse-experimental=kotlin.ExperimentalMultiplatform'
}
}
}
}

version = "$rootProject.module_version"
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package name.antonsmirnov.notes.app.controller.rest

import name.antonsmirnov.notes.usecase.AddNote
import name.antonsmirnov.notes.usecase.NoteJson

class AddNoteController(
val api: RestApi
) : AddNote {

override suspend fun execute(request: AddNote.Request): AddNote.Response {
val response = api.addNote(NoteJson(null, request.title, request.body))
return AddNote.Response(response.id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package name.antonsmirnov.notes.app.controller.rest

import name.antonsmirnov.notes.usecase.ListNotes

class ListNotesController(
val api: RestApi
) : ListNotes {

override suspend fun execute(): ListNotes.Response {
val response = api.listNotes()
return ListNotes.Response(response.notes.map {
ListNotes.Note(it.id, it.title, it.body)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package name.antonsmirnov.notes.app.controller.rest

import io.ktor.client.HttpClient
import io.ktor.client.features.json.JsonFeature
import io.ktor.client.features.json.serializer.KotlinxSerializer
import io.ktor.client.request.get
import io.ktor.client.request.url
import name.antonsmirnov.notes.usecase.AddResponseJson
import name.antonsmirnov.notes.usecase.ListResponseJson
import name.antonsmirnov.notes.usecase.NoteJson

class RestApi(val baseUrl: String) {
private val client = HttpClient {
install(JsonFeature) {
serializer = KotlinxSerializer()
}
}

private inline fun apiPath(relativePath: String) = "/api$relativePath"

suspend fun listNotes(): ListResponseJson = client.get {
url {
url(baseUrl)
encodedPath = apiPath("/list")
}
}

suspend fun addNote(note: NoteJson): AddResponseJson = client.get {
url {
url(baseUrl)
encodedPath = apiPath("/add")
parameters["title"] = note.title
note.body?.let { parameters["body"] = it }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package name.antonsmirnov.notes.app.controller.rest

fun buildRestApiImpl(protocol: String, host: String, port: UInt) : RestApi {
return RestApi("$protocol://$host:$port")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package name.antonsmirnov.notes.usecase

import kotlinx.serialization.Serializable

/**
* Add response model
*/
@Serializable
data class AddResponseJson(val id: String)

@Serializable
data class NoteJson(
val id: String?,
val title: String,
val body: String?)

/**
* List response model
*/
@Serializable
data class ListResponseJson(val notes: Array<NoteJson>) // using of `List` requires custom de-/serializer on K/N
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package name.antonsmirnov.notes.app.controller.rest

import com.github.tomakehurst.wiremock.client.WireMock.*
import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig
import com.github.tomakehurst.wiremock.junit.WireMockRule
import kotlinx.coroutines.runBlocking
import name.antonsmirnov.notes.usecase.NoteJson
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.*
import kotlin.math.absoluteValue

class RestApiTest {
private val random = Random()

@Rule
@JvmField
val wireMockRule = WireMockRule(wireMockConfig().dynamicPort())

private lateinit var api: RestApi

@Before
fun setUp() {
api = RestApi(wireMockRule.baseUrl())
}

private fun generateString() = random.nextLong().absoluteValue.toString()

@Test
fun testAddNote() {
val expectedId = generateString()
val expectedNote = NoteJson(null, generateString(), generateString())
wireMockRule.stubFor(get(urlMatching("/api/add.*"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""{ "id":"$expectedId" }""")))

runBlocking {
val response = api.addNote(expectedNote)
println(response)
assertEquals(expectedId, response.id)
}

verify(getRequestedFor(urlEqualTo("/api/add?title=${expectedNote.title}&body=${expectedNote.body}")))
}

@Test
fun testListNotes() {
val expectedNote = NoteJson(null, generateString(), generateString())
wireMockRule.stubFor(get(urlEqualTo("/api/list"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(
"""
|{ "notes": [
| {
| "id":"${expectedNote.id}",
| "title":"${expectedNote.title}",
| "body":"${expectedNote.body}"
| }
|]}""".trimMargin())))

runBlocking {
val response = api.listNotes()
println(response)
assertNotNull(response.notes)
assertTrue(response.notes.isNotEmpty())
val actualNote = response.notes.find {
it.title == expectedNote.title && it.body == expectedNote.body
}
assertNotNull(actualNote)
assertNotNull(actualNote!!.id)
}
}
}
4 changes: 2 additions & 2 deletions app-infra-rest-retrofit/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ targetCompatibility = 1.8
dependencies {
api "name.antonsmirnov.notes:app-api-jvm:$rootProject.server_module_version"

implementation 'com.squareup.retrofit2:retrofit:2.6.0'
implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
api 'com.squareup.retrofit2:retrofit:2.6.0'
api 'com.squareup.retrofit2:converter-gson:2.6.0'

implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class AddNoteController(
val api: RestApi
) : AddNote {

override fun execute(request: AddNote.Request): AddNote.Response {
override suspend fun execute(request: AddNote.Request): AddNote.Response {
val result = api.addNote(request.title, request.body).execute()
return if (result.isSuccessful)
result.body()!!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class ListNotesController(
val api: RestApi
) : ListNotes {

override fun execute(): ListNotes.Response {
override suspend fun execute(): ListNotes.Response {
val result = api.listNotes().execute()
return if (result.isSuccessful)
result.body()!!
Expand Down
Loading

0 comments on commit 5d7fd95

Please sign in to comment.