There is a kotlin class with the following structure.
data class Person(
#field:Length(max = 5)
val name: String,
val phones: List<Phone>
)
data class Phone(
#field:Length(max = 10)
val number: String
)
When converting the json string through objectMapper, I want to receive all the violation values.
ex) JSON object is not valid. Reasons (3) name length must be 5, number length must be 10, ...
#Test
fun test() {
val json = """
{
"name": "name",
"phones": [
{ "number": "1234567890123456" },
{ "number": "1234567890123456" }
]
}
""".trimIndent()
try {
objectMapper.readValue(json, Person::class.java)
} catch (ex: ConstraintViolationException) {
val violations = ex.constraintViolations
println(violations.size) // expected size = 3
}
}
However, the above code fails to catch the exception and causes the exception below.
com.fasterxml.jackson.databind.JsonMappingException: JSON object is not valid. Reasons (1): {"bean":"Phone","property":"number","value":"1234567890123456","message": "..."}, (through reference chain: Person["phones"]->java.util.ArrayList[0])
Looking at the reason, those wrapped in a list do not throw ConstructionViolationException, but throw JsonMappingException.
below dependencies
plugins {
id("org.springframework.boot") version "2.4.3"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.4.30"
kotlin("plugin.spring") version "1.4.30"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
class BeanValidationDeserializer(base: BeanDeserializerBase?) : BeanDeserializer(base) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val validator = Validation.buildDefaultValidatorFactory().validator
override fun deserialize(parser: JsonParser?, ctxt: DeserializationContext?): Any {
val instance = super.deserialize(parser, ctxt)
validate(instance)
return instance
}
private fun validate(instance: Any) {
val violations = validator.validate(instance)
if (violations.isNotEmpty()) {
val message = StringBuilder()
message.append("JSON object is not valid. Reasons (").append(violations.size).append("): ")
for (violation in violations) {
message.append("{\"bean\":\"${violation.rootBeanClass.name}\",")
.append("\"property\":\"${violation.propertyPath}\",")
.append("\"value\":\"${violation.invalidValue}\",")
.append("\"message\": \"${violation.message}\"}")
.append(", ")
}
logger.warn(message.toString())
throw ConstraintViolationException(message.toString(), violations)
}
}
}
#Bean
fun objectMapper(): ObjectMapper = Jackson2ObjectMapperBuilder.json()
.featuresToDisable(MapperFeature.DEFAULT_VIEW_INCLUSION)
.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.modules(ParameterNamesModule(), JavaTimeModule(), Jdk8Module(), KotlinModule(), customValidationModule())
.build()
#Bean
fun customValidationModule(): SimpleModule {
val validationModule = SimpleModule()
validationModule.setDeserializerModifier(object : BeanDeserializerModifier() {
override fun modifyDeserializer(
config: DeserializationConfig?,
beanDesc: BeanDescription?,
deserializer: JsonDeserializer<*>?
): JsonDeserializer<*>? {
return if (deserializer is BeanDeserializer) {
BeanValidationDeserializer(deserializer as BeanDeserializer?)
} else deserializer
}
})
return validationModule
}
I'm not sure how to do it. I ask for your help.
I would say an easier and more maintainable way would be to define a JSON Schema.
After that is in place, you can use one of the two json validation libraries mentioned here (https://json-schema.org/implementations.html#validator-kotlin) to validate your json.
The answer by #rbs is good but requires the overhead of creating json schema per json you want to validate.
Seems like object mapper can be configured not to wrap the exceptions it throws with JsonMappingException. - https://github.com/FasterXML/jackson-databind/issues/2033
All you need to do is to disable WRAP_EXCEPTIONS feature for the objectmapper
Note you cant choose a specific exception type, it will not wrap ALL the exceptions.
Related
I have a custom implementation of a JsonDeserializer which implements a ContextualDeserializer in order to deserialize a generic class. Everything works fine until the generic class itself has a property which is also a generic class.
For demo purposes, I simplified the code to the minimum
My class is
data class MyObject<out Content>(
val content: Content
)
Deserializing MyObject<String> works:
{
"content": "Hello"
}
but deserializing MyObject<MyObject<String>>> does not:
{
"content": {
"content": "Hello"
}
}
The custom deserializer is the following. The issue seems to be that contextualType.containedType is returning null.
I believe this issue is in the line
val content = ctxt.readTreeAsValue(contentNode, contentType!!.rawClass)
because the .rawClass does not have any additional information about the generic type. So the content is deserialized as just MyObject instead of MyObject<String>.
class MyObjectDeserializer : JsonDeserializer<MyObject<*>?>(), ContextualDeserializer {
private var contentType: JavaType? = null
override fun createContextual(ctxt: DeserializationContext?, property: BeanProperty?): JsonDeserializer<*> {
contentType = if (property == null)
ctxt!!.contextualType.containedType(0)
else
property.type.containedType(0)
if (contentType != null) {
println("${ctxt!!.contextualType} has contained type $contentType")
} else {
// Here is where the issue occurs
println("${ctxt!!.contextualType} does not have any contained types")
}
return this
}
override fun deserialize(p: JsonParser?, ctxt: DeserializationContext): MyObject<*> {
val codec = p?.codec ?: throw NullPointerException()
val node = codec.readTree<JsonNode>(p)
val contentNode = node.get("content")
val content = ctxt.readTreeAsValue(contentNode, contentType!!.rawClass)
return MyObject(content)
}
}
and I register the deserialzers like so
fun ObjectMapper.registerMyDeserializers(): ObjectMapper {
val module = SimpleModule().also {
it.addDeserializer(MyObject::class.java, MyObjectDeserializer())
}
return this.registerModule(module)
}
The unit test the demonstrates the issue is the follow. The first test works fine, the second fails.
class ResponseParsingTests {
private val objectMapper = jacksonObjectMapper().registerMyDeserializers()
#Test
fun `parses String as content`() {
val json = """
{
"content": "Hello"
}
""".trimIndent()
val result = objectMapper.readValue<MyObject<String>>(json)
assertEquals("Hello", result.content)
}
#Test
fun `parses MyObject as content`() {
val json = """
{
"content": {
"content": "Hello"
}
}
""".trimIndent()
val result = objectMapper.readValue<MyObject<MyObject<String>>>(json)
assertEquals("Hello", result.content.content)
}
}
Please note that I'm aware the this example class does not require any custom deserialzer at all. However my real use case is a bit more complex and I need to use a custom deserializer because I'm publishing my code as part of a library which suppoorts multiple serialization frameworks (gson, jackson, kotlinx). So the serialization cannot be part of my actual class but rather in a separate one.
I have this dependencies:
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-jackson:$ktor_version")
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-json:$ktor_version")
And this settings of ktor server:
fun Application.configureHTTP() {
install(DefaultHeaders)
install(CallLogging)
install(AutoHeadResponse)
install(Routing)
install(ContentNegotiation) {
register(ContentType.Application.Json, JacksonConverter())
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
writerWithDefaultPrettyPrinter()
}
}
install(CORS) {
method(HttpMethod.Options)
method(HttpMethod.Put)
method(HttpMethod.Delete)
method(HttpMethod.Patch)
header(HttpHeaders.Authorization)
header("MyCustomHeader")
// allowCredentials = true
anyHost() // #TODO: Don't do this in production if possible. Try to limit it.
}
}
I'd like to get joke's text about Chuck Norris, so I made this data classes:
data class ChuckNorrisJoke(
val type: String,
val value: Map<Any, Any>
)
data class JokeContent(
val id: Long,
val joke: String,
val categories: List<String>
)
And eventually this is my function for getting joke:
val client = HttpClient(CIO) {
install(JsonFeature)
}
suspend fun getChuckNorrisJoke(): ChuckNorrisJoke {
return client
.get("http://api.icndb.com/jokes/random")
}
When I call the method, I get this error:
Exception in thread "main" java.lang.ExceptionInInitializerError
Caused by: java.lang.IllegalStateException: Fail to find serializer. Consider to add one of the following dependencies:
- ktor-client-gson
- ktor-client-json
- ktor-client-serialization
at io.ktor.client.features.json.DefaultJvmKt.defaultSerializer(DefaultJvm.kt:14)
at io.ktor.client.features.json.JsonFeature$Feature.prepare(JsonFeature.kt:130)
at io.ktor.client.features.json.JsonFeature$Feature.prepare(JsonFeature.kt:125)
at io.ktor.client.HttpClientConfig$install$3.invoke(HttpClientConfig.kt:77)
at io.ktor.client.HttpClientConfig$install$3.invoke(HttpClientConfig.kt:74)
at io.ktor.client.HttpClientConfig.install(HttpClientConfig.kt:97)
at io.ktor.client.HttpClient.<init>(HttpClient.kt:172)
at io.ktor.client.HttpClient.<init>(HttpClient.kt:81)
at io.ktor.client.HttpClientKt.HttpClient(HttpClient.kt:43)
at com.example.ApplicationKt.<clinit>(Application.kt:109)
Can't understand how to set HttpClient correctly.
The implementation("io.ktor:ktor-jackson:$ktor_version") dependency declaration is for the server. You need to declare one for the client: implementation "io.ktor:ktor-client-jackson:$ktor_version". You can find more information here.
i have custom jackson configuration in kotlin:
#Configuration
class JacksonConfiguration {
#Bean
fun objectMapper(): com.fasterxml.jackson.databind.ObjectMapper = jacksonObjectMapper()
.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
.apply {
registerModules(customJavaTimeModule(), KotlinModule())
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
}
private fun customJavaTimeModule() = JavaTimeModule().apply {
addSerializer(String::class.java, StringSerializerTypeHandler())
}
private class StringSerializerTypeHandler : JsonSerializer<String?>() {
#Throws(IOException::class, JsonProcessingException::class)
override fun serialize(value: String?, jgen: JsonGenerator?, provider: SerializerProvider?) {
var outputValue: String = value.toString()
if (!value.isNullOrEmpty()) {
// logic for changing the string - removing diacritics
jgen?.writeString(outputValue)
} else {
//provider?. // what here?
// jgen?.writeString(value)
// dont serialize the field at all - just skip it
}
}
}
What's important is that i do not want that field to be serialized at all when the field value is null or empty.
I can not get rid of else block - then I will have error:
Can not write a field name, expecting a value
I want to set this logic globally.
Solution 1:
I had to override this method:
override fun isEmpty(provider: SerializerProvider?, value: String?): Boolean {
return value.isNullOrEmpty()
}
Then i can get rid of else block.
Currently, the ktor client logging implementation is as below, and it works as intended but not what I wanted to have.
public class Logging(
public val logger: Logger,
public var level: LogLevel,
public var filters: List<(HttpRequestBuilder) -> Boolean> = emptyList()
)
....
private suspend fun logRequest(request: HttpRequestBuilder): OutgoingContent? {
if (level.info) {
logger.log("REQUEST: ${Url(request.url)}")
logger.log("METHOD: ${request.method}")
}
val content = request.body as OutgoingContent
if (level.headers) {
logger.log("COMMON HEADERS")
logHeaders(request.headers.entries())
logger.log("CONTENT HEADERS")
logHeaders(content.headers.entries())
}
return if (level.body) {
logRequestBody(content)
} else null
}
Above creates a nightmare while looking at the logs because it's logging in each line. Since I'm a beginner in Kotlin and Ktor, I'd love to know the way to change the behaviour of this. Since in Kotlin, all classes are final unless opened specifically, I don't know how to approach on modifying the logRequest function behaviour. What I ideally wanted to achieve is something like below for an example.
....
private suspend fun logRequest(request: HttpRequestBuilder): OutgoingContent? {
...
if (level.body) {
val content = request.body as OutgoingContent
return logger.log(value("url", Url(request.url)),
value("method", request.method),
value("body", content))
}
Any help would be appreciative
No way to actually override a private method in a non-open class, but if you just want your logging to work differently, you're better off with a custom interceptor of the same stage in the pipeline:
val client = HttpClient(CIO) {
install("RequestLogging") {
sendPipeline.intercept(HttpSendPipeline.Monitoring) {
logger.info(
"Request: {} {} {} {}",
context.method,
Url(context.url),
context.headers.entries(),
context.body
)
}
}
}
runBlocking {
client.get<String>("https://google.com")
}
This will produce the logging you want. Of course, to properly log POST you will need to do some extra work.
Maybe this will be useful for someone:
HttpClient() {
install("RequestLogging") {
responsePipeline.intercept(HttpResponsePipeline.After) {
val request = context.request
val response = context.response
kermit.d(tag = "Network") {
"${request.method} ${request.url} ${response.status}"
}
GlobalScope.launch(Dispatchers.Unconfined) {
val responseBody =
response.content.tryReadText(response.contentType()?.charset() ?: Charsets.UTF_8)
?: "[response body omitted]"
kermit.d(tag = "Network") {
"${request.method} ${request.url} ${response.status}\nBODY START" +
"\n$responseBody" +
"\nBODY END"
}
}
}
}
}
You also need to add a method from the Ktor Logger.kt class to your calss with HttpClient:
internal suspend inline fun ByteReadChannel.tryReadText(charset: Charset): String? = try {
readRemaining().readText(charset = charset)
} catch (cause: Throwable) {
null
}
I'm building a very simple application in Kotlin with Vertx and RxJava 2 (RxKotlin), using Kovert REST framework and Retrofit. I have retrofit-vertx adapter and the RxJava2 Retrofit adapter. I can return an arbitrary list from my listUndergroundStations() method, but whenever I try to load from the remote API I get the following error:
Jun 23, 2017 2:16:29 PM uk.amb85.rxweb.api.UndergroundRestController
SEVERE: HTTP CODE 500 - /api/underground/stations - java.io.IOException: java.lang.IllegalStateException: message == null
java.lang.RuntimeException: java.io.IOException: java.lang.IllegalStateException: message == null
at io.reactivex.internal.util.ExceptionHelper.wrapOrThrow(ExceptionHelper.java:45)
at io.reactivex.internal.observers.BlockingMultiObserver.blockingGet(BlockingMultiObserver.java:91)
at io.reactivex.Single.blockingGet(Single.java:2148)
at uk.amb85.rxweb.api.UndergroundRestController$listUndergroundStations$1.invoke(UndergroundRestController.kt:35)
at uk.amb85.rxweb.api.UndergroundRestController$listUndergroundStations$1.invoke(UndergroundRestController.kt:13)
at nl.komponents.kovenant.TaskPromise$wrapper$1.invoke(promises-jvm.kt:138)
at nl.komponents.kovenant.TaskPromise$wrapper$1.invoke(promises-jvm.kt:130)
at nl.komponents.kovenant.NonBlockingDispatcher$ThreadContext.run(dispatcher-jvm.kt:327)
at java.lang.Thread.run(Thread.java:748)
Caused by: java.io.IOException: java.lang.IllegalStateException: message == null
at com.julienviet.retrofit.vertx.VertxCallFactory$VertxCall.lambda$enqueue$0(VertxCallFactory.java:90)
at io.vertx.core.impl.FutureImpl.tryFail(FutureImpl.java:170)
at io.vertx.core.http.impl.HttpClientResponseImpl.handleException(HttpClientResponseImpl.java:270)
at io.vertx.core.http.impl.HttpClientResponseImpl.handleEnd(HttpClientResponseImpl.java:259)
at io.vertx.core.http.impl.ClientConnection.handleResponseEnd(ClientConnection.java:361)
at io.vertx.core.http.impl.ClientHandler.doMessageReceived(ClientHandler.java:80)
at io.vertx.core.http.impl.ClientHandler.doMessageReceived(ClientHandler.java:38)
at io.vertx.core.http.impl.VertxHttpHandler.lambda$channelRead$0(VertxHttpHandler.java:71)
at io.vertx.core.impl.ContextImpl.lambda$wrapTask$2(ContextImpl.java:335)
at io.vertx.core.impl.ContextImpl.executeFromIO(ContextImpl.java:193)
at io.vertx.core.http.impl.VertxHttpHandler.channelRead(VertxHttpHandler.java:71)
at io.vertx.core.net.impl.VertxHandler.channelRead(VertxHandler.java:122)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:363)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:349)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:341)
at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:435)
at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:293)
at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:267)
at io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:250)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:363)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:349)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:341)
at io.netty.handler.ssl.SslHandler.unwrap(SslHandler.java:1228)
at io.netty.handler.ssl.SslHandler.decode(SslHandler.java:1039)
at io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:411)
at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:248)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:363)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:349)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:341)
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1334)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:363)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:349)
at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:926)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:129)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:642)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:565)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:479)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:441)
at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:858)
... 1 more
Caused by: java.lang.IllegalStateException: message == null
at okhttp3.Response$Builder.build(Response.java:431)
at com.julienviet.retrofit.vertx.VertxCallFactory$VertxCall.lambda$null$1(VertxCallFactory.java:109)
at io.vertx.core.http.impl.HttpClientResponseImpl$BodyHandler.notifyHandler(HttpClientResponseImpl.java:301)
at io.vertx.core.http.impl.HttpClientResponseImpl.lambda$bodyHandler$0(HttpClientResponseImpl.java:193)
at io.vertx.core.http.impl.HttpClientResponseImpl.handleEnd(HttpClientResponseImpl.java:257)
... 36 more
I can't for the life of me work out what is causing the IllegalStateException and have googled it to death. I don't think it's Rx related because I get the same error if I make the method return Observable<List<UndergroundLine>> or even get rid of Rx entirely and return Call<List<UndergroundLine>> (adjusting the controller accordingly). However, beyond that, I'm beating my head against a wall! Is anyone able to point out the error of my ways (besides putting a cushion under my head)?
Main Verticle:
class ApiVerticle : AbstractVerticle() {
override fun start(startFuture: Future<Void>?) {
// Initialise injection.
configureKodein()
val apiRouter = configureRouter(vertx)
vertx.createHttpServer()
.requestHandler { apiRouter.accept(it) }
.listen(8080)
}
private fun configureKodein() {
Kodein.global.addImport(Kodein.Module {
import(TflUndergroundService.module)
})
}
private fun configureRouter(vertx: Vertx): Router {
val apiMountPoint = "api"
val routerInit = fun Router.() {
bindController(UndergroundRestController(), apiMountPoint)
}
val router = Router.router(vertx) initializedBy { router ->
router.routerInit()
}
return router
}
}
TflService:
interface TflService {
#GET("/Line/Mode/tube")
fun getAllUndergroundLines(): Observable<UndergroundLine>
#GET("/Line/{lineName}/StopPoints")
fun getStationsForUndergroundLine(
#Path("lineName") lineName: String
): Observable<UndergroundStation>
#GET("/Line/{lineName}/Arrivals?stopPointId={stationNaptanId")
fun getArrivalsFor(
#Path("lineName") lineName: String,
#Path("stationNaptanId") stationNaptanId: String
) : Observable<Arrival>
}
data class UndergroundLine(val id: String, val name: String)
data class UndergroundStation(val naptanId: String, val commonName: String)
data class Arrival(
val platformName: String,
val towards: String,
val currentLocation: String,
val expectedArrival: LocalDateTime)
object TflUndergroundService {
val module = Kodein.Module {
val vertx: Vertx = Vertx.currentContext().owner()
val client: HttpClient = vertx.createHttpClient()
val jacksonMapper: ObjectMapper = ObjectMapper()
jacksonMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl("https://api.tfl.gov.uk/")
.callFactory(VertxCallFactory(client))
.addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync())
.addConverterFactory(JacksonConverterFactory.create(jacksonMapper))
.build()
val tflService: TflService = retrofit.create(TflService::class.java)
bind<TflService>() with instance(tflService)
}
}
ApiKeySecured (Just requires "appid" to be a parameter):
class ApiKeySecured(private val routingContext: RoutingContext) : KodeinGlobalAware {
val user: String = routingContext.request().getParam("appid") ?: throw HttpErrorUnauthorized()
}
The offending REST controller (in Kovert, Promise's are executed on Vertx worker thread):
class UndergroundRestController(val undergroundService: TflService = Kodein.global.instance()) {
fun ApiKeySecured.listUndergroundStations(): Promise<List<UndergroundLine>, Exception> {
//TODO: This is blocking, fix it!??
return task {
undergroundService
.getAllUndergroundLines()
.doOnError { println(it) }
.toList()
.blockingGet()
}
}
}
build.gradle:
mainClassName = "io.vertx.core.Launcher"
def mainVerticleName = "uk.amb85.rxweb.verticles.ApiVerticle"
def configurationFile = "conf/development.json"
run {
args = ["run",
mainVerticleName,
"--launcher-class=$mainClassName",
"-conf $configurationFile"
]
}
There's an issue with retrofit-vertx you are using. OkHttp3's ResponseBuilder requires message to be not null, but VertxCallFactory doesn't set it.
It's fixed in the latest version, but as it's still in development, you have to use snapshot:
repositories {
mavenCentral()
maven {
url "https://oss.sonatype.org/content/repositories/snapshots"
}
}
dependencies {
compile 'com.julienviet:retrofit-vertx:1.0.2-SNAPSHOT'
}
Switching to snapshot dependency fixes the issue you mention in your question, but there's an issue with json mapping, which can be easily fixed by switching code from:
#GET("/Line/Mode/tube")
fun getAllUndergroundLines(): Observable<UndergroundLine>
to:
#GET("/Line/Mode/tube")
fun getAllUndergroundLines(): Observable<List<UndergroundLine>>
And updating your data classes to have default empty constructor to let Jackson instantiate using reflection:
data class UndergroundLine(var id: String = "", var name: String = "")
More on emtpy default constructor for data classes.
But it's another question related to how to parse response from API you're using to Observable and should be asked if you don't find a workaround.